Ver Fonte

feat(web): session-row Rename menu action with a browser-owned dialog

The row menu's Rename item opens the same dialog pattern as workspace rename
(no client-side conflict rule; the host normalizes) and resolves through the
session face via ctx.sessions.binding. Assembled-app snapshot covers the
row-menu -> dialog -> unary-settle flow.
imccyu há 2 meses atrás
pai
commit
d257c3878e

+ 130 - 0
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<string, unknown>).__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<void> {
+  const root = document.querySelector<HTMLElement>('#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<HTMLElement>('[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<HTMLElement>(`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')
+})

+ 14 - 0
apps/web/tests/snapshots/session-actions/rename-rows.json

@@ -0,0 +1,14 @@
+[
+  {
+    "label": "fixture4 sessions"
+  },
+  {
+    "label": "New Sessionnow"
+  },
+  {
+    "label": "分叉 实验记录now"
+  },
+  {
+    "label": "fixture2min"
+  }
+]

+ 72 - 3
packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx

@@ -92,12 +92,14 @@ type SessionTreeProps = Pick<
   onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
   onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
   /** Open the browser-owned delete-confirmation dialog for a real Workspace group. */
   /** Open the browser-owned delete-confirmation dialog for a real Workspace group. */
   onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
   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. */
 /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
 function SessionTree({
 function SessionTree({
   useSessions, startSession, open, workspaces, query,
   useSessions, startSession, open, workspaces, query,
-  onRenameRequest, onDeleteRequest, insertSessionBefore,
+  onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore,
 }: SessionTreeProps) {
 }: SessionTreeProps) {
   const list = useSessions(s => s)
   const list = useSessions(s => s)
   const current = list.current
   const current = list.current
@@ -192,6 +194,7 @@ function SessionTree({
                   currentId={current}
                   currentId={current}
                   now={now}
                   now={now}
                   onOpen={open}
                   onOpen={open}
+                  onRename={onSessionRename}
                   onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
                   onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
                   drag={dragProps}
                   drag={dragProps}
                 />
                 />
@@ -206,7 +209,7 @@ function SessionTree({
 }
 }
 
 
 /** The flat "In one list" body: every session a top-level row, newest-first. */
 /** The flat "In one list" body: every session a top-level row, newest-first. */
-function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'query'>) {
+function FlatList({ useSessions, open, onSessionRename, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'onSessionRename' | 'query'>) {
   const list = useSessions(s => s)
   const list = useSessions(s => s)
   const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
   const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
   const now = Date.now()
   const now = Date.now()
@@ -224,6 +227,7 @@ function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessi
             currentId={list.current}
             currentId={list.current}
             now={now}
             now={now}
             onOpen={open}
             onOpen={open}
+            onRename={onSessionRename}
             /* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
             /* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
             onToggle={() => {}}
             onToggle={() => {}}
             flat
             flat
@@ -249,6 +253,7 @@ export function WorkspaceBrowser({
   actions,
   actions,
   startSession,
   startSession,
   open,
   open,
+  renameSession,
   renameWorkspace,
   renameWorkspace,
   deleteWorkspace,
   deleteWorkspace,
   insertSessionBefore,
   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<string | null>(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
   // Delete dialog is separate from the row so a successful removal can
   // unmount that row without tearing down the in-flight confirmation state.
   // unmount that row without tearing down the in-flight confirmation state.
   const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null)
   const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null)
@@ -424,10 +461,11 @@ export function WorkspaceBrowser({
           itself is wide-only. */}
           itself is wide-only. */}
       <div className={css.listArea}>
       <div className={css.listArea}>
         {wide && (groupBy === 'flat'
         {wide && (groupBy === 'flat'
-          ? <FlatList useSessions={useSessions} open={open} query={query} />
+          ? <FlatList useSessions={useSessions} open={open} onSessionRename={onSessionRename} query={query} />
           : (
           : (
             <SessionTree
             <SessionTree
               useSessions={useSessions}
               useSessions={useSessions}
+              onSessionRename={onSessionRename}
               workspaces={workspaces}
               workspaces={workspaces}
               startSession={startSession}
               startSession={startSession}
               open={open}
               open={open}
@@ -479,6 +517,37 @@ export function WorkspaceBrowser({
         )}
         )}
         {renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>}
         {renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>}
       </Modal>
       </Modal>
+
+      <Modal
+        open={sessionRenameTarget !== null}
+        onClose={closeSessionRename}
+        title="Rename session"
+        footer={(
+          <>
+            <Button variant="outline" disabled={sessionRenaming} onClick={closeSessionRename}>Cancel</Button>
+            <Button variant="primary" disabled={sessionRenameBlocked} onClick={confirmSessionRename}>Rename</Button>
+          </>
+        )}
+      >
+        <input
+          className={css.renameInput}
+          value={sessionRenameDraft}
+          aria-label="Session name"
+          autoFocus
+          disabled={sessionRenaming}
+          onFocus={(e) => { 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 && <div className={css.renameError} role="alert">{sessionRenameError}</div>}
+      </Modal>
       <Modal
       <Modal
         open={deleteTarget !== null}
         open={deleteTarget !== null}
         onClose={closeDelete}
         onClose={closeDelete}

+ 2 - 0
packages/client/ui-workspace/src/client/contract/slots.ts

@@ -93,6 +93,8 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
   startSession: (workspaceId?: WorkspaceId) => void
   startSession: (workspaceId?: WorkspaceId) => void
   /** Open a real Session. */
   /** Open a real Session. */
   open: (sessionId: SessionId) => void
   open: (sessionId: SessionId) => void
+  /** Rename a Session (explicit user title; resolves on host acceptance). */
+  renameSession: (sessionId: SessionId, title: string) => Promise<void>
   /** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
   /** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
   renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
   renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
   /** Delete only a Host Workspace registration; directory and Session logs remain. */
   /** Delete only a Host Workspace registration; directory and Session logs remain. */

+ 8 - 0
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).
     // the runtime's shared action (recent-Workspace projection inside).
     startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
     startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
     open: (sessionId) => { ctx.sessions.open(sessionId) },
     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) },
     renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
     deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
     deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
     insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
     insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {

+ 10 - 4
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 —
  * Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
  * all data and callbacks arrive via props. Hover swaps (folder->chevron,
  * all data and callbacks arrive via props. Hover swaps (folder->chevron,
  * time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
  * 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 { useState } from 'react'
 import clsx from 'clsx'
 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'
   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
   node: SessionNode
   depth: number
   depth: number
   currentId: string | undefined
   currentId: string | undefined
   now: number
   now: number
   onOpen: (id: SessionNode['id']) => void
   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
   onToggle: (id: SessionNode['id']) => void
   /** Present only on draggable rows (workspace-group roots outside search). */
   /** Present only on draggable rows (workspace-group roots outside search). */
   drag?: RowDragProps | undefined
   drag?: RowDragProps | undefined
@@ -232,7 +234,10 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle,
           open={menuOpen}
           open={menuOpen}
           onClose={() => { setMenuOpen(false) }}
           onClose={() => { setMenuOpen(false) }}
           items={SESSION_MENU_ITEMS}
           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
           portal
           closeOnPointerLeave
           closeOnPointerLeave
           anchor={(
           anchor={(
@@ -264,6 +269,7 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle,
           currentId={currentId}
           currentId={currentId}
           now={now}
           now={now}
           onOpen={onOpen}
           onOpen={onOpen}
+          onRename={onRename}
           onToggle={onToggle}
           onToggle={onToggle}
         />
         />
       ))}
       ))}

+ 29 - 11
packages/client/ui-workspace/tests/rows.spec.tsx

@@ -68,7 +68,8 @@ describe('workspace browser rows', () => {
     const onOpen = vi.fn()
     const onOpen = vi.fn()
     const onToggle = vi.fn()
     const onToggle = vi.fn()
     const view = render(
     const view = render(
-      <SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen} onToggle={onToggle} />,
+      <SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen}
+        onRename={vi.fn()} onToggle={onToggle} />,
     )
     )
 
 
     const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
     const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
@@ -88,7 +89,8 @@ describe('workspace browser rows', () => {
     view.rerender(
     view.rerender(
       <SessionNodeItem
       <SessionNodeItem
         node={{ ...parent, children: [], expanded: false, running: false }}
         node={{ ...parent, children: [], expanded: false, running: false }}
-        depth={1} currentId={undefined} now={0} onOpen={onOpen} onToggle={onToggle}
+        depth={1} currentId={undefined} now={0} onOpen={onOpen}
+        onRename={vi.fn()} onToggle={onToggle}
       />,
       />,
     )
     )
     expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
     expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
@@ -135,19 +137,29 @@ describe('workspace browser rows', () => {
     expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull()
     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 onOpen = vi.fn()
+    const onRename = vi.fn()
     const node: SessionNode = {
     const node: SessionNode = {
       id: sid('s1'), title: 'One', children: [], hasChildren: false,
       id: sid('s1'), title: 'One', children: [], hasChildren: false,
       expanded: false, running: false, updatedAt: 0,
       expanded: false, running: false, updatedAt: 0,
     }
     }
-    render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen} onToggle={vi.fn()} />)
+    render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen}
+      onRename={onRename} onToggle={vi.fn()} />)
     fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
     fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
     expect(onOpen).not.toHaveBeenCalled()
     expect(onOpen).not.toHaveBeenCalled()
     expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/)
     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(screen.queryByRole('menu')).toBeNull()
+    expect(onRename).toHaveBeenCalledWith(node.id, 'One')
     expect(onOpen).not.toHaveBeenCalled()
     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).
     // Escape closes without selecting (Menu onClose path).
     fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
     fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
     fireEvent.keyDown(document, { key: 'Escape' })
     fireEvent.keyDown(document, { key: 'Escape' })
@@ -159,7 +171,8 @@ describe('workspace browser rows', () => {
       id: sid('p'), title: 'Parent', children: [], hasChildren: true,
       id: sid('p'), title: 'Parent', children: [], hasChildren: true,
       expanded: false, running: false, updatedAt: 0,
       expanded: false, running: false, updatedAt: 0,
     }
     }
-    render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} flat />)
+    render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
+      onRename={vi.fn()} onToggle={vi.fn()} flat />)
     expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull()
     expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull()
   })
   })
 
 
@@ -170,7 +183,8 @@ describe('workspace browser rows', () => {
         id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
         id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
         expanded: false, running: true, updatedAt: 0,
         expanded: false, running: true, updatedAt: 0,
       }
       }
-      render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()} onToggle={vi.fn()} />)
+      render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()}
+        onRename={vi.fn()} onToggle={vi.fn()} />)
       const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
       const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
       fireEvent.pointerEnter(wrapper)
       fireEvent.pointerEnter(wrapper)
       act(() => { vi.advanceTimersByTime(500) })
       act(() => { vi.advanceTimersByTime(500) })
@@ -196,7 +210,8 @@ describe('workspace browser rows', () => {
         id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
         id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
         expanded: false, running: false, updatedAt: 0,
         expanded: false, running: false, updatedAt: 0,
       }
       }
-      render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} />)
+      render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
+        onRename={vi.fn()} onToggle={vi.fn()} />)
       fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
       fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
       act(() => { vi.advanceTimersByTime(500) })
       act(() => { vi.advanceTimersByTime(500) })
       expect(screen.getByText('Idle')).toBeTruthy()
       expect(screen.getByText('Idle')).toBeTruthy()
@@ -213,7 +228,8 @@ describe('workspace browser rows', () => {
     }
     }
     const inactive = dragProps()
     const inactive = dragProps()
     const { rerender } = render(
     const { rerender } = render(
-      <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
+      <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
+        onRename={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
     )
     )
     const row = screen.getByRole('treeitem')
     const row = screen.getByRole('treeitem')
     stubRect(row)
     stubRect(row)
@@ -230,7 +246,8 @@ describe('workspace browser rows', () => {
 
 
     const active = dragProps({ active: true, marker: 'before' })
     const active = dragProps({ active: true, marker: 'before' })
     rerender(
     rerender(
-      <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={active} />,
+      <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
+        onRename={vi.fn()} onToggle={vi.fn()} drag={active} />,
     )
     )
     stubRect(screen.getByRole('treeitem'))
     stubRect(screen.getByRole('treeitem'))
     // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
     // 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' })
     const after = dragProps({ active: true, marker: 'after' })
     rerender(
     rerender(
-      <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={after} />,
+      <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
+        onRename={vi.fn()} onToggle={vi.fn()} drag={after} />,
     )
     )
     expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
     expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
   })
   })

+ 1 - 0
packages/client/ui-workspace/tests/workspace-browser.spec.tsx

@@ -55,6 +55,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
     actions: store.actions,
     actions: store.actions,
     startSession: vi.fn(),
     startSession: vi.fn(),
     open: vi.fn(),
     open: vi.fn(),
+    renameSession: vi.fn(async () => {}),
     renameWorkspace: vi.fn(async () => {}),
     renameWorkspace: vi.fn(async () => {}),
     deleteWorkspace: vi.fn(async () => {}),
     deleteWorkspace: vi.fn(async () => {}),
     insertSessionBefore: vi.fn(async () => {}),
     insertSessionBefore: vi.fn(async () => {}),