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.
This commit is contained in:
imccyu
2026-07-29 18:59:46 +08:00
parent ff049f1e82
commit d257c3878e
8 changed files with 266 additions and 18 deletions
+130
View File
@@ -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')
})
@@ -0,0 +1,14 @@
[
{
"label": "fixture4 sessions"
},
{
"label": "New Sessionnow"
},
{
"label": "分叉 实验记录now"
},
{
"label": "fixture2min"
}
]
@@ -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<SessionTreeProps, 'useSessions' | 'open' | 'query'>) {
function FlatList({ useSessions, open, onSessionRename, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'onSessionRename' | 'query'>) {
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<SessionTreeProps, 'useSessi
currentId={list.current}
now={now}
onOpen={open}
onRename={onSessionRename}
/* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
onToggle={() => {}}
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<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
// 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. */}
<div className={css.listArea}>
{wide && (groupBy === 'flat'
? <FlatList useSessions={useSessions} open={open} query={query} />
? <FlatList useSessions={useSessions} open={open} onSessionRename={onSessionRename} query={query} />
: (
<SessionTree
useSessions={useSessions}
onSessionRename={onSessionRename}
workspaces={workspaces}
startSession={startSession}
open={open}
@@ -479,6 +517,37 @@ export function WorkspaceBrowser({
)}
{renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>}
</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
open={deleteTarget !== null}
onClose={closeDelete}
@@ -93,6 +93,8 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
startSession: (workspaceId?: WorkspaceId) => 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<void>
/** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
/** Delete only a Host Workspace registration; directory and Session logs remain. */
@@ -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) => {
@@ -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}
/>
))}
@@ -68,7 +68,8 @@ describe('workspace browser rows', () => {
const onOpen = vi.fn()
const onToggle = vi.fn()
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"]')!
@@ -88,7 +89,8 @@ describe('workspace browser rows', () => {
view.rerender(
<SessionNodeItem
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()
@@ -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(<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' }))
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(<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()
})
@@ -170,7 +183,8 @@ describe('workspace browser rows', () => {
id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
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
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(<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)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('Idle')).toBeTruthy()
@@ -213,7 +228,8 @@ describe('workspace browser rows', () => {
}
const inactive = dragProps()
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')
stubRect(row)
@@ -230,7 +246,8 @@ describe('workspace browser rows', () => {
const active = dragProps({ active: true, marker: 'before' })
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'))
// 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(
<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/)
})
@@ -55,6 +55,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
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 () => {}),