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

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')
})

View File

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

View File

@@ -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}

View File

@@ -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. */

View File

@@ -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) => {

View File

@@ -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 * except workspace Rename/Delete and session Rename; the session hover card is
* is open. Workspace Rename/Delete are wired; session actions remain visual-only. * 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}
/> />
))} ))}

View File

@@ -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/)
}) })

View File

@@ -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 () => {}),