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
@@ -35,8 +35,8 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView
workspaceId: wid(id), path: `/projects/${id}`, title,
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
})
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
items, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
const workspaceState = (items: readonly WorkspaceView[], archivedSessionIds: ReadonlySet<SessionId> = new Set()): WorkspaceListState => ({
items, archivedSessionIds, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
recentWorkspaceId: items[0]?.workspaceId,
})
function hook<T>(snapshot: T) {
@@ -68,6 +68,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
forkSession: vi.fn(),
renameWorkspace: vi.fn(async () => {}),
deleteWorkspace: vi.fn(async () => {}),
archiveSession: vi.fn(async () => {}),
insertSessionBefore: vi.fn(async () => {}),
createWorkspace: vi.fn(async () => workspace('created', [])),
useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }),
@@ -135,6 +136,50 @@ describe('WorkspaceBrowser', () => {
expect(screen.queryByText('alpha-s')).toBeNull()
})
it('archives a session from the row menu and hides archived rows in both modes', async () => {
const archiveSession = vi.fn(async () => {})
const b = mount({
useSessions: hook(sessionState([summary('kept-s', 2), summary('gone-s', 1)])),
useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])])),
archiveSession,
})
fireEvent.click(screen.getByText('alpha'))
fireEvent.click(screen.getByRole('button', { name: '会话“gone-s”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' }))
expect(archiveSession).toHaveBeenCalledWith(sid('gone-s'))
// The archive-set echo hides the row in grouped mode (count included) and flat mode.
rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])], new Set([sid('gone-s')]))) })
expect(screen.queryByText('gone-s')).toBeNull()
expect(screen.getByText('1 个会话')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
expect(screen.getByText('kept-s')).toBeTruthy()
expect(screen.queryByText('gone-s')).toBeNull()
})
it('logs and keeps the tree when the archive call rejects', async () => {
const rejection = new Error('archive exploded')
const archiveSession = vi.fn(async () => { throw rejection })
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
mount({
useSessions: hook(sessionState([summary('alpha-s', 1)])),
useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])),
archiveSession,
})
fireEvent.click(screen.getByText('alpha'))
fireEvent.click(screen.getByRole('button', { name: '会话“alpha-s”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' }))
await Promise.resolve()
await Promise.resolve()
expect(warn).toHaveBeenCalledWith('session archive rejected:', rejection)
expect(screen.getByText('alpha-s')).toBeTruthy()
} finally {
warn.mockRestore()
}
})
it('renders a fork child as a top-level row without a session twist', () => {
const parent = summary('parent-s', 2)
const child = { ...summary('child-s', 1), parentId: parent.id }