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
+58 -12
View File
@@ -26,19 +26,21 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView
const view = (expandedProjects: readonly string[] = []) => ({
expandedProjects,
})
const noArchive: readonly SessionId[] = []
const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid)
describe('deriveGroups', () => {
it('keeps Host Workspace and sessionIds order without Client recency sorting', () => {
const sessions = list(summary('newer', 20), summary('older', 10))
const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])]
const groups = deriveGroups(sessions, workspaces, view(['first']))
const groups = deriveGroups(sessions, workspaces, noArchive, view(['first']))
expect(groups.map(group => group.key)).toEqual(['first', 'empty'])
expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')])
})
it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => {
const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other'))
const groups = deriveGroups(sessions, [workspace('first', ['owned'])], view([UNGROUPED_KEY]))
const groups = deriveGroups(sessions, [workspace('first', ['owned'])], noArchive, view([UNGROUPED_KEY]))
expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY])
expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')])
})
@@ -52,7 +54,7 @@ describe('deriveGroups', () => {
current: currentBlank.id,
}
const groups = deriveGroups(
sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], view(['first']),
sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], noArchive, view(['first']),
)
expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id, currentBlank.id])
const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)!
@@ -63,7 +65,7 @@ describe('deriveGroups', () => {
expect(groups[0]!.sessions.find(session => session.id === real.id)!.blank).toBe(false)
expect(groups[0]!.sessionCount).toBe(2)
// A non-current blank stray never surfaces an Ungrouped bucket either.
const strayGroups = deriveGroups(list({ ...summary('stray', 2), blank: true }), [workspace('first', [])], view())
const strayGroups = deriveGroups(list({ ...summary('stray', 2), blank: true }), [workspace('first', [])], noArchive, view())
expect(strayGroups.map(group => group.key)).toEqual(['first'])
})
@@ -80,6 +82,7 @@ describe('deriveGroups', () => {
const groups = deriveGroups(
list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
[],
noArchive,
{ expandedProjects: [UNGROUPED_KEY] },
)
@@ -90,7 +93,7 @@ describe('deriveGroups', () => {
])
// Equal timestamps use ids as a deterministic tiebreak in either input order.
expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], view([UNGROUPED_KEY]))[0]!
expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, view([UNGROUPED_KEY]))[0]!
.sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')])
})
@@ -100,17 +103,32 @@ describe('deriveGroups', () => {
ids: [sid('present')],
byId: { [sid('present')]: summary('present', 1) },
}
const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], view(['project']))
const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], noArchive, view(['project']))
expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
})
it('hides archived sessions from workspace groups and Ungrouped', () => {
const kept = summary('kept', 1, '/projects/first')
const gone = summary('gone', 2, '/projects/first')
const looseGone = summary('loose-gone', 3, '/other')
const sessions = list(kept, gone, looseGone)
const groups = deriveGroups(
sessions, [workspace('first', ['kept', 'gone'])], archived('gone', 'loose-gone'), view(['first', UNGROUPED_KEY]),
)
// The archived member drops from its group AND the archived stray never
// surfaces an Ungrouped bucket; counts follow the visible rows.
expect(groups.map(group => group.key)).toEqual(['first'])
expect(groups[0]!.sessions.map(node => node.id)).toEqual([kept.id])
expect(groups[0]!.sessionCount).toBe(1)
})
it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => {
const owned = summary('owned', 1)
const loose = summary('loose', 2)
const ws = workspace('project', ['owned'])
const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], view())
const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], noArchive, view())
expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true)
const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], view())
const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], noArchive, view())
expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true)
})
})
@@ -121,13 +139,13 @@ describe('deriveFlat', () => {
const child = { ...summary('child', 30), parentId: parent.id }
const tieB = summary('tie-b', 20)
const tieA = summary('tie-a', 20)
const rows = deriveFlat(list(parent, child, tieB, tieA))
const rows = deriveFlat(list(parent, child, tieB, tieA), noArchive)
expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
})
it('tolerates ids whose summary has not landed yet', () => {
const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
expect(deriveFlat(partial).map(row => row.id)).toEqual([sid('present')])
expect(deriveFlat(partial, noArchive).map(row => row.id)).toEqual([sid('present')])
})
it('shows only the current blank session and excludes blanks from search', () => {
@@ -137,11 +155,35 @@ describe('deriveFlat', () => {
...list(summary('real', 1), currentBlank, staleBlank),
current: currentBlank.id,
}
const rows = deriveFlat(sessions)
const rows = deriveFlat(sessions, noArchive)
expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')])
expect(rows.map(row => row.title)).toEqual(['New Session', 'real'])
expect(rows.map(row => row.blank)).toEqual([true, false])
})
it('hides archived sessions in flat mode', () => {
const kept = summary('kept', 1)
const gone = summary('gone', 2)
expect(deriveFlat(list(kept, gone), archived('gone')).map(row => row.id)).toEqual([kept.id])
})
})
describe('deriveSearchResults archive filtering', () => {
it('archived sessions never match — not by title and not via a backend content hit', () => {
const hit = summary('hit', 2)
hit.displayTitle = 'Needle row'
const gone = summary('gone', 1)
gone.displayTitle = 'Needle archived'
const result = deriveSearchResults(
list(hit, gone),
[],
'needle',
archived('gone'),
{ items: [{ sessionId: gone.id, snippet: 'needle body' }], hasMore: false },
10,
)
expect(result.items.map(item => item.id)).toEqual([hit.id])
})
})
describe('deriveSearchResults', () => {
@@ -160,6 +202,7 @@ describe('deriveSearchResults', () => {
workspace('duplicate-owner', ['title-hit'], 'Ignored duplicate owner'),
],
' NEEDLE ',
noArchive,
{
items: [
{ sessionId: contentHit.id, snippet: 'body needle excerpt' },
@@ -212,6 +255,7 @@ describe('deriveSearchResults', () => {
sessions,
[workspace('first', ['opaque-current', 'new session stale'])],
'new session',
noArchive,
{
items: [
{ sessionId: staleBlank.id, snippet: 'stale body' },
@@ -234,6 +278,7 @@ describe('deriveSearchResults', () => {
list(...rows),
[],
'needle',
noArchive,
{ items: [], hasMore: false },
3,
)
@@ -244,12 +289,13 @@ describe('deriveSearchResults', () => {
list(summary('body', 1)),
[],
'needle',
noArchive,
{ items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true },
3,
)
expect(backendMore.items).toHaveLength(1)
expect(backendMore.hasMore).toBe(true)
expect(deriveSearchResults(list(), [], ' ', { items: [], hasMore: true }, 3))
expect(deriveSearchResults(list(), [], ' ', noArchive, { items: [], hasMore: true }, 3))
.toEqual({ items: [], hasMore: false })
})
})