diff --git a/apps/web/tests/session-actions.snapshot.ts b/apps/web/tests/session-actions.snapshot.ts new file mode 100644 index 0000000000..684afe5532 --- /dev/null +++ b/apps/web/tests/session-actions.snapshot.ts @@ -0,0 +1,130 @@ +// @vitest-environment jsdom +// Session row actions in the assembled fixture app: Rename opens the +// browser-owned dialog and settles the title from the unary response. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' +import { AppWebEntry } from '@deepseek-ai/dsh-client-web' + +const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ + { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] }, + { id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] }, + { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] }, +] + +const bundles = new Map(PLUGINS.map(plugin => [ + plugin.url, + readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'), +])) + +interface FixtureWindow extends Window { + __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] } + __ModuleLoader__?: unknown +} + +class ResizeObserverStub { + observe(): void {} + disconnect(): void {} + unobserve(): void {} +} + +const win = window as FixtureWindow +let unmount: (() => void) | undefined + +beforeEach(() => { + localStorage.clear() + history.replaceState(null, '', '/?fixture') + document.title = 'DeepSeek Harness' + const root = document.createElement('div') + root.id = 'root' + document.body.appendChild(root) + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => { callback(0) }, 0) as unknown as number) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) }) + win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } +}) + +afterEach(() => { + act(() => { unmount?.() }) + unmount = undefined + cleanup() + delete win.__DSH_BOOT__ + delete win.__ModuleLoader__ + delete (globalThis as Record).__fxTiming + document.body.innerHTML = '' + document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) + document.title = '' + history.replaceState(null, '', '/') + vi.unstubAllGlobals() +}) + +async function bootApp(): Promise { + const root = document.querySelector('#root') + if (root === null) throw new Error('snapshot root missing') + act(() => { + const entry = new AppWebEntry(root, { + fetchBundle: (url) => { + const code = bundles.get(url) + return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code) + }, + executeBundle: (code) => { (0, eval)(code) }, + }) + void entry.run() + unmount = () => { entry.dispose() } + }) + await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) +} + +/** The session row element carrying the given visible label. */ +function rowOf(label: string): HTMLElement { + const tree = screen.getByRole('tree', { name: 'Sessions' }) + const row = within(tree).getByText(label).closest('[role="treeitem"]') + if (row === null) throw new Error(`session row "${label}" missing`) + return row +} + +/** Open the row's ... menu and click one action. The anchor button is + * CSS-hover-revealed (real stylesheets are injected in this assembled run, + * so role queries filter it as hidden); target it directly. */ +function pickRowAction(label: string, action: string): void { + const anchor = rowOf(label).querySelector(`button[aria-label="Session actions for ${label}"]`) + if (anchor === null) throw new Error(`row menu anchor for "${label}" missing`) + fireEvent.click(anchor) + fireEvent.click(screen.getByRole('menuitem', { name: action, hidden: true })) +} + +it('renames a session through the row-menu dialog; the row settles from the unary response', async () => { + await bootApp() + const sourceLabel = 'Fixture 历史会话' + await screen.findByText(sourceLabel) + + pickRowAction(sourceLabel, 'Rename') + const input = await screen.findByLabelText('Session name') + expect((input as HTMLInputElement).value).toBe(sourceLabel) + fireEvent.change(input, { target: { value: ' 分叉 实验记录 ' } }) + fireEvent.click(screen.getByRole('button', { name: 'Rename' })) + + // Host-side normalization collapses whitespace; the dialog closes on + // acceptance and the row re-labels without any push-frame wait. + const renamed = '分叉 实验记录' + await waitFor(() => { expect(screen.queryByLabelText('Session name')).toBeNull() }) + await screen.findByText(renamed) + const tree = screen.getByRole('tree', { name: 'Sessions' }) + expect(within(tree).queryByText(sourceLabel)).toBeNull() + + const rows = [...tree.querySelectorAll('[role="treeitem"]')].map(row => ({ + label: row.textContent?.replace(/\s+/g, ' ').trim() ?? '', + })) + await expect(`${JSON.stringify(rows, null, 2)}\n`) + .toMatchFileSnapshot('./snapshots/session-actions/rename-rows.json') +}) diff --git a/apps/web/tests/snapshots/session-actions/rename-rows.json b/apps/web/tests/snapshots/session-actions/rename-rows.json new file mode 100644 index 0000000000..db30b0d121 --- /dev/null +++ b/apps/web/tests/snapshots/session-actions/rename-rows.json @@ -0,0 +1,14 @@ +[ + { + "label": "fixture4 sessions" + }, + { + "label": "New Sessionnow" + }, + { + "label": "分叉 实验记录now" + }, + { + "label": "fixture2min" + } +] diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 7effff36b5..0c607f1b70 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -92,12 +92,14 @@ type SessionTreeProps = Pick< onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void /** Open the browser-owned delete-confirmation dialog for a real Workspace group. */ onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void + /** Open the browser-owned session rename dialog. */ + onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void } /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ function SessionTree({ useSessions, startSession, open, workspaces, query, - onRenameRequest, onDeleteRequest, insertSessionBefore, + onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore, }: SessionTreeProps) { const list = useSessions(s => s) const current = list.current @@ -192,6 +194,7 @@ function SessionTree({ currentId={current} now={now} onOpen={open} + onRename={onSessionRename} onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }} drag={dragProps} /> @@ -206,7 +209,7 @@ function SessionTree({ } /** The flat "In one list" body: every session a top-level row, newest-first. */ -function FlatList({ useSessions, open, query }: Pick) { +function FlatList({ useSessions, open, onSessionRename, query }: Pick) { const list = useSessions(s => s) const rows = useMemo(() => deriveFlat(list, { query }), [list, query]) const now = Date.now() @@ -224,6 +227,7 @@ function FlatList({ useSessions, open, query }: Pick {}} flat @@ -249,6 +253,7 @@ export function WorkspaceBrowser({ actions, startSession, open, + renameSession, renameWorkspace, deleteWorkspace, insertSessionBefore, @@ -309,6 +314,38 @@ export function WorkspaceBrowser({ }) } + // Session rename dialog (same browser-owned pattern as workspace rename; + // sessions have no client-side name-conflict rule — the host normalizes). + const [sessionRenameTarget, setSessionRenameTarget] = useState<{ sessionId: SessionNode['id']; currentTitle: string } | null>(null) + const [sessionRenameDraft, setSessionRenameDraft] = useState('') + const [sessionRenaming, setSessionRenaming] = useState(false) + const [sessionRenameError, setSessionRenameError] = useState(null) + const sessionRenameTrimmed = sessionRenameDraft.trim() + const sessionRenameBlocked = sessionRenaming || sessionRenameTrimmed === '' + || sessionRenameTarget === null || sessionRenameTrimmed === sessionRenameTarget.currentTitle + const closeSessionRename = () => { + if (sessionRenaming) return + setSessionRenameTarget(null) + setSessionRenameError(null) + } + const confirmSessionRename = () => { + if (sessionRenameBlocked) return + setSessionRenaming(true) + setSessionRenameError(null) + renameSession(sessionRenameTarget.sessionId, sessionRenameTrimmed).then(() => { + setSessionRenaming(false) + setSessionRenameTarget(null) + }).catch((reason: unknown) => { + setSessionRenaming(false) + setSessionRenameError(reason instanceof Error ? reason.message : String(reason)) + }) + } + const onSessionRename = (sessionId: SessionNode['id'], currentTitle: string) => { + setSessionRenameTarget({ sessionId, currentTitle }) + setSessionRenameDraft(currentTitle) + setSessionRenameError(null) + } + // Delete dialog is separate from the row so a successful removal can // unmount that row without tearing down the in-flight confirmation state. const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null) @@ -424,10 +461,11 @@ export function WorkspaceBrowser({ itself is wide-only. */}
{wide && (groupBy === 'flat' - ? + ? : ( {renameError}
} + + + + + + )} + > + { e.target.select() }} + onChange={(e) => { setSessionRenameDraft(e.target.value); setSessionRenameError(null) }} + onCompositionStart={() => { composingRef.current = true }} + onCompositionEnd={() => { composingRef.current = false }} + onKeyDown={(e) => { + if (e.key === 'Enter' && !composingRef.current) { + e.preventDefault() + confirmSessionRename() + } + }} + /> + {sessionRenameError !== null &&
{sessionRenameError}
} +
void /** Open a real Session. */ open: (sessionId: SessionId) => void + /** Rename a Session (explicit user title; resolves on host acceptance). */ + renameSession: (sessionId: SessionId, title: string) => Promise /** Rename a Host Workspace (rejects on name conflict; resolves on durability). */ renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise /** Delete only a Host Workspace registration; directory and Session logs remain. */ diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 1cf5a7ae5a..c1f5e61ba4 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -51,6 +51,14 @@ export function apply(ctx: ClientContext): void { // the runtime's shared action (recent-Workspace projection inside). startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) }, open: (sessionId) => { ctx.sessions.open(sessionId) }, + renameSession: async (sessionId, title) => { + // Row → session-face hop: rename is a per-session verb (ISession), not + // a list-service verb; the binding resolves any listed session. + const session = ctx.sessions.binding(sessionId)?.session + if (session === undefined) throw new Error(`unknown session "${sessionId}"`) + const result = await session.rename(title) + if (!result.ok) throw new Error(result.error.message) + }, renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) }, insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 1ee2a84adc..d75fabdd8b 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -2,8 +2,8 @@ * Workspace browser tree row components (figma Cell set 14:3080): pure presentational — * all data and callbacks arrive via props. Hover swaps (folder->chevron, * time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only - * except workspace Rename; the session hover card is suppressed while a menu - * is open. Workspace Rename/Delete are wired; session actions remain visual-only. + * except workspace Rename/Delete and session Rename; the session hover card is + * suppressed while a menu is open. */ import { useState } from 'react' import clsx from 'clsx' @@ -159,12 +159,14 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' } -export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle, drag, flat = false }: { +export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, onToggle, drag, flat = false }: { node: SessionNode depth: number currentId: string | undefined now: number onOpen: (id: SessionNode['id']) => void + /** Open the browser-owned session rename dialog (row menu action). */ + onRename: (id: SessionNode['id'], currentTitle: string) => void onToggle: (id: SessionNode['id']) => void /** Present only on draggable rows (workspace-group roots outside search). */ drag?: RowDragProps | undefined @@ -232,7 +234,10 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle, open={menuOpen} onClose={() => { setMenuOpen(false) }} items={SESSION_MENU_ITEMS} - onSelect={() => { setMenuOpen(false) }} // Visual-only for now. + onSelect={(id) => { + setMenuOpen(false) + if (id === 'rename') onRename(node.id, row.title) // fork/delete stay visual-only. + }} portal closeOnPointerLeave anchor={( @@ -264,6 +269,7 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle, currentId={currentId} now={now} onOpen={onOpen} + onRename={onRename} onToggle={onToggle} /> ))} diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 15121e929a..bfaa8a36dd 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -68,7 +68,8 @@ describe('workspace browser rows', () => { const onOpen = vi.fn() const onToggle = vi.fn() const view = render( - , + , ) const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')! @@ -88,7 +89,8 @@ describe('workspace browser rows', () => { view.rerender( , ) expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy() @@ -135,19 +137,29 @@ describe('workspace browser rows', () => { expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull() }) - it('session row menu opens without opening the session and closes on selection', () => { + it('session row menu opens without opening the session and dispatches rename', () => { const onOpen = vi.fn() + const onRename = vi.fn() const node: SessionNode = { id: sid('s1'), title: 'One', children: [], hasChildren: false, expanded: false, running: false, updatedAt: 0, } - render() + render() fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' })) expect(onOpen).not.toHaveBeenCalled() expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/) - fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' })) + // Rename dispatches with the current display title (dialog prefill). + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) expect(screen.queryByRole('menu')).toBeNull() + expect(onRename).toHaveBeenCalledWith(node.id, 'One') expect(onOpen).not.toHaveBeenCalled() + // Fork and Delete stay visual-only. + fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' })) + fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete session' })) + expect(onRename).toHaveBeenCalledOnce() // Escape closes without selecting (Menu onClose path). fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' })) fireEvent.keyDown(document, { key: 'Escape' }) @@ -159,7 +171,8 @@ describe('workspace browser rows', () => { id: sid('p'), title: 'Parent', children: [], hasChildren: true, expanded: false, running: false, updatedAt: 0, } - render() + render() expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull() }) @@ -170,7 +183,8 @@ describe('workspace browser rows', () => { id: sid('s1'), title: 'Hovered', children: [], hasChildren: false, expanded: false, running: true, updatedAt: 0, } - render() + render() const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) @@ -196,7 +210,8 @@ describe('workspace browser rows', () => { id: sid('s1'), title: 'Quiet', children: [], hasChildren: false, expanded: false, running: false, updatedAt: 0, } - render() + render() fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) expect(screen.getByText('Idle')).toBeTruthy() @@ -213,7 +228,8 @@ describe('workspace browser rows', () => { } const inactive = dragProps() const { rerender } = render( - , + , ) const row = screen.getByRole('treeitem') stubRect(row) @@ -230,7 +246,8 @@ describe('workspace browser rows', () => { const active = dragProps({ active: true, marker: 'before' }) rerender( - , + , ) stubRect(screen.getByRole('treeitem')) // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117). @@ -243,7 +260,8 @@ describe('workspace browser rows', () => { const after = dragProps({ active: true, marker: 'after' }) rerender( - , + , ) expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/) }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 12a5efc264..abe896dffe 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -55,6 +55,7 @@ function mount(overrides: Partial = {}) { actions: store.actions, startSession: vi.fn(), open: vi.fn(), + renameSession: vi.fn(async () => {}), renameWorkspace: vi.fn(async () => {}), deleteWorkspace: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}),