feat(ui-workspace): archive session from the row menu

The visual-only Delete session placeholder becomes a wired Archive
session action: no confirmation dialog (non-destructive), failures stay
console diagnostics. tree.ts hides archived sessions in every
derivation (workspace groups, Ungrouped, search, flat list) through the
sessionVisible predicate. The workspace-management e2e pins the archive
round trip across reload.
This commit is contained in:
imccyu
2026-07-31 02:40:39 +08:00
committed by imccyu
parent c764ed7e64
commit e235be2bac
14 changed files with 261 additions and 66 deletions

View File

@@ -88,7 +88,7 @@ describe('workspace browser rows', () => {
const onOpen = vi.fn()
render(
<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={onOpen}
onRename={vi.fn()} onFork={vi.fn()} t={t} />,
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />,
)
const row = screen.getByRole('treeitem')
@@ -157,18 +157,20 @@ describe('workspace browser rows', () => {
expect(screen.queryByRole('button', { name: /工作区/ })).toBeNull()
})
it('session row menu opens without opening the session and dispatches rename and fork', () => {
it('session row menu opens without opening the session and dispatches rename, fork, and archive', () => {
const onOpen = vi.fn()
const onRename = vi.fn()
const onFork = vi.fn()
const onArchive = vi.fn()
const node: SessionNode = {
id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
onRename={onRename} onFork={onFork} t={t} />)
onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
expect(onOpen).not.toHaveBeenCalled()
expect(screen.getByRole('menuitem', { name: '删除会话' }).className).toMatch(/danger/)
// Archive is not destructive (log and accounting slot remain): no danger styling.
expect(screen.getByRole('menuitem', { name: '归档会话' }).className).not.toMatch(/danger/)
// Rename dispatches with the current display title (dialog prefill).
fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
expect(screen.queryByRole('menu')).toBeNull()
@@ -177,10 +179,12 @@ describe('workspace browser rows', () => {
fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '分叉会话' }))
expect(onFork).toHaveBeenCalledWith(node.id)
// Delete stays visual-only.
// Archive dispatches without opening the session.
fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '删除会话' }))
fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' }))
expect(onArchive).toHaveBeenCalledWith(node.id)
expect(onRename).toHaveBeenCalledOnce()
expect(onOpen).not.toHaveBeenCalled()
// Escape closes without selecting (Menu onClose path).
fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
fireEvent.keyDown(document, { key: 'Escape' })
@@ -194,7 +198,7 @@ describe('workspace browser rows', () => {
id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} t={t} />)
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
@@ -220,7 +224,7 @@ describe('workspace browser rows', () => {
id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} t={t} />)
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('空闲')).toBeTruthy()
@@ -237,7 +241,7 @@ describe('workspace browser rows', () => {
const inactive = dragProps()
const { rerender } = render(
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} drag={inactive} t={t} />,
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={inactive} t={t} />,
)
const row = screen.getByRole('treeitem')
stubRect(row)
@@ -255,7 +259,7 @@ describe('workspace browser rows', () => {
const active = dragProps({ active: true, marker: 'before' })
rerender(
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} drag={active} t={t} />,
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={active} t={t} />,
)
stubRect(screen.getByRole('treeitem'))
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
@@ -269,7 +273,7 @@ describe('workspace browser rows', () => {
const after = dragProps({ active: true, marker: 'after' })
rerender(
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} drag={after} t={t} />,
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={after} t={t} />,
)
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
})