refactor(client): remove dead sidebar ordering surfaces

This commit is contained in:
_Kerman
2026-08-11 17:51:18 +08:00
parent ed39516c20
commit 7222a99e33
29 changed files with 71 additions and 123 deletions

View File

@@ -1387,11 +1387,10 @@ interface FixtureWorld {
/** Build the fixture's legacy API and Remote RPC faces over one state graph. */ /** Build the fixture's legacy API and Remote RPC faces over one state graph. */
function createFixtureWorld(options: FixtureOptions): FixtureWorld { function createFixtureWorld(options: FixtureOptions): FixtureWorld {
// The resident fixture sessions all carry history, so none of them is blank. // The resident fixture sessions all carry history, so none of them is blank.
const fixtureSessionsNow = Date.now()
const sessions: SessionSummary[] = options.empty ? [] : [ const sessions: SessionSummary[] = options.empty ? [] : [
{ sessionId: sid('fx-alpha'), createdAt: fixtureSessionsNow - 180_000, updatedAt: fixtureSessionsNow, running: true, blank: false, cwd: '/tmp/fixture' }, { sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' },
{ sessionId: sid('fx-beta'), createdAt: fixtureSessionsNow - 120_000, updatedAt: fixtureSessionsNow - 60_000, running: false, blank: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' }, { sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, blank: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' },
{ sessionId: sid('fx-gamma'), createdAt: fixtureSessionsNow - 60_000, updatedAt: fixtureSessionsNow - 120_000, running: false, blank: false, cwd: '/tmp/fixture' }, { sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' },
] ]
const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]]) const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
const modelSelections = new Map<SessionId, ModelSelection>(sessions.map(session => [ const modelSelections = new Map<SessionId, ModelSelection>(sessions.map(session => [
@@ -2047,16 +2046,15 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
return ok(request, { sessionId: requestedId }) return ok(request, { sessionId: requestedId })
} }
} }
const createdAt = Date.now()
const created: SessionSummary = { const created: SessionSummary = {
sessionId: requestedId ?? sid(`fx-${nextSession++}`), createdAt, updatedAt: createdAt, running: false, blank: true, cwd, sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd,
} }
sessions.push(created) sessions.push(created)
modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' })
attachedSessions += 1 attachedSessions += 1
const emitSession = (): void => { const emitSession = (): void => {
// Mirrors the host: the frame fires at creation, so blank is constantly true. // Mirrors the host: the frame fires at creation, so blank is constantly true.
emitHost({ type: 'host/session-added', sessionId: created.sessionId, createdAt, blank: true, cwd }) emitHost({ type: 'host/session-added', sessionId: created.sessionId, blank: true, cwd })
} }
if (workspace !== undefined && options.failWorkspaceAttach) { if (workspace !== undefined && options.failWorkspaceAttach) {
emitSession() emitSession()
@@ -2123,16 +2121,15 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
} }
let cut = boundary.seq + 1 let cut = boundary.seq + 1
while (cut < log.length && log[cut]?.type !== 'turn/start') cut++ while (cut < log.length && log[cut]?.type !== 'turn/start') cut++
const createdAt = Date.now()
const child: SessionSummary = { const child: SessionSummary = {
sessionId: sid(`fx-${nextSession++}`), createdAt, updatedAt: createdAt, running: false, blank: false, sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false,
parentSessionId: sessionId, parentSessionId: sessionId,
...source.cwd === undefined ? {} : { cwd: source.cwd }, ...source.cwd === undefined ? {} : { cwd: source.cwd },
} }
logs.set(child.sessionId, log.slice(0, cut)) logs.set(child.sessionId, log.slice(0, cut))
sessions.push(child) sessions.push(child)
emitHost({ emitHost({
type: 'host/session-added', sessionId: child.sessionId, createdAt, blank: false, type: 'host/session-added', sessionId: child.sessionId, blank: false,
parentSessionId: sessionId, parentSessionId: sessionId,
...source.cwd === undefined ? {} : { cwd: source.cwd }, ...source.cwd === undefined ? {} : { cwd: source.cwd },
}) })

View File

@@ -267,9 +267,8 @@ describe('createFixtureApi', () => {
expect(seen).toHaveLength(1) expect(seen).toHaveLength(1)
const added = seen[0] const added = seen[0]
if (added?.type !== 'host/session-added') throw new Error('session-added frame missing') if (added?.type !== 'host/session-added') throw new Error('session-added frame missing')
expect(Number.isFinite(added.createdAt)).toBe(true)
expect(added).toEqual({ expect(added).toEqual({
type: 'host/session-added', sessionId: createdId, createdAt: added.createdAt, blank: true, cwd: '/tmp/fixture', type: 'host/session-added', sessionId: createdId, blank: true, cwd: '/tmp/fixture',
}) })
const list = await api.sessions.list(req({})) const list = await api.sessions.list(req({}))
if (!list.result.ok) throw new Error('list failed') if (!list.result.ok) throw new Error('list failed')
@@ -707,9 +706,8 @@ describe('createFixtureApi', () => {
// write pushes the fresh workspace snapshot after session-added. // write pushes the fresh workspace snapshot after session-added.
const added = seen[0] const added = seen[0]
if (added?.type !== 'host/session-added') throw new Error('session-added frame missing') if (added?.type !== 'host/session-added') throw new Error('session-added frame missing')
expect(Number.isFinite(added.createdAt)).toBe(true)
expect(added).toEqual({ expect(added).toEqual({
type: 'host/session-added', sessionId: id, createdAt: added.createdAt, blank: true, cwd: '/tmp/fixture', type: 'host/session-added', sessionId: id, blank: true, cwd: '/tmp/fixture',
}) })
expect(seen[1]).toMatchObject({ expect(seen[1]).toMatchObject({
type: 'host/workspace-changed', type: 'host/workspace-changed',
@@ -741,9 +739,8 @@ describe('createFixtureApi', () => {
}) })
const added = frames[1] const added = frames[1]
if (added?.type !== 'host/session-added') throw new Error('session-added frame missing') if (added?.type !== 'host/session-added') throw new Error('session-added frame missing')
expect(Number.isFinite(added.createdAt)).toBe(true)
expect(added).toEqual({ expect(added).toEqual({
type: 'host/session-added', sessionId: preallocated, createdAt: added.createdAt, blank: true, type: 'host/session-added', sessionId: preallocated, blank: true,
cwd: made.result.value.workspace.path, cwd: made.result.value.workspace.path,
}) })

View File

@@ -16,7 +16,6 @@ export interface SessionsPortSummary {
/** Empty-log bit (blank sessions are reused by New Session instead of minting another). */ /** Empty-log bit (blank sessions are reused by New Session instead of minting another). */
blank: boolean blank: boolean
cwd?: string cwd?: string
createdAt: number
updatedAt: number updatedAt: number
} }

View File

@@ -17,7 +17,6 @@ export interface TitledSessionSummary extends SessionSummary {
export interface SessionListEntry { export interface SessionListEntry {
sessionId: SessionId sessionId: SessionId
title?: string title?: string
createdAt: number
updatedAt: number updatedAt: number
running: boolean running: boolean
/** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */ /** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */
@@ -78,7 +77,6 @@ export function flattenLineage(
const pendingInteraction = pendingInteractions?.get(s.sessionId) const pendingInteraction = pendingInteractions?.get(s.sessionId)
out.push({ out.push({
...s, ...s,
createdAt: s.createdAt ?? s.updatedAt,
...(pendingInteraction === undefined ? {} : { pendingInteraction }), ...(pendingInteraction === undefined ? {} : { pendingInteraction }),
completed: completed?.has(s.sessionId) ?? false, completed: completed?.has(s.sessionId) ?? false,
depth, depth,

View File

@@ -541,9 +541,8 @@ export class SessionManager {
: { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared } : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared }
const { result } = await this.api.sessions.create(payload) const { result } = await this.api.sessions.create(payload)
if (result.ok) { if (result.ok) {
const createdAt = Date.now()
this.recordMutation({ kind: 'upsert', summary: { this.recordMutation({ kind: 'upsert', summary: {
sessionId: result.value.sessionId, createdAt, updatedAt: createdAt, running: false, blank: true, sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
...(result.value.agentPreset !== undefined ? { agentPreset: result.value.agentPreset } : {}), ...(result.value.agentPreset !== undefined ? { agentPreset: result.value.agentPreset } : {}),
} }) } })
@@ -553,11 +552,9 @@ export class SessionManager {
// so expose it immediately as Ungrouped while the caller keeps the // so expose it immediately as Ungrouped while the caller keeps the
// prompt buffer and decides whether to retry attachment. // prompt buffer and decides whether to retry attachment.
if (publishedSessionId !== undefined) { if (publishedSessionId !== undefined) {
const createdAt = Date.now()
this.recordMutation({ kind: 'upsert', summary: { this.recordMutation({ kind: 'upsert', summary: {
sessionId: publishedSessionId, sessionId: publishedSessionId,
createdAt, updatedAt: Date.now(),
updatedAt: createdAt,
running: false, running: false,
blank: true, blank: true,
} }) } })
@@ -591,9 +588,8 @@ export class SessionManager {
? result.value.sessionId ? result.value.sessionId
: workspaceAttachSessionId(result.error) : workspaceAttachSessionId(result.error)
if (childId !== undefined) { if (childId !== undefined) {
const createdAt = Date.now()
this.recordMutation({ kind: 'upsert', summary: { this.recordMutation({ kind: 'upsert', summary: {
sessionId: childId, createdAt, updatedAt: createdAt, running: false, blank: false, sessionId: childId, updatedAt: Date.now(), running: false, blank: false,
parentSessionId: opts.sessionId, parentSessionId: opts.sessionId,
...(source?.cwd !== undefined ? { cwd: source.cwd } : {}), ...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
} }) } })
@@ -620,9 +616,8 @@ export class SessionManager {
* @param agentPreset - the preset id the host confirmed. * @param agentPreset - the preset id the host confirmed.
*/ */
noteAgentPreset(sessionId: SessionId, agentPreset: string): void { noteAgentPreset(sessionId: SessionId, agentPreset: string): void {
const createdAt = Date.now()
this.recordMutation({ kind: 'upsert', summary: { this.recordMutation({ kind: 'upsert', summary: {
sessionId, createdAt, updatedAt: createdAt, running: false, blank: true, agentPreset, sessionId, updatedAt: Date.now(), running: false, blank: true, agentPreset,
} }) } })
} }
@@ -799,10 +794,8 @@ export class SessionManager {
const frame = envelope.payload const frame = envelope.payload
switch (frame.type) { switch (frame.type) {
case 'host/session-added': { case 'host/session-added': {
const createdAt = frame.createdAt ?? Date.now()
this.mergeSummary({ this.mergeSummary({
sessionId: frame.sessionId, createdAt, updatedAt: createdAt, sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank,
running: false, blank: frame.blank,
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}), ...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
...(frame.origin !== undefined ? { origin: frame.origin } : {}), ...(frame.origin !== undefined ? { origin: frame.origin } : {}),
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}), ...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
@@ -1055,8 +1048,7 @@ export class SessionManager {
const items = fresh.map((entry) => { const items = fresh.map((entry) => {
const prev = this.entryCache.get(entry.sessionId) const prev = this.entryCache.get(entry.sessionId)
if ( if (
prev !== undefined && prev.createdAt === entry.createdAt prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
&& prev.updatedAt === entry.updatedAt && prev.running === entry.running
&& prev.blank === entry.blank && prev.agentPreset === entry.agentPreset && prev.blank === entry.blank && prev.agentPreset === entry.agentPreset
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth

View File

@@ -66,8 +66,6 @@ export interface SessionSummary {
* selected blank entry. * selected blank entry.
*/ */
blank: boolean blank: boolean
/** Durable session creation time. */
createdAt: number
updatedAt: number updatedAt: number
/** Current host-computed projection values retained by the object layer. */ /** Current host-computed projection values retained by the object layer. */
projectionValues?: Readonly<Partial<SessionProjectionMap>> projectionValues?: Readonly<Partial<SessionProjectionMap>>
@@ -669,7 +667,6 @@ export class SessionsService implements ISessions {
running: entry.running, running: entry.running,
...(entry.completed ? { completed: true } : {}), ...(entry.completed ? { completed: true } : {}),
blank: entry.blank, blank: entry.blank,
createdAt: entry.createdAt,
updatedAt: entry.updatedAt, updatedAt: entry.updatedAt,
...(entry.pendingInteraction === undefined ...(entry.pendingInteraction === undefined
? {} ? {}
@@ -703,7 +700,6 @@ export class SessionsService implements ISessions {
origin: 'subagent', origin: 'subagent',
running: child.activity === 'running', running: child.activity === 'running',
blank: false, blank: false,
createdAt: 0,
updatedAt: 0, updatedAt: 0,
} }
} else if (summary.displayTitle !== displayTitle) { } else if (summary.displayTitle !== displayTitle) {

View File

@@ -11,7 +11,7 @@ function summary(
running = false, running = false,
): SessionSummary { ): SessionSummary {
return { return {
id: sid(id), displayTitle: id, running, blank: false, createdAt: 0, updatedAt: 0, id: sid(id), displayTitle: id, running, blank: false, updatedAt: 0,
...(parentId === undefined ? {} : { parentId }), ...(parentId === undefined ? {} : { parentId }),
...(origin === undefined ? {} : { origin }), ...(origin === undefined ? {} : { origin }),
} }

View File

@@ -233,7 +233,6 @@ export class TestSessions implements ISessions {
displayTitle: fixture.id, displayTitle: fixture.id,
running: false, running: false,
blank: false, blank: false,
createdAt: this.records.size + 1,
updatedAt: this.records.size + 1, updatedAt: this.records.size + 1,
...fixture.summary, ...fixture.summary,
} }

View File

@@ -99,10 +99,10 @@ function mount(
} = {}, } = {},
) { ) {
const root = sid('root') const root = sid('root')
const rootRow = { id: root, displayTitle: 'Root', running: false, blank: false, createdAt: 1, updatedAt: 1 } const rootRow = { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 }
const childRow = { const childRow = {
id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one',
running: false, blank: options.summaryBlank ?? false, createdAt: 2, updatedAt: 2, running: false, blank: options.summaryBlank ?? false, updatedAt: 2,
...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }), ...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }),
} }
const listed = options.omitSummaryRow !== true const listed = options.omitSummaryRow !== true

View File

@@ -56,7 +56,6 @@ function props(
displayTitle: 'worker', displayTitle: 'worker',
running: true, running: true,
blank: false, blank: false,
createdAt: Date.now(),
updatedAt: Date.now(), updatedAt: Date.now(),
}, },
}, },
@@ -84,7 +83,6 @@ function summary(id: SessionId, updatedAt: number): SessionSummary {
displayTitle: id, displayTitle: id,
running: false, running: false,
blank: false, blank: false,
createdAt: updatedAt,
updatedAt, updatedAt,
} }
} }

View File

@@ -110,7 +110,7 @@ async function bench(snapshot: ConversationSnapshot) {
const session = createSnapshotStore<ConversationSnapshot>(snapshot) const session = createSnapshotStore<ConversationSnapshot>(snapshot)
const list = createSnapshotStore<SessionListState>({ const list = createSnapshotStore<SessionListState>({
ids: [SID], ids: [SID],
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, createdAt: 1, updatedAt: 1 } }, byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
current: SID, current: SID,
phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
}) })

View File

@@ -26,7 +26,7 @@ function listStore() {
return createSnapshotStore<SessionListState>({ return createSnapshotStore<SessionListState>({
ids: [SID], ids: [SID],
byId: { byId: {
[SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, blank: false, createdAt: 0, updatedAt: 0 }, [SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 },
}, },
current: undefined, current: undefined,
phase: 'ready', phase: 'ready',

View File

@@ -156,7 +156,7 @@ describe('chat row diff body', () => {
describe('FileMutationRow diff card', () => { describe('FileMutationRow diff card', () => {
const list = () => createSnapshotStore<SessionListState>({ const list = () => createSnapshotStore<SessionListState>({
ids: [SID], ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, createdAt: 0, updatedAt: 0, cwd: '/w/app' } }, byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
current: SID, current: SID,
phase: 'ready', phase: 'ready',
subagentsByParent: {}, tasksBySession: {}, subagentsByParent: {}, tasksBySession: {},
@@ -314,7 +314,7 @@ describe('DetailsPanel diff Output section', () => {
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined } ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }
: { : {
ids: [SID], ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, createdAt: 0, updatedAt: 0, cwd } }, byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
current: SID, current: SID,
phase: 'ready', phase: 'ready',
subagentsByParent: {}, tasksBySession: {}, subagentsByParent: {}, tasksBySession: {},

View File

@@ -170,7 +170,7 @@ describe('GenericToolCard read body', () => {
describe('ReadRow keyed toolview', () => { describe('ReadRow keyed toolview', () => {
const list = () => createSnapshotStore<SessionListState>({ const list = () => createSnapshotStore<SessionListState>({
ids: [SID], ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, createdAt: 0, updatedAt: 0, cwd: '/w/app' } }, byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
current: SID, current: SID,
phase: 'ready', phase: 'ready',
subagentsByParent: {}, tasksBySession: {}, subagentsByParent: {}, tasksBySession: {},
@@ -260,7 +260,7 @@ describe('DetailsPanel Output section (read)', () => {
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined } ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }
: { : {
ids: [SID], ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, createdAt: 0, updatedAt: 0, cwd } }, byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
current: SID, current: SID,
phase: 'ready', phase: 'ready',
subagentsByParent: {}, tasksBySession: {}, subagentsByParent: {}, tasksBySession: {},

View File

@@ -345,7 +345,7 @@ describe('chat row terminal body', () => {
describe('BashRow terminal card', () => { describe('BashRow terminal card', () => {
const list = () => createSnapshotStore<SessionListState>({ const list = () => createSnapshotStore<SessionListState>({
ids: [SID], ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, createdAt: 0, updatedAt: 0 } }, byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
current: undefined, current: undefined,
phase: 'ready', phase: 'ready',
subagentsByParent: {}, tasksBySession: {}, subagentsByParent: {}, tasksBySession: {},
@@ -451,7 +451,7 @@ describe('DetailsPanel Output section', () => {
? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined } ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }
: { : {
ids: [SID], ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, createdAt: 0, updatedAt: 0, cwd } }, byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
current: SID, current: SID,
phase: 'ready', phase: 'ready',
subagentsByParent: {}, tasksBySession: {}, subagentsByParent: {}, tasksBySession: {},

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
README.md: 24d73beba56f527eb57276accb2693255bad9020 README.md: 359979fa26b0e12c7e28b929029fb7d4e9fca324
README.zh.md: 85a5ce9210dd084e6e6e0746eb19aac12d98d54d README.zh.md: 2139ea3cf7ec3ae84aaa84e181d2f36a60c9ba60

View File

@@ -6,7 +6,7 @@ Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar
The browser renders grouped or flat Session rows from the global runtime hooks and owns Workspace add/rename/reorder plus in-Workspace Session reorder. A Workspace remembers whether it is closed or showing Sessions; an open Workspace shows five Sessions by default, offers a transient **Show more** control for the remainder, and returns to five after the whole Workspace is closed and reopened. View options combine grouping with one browser-persisted Session order: entering **Last updated** performs a complete recency sort and later user prompts or steers promote their Session once, while entering **Manual** preserves every current position and disables later promotion. Dragging edits the current order in either mode; Manual-mode drags also update the Host Workspace account. Workspace drag order is Host-durable in either Session order mode. The browser renders grouped or flat Session rows from the global runtime hooks and owns Workspace add/rename/reorder plus in-Workspace Session reorder. A Workspace remembers whether it is closed or showing Sessions; an open Workspace shows five Sessions by default, offers a transient **Show more** control for the remainder, and returns to five after the whole Workspace is closed and reopened. View options combine grouping with one browser-persisted Session order: entering **Last updated** performs a complete recency sort and later user prompts or steers promote their Session once, while entering **Manual** preserves every current position and disables later promotion. Dragging edits the current order in either mode; Manual-mode drags also update the Host Workspace account. Workspace drag order is Host-durable in either Session order mode.
Collapsed search is one header action beside the view and add actions. Activating it expands the field across the header; an outside click collapses only an empty query, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. Collapsed search is one header action beside the view and add actions. Activating it expands the field across the header; an outside click collapses only a query that is empty after trimming, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail exposes the full path. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail exposes the full path. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands.

View File

@@ -6,7 +6,7 @@
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Workspace 内的 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session;打开后默认显示五条 Session,其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。视图选项把分组方式和一份浏览器持久化的 Session 顺序放在一起:进入**最近更新**时执行一次完整的时间排序,后续 user prompt 或 steer 会将对应 Session 置顶一次;进入**手动排序**则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序,手动模式下的拖拽还会更新 Host Workspace 记账。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。 该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Workspace 内的 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session;打开后默认显示五条 Session,其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。视图选项把分组方式和一份浏览器持久化的 Session 顺序放在一起:进入**最近更新**时执行一次完整的时间排序,后续 user prompt 或 steer 会将对应 Session 置顶一次;进入**手动排序**则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序,手动模式下的拖拽还会更新 Host Workspace 记账。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。
折叠搜索是视图和添加操作旁的一枚区头按钮。激活后,输入框会扩展并占据区头;点击外部只会收起空查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 折叠搜索是视图和添加操作旁的一枚区头按钮。激活后,输入框会扩展并占据区头;点击外部只会收起经清除首尾空白后为空的查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范化路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace;侧边栏的悬停详情会显示完整路径。每个注册各自声明一个**目录流子 slot**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在当前界面的 slot 被占用时渲染(每次菜单渲染读取占用状态;slot 为空意味着该组合没有目录选择能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。空白的「新会话」行只是占位符:不渲染行菜单和时间标签(其中还没有发生任何事),重命名、fork 和归档都从首条提示词落地后才可用。 该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范化路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace;侧边栏的悬停详情会显示完整路径。每个注册各自声明一个**目录流子 slot**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在当前界面的 slot 被占用时渲染(每次菜单渲染读取占用状态;slot 为空意味着该组合没有目录选择能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。空白的「新会话」行只是占位符:不渲染行菜单和时间标签(其中还没有发生任何事),重命名、fork 和归档都从首条提示词落地后才可用。

View File

@@ -38,19 +38,6 @@
background: var(--dsw-alias-interactive-bg-hover); background: var(--dsw-alias-interactive-bg-hover);
} }
.viewOptionLabel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.viewOptionCheck {
flex: none;
color: var(--dsw-alias-label-primary);
}
/* Section header: title, an inline search control, and the two trailing /* Section header: title, an inline search control, and the two trailing
actions. Expanding search collapses the action cluster and takes its room. */ actions. Expanding search collapses the action cluster and takes its room. */
.sectionHeader { .sectionHeader {

View File

@@ -12,7 +12,7 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { import {
Button, IconCheckOutline16, IconCloseFill14, IconPersonalizationOutline16, Button, IconCloseFill14, IconPersonalizationOutline16,
IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip, IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives' } from '@deepseek-ai/dsh-client-ui-primitives'
import type { import type {
@@ -89,24 +89,19 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
t: WorkspaceBrowserProps['t'] t: WorkspaceBrowserProps['t']
}) { }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const optionLabel = (label: string, selected: boolean) => (
<span className={css.viewOptionLabel}>
<span>{label}</span>
{selected && <IconCheckOutline16 className={css.viewOptionCheck} />}
</span>
)
return ( return (
<Menu <Menu
open={open} open={open}
onClose={() => { setOpen(false) }} onClose={() => { setOpen(false) }}
items={[ items={[
{ type: 'label' as const, id: 'group-by', text: t('groupBy.label') }, { type: 'label' as const, id: 'group-by', text: t('groupBy.label') },
{ id: 'workspace', label: optionLabel(t('groupBy.workspace'), groupBy === 'workspace') }, { id: 'workspace', label: t('groupBy.workspace') },
{ id: 'flat', label: optionLabel(t('groupBy.flat'), groupBy === 'flat') }, { id: 'flat', label: t('groupBy.flat') },
{ type: 'label' as const, id: 'order-by', text: t('orderBy.label') }, { type: 'label' as const, id: 'order-by', text: t('orderBy.label') },
{ id: 'manual', label: optionLabel(t('orderBy.manual'), orderBy === 'manual'), disabled: groupBy !== 'workspace' }, { id: 'manual', label: t('orderBy.manual'), disabled: groupBy !== 'workspace' },
{ id: 'updated', label: optionLabel(t('orderBy.updated'), orderBy === 'updated') }, { id: 'updated', label: t('orderBy.updated') },
]} ]}
selectedIds={[groupBy, orderBy]}
onSelect={(id) => { onSelect={(id) => {
if (id === 'workspace' || id === 'flat') onGroupPick(id) if (id === 'workspace' || id === 'flat') onGroupPick(id)
else if (id === 'manual' || id === 'updated') onOrderPick(id) else if (id === 'manual' || id === 'updated') onOrderPick(id)
@@ -280,7 +275,7 @@ function SessionTree({
}) })
}, [recentSessionOrder, workspaces]) }, [recentSessionOrder, workspaces])
const groups = useMemo( const groups = useMemo(
() => deriveGroups(list, orderedWorkspaces, archivedSessionIds, { expandedProjects }, 'manual'), () => deriveGroups(list, orderedWorkspaces, archivedSessionIds, { expandedProjects }),
[list, orderedWorkspaces, archivedSessionIds, expandedProjects], [list, orderedWorkspaces, archivedSessionIds, expandedProjects],
) )
const now = Date.now() const now = Date.now()
@@ -684,12 +679,12 @@ export function WorkspaceBrowser({
const onClick = (event: MouseEvent): void => { const onClick = (event: MouseEvent): void => {
if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return
searchInput.current?.blur() searchInput.current?.blur()
if (query !== '') return if (normalizedQuery !== '') return
setSearchExpanded(false) setSearchExpanded(false)
} }
document.addEventListener('click', onClick) document.addEventListener('click', onClick)
return () => { document.removeEventListener('click', onClick) } return () => { document.removeEventListener('click', onClick) }
}, [query, wide, searchExpanded]) }, [normalizedQuery, wide, searchExpanded])
useEffect(() => { useEffect(() => {
if (normalizedQuery === '') { if (normalizedQuery === '') {

View File

@@ -29,7 +29,6 @@ export interface SessionNode {
runningSubagentCount: number runningSubagentCount: number
/** Finished running while not selected and not yet opened (the green "done" reminder dot). */ /** Finished running while not selected and not yet opened (the green "done" reminder dot). */
completed: boolean completed: boolean
createdAt: number
updatedAt: number updatedAt: number
} }
@@ -108,10 +107,6 @@ function byRecency(a: SessionSummary, b: SessionSummary): number {
return a.id < b.id ? -1 : 1 return a.id < b.id ? -1 : 1
} }
function sortSessions(sessions: SessionSummary[]): void {
sessions.sort(byRecency)
}
/** /**
* Ordinary sessions are visible; among blank sessions, only the current one * Ordinary sessions are visible; among blank sessions, only the current one
* is visible. Subagent children use their parent header catalog; archived * is visible. Subagent children use their parent header catalog; archived
@@ -141,10 +136,12 @@ function buildGroup(
createdAt: number | undefined, createdAt: number | undefined,
label: string, label: string,
members: readonly SessionSummary[], members: readonly SessionSummary[],
orderBy: SessionOrderBy, order: 'account' | 'recency',
): Group { ): Group {
const sessions = [...members] const sessions = [...members]
if (orderBy !== 'manual') sortSessions(sessions) // Workspace order is the caller-selected sessionIds; only Ungrouped lacks
// an account order and therefore falls back to recency.
if (order === 'recency') sessions.sort(byRecency)
return { key, workspaceId, cwd, createdAt, label, sessions } return { key, workspaceId, cwd, createdAt, label, sessions }
} }
@@ -157,7 +154,6 @@ function groupByWorkspace(
list: SessionListState, list: SessionListState,
workspaces: readonly WorkspaceView[], workspaces: readonly WorkspaceView[],
archived: ReadonlySet<SessionId>, archived: ReadonlySet<SessionId>,
orderBy: SessionOrderBy,
): Group[] { ): Group[] {
const groups: Group[] = [] const groups: Group[] = []
const accounted = new Set<SessionId>() const accounted = new Set<SessionId>()
@@ -172,7 +168,7 @@ function groupByWorkspace(
} }
groups.push(buildGroup( groups.push(buildGroup(
workspace.workspaceId, workspace.workspaceId, workspace.path, workspace.workspaceId, workspace.workspaceId, workspace.path,
Date.parse(workspace.createdAt), workspace.title, members, orderBy, Date.parse(workspace.createdAt), workspace.title, members, 'account',
)) ))
} }
const stray = list.ids const stray = list.ids
@@ -180,10 +176,7 @@ function groupByWorkspace(
.filter((s): s is SessionSummary => .filter((s): s is SessionSummary =>
s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current, archived)) s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current, archived))
if (stray.length > 0) { if (stray.length > 0) {
groups.push(buildGroup( groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray,
orderBy === 'manual' ? 'updated' : orderBy,
))
} }
return groups return groups
} }
@@ -199,7 +192,6 @@ function sessionNode(
running: s.running, running: s.running,
runningSubagentCount: descendants.get(s.id)?.runningCount ?? 0, runningSubagentCount: descendants.get(s.id)?.runningCount ?? 0,
completed: s.completed === true, completed: s.completed === true,
createdAt: s.createdAt,
updatedAt: s.updatedAt, updatedAt: s.updatedAt,
...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }), ...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }),
} }
@@ -217,7 +209,6 @@ function sessionNode(
* @param workspaces - real workspaces in stable Host order. * @param workspaces - real workspaces in stable Host order.
* @param archivedSessionIds - registry-global archive set. * @param archivedSessionIds - registry-global archive set.
* @param view - local expansion arrays. * @param view - local expansion arrays.
* @param orderBy - local session ordering mode.
* @returns group sections in render order. * @returns group sections in render order.
*/ */
export function deriveGroups( export function deriveGroups(
@@ -225,7 +216,6 @@ export function deriveGroups(
workspaces: readonly WorkspaceView[], workspaces: readonly WorkspaceView[],
archivedSessionIds: readonly SessionId[], archivedSessionIds: readonly SessionId[],
view: TreeView, view: TreeView,
orderBy: SessionOrderBy = 'manual',
): GroupNode[] { ): GroupNode[] {
const archived = new Set(archivedSessionIds) const archived = new Set(archivedSessionIds)
const expandedProjects = new Set(view.expandedProjects) const expandedProjects = new Set(view.expandedProjects)
@@ -235,7 +225,7 @@ export function deriveGroups(
: (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined) : (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
?? UNGROUPED_KEY ?? UNGROUPED_KEY
const groups: GroupNode[] = [] const groups: GroupNode[] = []
for (const g of groupByWorkspace(list, workspaces, archived, orderBy)) { for (const g of groupByWorkspace(list, workspaces, archived)) {
const expanded = expandedProjects.has(g.key) const expanded = expandedProjects.has(g.key)
groups.push({ groups.push({
key: g.key, key: g.key,
@@ -273,7 +263,7 @@ export function deriveFlat(
if (s === undefined || !sessionVisible(s, list.current, archived)) continue if (s === undefined || !sessionVisible(s, list.current, archived)) continue
rows.push(s) rows.push(s)
} }
sortSessions(rows) rows.sort(byRecency)
return rows.map(session => sessionNode(session, descendants)) return rows.map(session => sessionNode(session, descendants))
} }

View File

@@ -116,7 +116,7 @@ describe('workspace browser rows', () => {
it('renders and opens a selected running Session row', () => { it('renders and opens a selected running Session row', () => {
const node: SessionNode = { const node: SessionNode = {
id: sid('session'), title: 'Session', blank: false, running: true, id: sid('session'), title: 'Session', blank: false, running: true,
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0, runningSubagentCount: 0, completed: false, updatedAt: 0,
} }
const onOpen = vi.fn() const onOpen = vi.fn()
render( render(
@@ -137,7 +137,7 @@ describe('workspace browser rows', () => {
<SessionNodeItem <SessionNodeItem
node={{ node={{
id: sid('s1'), title: 'One', blank: false, running: false, id: sid('s1'), title: 'One', blank: false, running: false,
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0, ...over, runningSubagentCount: 0, completed: false, updatedAt: 0, ...over,
}} }}
currentId={undefined} now={0} onOpen={vi.fn()} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t}
@@ -169,7 +169,7 @@ describe('workspace browser rows', () => {
try { try {
const node: SessionNode = { const node: SessionNode = {
id: sid('owner'), title: 'Delegating', blank: false, running: false, id: sid('owner'), title: 'Delegating', blank: false, running: false,
runningSubagentCount: 2, completed: false, createdAt: 0, updatedAt: 0, runningSubagentCount: 2, completed: false, updatedAt: 0,
} }
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()} render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -191,7 +191,7 @@ describe('workspace browser rows', () => {
try { try {
const node: SessionNode = { const node: SessionNode = {
id: sid('owner'), title: 'Delegating', blank: false, running: true, id: sid('owner'), title: 'Delegating', blank: false, running: true,
runningSubagentCount: 1, completed: false, createdAt: 0, updatedAt: 0, runningSubagentCount: 1, completed: false, updatedAt: 0,
} }
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()} render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -212,7 +212,7 @@ describe('workspace browser rows', () => {
it('keeps child activity as a secondary status while user attention is primary', () => { it('keeps child activity as a secondary status while user attention is primary', () => {
const node: SessionNode = { const node: SessionNode = {
id: sid('owner'), title: 'Needs input', blank: false, pendingInteraction: 'question', id: sid('owner'), title: 'Needs input', blank: false, pendingInteraction: 'question',
running: false, runningSubagentCount: 1, completed: false, createdAt: 0, updatedAt: 0, running: false, runningSubagentCount: 1, completed: false, updatedAt: 0,
} }
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()} render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -303,7 +303,7 @@ describe('workspace browser rows', () => {
try { try {
const node: SessionNode = { const node: SessionNode = {
id: sid('s-blank'), title: 'ignored', blank: true, running: false, id: sid('s-blank'), title: 'ignored', blank: true, running: false,
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0, runningSubagentCount: 0, completed: false, updatedAt: 0,
} }
render(<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={vi.fn()} render(<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -330,7 +330,7 @@ describe('workspace browser rows', () => {
const onArchive = vi.fn() const onArchive = vi.fn()
const node: SessionNode = { const node: SessionNode = {
id: sid('s1'), title: 'One', blank: false, running: false, id: sid('s1'), title: 'One', blank: false, running: false,
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0, runningSubagentCount: 0, completed: false, updatedAt: 0,
} }
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen} render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />) onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
@@ -364,7 +364,7 @@ describe('workspace browser rows', () => {
try { try {
const node: SessionNode = { const node: SessionNode = {
id: sid('s1'), title: 'Hovered', blank: false, running: true, id: sid('s1'), title: 'Hovered', blank: false, running: true,
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0, runningSubagentCount: 0, completed: false, updatedAt: 0,
} }
render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()} render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -395,7 +395,7 @@ describe('workspace browser rows', () => {
try { try {
const node: SessionNode = { const node: SessionNode = {
id: sid(pendingInteraction), title: 'Needs input', blank: false, id: sid(pendingInteraction), title: 'Needs input', blank: false,
pendingInteraction, running: true, runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0, pendingInteraction, running: true, runningSubagentCount: 0, completed: false, updatedAt: 0,
} }
const view = render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()} const view = render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -422,7 +422,7 @@ describe('workspace browser rows', () => {
try { try {
const node: SessionNode = { const node: SessionNode = {
id: sid('s1'), title: 'Quiet', blank: false, running: false, id: sid('s1'), title: 'Quiet', blank: false, running: false,
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0, runningSubagentCount: 0, completed: false, updatedAt: 0,
} }
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()} render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -440,7 +440,7 @@ describe('workspace browser rows', () => {
try { try {
const node: SessionNode = { const node: SessionNode = {
id: sid('s1'), title: 'Done', blank: false, running: false, id: sid('s1'), title: 'Done', blank: false, running: false,
runningSubagentCount: 0, completed: true, createdAt: 0, updatedAt: 0, runningSubagentCount: 0, completed: true, updatedAt: 0,
} }
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()} render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -456,7 +456,7 @@ describe('workspace browser rows', () => {
it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => { it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
const node: SessionNode = { const node: SessionNode = {
id: sid('s1'), title: 'Drag me', blank: false, running: false, id: sid('s1'), title: 'Drag me', blank: false, running: false,
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0, runningSubagentCount: 0, completed: false, updatedAt: 0,
} }
const inactive = dragProps() const inactive = dragProps()
const { rerender } = render( const { rerender } = render(

View File

@@ -12,7 +12,7 @@ const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId const wid = (id: string) => id as WorkspaceId
const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({ const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({
id: sid(id), displayTitle: id, running: false, blank: false, id: sid(id), displayTitle: id, running: false, blank: false,
createdAt: updatedAt, updatedAt, ...(cwd === undefined ? {} : { cwd }), updatedAt, ...(cwd === undefined ? {} : { cwd }),
}) })
const list = (...items: SessionSummary[]): SessionListState => ({ const list = (...items: SessionSummary[]): SessionListState => ({
ids: items.map(item => item.id), ids: items.map(item => item.id),

View File

@@ -22,7 +22,7 @@ const t: WorkspaceBrowserProps['t'] = makeTranslate(zh, commonZh)
const sid = (id: string) => id as SessionId const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId const wid = (id: string) => id as WorkspaceId
const summary = (id: string, updatedAt: number, overrides: Partial<SessionSummary> = {}): SessionSummary => ({ const summary = (id: string, updatedAt: number, overrides: Partial<SessionSummary> = {}): SessionSummary => ({
id: sid(id), displayTitle: id, running: false, blank: false, createdAt: updatedAt, updatedAt, ...overrides, id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...overrides,
}) })
const sessionState = (items: readonly SessionSummary[], overrides: Partial<SessionListState> = {}): SessionListState => ({ const sessionState = (items: readonly SessionSummary[], overrides: Partial<SessionListState> = {}): SessionListState => ({
ids: items.map(item => item.id), ids: items.map(item => item.id),
@@ -107,8 +107,11 @@ describe('WorkspaceBrowser', () => {
fireEvent.click(screen.getByRole('button', { name: '视图选项' })) fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
'按工作区', '单列表', '手动排序', '最近更新',
])
expect(screen.getByRole('menuitem', { name: '按工作区' }).querySelector('svg')).toBeTruthy()
expect(screen.getByRole('menuitem', { name: '手动排序' }).querySelector('svg')).toBeTruthy() expect(screen.getByRole('menuitem', { name: '手动排序' }).querySelector('svg')).toBeTruthy()
expect(screen.queryByText('创建时间')).toBeNull()
fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
// Store-driven flip: title changes, rows flatten newest-first, headers gone. // Store-driven flip: title changes, rows flatten newest-first, headers gone.
expect(b.store.getSnapshot().groupBy).toBe('flat') expect(b.store.getSnapshot().groupBy).toBe('flat')
@@ -405,6 +408,11 @@ describe('WorkspaceBrowser', () => {
fireEvent.click(search) fireEvent.click(search)
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…') const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…')
fireEvent.change(input, { target: { value: ' ' } })
fireEvent.click(document.body)
expect(search.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(search)
fireEvent.change(input, { target: { value: 'kept' } }) fireEvent.change(input, { target: { value: 'kept' } })
fireEvent.click(document.body) fireEvent.click(document.body)
expect(search.getAttribute('aria-expanded')).toBe('true') expect(search.getAttribute('aria-expanded')).toBe('true')

View File

@@ -466,7 +466,6 @@ function sessionListFields(header: SessionHeader, events: readonly SessionEvent[
function summarize(session: Session, running: boolean): SessionSummary { function summarize(session: Session, running: boolean): SessionSummary {
return { return {
sessionId: session.id, sessionId: session.id,
createdAt: session.header.createdAt,
// Excludes end-seed: a resumed-but-untouched session // Excludes end-seed: a resumed-but-untouched session
// must not sort as freshly worked in. // must not sort as freshly worked in.
updatedAt: lastActivityTime(session.events) ?? session.header.createdAt, updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
@@ -500,7 +499,6 @@ async function summarizeCold(
} }
return { return {
sessionId: meta.id, sessionId: meta.id,
createdAt: meta.createdAt,
updatedAt, updatedAt,
running: false, running: false,
// Lazy persistence keeps never-appended sessions out of list(); reading // Lazy persistence keeps never-appended sessions out of list(); reading
@@ -3394,7 +3392,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
queue.push(frame({ queue.push(frame({
type: 'host/session-added', type: 'host/session-added',
sessionId: session.id, sessionId: session.id,
createdAt: session.header.createdAt,
// Derived at frame time like summarize(); a just-created session // Derived at frame time like summarize(); a just-created session
// has run no turn yet, so this is constantly true in practice. // has run no turn yet, so this is constantly true in practice.
blank: sessionBlank(session), blank: sessionBlank(session),

View File

@@ -71,7 +71,6 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
z.object({ z.object({
type: z.literal('host/session-added'), type: z.literal('host/session-added'),
sessionId: sessionIdSchema, sessionId: sessionIdSchema,
createdAt: z.number().optional(),
blank: z.boolean(), blank: z.boolean(),
parentSessionId: sessionIdSchema.optional(), parentSessionId: sessionIdSchema.optional(),
origin: z.literal('subagent').optional(), origin: z.literal('subagent').optional(),

View File

@@ -128,7 +128,6 @@ export type HostFrame =
| { | {
type: 'host/session-added' type: 'host/session-added'
sessionId: SessionId sessionId: SessionId
createdAt?: number
blank: boolean blank: boolean
parentSessionId?: SessionId parentSessionId?: SessionId
origin?: 'subagent' origin?: 'subagent'

View File

@@ -51,7 +51,6 @@ export const sessionEventSchema = z.object({
/** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */ /** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */
export const sessionSummarySchema = z.object({ export const sessionSummarySchema = z.object({
sessionId: sessionIdSchema, sessionId: sessionIdSchema,
createdAt: z.number().optional(),
updatedAt: z.number(), updatedAt: z.number(),
running: z.boolean(), running: z.boolean(),
blank: z.boolean(), blank: z.boolean(),

View File

@@ -147,8 +147,6 @@ export type QueueAction =
/** Session list entry (v1 builds no index: list does readdir+stat). */ /** Session list entry (v1 builds no index: list does readdir+stat). */
export interface SessionSummary { export interface SessionSummary {
sessionId: SessionId sessionId: SessionId
/** Session creation time from the durable session header when supplied by the Host. */
createdAt?: number
/** /**
* Last activity. Attached: the last non-`session/end-seed` event, since a * Last activity. Attached: the last non-`session/end-seed` event, since a
* pickup is not activity. Cold: the log's mtime, or `createdAt` for a backend * pickup is not activity. Cold: the log's mtime, or `createdAt` for a backend