{displayTitle(node, t)}
-
{hoverTimeLabel(node.updatedAt, now, t)}
+ {/* Same placeholder rule as the row's trailing cell: no timestamp
+ before the first prompt. */}
+ {!node.blank &&
{hoverTimeLabel(node.updatedAt, now, t)}
}
{node.running ? t('status.running') : t('status.idle')}
@@ -243,7 +245,7 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
}
-export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, drag, t }: {
+export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, t }: {
node: SessionNode
currentId: string | undefined
now: number
@@ -252,6 +254,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
onRename: (id: SessionNode['id'], currentTitle: string) => void
/** Fork a session at its last completed turn (row menu action). */
onFork: (id: SessionNode['id']) => void
+ /** Archive this session (row menu action; commits without a dialog). */
+ onArchive: (id: SessionNode['id']) => void
/** Present only on draggable rows (workspace-group sessions outside search). */
drag?: RowDragProps | undefined
t: RowTranslate
@@ -260,10 +264,13 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
const title = displayTitle(node, t)
const selected = node.id === currentId
const [menuOpen, setMenuOpen] = useState(false)
+ // Archive replaces the former Delete placeholder: it hides the row through
+ // the registry-global archive set and never touches the session log, so it
+ // is not styled as destructive and needs no confirmation dialog.
const sessionMenuItems = [
{ id: 'rename', label: t('rename'), icon: },
{ id: 'fork', label: t('menu.fork'), icon: },
- { id: 'delete', label: t('menu.deleteSession'), icon: , danger: true },
+ { id: 'archive', label: t('menu.archiveSession'), icon: },
]
// Figma session cell: pad 8, status slot 16, then a 4px title gap.
const ownRow = (
@@ -301,31 +308,38 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
>
{row.running && }
{title}
- {timeLabel(row.updatedAt, now, t)}
-
-
+ {/* A blank New Session row is a provisional placeholder: nothing has
+ happened in it yet, so a "now" timestamp and the row verbs
+ (rename/fork/archive) would all act on content that does not
+ exist — both trailing cells stay off until the first prompt. */}
+ {!row.blank && {timeLabel(row.updatedAt, now, t)}}
+ {!row.blank && (
+
+
+ )}
)
return (
diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts
index aed01c21cb..7ed7e684d1 100644
--- a/packages/client/ui-workspace/src/client/tree.ts
+++ b/packages/client/ui-workspace/src/client/tree.ts
@@ -90,9 +90,13 @@ function byRecency(a: SessionSummary, b: SessionSummary): number {
return a.id < b.id ? -1 : 1
}
-/** Ordinary sessions are visible; among blank sessions, only the current one is visible. */
-function sessionVisible(session: SessionSummary, current: SessionId | undefined): boolean {
- return !session.blank || session.id === current
+/**
+ * Ordinary sessions are visible; among blank sessions, only the current one
+ * is visible; archived sessions are visible nowhere (their accounting slots
+ * remain, so unarchiving restores position).
+ */
+function sessionVisible(session: SessionSummary, current: SessionId | undefined, archived: ReadonlySet
): boolean {
+ return !archived.has(session.id) && (!session.blank || session.id === current)
}
/**
@@ -126,7 +130,11 @@ function buildGroup(
* order, with members resolved from sessionIds in their stored order. Sessions
* outside every Workspace trail in the recency-ordered Ungrouped bucket.
*/
-function groupByWorkspace(list: SessionListState, workspaces: readonly WorkspaceView[]): Group[] {
+function groupByWorkspace(
+ list: SessionListState,
+ workspaces: readonly WorkspaceView[],
+ archived: ReadonlySet,
+): Group[] {
const groups: Group[] = []
const accounted = new Set()
for (const workspace of workspaces) {
@@ -135,7 +143,7 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace
const summary = list.byId[id]
if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands
accounted.add(id)
- if (!sessionVisible(summary, list.current)) continue
+ if (!sessionVisible(summary, list.current, archived)) continue
members.push(summary)
}
groups.push(buildGroup(
@@ -146,7 +154,7 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace
const stray = list.ids
.map(id => list.byId[id])
.filter((s): s is SessionSummary =>
- s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current))
+ s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current, archived))
if (stray.length > 0) {
groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
}
@@ -168,25 +176,29 @@ function sessionNode(s: SessionSummary): SessionNode {
*
* Every group shows; sessions populate under expanded groups, preserving
* Host account order. Blank sessions are excluded except for the selected
- * provisional New Session row. Content search lives outside this derivation
+ * provisional New Session row; archived sessions are excluded everywhere.
+ * Content search lives outside this derivation
* (see {@link deriveSearchResults}).
* @param list - sessions list snapshot (`current` feeds containsCurrent).
* @param workspaces - real workspaces in stable Host order.
+ * @param archivedSessionIds - registry-global archive set.
* @param view - local expansion arrays.
* @returns group sections in render order.
*/
export function deriveGroups(
list: SessionListState,
workspaces: readonly WorkspaceView[],
+ archivedSessionIds: readonly SessionId[],
view: TreeView,
): GroupNode[] {
+ const archived = new Set(archivedSessionIds)
const expandedProjects = new Set(view.expandedProjects)
const currentGroup = list.current === undefined
? undefined
: (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
?? UNGROUPED_KEY
const groups: GroupNode[] = []
- for (const g of groupByWorkspace(list, workspaces)) {
+ for (const g of groupByWorkspace(list, workspaces, archived)) {
const expanded = expandedProjects.has(g.key)
groups.push({
key: g.key,
@@ -209,13 +221,15 @@ export function deriveGroups(
* no parent/child adjacency. Content search lives outside this derivation
* (see {@link deriveSearchResults}).
* @param list - sessions list snapshot.
+ * @param archivedSessionIds - registry-global archive set.
* @returns flat rows in render order.
*/
-export function deriveFlat(list: SessionListState): SessionNode[] {
+export function deriveFlat(list: SessionListState, archivedSessionIds: readonly SessionId[]): SessionNode[] {
+ const archived = new Set(archivedSessionIds)
const rows: SessionSummary[] = []
for (const id of list.ids) {
const s = list.byId[id]
- if (s === undefined || !sessionVisible(s, list.current)) continue
+ if (s === undefined || !sessionVisible(s, list.current, archived)) continue
rows.push(s)
}
rows.sort(byRecency)
@@ -238,6 +252,7 @@ export interface RelativeTime {
* @param list - session metadata authority.
* @param workspaces - Workspace membership and display labels.
* @param query - caller text; surrounding whitespace is ignored.
+ * @param archivedSessionIds - registry-global archive set (members never match).
* @param content - ranked Host content-search page.
* @param limit - protocol-owned maximum merged row count.
* @returns bounded deduplicated flat rows and a refine-query hint bit.
@@ -246,11 +261,13 @@ export function deriveSearchResults(
list: SessionListState,
workspaces: readonly WorkspaceView[],
query: string,
+ archivedSessionIds: readonly SessionId[],
content: { items: readonly SessionSearchResultItem[]; hasMore: boolean },
limit: number,
): SearchResultSet {
const q = query.trim().toLowerCase()
if (q === '') return { items: [], hasMore: false }
+ const archived = new Set(archivedSessionIds)
const workspaceBySession = new Map()
for (const workspace of workspaces) {
@@ -270,7 +287,7 @@ export function deriveSearchResults(
const summary = list.byId[id]
// Blank placeholders never match a query (their canonical title displays
// localized, so matching it would tie search to one language).
- if (summary === undefined || summary.blank || !sessionVisible(summary, list.current)) continue
+ if (summary === undefined || summary.blank || !sessionVisible(summary, list.current, archived)) continue
if (
sessionTitle(summary).toLowerCase().includes(q)
|| labelOf(summary).toLowerCase().includes(q)
@@ -290,7 +307,7 @@ export function deriveSearchResults(
for (const summary of local) include(summary)
for (const item of content.items) {
const summary = list.byId[item.sessionId]
- if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current)) include(summary)
+ if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current, archived)) include(summary)
}
return {
diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx
index 59fe95fe0c..c0b4959b17 100644
--- a/packages/client/ui-workspace/tests/rows.spec.tsx
+++ b/packages/client/ui-workspace/tests/rows.spec.tsx
@@ -88,7 +88,7 @@ describe('workspace browser rows', () => {
const onOpen = vi.fn()
render(
,
+ onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />,
)
const row = screen.getByRole('treeitem')
@@ -157,18 +157,43 @@ 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('blank New Session rows carry no menu, no time label, and no hover-card time', () => {
+ vi.useFakeTimers()
+ try {
+ const node: SessionNode = {
+ id: sid('s-blank'), title: 'ignored', blank: true, running: false, updatedAt: 0,
+ }
+ render()
+ // The placeholder has no content yet: no row verbs, no "now" stamp.
+ expect(screen.queryByRole('button', { name: /会话.*的操作/ })).toBeNull()
+ expect(screen.queryByText('刚刚')).toBeNull()
+ // The hover card keeps title + status but drops the timestamp line.
+ const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
+ fireEvent.pointerEnter(wrapper)
+ act(() => { vi.advanceTimersByTime(500) })
+ expect(screen.getAllByText('新会话').length).toBeGreaterThanOrEqual(2)
+ expect(screen.getByText('空闲')).toBeTruthy()
+ expect(screen.queryByText('刚刚')).toBeNull()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ 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()
+ 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 +202,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 +221,7 @@ describe('workspace browser rows', () => {
id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0,
}
render()
+ 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 +247,7 @@ describe('workspace browser rows', () => {
id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0,
}
render()
+ 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 +264,7 @@ describe('workspace browser rows', () => {
const inactive = dragProps()
const { rerender } = render(
,
+ onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={inactive} t={t} />,
)
const row = screen.getByRole('treeitem')
stubRect(row)
@@ -255,7 +282,7 @@ describe('workspace browser rows', () => {
const active = dragProps({ active: true, marker: 'before' })
rerender(
,
+ 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 +296,7 @@ describe('workspace browser rows', () => {
const after = dragProps({ active: true, marker: 'after' })
rerender(
,
+ onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={after} t={t} />,
)
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
})
diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts
index 8fb750a4ad..7476de29f5 100644
--- a/packages/client/ui-workspace/tests/tree.spec.ts
+++ b/packages/client/ui-workspace/tests/tree.spec.ts
@@ -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 })
})
})
diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx
index 7dc3f7239b..e5c89ce0de 100644
--- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx
+++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx
@@ -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: readonly SessionId[] = []): WorkspaceListState => ({
+ items, archivedSessionIds, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
recentWorkspaceId: items[0]?.workspaceId,
})
function hook(snapshot: T) {
@@ -68,6 +68,7 @@ function mount(overrides: Partial = {}) {
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'])], [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 }
diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx
index 3a1bb8e2b2..b2d1479d17 100644
--- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx
+++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx
@@ -32,7 +32,7 @@ const sessions: SessionListState = {
ids: [], byId: {}, current: undefined, phase: 'ready',
}
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
- items, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
+ items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true,
recentWorkspaceId: items[0]?.workspaceId,
})
function anchor(): { current: HTMLElement } {
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index 9120a6364c..bceef94db3 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -1166,6 +1166,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'delete(id: WorkspaceId): Promise',
jsDoc: '/**\n * Delete one workspace registration while retaining its directory and every\n * session log. The durable order is updated before the table deletion; a\n * failed table write restores the prior order and keeps the entity\n * published. Unknown ids are an idempotent no-op for domain callers.\n * @param id - Workspace registration to remove.\n * @returns `true` when a record was deleted, `false` when it was unknown.\n */',
},
+ {
+ signature: 'archiveSession(sessionId: SessionId): Promise',
+ jsDoc: '/**\n * Archive one session durably. The session must exist (live or in session\n * persistence); its workspace accounting — or lack of one — is irrelevant.\n * An already archived id resolves without writing.\n * @param sessionId - The session to archive.\n * @returns resolution after durability.\n */',
+ },
{
signature: 'async resolveByPath(path: string): Promise',
jsDoc: '/**\n * Resolve by canonical directory path without creating or mutating a\n * workspace. A missing path rejects during `realpath`; an existing unowned\n * directory returns `undefined`.\n * @param path - Existing directory path in any spelling.\n * @returns the workspace owning the canonical path, when one exists.\n */',
@@ -2215,6 +2219,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'PtyWaitReason',
declaration: 'export type PtyWaitReason = \'stdin_read\' | \'inferred_idle\' | \'timeout\' | \'session_exit\';',
},
+ {
+ name: 'ReadFileLine',
+ declaration: 'export interface ReadFileLine {\n number: number;\n text: string;\n}',
+ },
+ {
+ name: 'ReadResultView',
+ declaration: 'export interface ReadResultView {\n card: \'read\';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n}',
+ },
{
name: 'ReasoningBlock',
declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}',
@@ -2853,7 +2865,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolResultView',
- declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView;',
+ declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | ReadResultView | WebResultView;',
},
{
name: 'ToolRunContext',
diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml
index e888160b0f..064f3f9e1f 100644
--- a/packages/core/tools/README.i18n.yaml
+++ b/packages/core/tools/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/tools/README.md
-README.md: e7f395f8c1d6417db856e590f5267cf6887e4d12
-README.zh.md: acb4c047bf86e36c828882ff751d4be1f627f99e
+README.md: dcce455f9551318f3871e3df84c29789078fef7c
+README.zh.md: 63eaa2e0b66797c74a1d4845c29ca970b63f5f99
diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md
index e7f395f8c1..dcce455f95 100644
--- a/packages/core/tools/README.md
+++ b/packages/core/tools/README.md
@@ -108,7 +108,7 @@ Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. E
Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names:
- Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`.
-- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content).
+- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lines` is `{ number, text }[]` keeping each file line number, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content).
Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary.
diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md
index acb4c047bf..63eaa2e0b6 100644
--- a/packages/core/tools/README.zh.md
+++ b/packages/core/tools/README.zh.md
@@ -108,7 +108,7 @@ ctx.tools.register(defineTool({
工具可以选择拥有纯 `presentCall()` 和 `presentResult()` 呈现意图,使 UI 无需特殊处理工具名称:
- 调用视图为 `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`、`{ card: 'terminal', title, description?, cwd? }` 或 `{ card: 'diff', title, diffs, locations? }`。
-- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }` 或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。
+- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }`、`{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lines` 是 `{ number, text }[]`,保留每一行的文件行号,`content` 是无读取能力的 UI 回退时使用的去信封文本)或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。
返回 `undefined` 会选择通用回退。呈现器只依赖其参数和持久结果,因为 UI 会在实时流式输出和日志回放期间调用它们。`output.presentationMeta(args, value)` 为直接接口调用派生 JSON 元数据;该元数据随 `tool/result` 持久化并传回 `presentResult`,而规范值本身仍只存在于执行局部,绝不会回放。嵌套 Code 分发不会计算元数据。`defineTool` 会软验证较旧的日志参数并回退,而不会使回放崩溃。`dsh-tool-bash` 与 `dsh-tool-fs` 是参考实现;[规范输出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) 规定值/呈现拆分,[呈现意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) 规定卡片词汇。
diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts
index e825a1a0dc..60dafed720 100644
--- a/packages/core/tools/src/index.ts
+++ b/packages/core/tools/src/index.ts
@@ -74,6 +74,7 @@ export type {
ToolCallKind,
FileLocation,
FileDiff,
+ ReadFileLine,
ToolCallView,
GenericCallView,
TerminalCallView,
@@ -82,6 +83,7 @@ export type {
GenericResultView,
TerminalResultView,
DiffResultView,
+ ReadResultView,
WebResultView,
WebSearchResultView,
WebFetchResultView,
diff --git a/packages/core/tools/src/presentation.ts b/packages/core/tools/src/presentation.ts
index d1e7d552b5..b2b24554c4 100644
--- a/packages/core/tools/src/presentation.ts
+++ b/packages/core/tools/src/presentation.ts
@@ -117,6 +117,18 @@ export interface DiffCallView {
locations?: FileLocation[]
}
+/**
+ * One numbered line of a file, the unit a {@link ReadResultView} carries so a
+ * capable UI can render a syntax-highlighted, line-numbered code view. `number`
+ * is the 1-based line number in the file (a window past `offset` keeps the file's
+ * own numbering, not a 1-based re-count); `text` is the line without its trailing
+ * newline, already truncated to the read tool's per-line cap.
+ */
+export interface ReadFileLine {
+ number: number
+ text: string
+}
+
/**
* How a tool wants the COMPLETED call shown — the *result* state, after `execute`
* returns. A `card`-tagged union mirroring {@link ToolCallView}: a UI switches on
@@ -125,7 +137,7 @@ export interface DiffCallView {
* `ToolDefinition.presentResult`; omitting the method keeps the pending
* title and renders the raw result content.
*/
-export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView
+export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | ReadResultView | WebResultView
/**
* The default completed card: an optional replacement title and reformatted
@@ -177,6 +189,47 @@ export interface DiffResultView {
diffs: FileDiff[]
}
+/**
+ * A completed file read rendered as a line-numbered, optionally syntax-highlighted
+ * code view by a capable UI. Set by a tool whose call reads file text (e.g.
+ * `read`); the pending state stays a {@link GenericCallView} (`kind: 'read'`)
+ * because a call carries no content until `execute` returns. The structured
+ * `lines`/`path`/`lang`/`totalLines` fields cannot be reconstructed from the
+ * model-facing result text alone, so the read tool projects them through its
+ * `output.presentationMeta` (persisted with the session log) and `presentResult`
+ * narrows that metadata back into this view on live and replay paths alike. A UI
+ * without the read capability falls back to `content` (the model-facing text with
+ * its envelope stripped), so this view degrades to the generic text card.
+ */
+export interface ReadResultView {
+ card: 'read'
+ /** Replacement title for the completed call. Omit to keep the pending-state title. */
+ title?: string
+ /** The read file's path (the model-facing path; the bridge relativizes it). */
+ path: string
+ /**
+ * The 1-based first line the window requested, preserved even when `lines` is
+ * empty (a byte cap below the first selected line yields an empty window) so a
+ * UI knows where the window starts and where a continuation resumes.
+ */
+ offset: number
+ /** The returned window's lines, in file order, each keeping its file line number. */
+ lines: ReadFileLine[]
+ /** Exact total line count in the file, so a UI can show a "showing N of M" affordance. */
+ totalLines: number
+ /**
+ * A syntax-highlighting language hint derived from the file extension (e.g.
+ * `ts`, `py`), or omitted when the extension maps to no known language so a UI
+ * renders the lines as plain text.
+ */
+ lang?: string
+ /**
+ * The model-facing result content with its envelope stripped, for a UI without
+ * the read capability. Omit to let such a UI render the raw result content.
+ */
+ content?: ContentBlock[]
+}
+
/**
* One citeable source in a completed {@link WebSearchResultView}, the faithful
* projection of one web-search source. The presentation projection of `dsh-web`'s
diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml
index a3ebe97bc4..65c6b65268 100644
--- a/packages/fs/tool-fs/README.i18n.yaml
+++ b/packages/fs/tool-fs/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/fs/tool-fs/README.md
-README.md: 4ff9b043525e8e7a0b59e3d91410951d88bb9a69
-README.zh.md: ce93e10072d74ce268273aa472bfbb3f34f46259
+README.md: c00b59fed06249e6d9479c4a809cdf7d78f93239
+README.zh.md: f90fbb36391c1388ab0f6836daa2a9061d046be6
diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md
index 4ff9b04352..c00b59fed0 100644
--- a/packages/fs/tool-fs/README.md
+++ b/packages/fs/tool-fs/README.md
@@ -34,7 +34,7 @@ All keys are optional; the defaults are the shipped read caps.
Field names are snake_case to match Claude Code and existing harness tool schemas.
-Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. Write/edit derive replayable diff-card metadata from these values; the values themselves are execution-local and are not added to `tool/result`.
+Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`, from these canonical values; the canonical values themselves are execution-local and are not added to `tool/result`, only the derived presentation metadata is persisted.
## The tool is the executor; policy is an event gate
diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md
index ce93e10072..f90fbb3639 100644
--- a/packages/fs/tool-fs/README.zh.md
+++ b/packages/fs/tool-fs/README.zh.md
@@ -34,7 +34,7 @@ await ctx.plugin(ToolFs) // this package — re
字段名使用 snake_case,与 Claude Code 和现有 harness 工具 schema 一致。
-规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。写入/编辑从这些值派生可回放的 diff 卡片元数据;这些值本身仅限于本次执行,不会添加到 `tool/result`。
+规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。`write`/`edit` 从这些规范值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;规范值本身仅限于本次执行,不会添加到 `tool/result`,只有派生出的呈现元数据会被持久化。
## 工具就是执行器;策略是事件门禁
diff --git a/packages/fs/tool-fs/src/read-render.ts b/packages/fs/tool-fs/src/read-render.ts
index 7e581bb22c..19b6c0b1e7 100644
--- a/packages/fs/tool-fs/src/read-render.ts
+++ b/packages/fs/tool-fs/src/read-render.ts
@@ -168,3 +168,105 @@ export function formatReadOutput(displayPath: string, outcome: FileReadOutcome):
${body}
`
}
+
+/**
+ * Lowercased file-extension to syntax-highlighting language hint. Keys are the
+ * extension without its dot; a UI treats an absent key as plain text. The map is
+ * intentionally small — common source, config, and markup extensions a
+ * line-numbered code view benefits from highlighting — not an exhaustive registry.
+ */
+const LANG_BY_EXTENSION: Readonly> = {
+ ts: 'ts', tsx: 'tsx', mts: 'ts', cts: 'ts',
+ js: 'js', jsx: 'jsx', mjs: 'js', cjs: 'js',
+ json: 'json', jsonc: 'json',
+ py: 'py', rb: 'rb', go: 'go', rs: 'rs', java: 'java',
+ c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp', cxx: 'cpp',
+ cs: 'cs', kt: 'kotlin', swift: 'swift', php: 'php',
+ sh: 'sh', bash: 'sh', zsh: 'sh',
+ yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini',
+ md: 'md', markdown: 'md', mdx: 'mdx',
+ html: 'html', htm: 'html', css: 'css', scss: 'scss', less: 'less',
+ sql: 'sql', xml: 'xml', lua: 'lua',
+}
+
+/**
+ * Derive a syntax-highlighting language hint from a read path's file extension.
+ * Pure and case-insensitive on the extension; a dotfile with no extension
+ * (`.gitignore`) and an unknown extension both yield `undefined`.
+ * @param path - the model-facing path the read reported.
+ * @returns the language hint for {@link LANG_BY_EXTENSION}, or `undefined` when the extension maps to none.
+ */
+export function langFromPath(path: string): string | undefined {
+ const base = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
+ const dot = base.lastIndexOf('.')
+ // A leading dot is a dotfile (no extension), not an empty extension.
+ if (dot <= 0) return undefined
+ const ext = base.slice(dot + 1).toLowerCase()
+ // Own-property check only: a filename whose extension is an Object.prototype
+ // key (`foo.constructor`, `foo.__proto__`) must map to no language, not to the
+ // inherited member — otherwise a function would reach `lang` and fail the
+ // tool-output JSON validation.
+ return Object.hasOwn(LANG_BY_EXTENSION, ext) ? LANG_BY_EXTENSION[ext] : undefined
+}
+
+/**
+ * The `read` tool's private `tool/result` `meta` payload: the structured
+ * line-numbered window a capable UI renders as a code view. Attached opaquely (as
+ * `unknown`) on the tool result and persisted with the session log — it must be
+ * JSON-serializable (the session validates this at `append`), so `presentResult`
+ * reproduces the read card on replay when the raw structured output is no longer
+ * on the wire. The producing tool owns and narrows this opaque shape.
+ */
+export interface FsReadMeta {
+ /** The read file's model-facing path. */
+ path: string
+ /** The 1-based first line the window requested, kept even when `lines` is empty. */
+ offset: number
+ /** The returned window's lines, each keeping its file line number. */
+ lines: FileTextLine[]
+ /** Exact total line count in the file. */
+ totalLines: number
+ /** Syntax-highlighting language hint from the extension, or omitted for plain text. */
+ lang?: string
+}
+
+/**
+ * Whether `value` is a valid {@link FileTextLine} (defensive narrowing from
+ * opaque `meta`). `number` must be a 1-based integer line number, since a card
+ * rendered from a zero, fractional, or non-finite line number would violate the
+ * 1-based numbering contract the read window promises.
+ */
+function isFileTextLine(value: unknown): value is FileTextLine {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
+ const { number, text } = value as Record
+ return typeof number === 'number' && Number.isInteger(number) && number >= 1 && typeof text === 'string'
+}
+
+/**
+ * Narrow opaque live or replayed result metadata to a structured read window.
+ * Malformed metadata returns `undefined` so presentation can fall back to the
+ * generic text card instead of throwing during replay. Beyond shape, the
+ * semantic contract of a read window is enforced against replayed JSON that is
+ * well-typed but out of range: `offset` must be a 1-based integer, `totalLines`
+ * must be a non-negative integer, each line number must be a 1-based integer no
+ * less than `offset`, the line numbers must strictly increase, and no line number
+ * may exceed `totalLines`. Any violation declines to the generic fallback rather
+ * than emitting a card that misnumbers or overcounts.
+ * @param meta - result metadata.
+ * @returns the validated read window, or `undefined` for absent, malformed, or semantically invalid data.
+ */
+export function readMetaFromMeta(meta: unknown): FsReadMeta | undefined {
+ if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
+ const { path, offset, lines, totalLines, lang } = meta as Record
+ if (typeof path !== 'string' || typeof totalLines !== 'number' || typeof offset !== 'number') return undefined
+ if (!Number.isInteger(offset) || offset < 1) return undefined
+ if (!Number.isInteger(totalLines) || totalLines < 0) return undefined
+ if (!Array.isArray(lines) || !lines.every(isFileTextLine)) return undefined
+ if (lang !== undefined && typeof lang !== 'string') return undefined
+ let previous = offset - 1
+ for (const { number } of lines) {
+ if (number <= previous || number > totalLines) return undefined
+ previous = number
+ }
+ return { path, offset, lines, totalLines, ...lang === undefined ? {} : { lang } }
+}
diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts
index 05e1b41ae2..a92fdcaad8 100644
--- a/packages/fs/tool-fs/src/read.ts
+++ b/packages/fs/tool-fs/src/read.ts
@@ -6,11 +6,11 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
-import type { GenericCallView, GenericResultView, ToolResult } from '@deepseek-ai/dsh-tools'
+import type { GenericCallView, ReadResultView, ToolResult } from '@deepseek-ai/dsh-tools'
import { FsError } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
-import { buildWindow, formatReadOutput } from './read-render.ts'
+import { buildWindow, formatReadOutput, langFromPath, readMetaFromMeta } from './read-render.ts'
import { sessionResolveOptions } from './session-cwd.ts'
/** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */
@@ -118,6 +118,19 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
}),
}]
},
+ // Project the structured window into persisted `meta` so a UI's read card
+ // survives replay: the raw canonical output object is not on the wire, only
+ // the model-facing text, from which the line/lang data cannot be recovered.
+ presentationMeta: (_args, value) => {
+ const lang = langFromPath(value.path)
+ return {
+ path: value.path,
+ offset: value.offset,
+ lines: value.lines.map(({ number, text }) => ({ number, text })),
+ totalLines: value.totalLines,
+ ...lang === undefined ? {} : { lang },
+ }
+ },
},
// Observation races fail closed because guarded mutations re-check the version in-lock.
isConcurrencySafe: () => true,
@@ -154,15 +167,32 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
ctx.emit('fs/observed', target, info.version, exec)
return outcome
},
- presentResult(_args, result: ToolResult): GenericResultView | undefined {
+ // Result-time display: a `read` card carrying the structured line window a
+ // capable UI renders as a line-numbered, syntax-highlighted view. The
+ // structured data is narrowed from the persisted `meta` (replay-safe); the
+ // envelope-stripped model-facing text rides along as `content` so a UI without
+ // the read capability still shows the file text. A malformed or absent meta,
+ // or a result whose text is not the read envelope, declines to `undefined`
+ // (the generic fallback), never throwing on replay of obsolete logged output.
+ presentResult(_args, result: ToolResult): ReadResultView | undefined {
if (result.isError) return undefined
+ const meta = readMetaFromMeta(result.meta)
+ if (meta === undefined) return undefined
const only = result.content.length === 1 ? result.content[0] : undefined
const text = only?.type === 'text' ? only.text : undefined
if (text === undefined) return undefined
// Group 1 always captures (possibly empty) when the envelope matches.
const body = /^[^\n]*<\/path>\nfile<\/type>\n\n([\s\S]*)\n<\/content>$/u.exec(text)?.[1]
if (body === undefined) return undefined
- return { card: 'generic', content: [{ type: 'text', text: body }] }
+ return {
+ card: 'read',
+ path: meta.path,
+ offset: meta.offset,
+ lines: meta.lines,
+ totalLines: meta.totalLines,
+ ...meta.lang === undefined ? {} : { lang: meta.lang },
+ content: [{ type: 'text', text: body }],
+ }
},
// Pure display: a generic card titled by the file with the read window appended (`Read
// foo.txt (5 - 8)`), `read` kind (icon), and a follow-along location whose line is the
diff --git a/packages/fs/tool-fs/tests/read-render.spec.ts b/packages/fs/tool-fs/tests/read-render.spec.ts
index c2afaf002e..cc03a0c8f9 100644
--- a/packages/fs/tool-fs/tests/read-render.spec.ts
+++ b/packages/fs/tool-fs/tests/read-render.spec.ts
@@ -6,7 +6,7 @@
*/
import { describe, expect, it } from 'vitest'
-import { buildWindow, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts'
+import { buildWindow, langFromPath, readMetaFromMeta, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts'
import type { ReadWindow } from '../src/read-render.ts'
const DEFAULT_CAPS = { maxLineLength: READ_MAX_LINE_LENGTH, maxBytes: READ_MAX_BYTES }
@@ -116,3 +116,103 @@ describe('buildWindow', () => {
})
})
})
+
+describe('langFromPath', () => {
+ it('maps a known extension to its language hint, case-insensitively', () => {
+ expect(langFromPath('src/a.ts')).toBe('ts')
+ expect(langFromPath('src/a.TSX')).toBe('tsx')
+ expect(langFromPath('/abs/module.mjs')).toBe('js')
+ expect(langFromPath('conf.yml')).toBe('yaml')
+ expect(langFromPath('README.md')).toBe('md')
+ })
+
+ it('reads the extension after the last path segment and last dot', () => {
+ expect(langFromPath('a.py.bak')).toBeUndefined()
+ expect(langFromPath('archive.tar.gz')).toBeUndefined()
+ expect(langFromPath('/dir.py/plain')).toBeUndefined()
+ expect(langFromPath('C:\\src\\main.rs')).toBe('rs')
+ })
+
+ it('returns undefined for a dotfile, an extensionless name, and an unknown extension', () => {
+ expect(langFromPath('.gitignore')).toBeUndefined()
+ expect(langFromPath('/etc/hosts')).toBeUndefined()
+ expect(langFromPath('data.unknownext')).toBeUndefined()
+ expect(langFromPath('trailingdot.')).toBeUndefined()
+ })
+
+ it('returns undefined for a filename whose extension is an Object.prototype key', () => {
+ // Own-property lookup only: these must not resolve to the inherited member
+ // (a function/object), which would fail the tool-output JSON validation.
+ expect(langFromPath('foo.constructor')).toBeUndefined()
+ expect(langFromPath('foo.__proto__')).toBeUndefined()
+ expect(langFromPath('foo.toString')).toBeUndefined()
+ expect(langFromPath('foo.hasOwnProperty')).toBeUndefined()
+ })
+})
+
+describe('readMetaFromMeta', () => {
+ const good = { path: '/abs/a.ts', offset: 1, lines: [{ number: 1, text: 'x' }], totalLines: 1, lang: 'ts' }
+
+ it('narrows a well-formed read meta, with and without a lang hint', () => {
+ expect(readMetaFromMeta(good)).toEqual(good)
+ const noLang = { path: '/abs/a', offset: 1, lines: [], totalLines: 0 }
+ expect(readMetaFromMeta(noLang)).toEqual(noLang)
+ })
+
+ it('narrows an empty window at a positive offset (byte cap below the first selected line)', () => {
+ const empty = { path: '/abs/a', offset: 5, lines: [], totalLines: 9 }
+ expect(readMetaFromMeta(empty)).toEqual(empty)
+ })
+
+ it('returns undefined for absent, non-object, or array meta', () => {
+ expect(readMetaFromMeta(undefined)).toBeUndefined()
+ expect(readMetaFromMeta(null)).toBeUndefined()
+ expect(readMetaFromMeta('nope')).toBeUndefined()
+ expect(readMetaFromMeta([good])).toBeUndefined()
+ })
+
+ it('returns undefined when a field is missing or the wrong type (defensive narrowing)', () => {
+ expect(readMetaFromMeta({ ...good, path: 5 })).toBeUndefined()
+ expect(readMetaFromMeta({ ...good, offset: '1' })).toBeUndefined()
+ expect(readMetaFromMeta({ ...good, totalLines: '1' })).toBeUndefined()
+ expect(readMetaFromMeta({ ...good, lines: 'nope' })).toBeUndefined()
+ expect(readMetaFromMeta({ ...good, lines: [{ number: '1', text: 'x' }] })).toBeUndefined()
+ expect(readMetaFromMeta({ ...good, lines: [{ number: 1 }] })).toBeUndefined()
+ expect(readMetaFromMeta({ ...good, lines: [null] })).toBeUndefined()
+ expect(readMetaFromMeta({ ...good, lang: 5 })).toBeUndefined()
+ })
+
+ it('rejects an offset that is not a 1-based integer', () => {
+ expect(readMetaFromMeta({ ...good, offset: 0 })).toBeUndefined()
+ expect(readMetaFromMeta({ ...good, offset: 1.5 })).toBeUndefined()
+ expect(readMetaFromMeta({ ...good, offset: NaN })).toBeUndefined()
+ expect(readMetaFromMeta({ ...good, offset: Infinity })).toBeUndefined()
+ })
+
+ it('rejects a first line number below offset', () => {
+ expect(readMetaFromMeta({ ...good, offset: 2, lines: [{ number: 1, text: 'x' }], totalLines: 2 })).toBeUndefined()
+ })
+
+ it('rejects a line number that is not a 1-based integer', () => {
+ expect(readMetaFromMeta({ ...good, lines: [{ number: 0, text: 'x' }], totalLines: 1 })).toBeUndefined()
+ expect(readMetaFromMeta({ ...good, lines: [{ number: 1.5, text: 'x' }], totalLines: 2 })).toBeUndefined()
+ expect(readMetaFromMeta({ ...good, lines: [{ number: NaN, text: 'x' }], totalLines: 1 })).toBeUndefined()
+ expect(readMetaFromMeta({ ...good, lines: [{ number: Infinity, text: 'x' }], totalLines: 1 })).toBeUndefined()
+ })
+
+ it('rejects a totalLines that is not a non-negative integer', () => {
+ expect(readMetaFromMeta({ ...good, totalLines: -1 })).toBeUndefined()
+ expect(readMetaFromMeta({ ...good, totalLines: 1.5 })).toBeUndefined()
+ expect(readMetaFromMeta({ ...good, totalLines: NaN })).toBeUndefined()
+ })
+
+ it('rejects lines that do not strictly increase or exceed totalLines', () => {
+ const twoLines = { path: '/abs/a', offset: 1, lang: 'ts' }
+ // Duplicate line numbers.
+ expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 1, text: 'a' }, { number: 1, text: 'b' }], totalLines: 2 })).toBeUndefined()
+ // Out-of-order line numbers.
+ expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 2, text: 'b' }, { number: 1, text: 'a' }], totalLines: 2 })).toBeUndefined()
+ // A line number past totalLines.
+ expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 3, text: 'c' }], totalLines: 2 })).toBeUndefined()
+ })
+})
diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts
index de844dcaf4..914a1bf7de 100644
--- a/packages/fs/tool-fs/tests/tools.spec.ts
+++ b/packages/fs/tool-fs/tests/tools.spec.ts
@@ -320,6 +320,39 @@ describe('read tool', () => {
expect(text(result)).toContain('Output capped.')
})
+ it('attaches the structured window as presentation meta, and presentResult narrows it into a read card', async () => {
+ const { ctx, fs } = await setup()
+ fs.files.set('key:a.ts', 'const x = 1\nconst y = 2')
+ const result = await call(ctx, 'read', { file_path: 'a.ts' })
+ expect(result.isError).toBe(false)
+ if (result.isError) throw new Error('expected read success')
+ // The extension drives the lang hint; the window rides on persisted meta.
+ expect(result.meta).toEqual({
+ path: '/abs/a.ts',
+ offset: 1,
+ lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }],
+ totalLines: 2,
+ lang: 'ts',
+ })
+ const view = ctx.tools.get('read')?.presentResult?.({ file_path: 'a.ts' }, result)
+ expect(view).toEqual({
+ card: 'read',
+ path: '/abs/a.ts',
+ offset: 1,
+ lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }],
+ totalLines: 2,
+ lang: 'ts',
+ content: [{ type: 'text', text: '1: const x = 1\n2: const y = 2\n\n(End of file - total 2 lines)' }],
+ })
+ })
+
+ it('omits the lang hint in meta for an extension that maps to no language', async () => {
+ const { ctx, fs } = await setup()
+ fs.files.set('key:notes', 'plain')
+ const result = await call(ctx, 'read', { file_path: 'notes' })
+ if (result.isError) throw new Error('expected read success')
+ expect(result.meta).toEqual({ path: '/abs/notes', offset: 1, lines: [{ number: 1, text: 'plain' }], totalLines: 1 })
+ })
})
describe('formatReadOutput footer variants', () => {
@@ -450,33 +483,72 @@ describe('tool-owned presentation (pure presentCall)', () => {
})
})
- it('read: completed presentation removes the model-facing XML envelope', async () => {
- expect(await presentResult('read', { file_path: 'a.txt' }, {
- content: [{ type: 'text', text: '/tmp/a.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n' }],
+ it('read: completed presentation is a read card carrying the structured window with the envelope stripped', async () => {
+ // The structured line data rides on persisted meta (the raw output object is
+ // not on the wire); presentResult narrows it and appends the stripped text as
+ // the no-capability `content` fallback.
+ const meta = { path: '/tmp/a.ts', offset: 1, lines: [{ number: 1, text: 'hello' }], totalLines: 1, lang: 'ts' }
+ expect(await presentResult('read', { file_path: 'a.ts' }, {
+ content: [{ type: 'text', text: '/tmp/a.ts\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n' }],
isError: false,
+ meta,
})).toEqual({
- card: 'generic',
+ card: 'read',
+ path: '/tmp/a.ts',
+ offset: 1,
+ lines: [{ number: 1, text: 'hello' }],
+ totalLines: 1,
+ lang: 'ts',
content: [{ type: 'text', text: '1: hello\n\n(End of file - total 1 lines)' }],
})
- expect(await presentResult('read', { file_path: 'a.txt' }, {
+ // A window whose extension maps to no language omits `lang` from the card.
+ expect(await presentResult('read', { file_path: 'notes' }, {
+ content: [{ type: 'text', text: '/tmp/notes\nfile\n\nbody\n' }],
+ isError: false,
+ meta: { path: '/tmp/notes', offset: 1, lines: [{ number: 1, text: 'body' }], totalLines: 1 },
+ })).toEqual({
+ card: 'read',
+ path: '/tmp/notes',
+ offset: 1,
+ lines: [{ number: 1, text: 'body' }],
+ totalLines: 1,
+ content: [{ type: 'text', text: 'body' }],
+ })
+ // Malformed envelope text with valid meta still declines (the fallback text is unavailable).
+ expect(await presentResult('read', { file_path: 'a.ts' }, {
content: [{ type: 'text', text: 'malformed replay' }],
isError: false,
+ meta,
+ })).toBeUndefined()
+ // Valid envelope but absent/malformed meta declines to the generic fallback.
+ expect(await presentResult('read', { file_path: 'a.ts' }, {
+ content: [{ type: 'text', text: '/tmp/a.ts\nfile\n\n1: hello\n' }],
+ isError: false,
+ })).toBeUndefined()
+ expect(await presentResult('read', { file_path: 'a.ts' }, {
+ content: [{ type: 'text', text: '/tmp/a.ts\nfile\n\n1: hello\n' }],
+ isError: false,
+ meta: { path: '/tmp/a.ts', lines: 'nope', totalLines: 1 },
})).toBeUndefined()
})
it('read: completed presentation declines errors and non-single-text content', async () => {
const envelope = '/tmp/a.txt\nfile\n\nbody\n'
+ const meta = { path: '/tmp/a.txt', offset: 1, lines: [{ number: 1, text: 'body' }], totalLines: 1 }
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'text', text: envelope }],
isError: true,
+ meta,
})).toBeUndefined()
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'text', text: envelope }, { type: 'text', text: 'second' }],
isError: false,
+ meta,
})).toBeUndefined()
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'reasoning', text: envelope }],
isError: false,
+ meta,
})).toBeUndefined()
})
diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml
index 573790009f..1bd0f19d0b 100644
--- a/packages/host/apiproxy/README.i18n.yaml
+++ b/packages/host/apiproxy/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
-README.md: b12c1179a7b64b4b67efa01598e19bffc9f4198c
-README.zh.md: 740c01bf46a0df56470fd2b4655e3c21317199bb
+README.md: 8f08d90f8a91afc2ff022761d2d83de055df18a0
+README.zh.md: febeba0e601d75cbc69490d29135c206189efe29
diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md
index b12c1179a7..8f08d90f8a 100644
--- a/packages/host/apiproxy/README.md
+++ b/packages/host/apiproxy/README.md
@@ -22,7 +22,7 @@ Session model routing is a session-domain contract. `session.models` returns the
Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events.
-Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
+Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.
diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md
index 740c01bf46..febeba0e60 100644
--- a/packages/host/apiproxy/README.zh.md
+++ b/packages/host/apiproxy/README.zh.md
@@ -22,7 +22,7 @@
待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。
-Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
+Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts
index 205c9912f5..b6365a4a52 100644
--- a/packages/host/apiproxy/src/api-proxy.ts
+++ b/packages/host/apiproxy/src/api-proxy.ts
@@ -21,7 +21,7 @@ import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-se
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
import {
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
- WorkspaceMoveInvalidError, WorkspaceNameConflictError,
+ WorkspaceMoveInvalidError, WorkspaceNameConflictError, WorkspaceUnknownSessionError,
} from '@deepseek-ai/dsh-workspace'
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
import type {} from '@deepseek-ai/dsh-tools'
@@ -1582,7 +1582,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
workspace: {
list(request) {
- return Promise.resolve(ok(request, { items: ctx.workspace.list().map(workspaceView) }))
+ return Promise.resolve(ok(request, {
+ items: ctx.workspace.list().map(workspaceView),
+ archivedSessionIds: [...ctx.workspace.archivedSessionIds],
+ }))
},
// Exactly one of path/name arrives (schema refine). Existing-folder
@@ -1698,6 +1701,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
return ok(request, { workspace: workspaceView(workspace) })
},
+
+ async archiveSession(request) {
+ const { sessionId } = request.payload
+ try {
+ await ctx.workspace.archiveSession(sessionId)
+ } catch (error: unknown) {
+ // Only the registry's unknown-session rejection is the business
+ // code; storage/durability failures propagate as internal errors.
+ if (!(error instanceof WorkspaceUnknownSessionError)) throw error
+ return err(request, {
+ code: 'session-not-found',
+ message: error.message,
+ details: { sessionId },
+ })
+ }
+ return ok(request, { archivedSessionIds: [...ctx.workspace.archivedSessionIds] })
+ },
},
host: {
@@ -2109,6 +2129,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const committedWorkspaceIds = new Set(
ctx.workspace.list().map(workspace => String(workspace.id)),
)
+ // Frame-dedup baseline, same posture as committedWorkspaceIds: the
+ // stream opens against the current set; workspace.list re-baselines
+ // reconnecting clients, so only later changes need frames.
+ let archivedSessionIds = ctx.workspace.archivedSessionIds
const disposers = [
ctx.on('session/created', (session: Session) => {
queue.push(frame({
@@ -2145,6 +2169,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
committedWorkspaceIds.add(workspaceId)
queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) }))
}
+ if (state.archivedSessionIds.length !== archivedSessionIds.length
+ || state.archivedSessionIds.some((id, index) => id !== archivedSessionIds[index])) {
+ archivedSessionIds = state.archivedSessionIds
+ queue.push(frame({
+ type: 'host/archived-sessions-changed',
+ archivedSessionIds: [...state.archivedSessionIds],
+ }))
+ }
return
}
if (change.table !== 'workspaces') return
diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts
index 186c189879..ea4b6c892f 100644
--- a/packages/host/apiproxy/src/api/events.schema.ts
+++ b/packages/host/apiproxy/src/api/events.schema.ts
@@ -71,6 +71,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }),
z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }),
z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }),
+ z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }),
z.object({ type: z.literal('host/commands-changed') }),
z.object({ type: z.literal('host/settings-changed'), ns: z.string() }),
z.object({ type: z.literal('host/credentials-changed'), ref: z.string() }),
diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts
index d678f2f911..d8ee3f6bff 100644
--- a/packages/host/apiproxy/src/api/events.ts
+++ b/packages/host/apiproxy/src/api/events.ts
@@ -101,7 +101,9 @@ export type MuxFrame =
* workspace mutation (create/attach/order change — the client upserts, while
* `workspace.list` provides the reconnect baseline); workspace-removed is the
* committed registration-deletion increment and never implies directory or
- * session-log deletion.
+ * session-log deletion; archived-sessions-changed pushes the full registry
+ * archive set after every durable change (same full-snapshot posture as
+ * workspace-changed — `workspace.list` re-baselines it on reconnect).
*/
export type HostFrame =
| { type: 'host/session-added'; sessionId: SessionId; blank: boolean; parentSessionId?: SessionId; cwd?: string }
@@ -110,6 +112,7 @@ export type HostFrame =
| { type: 'host/agent-error'; sessionId: SessionId; message: string }
| { type: 'host/workspace-changed'; workspace: WorkspaceView }
| { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] }
+ | { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] }
/**
* The command registry changed (`commands/change` passthrough). Pure
* invalidation signal, no payload: clients refetch `command.list` in the
diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts
index f2f112f7cd..88e2c05575 100644
--- a/packages/host/apiproxy/src/api/rpc-map.ts
+++ b/packages/host/apiproxy/src/api/rpc-map.ts
@@ -42,6 +42,7 @@ export interface RpcMethodMap {
'workspace.rename': WorkspaceApi['rename']
'workspace.delete': WorkspaceApi['delete']
'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore']
+ 'workspace.archiveSession': WorkspaceApi['archiveSession']
'command.list': CommandsApi['list']
'command.execute': CommandsApi['execute']
'skill.list': SkillsApi['list']
diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts
index e16e5339da..20b3038301 100644
--- a/packages/host/apiproxy/src/api/workspace.schema.ts
+++ b/packages/host/apiproxy/src/api/workspace.schema.ts
@@ -28,6 +28,7 @@ export const workspaceListRequestSchema = z.object({}) satisfies z.ZodType>>
/** workspace.create request payload: exactly one of path/name (the contract's create spellings). */
@@ -80,3 +81,13 @@ export const workspaceInsertSessionBeforeRequestSchema = z.object({
export const workspaceInsertSessionBeforeValueSchema = z.object({
workspace: workspaceViewSchema,
}) satisfies z.ZodType>>
+
+/** workspace.archiveSession request payload. */
+export const workspaceArchiveSessionRequestSchema = z.object({
+ sessionId: sessionIdSchema,
+}) satisfies z.ZodType>>
+
+/** workspace.archiveSession response value: the full updated archive set. */
+export const workspaceArchiveSessionValueSchema = z.object({
+ archivedSessionIds: z.array(sessionIdSchema),
+}) satisfies z.ZodType>>
diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts
index ff22d845fb..957c566bbd 100644
--- a/packages/host/apiproxy/src/api/workspace.ts
+++ b/packages/host/apiproxy/src/api/workspace.ts
@@ -37,8 +37,13 @@ export interface WorkspaceView {
/** Workspace-domain unary methods (the map keys workspace.* of RpcMethodMap). */
export interface WorkspaceApi {
- /** Lists all workspaces in the registry's durable display order. */
- list(request: RpcRequest<{}>): Promise>
+ /**
+ * Lists all workspaces in the registry's durable display order, plus the
+ * registry-global archive set (the reconnect baseline of
+ * `host/archived-sessions-changed`). Archived sessions stay in their
+ * workspace's `sessionIds` account; grouping surfaces hide them.
+ */
+ list(request: RpcRequest<{}>): Promise>
/**
* Creates (or idempotently resolves) a workspace. Exactly one of `path` /
@@ -86,4 +91,15 @@ export interface WorkspaceApi {
sessionId: SessionId
beforeSessionId?: SessionId
}>): Promise>
+
+ /**
+ * Adds one session to the registry-global archive set: the session
+ * disappears from every grouping surface but keeps its session log and its
+ * workspace accounting slot (a future unarchive restores its position).
+ * Idempotent for an already archived id. A session neither live nor in
+ * session persistence fails with `session-not-found`. Returns the full
+ * updated set (same snapshot the changed frame carries).
+ */
+ archiveSession(request: RpcRequest<{ sessionId: SessionId }>):
+ Promise>
}
diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts
index 5261658ee0..7b5c1545de 100644
--- a/packages/host/apiproxy/src/fetch/client.ts
+++ b/packages/host/apiproxy/src/fetch/client.ts
@@ -31,6 +31,7 @@ import {
sessionUpdateQueueValueSchema,
} from '../api/sessions.schema.ts'
import {
+ workspaceArchiveSessionValueSchema,
workspaceCreateValueSchema,
workspaceDeleteValueSchema,
workspaceInsertSessionBeforeValueSchema,
@@ -98,6 +99,7 @@ export interface IApiClient {
rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise>>
delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise>>
insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise>>
+ archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise>>
}
commands: {
list(payload: RequestPayload<'command.list'>, signal?: AbortSignal): Promise>>
@@ -163,6 +165,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('workspace.rename', payload, signal),
delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal),
insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal),
+ archiveSession: (payload, signal) => this.callUnary('workspace.archiveSession', payload, signal),
}
readonly commands: IApiClient['commands'] = {
diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts
index 0cf7625a65..6bc060e969 100644
--- a/packages/host/apiproxy/src/fetch/handler.ts
+++ b/packages/host/apiproxy/src/fetch/handler.ts
@@ -33,6 +33,7 @@ import {
hostPickDirectoryRequestSchema,
} from '../api/host.schema.ts'
import {
+ workspaceArchiveSessionRequestSchema,
workspaceCreateRequestSchema,
workspaceDeleteRequestSchema,
workspaceInsertSessionBeforeRequestSchema,
@@ -96,6 +97,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },
'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) },
'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) },
+ 'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) },
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
index 32264114f5..12d76dac71 100644
--- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
@@ -442,4 +442,44 @@ describe('Host Workspace increments', () => {
expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
abort.abort()
})
+
+ it('archives a session into the global set, keeps its accounting, and streams the set once', async () => {
+ const { api } = await harness()
+ const workspace = expectOk(await api.workspace.create(request({ name: 'archive-home' }))).workspace
+ const sessionId = SessionId('session-to-archive')
+ expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
+ expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([])
+
+ const abort = new AbortController()
+ const stream: AsyncIterator> =
+ api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
+ const changed = nextHostFrame(stream)
+ expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds)
+ .toEqual([sessionId])
+ expect(await changed).toMatchObject({
+ payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sessionId] },
+ })
+
+ // Accounting and the session itself are untouched; list re-baselines the set.
+ const listed = expectOk(await api.workspace.list(request({})))
+ expect(listed.archivedSessionIds).toEqual([sessionId])
+ expect(listed.items[0]?.sessionIds).toEqual([sessionId])
+ expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
+
+ // The idempotent repeat emits no second frame: the next observed frame is
+ // the workspace-changed of a later attach, not another archive snapshot.
+ const after = nextHostFrame(stream)
+ expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds)
+ .toEqual([sessionId])
+ const otherSession = SessionId('session-after-archive')
+ expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId: otherSession })))
+ expect((await after).payload.type).not.toBe('host/archived-sessions-changed')
+
+ const missing = await api.workspace.archiveSession(request({ sessionId: SessionId('session-ghost') }))
+ expect(missing.result).toMatchObject({
+ ok: false,
+ error: { code: 'session-not-found', details: { sessionId: 'session-ghost' } },
+ })
+ abort.abort()
+ })
})
diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts
index 1a667bf4a6..6307dfe8f9 100644
--- a/packages/host/apiproxy/tests/client-handler.spec.ts
+++ b/packages/host/apiproxy/tests/client-handler.spec.ts
@@ -66,11 +66,12 @@ function scriptedApi(overrides: {
...overrides.host,
},
workspace: {
- list: r => ok(r, { items: [] }),
+ list: r => ok(r, { items: [], archivedSessionIds: [] }),
create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }),
rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
delete: r => ok(r, { deleted: true as const }),
insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
+ archiveSession: r => ok(r, { archivedSessionIds: [r.payload.sessionId] }),
},
commands: {
list: r => ok(r, { commands: [] }),
@@ -360,10 +361,12 @@ describe('workspace domain round trip', () => {
it('routes both workspace methods through their handler rows and value schemas', async () => {
const c = client(scriptedApi())
const list = await c.workspace.list({})
- expect(list.result).toEqual({ ok: true, value: { items: [] } })
+ expect(list.result).toEqual({ ok: true, value: { items: [], archivedSessionIds: [] } })
const created = await c.workspace.create({ path: '/t' })
expect(created.result.ok).toBe(true)
if (created.result.ok) expect(created.result.value.created).toBe(true)
+ const archivedResponse = await c.workspace.archiveSession({ sessionId: 's-arch' as never })
+ expect(archivedResponse.result).toEqual({ ok: true, value: { archivedSessionIds: ['s-arch'] } })
})
it('rejects a create payload violating the exactly-one refine at the handler', async () => {
diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts
index 3a21d6d1ce..ef111afe12 100644
--- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts
+++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts
@@ -122,7 +122,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
},
workspace: {
async list(request) {
- return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } }
+ return { rpcId: request.rpcId, result: { ok: true, value: { items: [], archivedSessionIds: [] } } }
},
async create(request) {
return {
@@ -145,6 +145,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } },
}
},
+ async archiveSession(request) {
+ return { rpcId: request.rpcId, result: { ok: true, value: { archivedSessionIds: [request.payload.sessionId] } } }
+ },
},
commands: {
async list(request) {
diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts
index aa9c46d9ae..f6bd093178 100644
--- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts
+++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts
@@ -20,6 +20,7 @@ import {
hostListDirectoryRequestSchema, hostListDirectoryValueSchema,
} from '../src/api/host.schema.ts'
import {
+ workspaceArchiveSessionRequestSchema, workspaceArchiveSessionValueSchema,
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema,
workspaceDeleteRequestSchema, workspaceDeleteValueSchema,
workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema,
@@ -312,7 +313,16 @@ describe('workspace domain schemas', () => {
expect(workspaceViewSchema.parse(view).sessionIds).toEqual(['s1'])
expect(() => workspaceViewSchema.parse({ ...view, sessionIds: 's1' })).toThrow()
expect(workspaceListRequestSchema.parse({})).toEqual({})
- expect(workspaceListValueSchema.parse({ items: [view] }).items).toHaveLength(1)
+ expect(workspaceListValueSchema.parse({ items: [view], archivedSessionIds: ['s1'] }).items).toHaveLength(1)
+ expect(() => workspaceListValueSchema.parse({ items: [view] })).toThrow()
+ })
+
+ it('archiveSession request/value carry the id and the full updated set', () => {
+ expect(workspaceArchiveSessionRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
+ expect(() => workspaceArchiveSessionRequestSchema.parse({})).toThrow()
+ expect(workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: ['s1', 's2'] }).archivedSessionIds)
+ .toEqual(['s1', 's2'])
+ expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow()
})
it('create requires exactly one of path/name (both refine arms)', () => {
diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts
index 7fc675601f..6375e69082 100644
--- a/packages/ui/tui/src/components/transcript.ts
+++ b/packages/ui/tui/src/components/transcript.ts
@@ -429,12 +429,15 @@ export class ToolCardComponent implements Component {
const glyph = this.result === undefined ? '○' : '●'
const rawBody = this.renderBody()
const view = this.resultView ?? this.callView
- // A generic card's own content, or a web card's fallback to the raw result
- // content (the `web` view carries no `content` copy), both render as one dim
- // Markdown block below, so links/lists/headings keep the unified dim styling
- // rather than reading as bare text. Terminal and diff cards own their body
- // styling, so they are excluded (mirrors renderBody's post-terminal/diff fallback).
- const markdownContent = view.card === 'generic'
+ // A generic card's own content, or a read card's `content` fallback (the
+ // envelope-stripped file text — the TUI has no dedicated read rendering, so a
+ // read renders exactly as before the read card existed), or a web card's
+ // fallback to the raw result content (the `web` view carries no `content`
+ // copy), all render as one dim Markdown block below, so links/lists/headings
+ // keep the unified dim styling rather than reading as bare text. Terminal and
+ // diff cards own their body styling, so they are excluded (mirrors
+ // renderBody's post-terminal/diff fallback).
+ const markdownContent = view.card === 'generic' || view.card === 'read'
? view.content ?? this.result?.content
: view.card === 'web'
// A web resultView is only assigned alongside this.result (the result
@@ -559,11 +562,12 @@ export class ToolCardComponent implements Component {
// rather than under the dim result-output color.
return { prelude: [...hunks, footer], lines: [] }
}
- // The web card carries no `content` copy, so a `web` result view falls back
- // to the raw result content here (`view.card === 'generic'` narrows the
- // generic union arm; a `web` card takes the same fallback, mirroring the
- // `markdownContent` selection in render()).
- const content = (view.card === 'generic' ? view.content : undefined) ?? this.result?.content
+ // A generic or read card carries its own envelope-stripped `content`; a `web`
+ // card carries no `content` copy and falls back to the raw result content
+ // here. (Mirrors the `markdownContent` selection in render(); a read card has
+ // no dedicated TUI rendering, so its `content` takes the same body path,
+ // keeping read output as it was before the read card existed.)
+ const content = (view.card === 'generic' || view.card === 'read' ? view.content : undefined) ?? this.result?.content
const prelude: string[] = []
const lines: string[] = []
// The presenter title headlines the body now that the header is a fixed
diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml
index a24e81d977..33f0029093 100644
--- a/packages/workspace/workspace/README.i18n.yaml
+++ b/packages/workspace/workspace/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/workspace/workspace/README.md
-README.md: bee3e4fcb5dded273f30942ee2e42ee93b839e62
-README.zh.md: 9a052f796fb7cc8756999bdc9b2ce905805730ab
+README.md: 11dc8172392e530ab4ea16f1b60473e5befb8089
+README.zh.md: 5c6e3cefe31759df27b8008861505527648ce3c4
diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md
index bee3e4fcb5..11dc817239 100644
--- a/packages/workspace/workspace/README.md
+++ b/packages/workspace/workspace/README.md
@@ -12,7 +12,7 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n
- `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups. `list()` is synchronous and follows durable registry order; `resolveByPath` is async because it applies the same `realpath` canon and rejects a missing path rather than creating it.
- `ctx.workspace.delete(id)` — removes only the Workspace registration, its durable order entry, and its session account. Unknown ids return `false`; a removed record returns `true`. The directory, user files, live Sessions, and persisted session logs are never touched, so those Sessions become Ungrouped. A table-write failure restores the prior order and published entity.
- `Workspace.attachSession(id)` — validates a live or persisted session header cwd against the workspace path and prepends a new id. Unknown sessions, absent/unresolvable/non-directory cwd values, and mismatches reject without writing. `detachSession` removes only the candidate index entry.
-- `ctx.workspace.touchSession(id)` — moves only that validated, accounted session to the front. Ungrouped or filtered sessions are no-ops, and workspace order never changes.
+- `ctx.workspace.archiveSession(id)` / `archivedSessionIds` — the registry-global archive set, layered over workspace accounting: an archived session disappears from grouping surfaces but keeps its session log and its `sessionIds` slot, so a future unarchive restores its position. Archiving accepts any live or persisted session (accounted or Ungrouped), resolves without writing for an already archived id, and rejects an unknown id. State written before the field existed parses with an empty set.
- `Workspace.sessionIds` — synchronous id-plus-canonical-cwd membership projection in durable candidate order. Missing headers, invalid cwd values, and mismatches are filtered; the next workspace mutation prunes them. A medium indexing one session under two workspaces, claiming one path from two records, or diverging from durable workspace order rejects at startup.
- `Workspace.status()` — uncached directory check, `'ok' | 'missing-dir'`; a missing directory never mutates the record.
diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md
index 9a052f796f..5c6e3cefe3 100644
--- a/packages/workspace/workspace/README.zh.md
+++ b/packages/workspace/workspace/README.zh.md
@@ -12,7 +12,7 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领
- `ctx.workspace.get(id)`/`list()`/`resolveByPath(path)`:由缓存提供的查找。`list()` 为同步操作,并遵循持久注册表顺序;`resolveByPath` 为异步操作,因为它采用相同的 `realpath` 规范化方式,并会拒绝缺失路径,而不是创建路径。
- `ctx.workspace.delete(id)`:只移除 Workspace 注册记录、对应的持久顺序条目及会话归属记录。未知 id 返回 `false`,成功移除记录则返回 `true`。目录、用户文件、活跃会话和持久化会话日志绝不受影响,因此相关会话会进入 Ungrouped。表写入失败时会恢复原顺序和此前发布的实体。
- `Workspace.attachSession(id)`:对照 workspace 路径验证实时或已持久化的会话头 cwd,并将新 id 前置。未知会话、缺失/无法解析/非目录的 cwd 值和不匹配情况都会在不写入的前提下被拒绝。`detachSession` 只移除候选索引条目。
-- `ctx.workspace.touchSession(id)`:仅将已验证、已记账的会话移到最前。未分组或被过滤的会话不会触发任何操作,workspace 顺序绝不改变。
+- `ctx.workspace.archiveSession(id)`/`archivedSessionIds`:覆盖在 workspace 记账之上的注册表级全局归档集合:被归档的会话从各分组视图中消失,但其会话日志和 `sessionIds` 席位保持不变,未来取消归档时可恢复原位置。归档接受任何实时或已持久化的会话(无论已记账还是 Ungrouped),对已归档的 id 直接完成而不写入,并拒绝未知 id。在该字段出现之前写入的状态解析为一个空集合。
- `Workspace.sessionIds`:按持久候选顺序提供同步 id 加规范 cwd 成员投影。缺失头部、无效 cwd 值和不匹配情况都被过滤;下一次 workspace 变更会剪除它们。如果同一存储介质将一个会话索引到两个 workspace 下、用两条记录声明同一路径,或偏离持久 workspace 顺序,启动会被拒绝。
- `Workspace.status()`:未缓存的目录检查,返回 `'ok' | 'missing-dir'`;目录缺失绝不会改动记录。
diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts
index 5172c63805..5262043b03 100644
--- a/packages/workspace/workspace/src/index.ts
+++ b/packages/workspace/workspace/src/index.ts
@@ -49,6 +49,20 @@ export class WorkspaceNameConflictError extends Error {
}
}
+/**
+ * An archiveSession request named a session neither live nor in session
+ * persistence — a definite miss only; storage faults propagate as themselves.
+ */
+export class WorkspaceUnknownSessionError extends Error {
+ /**
+ * @param sessionId - The unknown session id.
+ */
+ constructor(readonly sessionId: SessionId) {
+ super(`cannot archive session '${sessionId}': live sessions and session persistence hold no such session`)
+ this.name = 'WorkspaceUnknownSessionError'
+ }
+}
+
declare module 'cordis' {
interface Context {
@@ -181,6 +195,49 @@ export class WorkspaceRegistry extends Service {
return this.enqueueOperation(() => this.deleteKnown(id))
}
+ /**
+ * The registry-global archive set: sessions hidden from every grouping
+ * surface. Archiving never touches workspace accounting — an archived
+ * session keeps its `sessionIds` slot so unarchiving restores its position.
+ * @returns the archived session ids in archive order.
+ */
+ get archivedSessionIds(): readonly SessionId[] {
+ return this.requireState().archivedSessionIds
+ }
+
+ /**
+ * Archive one session durably. The session must exist (live or in session
+ * persistence); its workspace accounting — or lack of one — is irrelevant.
+ * An already archived id resolves without writing.
+ * @param sessionId - The session to archive.
+ * @returns resolution after durability.
+ */
+ archiveSession(sessionId: SessionId): Promise {
+ return this.enqueueOperation(async () => {
+ // The chain slot serializes against every other registry write, so this
+ // check-then-write pair cannot interleave with another archive.
+ if (this.requireState().archivedSessionIds.includes(sessionId)) return
+ if (!(await this.sessionKnown(sessionId))) {
+ throw new WorkspaceUnknownSessionError(sessionId)
+ }
+ const state = this.requireState()
+ await this.setState({ ...state, archivedSessionIds: [...state.archivedSessionIds, sessionId] })
+ })
+ }
+
+ /**
+ * Whether a session is live, header-indexed, or present in a fresh
+ * persistence listing. Only a definite miss returns false — a failing
+ * `sessionPersistence.list()` propagates so storage faults never
+ * masquerade as an unknown session.
+ */
+ private async sessionKnown(id: SessionId): Promise {
+ if (this.ctx.get('sessions')?.get(id) !== undefined) return true
+ if (this.headers.has(id)) return true
+ await this.indexHeaders(await this.ctx.sessionPersistence.list())
+ return this.headers.has(id)
+ }
+
/**
* Resolve by canonical directory path without creating or mutating a
* workspace. A missing path rejects during `realpath`; an existing unowned
@@ -245,7 +302,11 @@ export class WorkspaceRegistry extends Service {
}
try {
- await this.setState({ initialized: true, workspaceIds: [id, ...state.workspaceIds] })
+ await this.setState({
+ initialized: true,
+ workspaceIds: [id, ...state.workspaceIds],
+ archivedSessionIds: state.archivedSessionIds,
+ })
} catch (error) {
this.entities.delete(id)
try {
@@ -276,6 +337,7 @@ export class WorkspaceRegistry extends Service {
const nextState = {
initialized: true,
workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id),
+ archivedSessionIds: state.archivedSessionIds,
}
await this.setState({
...nextState,
@@ -329,7 +391,11 @@ export class WorkspaceRegistry extends Service {
)
}
await this.requireTable().delete(pending.workspaceId)
- await this.setState({ initialized: state.initialized, workspaceIds: state.workspaceIds })
+ await this.setState({
+ initialized: state.initialized,
+ workspaceIds: state.workspaceIds,
+ archivedSessionIds: state.archivedSessionIds,
+ })
}
private async bootstrap(headers: readonly SessionHeader[]): Promise {
@@ -411,9 +477,9 @@ export class WorkspaceRegistry extends Service {
.map(([id]) => id)
if (!sameIds(state.workspaceIds, workspaceIds)) {
- await this.setState({ initialized: false, workspaceIds })
+ await this.setState({ initialized: false, workspaceIds, archivedSessionIds: state.archivedSessionIds })
}
- await this.setState({ initialized: true, workspaceIds })
+ await this.setState({ initialized: true, workspaceIds, archivedSessionIds: state.archivedSessionIds })
}
private validateStoredState(state: WorkspaceDomainState): void {
diff --git a/packages/workspace/workspace/src/spec.ts b/packages/workspace/workspace/src/spec.ts
index 7b1a6a41d0..ba1b89e39d 100644
--- a/packages/workspace/workspace/src/spec.ts
+++ b/packages/workspace/workspace/src/spec.ts
@@ -42,11 +42,16 @@ const workspacePendingMutation = z.discriminatedUnion('operation', [
/**
* Durable registry state. `initialized` distinguishes a valid empty registry
* from one that still needs the header-only history bootstrap;
- * `workspaceIds` is the authoritative display order.
+ * `workspaceIds` is the authoritative display order. `archivedSessionIds` is
+ * the registry-global archive set layered over workspace accounting: an
+ * archived session keeps its `sessionIds` slot (unarchiving must restore the
+ * position), so the set never participates in the one-owner accounting
+ * invariant. Defaulted so records written before the field parse unchanged.
*/
export const workspaceDomainState = z.object({
initialized: z.boolean(),
workspaceIds: z.array(workspaceId),
+ archivedSessionIds: z.array(z.string().transform(SessionId)).default([]),
pendingMutation: workspacePendingMutation.optional(),
})
@@ -64,7 +69,7 @@ export const workspaceDomainSpec = defineDomain({
version: 2,
global: {
schema: workspaceDomainState,
- initial: { initialized: false, workspaceIds: [] },
+ initial: { initialized: false, workspaceIds: [], archivedSessionIds: [] },
},
tables: { workspaces: domainTable(workspaceRecord) },
})
diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts
index 4576155f3b..ae4567b27e 100644
--- a/packages/workspace/workspace/tests/workspace.spec.ts
+++ b/packages/workspace/workspace/tests/workspace.spec.ts
@@ -135,9 +135,16 @@ function record(path: string, sessionIds: string[], createdAt = '2026-07-24T00:0
}
}
+/**
+ * Media written before archivedSessionIds existed omit the field; keeping the
+ * fixtures in that shape continuously proves the schema default upgrades them.
+ */
+type StoredDomainState = Omit
+ & Partial>
+
function storedPool(
entries: Array<[string, WorkspaceRecord]>,
- state: WorkspaceDomainState,
+ state: StoredDomainState,
): MemoryMediaPool {
const pool = new MemoryMediaPool()
pool.versions.set('workspace', DOMAIN_VERSION)
@@ -185,7 +192,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => {
await fiber.await()
expect(ctx.workspace.list()).toEqual([])
expect(list).toHaveBeenCalledTimes(1)
- expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [] })
+ expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] })
})
it('bootstraps once from list headers only, in workspace/session createdAt order', async () => {
@@ -218,6 +225,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => {
expect(storedState(result.pool)).toEqual({
initialized: true,
workspaceIds: result.registry.list().map(workspace => workspace.id),
+ archivedSessionIds: [],
})
})
@@ -246,7 +254,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => {
const second = await harness({ pool, sessions: [header('late', late, 100)] })
expect(second.list).not.toHaveBeenCalled()
expect(second.registry.list()).toEqual([])
- expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [] })
+ expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] })
})
it('reuses partial records after a bootstrap record write fails', async () => {
@@ -476,7 +484,7 @@ describe('WorkspaceRegistry create and lookup', () => {
await expect(result.registry.delete(workspace.id)).resolves.toBe(false)
expect(result.registry.get(workspace.id)).toBeUndefined()
expect(result.registry.list()).toEqual([])
- expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: [] })
+ expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] })
expect(result.pool.media.get('workspace')!.tables.get('workspaces')!.has(workspace.id)).toBe(false)
await expect(realpath(dir)).resolves.toBe(dir)
expect(result.list).toHaveBeenCalledTimes(1)
@@ -519,6 +527,7 @@ describe('WorkspaceRegistry create and lookup', () => {
expect(storedState(pool)).toEqual({
initialized: true,
workspaceIds: [],
+ archivedSessionIds: [],
pendingMutation: { operation: 'delete', workspaceId: workspace.id },
})
const reregistered = await first.registry.create(dir)
@@ -526,6 +535,7 @@ describe('WorkspaceRegistry create and lookup', () => {
expect(storedState(pool)).toEqual({
initialized: true,
workspaceIds: [reregistered.id],
+ archivedSessionIds: [],
})
await first.fiber.dispose()
@@ -762,7 +772,7 @@ describe('header-validated membership projection', () => {
const createRecovery = await harness({ pool: interruptedCreate })
expect(createRecovery.registry.list()).toEqual([])
expect(interruptedCreate.media.get('workspace')!.tables.get('workspaces')!.has(createId)).toBe(false)
- expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [] })
+ expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] })
const interruptedDelete = storedPool(
[[deleteId, record(deleteDir, [])]],
@@ -775,7 +785,7 @@ describe('header-validated membership projection', () => {
const deleteRecovery = await harness({ pool: interruptedDelete })
expect(deleteRecovery.registry.list()).toEqual([])
expect(interruptedDelete.media.get('workspace')!.tables.get('workspaces')!.has(deleteId)).toBe(false)
- expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [] })
+ expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] })
const corruptPending = storedPool(
[[deleteId, record(deleteDir, [])]],
@@ -816,3 +826,74 @@ describe('workspace mutation and status', () => {
expect(registry.get(workspace.id)).toBe(workspace)
})
})
+
+describe('registry-global session archive', () => {
+ it('archives durably in order, idempotently skips repeats, and leaves accounting untouched', async () => {
+ const dir = await makeDir('archive-home')
+ const result = await harness({ sessions: [header('kept', dir, 100), header('gone', dir, 200)] })
+ const workspace = result.registry.list()[0]!
+ expect(result.registry.archivedSessionIds).toEqual([])
+
+ await result.registry.archiveSession(SessionId('gone'))
+ expect(result.registry.archivedSessionIds).toEqual(['gone'])
+ // Archiving is a display-set write: the workspace account keeps the id.
+ expect(workspace.sessionIds).toContain('gone')
+ expect(storedState(result.pool).archivedSessionIds).toEqual(['gone'])
+ const changesAfterFirst = result.changes.filter(change => change.table === '').length
+
+ await result.registry.archiveSession(SessionId('gone'))
+ expect(result.registry.archivedSessionIds).toEqual(['gone'])
+ // The idempotent repeat neither rewrites the medium nor emits a change.
+ expect(result.changes.filter(change => change.table === '').length).toBe(changesAfterFirst)
+
+ await result.registry.archiveSession(SessionId('kept'))
+ expect(result.registry.archivedSessionIds).toEqual(['gone', 'kept'])
+ })
+
+ it('accepts unaccounted and live sessions but rejects unknown ids without writing', async () => {
+ const dir = await makeDir('archive-strays')
+ const live = await makeDir('archive-live')
+ const result = await harness({
+ sessions: [header('stray', dir, 100)],
+ liveSessions: [header('live-only', live, 200)],
+ })
+ await result.registry.archiveSession(SessionId('stray'))
+ await result.registry.archiveSession(SessionId('live-only'))
+ expect(result.registry.archivedSessionIds).toEqual(['stray', 'live-only'])
+
+ await expect(result.registry.archiveSession(SessionId('ghost')))
+ .rejects.toThrow(/cannot archive session 'ghost'/)
+ expect(storedState(result.pool).archivedSessionIds).toEqual(['stray', 'live-only'])
+ })
+
+ it('propagates a persistence-listing failure instead of reporting an unknown session', async () => {
+ const result = await harness({ sessions: [] })
+ result.list.mockRejectedValueOnce(new Error('persistence backend down'))
+ // The storage fault is the error — never WorkspaceUnknownSessionError,
+ // which the API layer would misreport as session-not-found.
+ await expect(result.registry.archiveSession(SessionId('unlisted')))
+ .rejects.toThrow(/persistence backend down/)
+ expect(storedState(result.pool).archivedSessionIds).toEqual([])
+ })
+
+ it('restores the archive set across restarts and defaults it for pre-field media', async () => {
+ const dir = await makeDir('archive-restart')
+ const pool = new MemoryMediaPool()
+ const first = await harness({ pool, sessions: [header('s1', dir, 100)] })
+ await first.registry.archiveSession(SessionId('s1'))
+ await first.fiber.dispose()
+
+ const second = await harness({ pool, sessions: [header('s1', dir, 100)] })
+ expect(second.registry.archivedSessionIds).toEqual(['s1'])
+ await second.fiber.dispose()
+
+ // A medium written before the field existed parses through the schema default.
+ const legacyId = WorkspaceId('00000000-0000-4000-8000-00000000000a')
+ const legacy = storedPool(
+ [[legacyId, record(dir, [])]],
+ { initialized: true, workspaceIds: [legacyId] },
+ )
+ const upgraded = await harness({ pool: legacy })
+ expect(upgraded.registry.archivedSessionIds).toEqual([])
+ })
+})