diff --git a/docs/design/2026-09-01-web-shell-clear-manual-title.md b/docs/design/2026-09-01-web-shell-clear-manual-title.md new file mode 100644 index 00000000000..ed6b601a5c4 --- /dev/null +++ b/docs/design/2026-09-01-web-shell-clear-manual-title.md @@ -0,0 +1,11 @@ +# Preserve manual titles across `/clear` + +`/clear` creates a deferred successor session. Remember the current title only +when its persisted provenance is `manual`, then rename the successor before it +attaches and before its first prompt. + +The existing session catalog is the durable source of title provenance after a +reload. Live rename events provide the same provenance without another read. +`/new`, `/reset`, session navigation, and workspace changes discard the carry. + +Automatic and legacy titles with unknown provenance are never carried. diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index a5f7b8281a5..92b2162517a 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -28997,8 +28997,55 @@ describe('createAcpSessionBridge', () => { (e) => e.type === 'session_metadata_updated', ); expect(metaEvent).toBeDefined(); - expect((metaEvent?.data as { displayName: string }).displayName).toBe( - 'Test Session', + expect(metaEvent?.data).toMatchObject({ + displayName: 'Test Session', + titleSource: 'manual', + }); + + await bridge.closeSession(session.sessionId); + await drain; + await bridge.shutdown(); + }); + + it('uses automatic provenance for programmatic renames', async () => { + const titleUpdates: unknown[] = []; + const bridge = makeBridge({ + channelFactory: async () => + makeChannel({ + extMethodImpl: (method, params) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionTitle) { + titleUpdates.push(params); + } + return { persisted: true }; + }, + }).channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const events: BridgeEvent[] = []; + const sub = bridge.subscribeEvents(session.sessionId); + const drain = (async () => { + for await (const event of sub) events.push(event); + })(); + await new Promise((resolve) => setImmediate(resolve)); + + bridge.updateSessionMetadata(session.sessionId, { + displayName: 'Voice chat', + titleSource: 'auto', + }); + + await vi.waitFor(() => expect(titleUpdates).toHaveLength(1)); + expect(titleUpdates[0]).toMatchObject({ + displayName: 'Voice chat', + titleSource: 'auto', + }); + await vi.waitFor(() => + expect( + events.find((event) => event.type === 'session_metadata_updated') + ?.data, + ).toMatchObject({ + displayName: 'Voice chat', + titleSource: 'auto', + }), ); await bridge.closeSession(session.sessionId); @@ -29006,6 +29053,48 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('rejects an empty displayName instead of clearing only the live entry', async () => { + const bridge = makeBridge({ + channelFactory: async () => makeChannel().channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + bridge.updateSessionMetadata(session.sessionId, { + displayName: 'Payments bug', + }); + + const events: BridgeEvent[] = []; + const sub = bridge.subscribeEvents(session.sessionId); + const drain = (async () => { + for await (const ev of sub) events.push(ev); + })(); + await new Promise((r) => setImmediate(r)); + + // A clear is never persisted (the `sessionTitle` persist skips + // falsy names), so accepting it would let the stale manual record + // resurface through the session-list merge and the `/clear` carry. + expect(() => + bridge.updateSessionMetadata(session.sessionId, { displayName: '' }), + ).toThrow(InvalidSessionMetadataError); + expect(() => + bridge.updateSessionMetadata(session.sessionId, { + displayName: ' ', + }), + ).toThrow(InvalidSessionMetadataError); + + await new Promise((r) => setImmediate(r)); + expect(bridge.getSessionSummary(session.sessionId)).toMatchObject({ + displayName: 'Payments bug', + }); + expect( + events.filter((e) => e.type === 'session_metadata_updated'), + ).toHaveLength(0); + + await bridge.closeSession(session.sessionId); + await drain; + await bridge.shutdown(); + }); + it('keeps the optimistic update and logs a generic persistence failure', async () => { const stderrSpy = vi .spyOn(process.stderr, 'write') diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 21b3478acbc..0549b321367 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -11363,6 +11363,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } } if (metadata.displayName !== undefined) { + if ( + metadata.titleSource !== undefined && + metadata.titleSource !== 'manual' && + metadata.titleSource !== 'auto' + ) { + throw new InvalidSessionMetadataError( + 'titleSource', + 'must be either `manual` or `auto`', + ); + } if ( typeof metadata.displayName !== 'string' || metadata.displayName.length > MAX_DISPLAY_NAME_LENGTH @@ -11378,7 +11388,22 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { 'must not contain control characters', ); } + // An empty name would only clear the live entry: the `sessionTitle` + // persist below runs for truthy names, so no tombstone reaches the + // transcript. The persisted manual record would then resurface + // through the session-list merge (`live.displayName ?? + // existing.displayName`) and be carried into a `/clear` successor as + // if the clear never happened. Reject the clear instead of serving a + // name the catalog no longer backs. Mirrors the workspace-scoped + // metadata route, which rejects empty names for the same reason. + if (metadata.displayName.trim() === '') { + throw new InvalidSessionMetadataError( + 'displayName', + 'must not be empty', + ); + } const nextDisplayName = metadata.displayName || undefined; + const titleSource = metadata.titleSource ?? 'manual'; if (entry.displayName !== nextDisplayName) { entry.displayName = nextDisplayName; // The catalog exposes display names; an actual rename is a @@ -11397,7 +11422,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { .extMethod(SERVE_CONTROL_EXT_METHODS.sessionTitle, { sessionId, displayName: nextDisplayName, - titleSource: 'manual', + titleSource, }) .then((res: unknown) => { const r = res as { persisted?: boolean } | undefined; @@ -11418,7 +11443,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { try { entry.events.publish({ type: 'session_metadata_updated', - data: { sessionId, displayName: entry.displayName }, + data: { + sessionId, + displayName: entry.displayName, + ...(entry.displayName ? { titleSource } : {}), + }, ...(metadataOriginatorClientId ? { originatorClientId: metadataOriginatorClientId } : {}), diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 995b6a302e0..f7dd75c01e2 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -720,6 +720,7 @@ export interface BridgeSessionSummary { createdAt: string; updatedAt?: string; displayName?: string; + titleSource?: 'manual' | 'auto'; /** Id of the session that spawned this one (via `create_sub_session`), or * absent for a top-level session. Lets a UI link a sub-session back to its * parent. Immutable — set when the session is created. */ @@ -811,6 +812,7 @@ export interface SessionPrIssueInfo { export interface SessionMetadataUpdate { displayName?: string; + titleSource?: 'manual' | 'auto'; /** Issues are daemon-derived, never client-bound — the input omits them. */ pr?: Omit; /** Full binding list after the update (return value only; ignored on input). */ diff --git a/packages/cli/src/serve/conversations/standalone-session-service.ts b/packages/cli/src/serve/conversations/standalone-session-service.ts index 6660cd60f41..f9057fc9cde 100644 --- a/packages/cli/src/serve/conversations/standalone-session-service.ts +++ b/packages/cli/src/serve/conversations/standalone-session-service.ts @@ -383,6 +383,9 @@ function toStandaloneSummary( createdAt: item.startTime, updatedAt: new Date(item.mtime).toISOString(), ...(displayName ? { displayName } : {}), + ...(item.customTitle && item.titleSource + ? { titleSource: item.titleSource } + : {}), sourceType: STANDALONE_SESSION_SOURCE_TYPE, context: { kind: 'standalone' }, ...(source.metadata.parentSessionId !== undefined diff --git a/packages/cli/src/serve/create-sub-session.test.ts b/packages/cli/src/serve/create-sub-session.test.ts index 88cacb88d8d..1a3dee313fa 100644 --- a/packages/cli/src/serve/create-sub-session.test.ts +++ b/packages/cli/src/serve/create-sub-session.test.ts @@ -81,7 +81,11 @@ function makeFakeBridge(opts?: { }> = []; const prompts: Array<{ sessionId: string; promptId?: string; text: string }> = []; - const names: Array<{ sessionId: string; displayName?: string }> = []; + const names: Array<{ + sessionId: string; + displayName?: string; + titleSource?: 'manual' | 'auto'; + }> = []; const closes: string[] = []; const relocations: Array<{ sessionId: string; @@ -131,9 +135,12 @@ function makeFakeBridge(opts?: { }, updateSessionMetadata: ( sessionId: string, - metadata: { displayName?: string }, + metadata: { + displayName?: string; + titleSource?: 'manual' | 'auto'; + }, ) => { - names.push({ sessionId, displayName: metadata.displayName }); + names.push({ sessionId, ...metadata }); return metadata; }, getSessionLastEventId: () => 0, @@ -339,6 +346,7 @@ describe('sub-session launcher', () => { ]); expect(fake.prompts[0]!.text).toBe('do the thing'); expect(fake.names[0]!.displayName).toContain('my task'); + expect(fake.names[0]!.titleSource).toBe('auto'); // 'sent' returns immediately but starts a background subscription to hold // the concurrency slot until the sub-session's turn finishes (so the cap // stays meaningful). The subscription is fire-and-forget — the launch @@ -386,6 +394,7 @@ describe('sub-session launcher', () => { sourceId: 'scheduled_task_run:task-1', }); expect(fake.names[0]?.displayName).toBe('Hourly review'); + expect(fake.names[0]?.titleSource).toBe('auto'); }); it('rejects a scheduled-task run when prompt admission fails', async () => { diff --git a/packages/cli/src/serve/create-sub-session.ts b/packages/cli/src/serve/create-sub-session.ts index 5e5a7f6c1c6..cc90fa4e0bd 100644 --- a/packages/cli/src/serve/create-sub-session.ts +++ b/packages/cli/src/serve/create-sub-session.ts @@ -911,6 +911,7 @@ export function createSubSessionLauncher( info.name ?? info.prompt, !isScheduledTaskRunSource(info), ), + titleSource: 'auto', }); } catch (err) { log.debug('sub-session: updateSessionMetadata failed', sessionId, err); diff --git a/packages/cli/src/serve/live/live-session-coordinator.test.ts b/packages/cli/src/serve/live/live-session-coordinator.test.ts index 7b226b7a859..16465918f92 100644 --- a/packages/cli/src/serve/live/live-session-coordinator.test.ts +++ b/packages/cli/src/serve/live/live-session-coordinator.test.ts @@ -394,7 +394,7 @@ describe('LiveSessionCoordinator', () => { }); expect(harness.bridge.updateSessionMetadata).toHaveBeenCalledWith( 'live-new', - { displayName: 'Voice chat' }, + { displayName: 'Voice chat', titleSource: 'auto' }, ); expect(harness.host.setCallState).toHaveBeenLastCalledWith(1, 'listening'); diff --git a/packages/cli/src/serve/live/live-session-coordinator.ts b/packages/cli/src/serve/live/live-session-coordinator.ts index 3da56b2f7f5..56d3f50ee32 100644 --- a/packages/cli/src/serve/live/live-session-coordinator.ts +++ b/packages/cli/src/serve/live/live-session-coordinator.ts @@ -520,7 +520,7 @@ export class LiveSessionCoordinator { try { context.runtime?.bridge.updateSessionMetadata( context.coordinator.sessionId, - { displayName: 'Voice chat' }, + { displayName: 'Voice chat', titleSource: 'auto' }, ); } catch { /* the session remains usable when a title write fails */ diff --git a/packages/cli/src/serve/routes/scheduled-tasks.test.ts b/packages/cli/src/serve/routes/scheduled-tasks.test.ts index f1e1f8cfe8d..77b19154aa3 100644 --- a/packages/cli/src/serve/routes/scheduled-tasks.test.ts +++ b/packages/cli/src/serve/routes/scheduled-tasks.test.ts @@ -64,7 +64,10 @@ interface StubBridge { ensureDefaultSessionPersisted(sessionId: string): Promise; updateSessionMetadata( sessionId: string, - metadata: { displayName?: string }, + metadata: { + displayName?: string; + titleSource?: 'manual' | 'auto'; + }, ): unknown; getSessionSummary(sessionId: string): { sessionId: string; @@ -95,7 +98,11 @@ interface StubBridge { prompts: Array<{ sessionId: string; text: string }>; closed: string[]; persisted: string[]; - named: Array<{ sessionId: string; displayName?: string }>; + named: Array<{ + sessionId: string; + displayName?: string; + titleSource?: 'manual' | 'auto'; + }>; failNext: boolean; persistenceError?: Error; } @@ -469,6 +476,7 @@ describe('scheduled-tasks routes', () => { displayName: expect.stringMatching( /^Review PRs · \d{2}-\d{2} \d{2}:\d{2}$/, ), + titleSource: 'auto', }); expect(h.bridge.prompts).toHaveLength(1); expect(h.bridge.prompts[0]).toMatchObject({ sessionId: childSessionId }); @@ -1416,13 +1424,18 @@ describe('scheduled-tasks routes', () => { prompt: 'summarize the day', }); expect(h.bridge.named).toEqual([ - { sessionId: named.body.sessionId, displayName: 'Digest' }, + { + sessionId: named.body.sessionId, + displayName: 'Digest', + titleSource: 'auto', + }, ]); const unnamed = await create({ cron: '0 9 * * *', prompt: 'do the thing' }); expect(h.bridge.named[1]).toEqual({ sessionId: unnamed.body.sessionId, displayName: 'do the thing', + titleSource: 'auto', }); }); @@ -2036,7 +2049,9 @@ describe('scheduled-tasks routes', () => { }); const id = created.body.id as string; const sid = created.body.sessionId as string; - expect(h.bridge.named).toEqual([{ sessionId: sid, displayName: 'Old' }]); + expect(h.bridge.named).toEqual([ + { sessionId: sid, displayName: 'Old', titleSource: 'auto' }, + ]); // Renaming the task re-labels its session. const rename = await request(h.app) @@ -2046,6 +2061,7 @@ describe('scheduled-tasks routes', () => { expect(h.bridge.named).toContainEqual({ sessionId: sid, displayName: 'New', + titleSource: 'auto', }); // A bare cron edit does NOT re-touch the session name. @@ -2060,6 +2076,7 @@ describe('scheduled-tasks routes', () => { expect(h.bridge.named).toContainEqual({ sessionId: sid, displayName: 'p', + titleSource: 'auto', }); }); diff --git a/packages/cli/src/serve/routes/scheduled-tasks.ts b/packages/cli/src/serve/routes/scheduled-tasks.ts index 52cddb3516b..77fee250e41 100644 --- a/packages/cli/src/serve/routes/scheduled-tasks.ts +++ b/packages/cli/src/serve/routes/scheduled-tasks.ts @@ -120,7 +120,10 @@ export interface ScheduledTasksSessionBridge { * session list (rather than a bare id). Best-effort. */ updateSessionMetadata( sessionId: string, - metadata: { displayName?: string }, + metadata: { + displayName?: string; + titleSource?: 'manual' | 'auto'; + }, ): unknown; getSessionSummary(sessionId: string): { workspaceCwd: string; @@ -550,6 +553,7 @@ async function dispatchTaskToFreshSession( task.name ?? task.prompt, triggeredAt, ), + titleSource: 'auto', }); } catch { // The prompt can still run with the generated session id as its label. @@ -1076,6 +1080,7 @@ function registerScheduledTaskCrudRoutes( displayName: scheduledTaskSessionName( nameResult.value ?? prompt, ), + titleSource: 'auto', }), ); } catch { @@ -1544,6 +1549,7 @@ function registerScheduledTaskCrudRoutes( displayName: scheduledTaskSessionName( updated.name ?? updated.prompt, ), + titleSource: 'auto', }); } catch { // non-critical — the schedule change already persisted diff --git a/packages/cli/src/serve/scheduled-task-keepalive.test.ts b/packages/cli/src/serve/scheduled-task-keepalive.test.ts index 33c6ef65c1c..ed97e1b5d3e 100644 --- a/packages/cli/src/serve/scheduled-task-keepalive.test.ts +++ b/packages/cli/src/serve/scheduled-task-keepalive.test.ts @@ -235,7 +235,9 @@ describe('scheduled-task keepalive', () => { condition: 'files_changed', } as unknown as Partial), ]); - const names: Array<[string, { displayName?: string }]> = []; + const names: Array< + [string, { displayName?: string; titleSource?: 'manual' | 'auto' }] + > = []; const naming = { ...bridge, recordHeartbeat: () => { @@ -243,7 +245,10 @@ describe('scheduled-task keepalive', () => { // be attempted for this session in the first place. throw new Error('unexpected heartbeat for legacy session'); }, - updateSessionMetadata: (id: string, m: { displayName?: string }) => { + updateSessionMetadata: ( + id: string, + m: { displayName?: string; titleSource?: 'manual' | 'auto' }, + ) => { names.push([id, m]); }, }; @@ -665,7 +670,9 @@ describe('scheduled-task keepalive', () => { task({ id: 'unbound-1', prompt: 'check build' }), ]); const spawns: unknown[] = []; - const names: Array<[string, { displayName?: string }]> = []; + const names: Array< + [string, { displayName?: string; titleSource?: 'manual' | 'auto' }] + > = []; const binding = { ...bridge, spawnOrAttach: async (req: unknown) => { @@ -673,7 +680,10 @@ describe('scheduled-task keepalive', () => { return { sessionId: 'new-sess-1' }; }, closeSession: async () => {}, - updateSessionMetadata: (id: string, m: { displayName?: string }) => { + updateSessionMetadata: ( + id: string, + m: { displayName?: string; titleSource?: 'manual' | 'auto' }, + ) => { names.push([id, m]); }, }; @@ -694,6 +704,7 @@ describe('scheduled-task keepalive', () => { expect(names).toHaveLength(1); expect(names[0]![0]).toBe('new-sess-1'); expect(names[0]![1].displayName).toBe('check build'); + expect(names[0]![1].titleSource).toBe('auto'); const tasks = await readCronTasks(workspace); expect(tasks[0]!.sessionId).toBe('new-sess-1'); }); @@ -707,10 +718,15 @@ describe('scheduled-task keepalive', () => { sessionOwnedByTask: false, }), ]); - const names: Array<[string, { displayName?: string }]> = []; + const names: Array< + [string, { displayName?: string; titleSource?: 'manual' | 'auto' }] + > = []; const naming = { ...bridge, - updateSessionMetadata: (id: string, m: { displayName?: string }) => { + updateSessionMetadata: ( + id: string, + m: { displayName?: string; titleSource?: 'manual' | 'auto' }, + ) => { names.push([id, m]); }, }; @@ -725,6 +741,7 @@ describe('scheduled-task keepalive', () => { expect(names).toHaveLength(1); expect(names[0]![0]).toBe('existing-sess'); expect(names[0]![1].displayName).toBe('lint'); + expect(names[0]![1].titleSource).toBe('auto'); }); it('does not bind disabled unbound tasks', async () => { diff --git a/packages/cli/src/serve/scheduled-task-keepalive.ts b/packages/cli/src/serve/scheduled-task-keepalive.ts index 2246cc24ac5..cf2fe18a012 100644 --- a/packages/cli/src/serve/scheduled-task-keepalive.ts +++ b/packages/cli/src/serve/scheduled-task-keepalive.ts @@ -100,7 +100,10 @@ export interface KeepaliveBridge { markSessionCatalogChanged?(): void; updateSessionMetadata( sessionId: string, - metadata: { displayName?: string }, + metadata: { + displayName?: string; + titleSource?: 'manual' | 'auto'; + }, ): unknown; } @@ -194,6 +197,7 @@ async function bindAndNameSessions( try { bridge.updateSessionMetadata(sessionId, { displayName: scheduledTaskSessionName(task.prompt), + titleSource: 'auto', }); renamed.add(sessionId); } catch { @@ -243,6 +247,7 @@ async function bindAndNameSessions( try { bridge.updateSessionMetadata(sessionId, { displayName: scheduledTaskSessionName(task.prompt), + titleSource: 'auto', }); renamed.add(sessionId); } catch (err) { diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 1bc6ed12064..a64804127fe 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -16038,6 +16038,8 @@ describe('createServeApp', () => { timestamp: string; prompt: string; mtime: Date; + customTitle?: string; + titleSource?: 'manual' | 'auto'; state?: 'active' | 'archived'; parentSessionId?: string; sourceType?: string; @@ -16061,6 +16063,21 @@ describe('createServeApp', () => { cwd: input.cwd, }; const lines = [JSON.stringify(record)]; + if (input.customTitle !== undefined) { + lines.push( + JSON.stringify({ + ...record, + uuid: `${input.sessionId}-title-1`, + parentUuid: record.uuid, + type: 'system', + subtype: 'custom_title', + systemPayload: { + customTitle: input.customTitle, + ...(input.titleSource ? { titleSource: input.titleSource } : {}), + }, + }), + ); + } if (input.parentSessionId !== undefined) { // Mirror ChatRecordingService.recordParentSession: a single // `parent_session` system record near the head of the transcript that @@ -16340,6 +16357,8 @@ describe('createServeApp', () => { timestamp: '2026-05-17T12:00:00.000Z', prompt: 'stored only prompt', mtime: new Date('2026-05-17T12:10:00.000Z'), + customTitle: 'Manual title', + titleSource: 'manual', }); await writeStoredSession({ sessionId: liveAndStoredId, @@ -16378,7 +16397,8 @@ describe('createServeApp', () => { expect.objectContaining({ sessionId: storedOnlyId, workspaceCwd: WS_BOUND, - displayName: 'stored only prompt', + displayName: 'Manual title', + titleSource: 'manual', clientCount: 0, hasActivePrompt: false, }), diff --git a/packages/cli/src/serve/server/session-list.ts b/packages/cli/src/serve/server/session-list.ts index 5415c1a0ee1..df2a044d2f0 100644 --- a/packages/cli/src/serve/server/session-list.ts +++ b/packages/cli/src/serve/server/session-list.ts @@ -473,6 +473,7 @@ function toSummary(item: { mtime: number; prompt: string; customTitle?: string; + titleSource?: 'manual' | 'auto'; parentSessionId?: string; sourceType?: string; sourceId?: string; @@ -484,6 +485,9 @@ function toSummary(item: { createdAt: item.startTime, updatedAt: new Date(item.mtime).toISOString(), displayName: item.customTitle || item.prompt, + ...(item.customTitle && item.titleSource + ? { titleSource: item.titleSource } + : {}), ...(item.parentSessionId ? { parentSessionId: item.parentSessionId } : {}), ...(item.sourceType ? { sourceType: item.sourceType } : {}), ...(item.sourceId !== undefined ? { sourceId: item.sourceId } : {}), diff --git a/packages/sdk-typescript/src/daemon/events.ts b/packages/sdk-typescript/src/daemon/events.ts index 86ae98c5ac4..13449c6d7b0 100644 --- a/packages/sdk-typescript/src/daemon/events.ts +++ b/packages/sdk-typescript/src/daemon/events.ts @@ -298,6 +298,7 @@ export interface DaemonSessionClosedData { export interface DaemonSessionMetadataUpdatedData { sessionId: string; displayName?: string; + titleSource?: 'manual' | 'auto'; prs?: DaemonSessionPrInfo[]; [key: string]: unknown; } @@ -2656,7 +2657,10 @@ function isSessionMetadataUpdatedData( if ( !isRecord(value) || !isNonEmptyString(value['sessionId']) || - !isOptionalStringOrNull(value['displayName']) + !isOptionalStringOrNull(value['displayName']) || + (value['titleSource'] !== undefined && + value['titleSource'] !== 'manual' && + value['titleSource'] !== 'auto') ) { return false; } diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 8437cb27b1c..a3ec5c18b83 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -1300,6 +1300,7 @@ export interface DaemonSessionSummary { createdAt?: string; updatedAt?: string; displayName?: string; + titleSource?: 'manual' | 'auto'; /** Id of the session that spawned this one (via `create_sub_session`), or * absent for a top-level session. Lets a UI link a sub-session back to its * parent. */ diff --git a/packages/sdk-typescript/test/unit/daemonEvents.test.ts b/packages/sdk-typescript/test/unit/daemonEvents.test.ts index 2b3a2e63b29..4bfcdcb2cd4 100644 --- a/packages/sdk-typescript/test/unit/daemonEvents.test.ts +++ b/packages/sdk-typescript/test/unit/daemonEvents.test.ts @@ -964,7 +964,11 @@ describe('daemon event schema', () => { id: 1, v: 1, type: 'session_metadata_updated', - data: { sessionId: 's-1', displayName: 'My Session' }, + data: { + sessionId: 's-1', + displayName: 'My Session', + titleSource: 'manual', + }, }), ).toBeDefined(); @@ -985,6 +989,15 @@ describe('daemon event schema', () => { data: {}, }), ).toBeUndefined(); + + expect( + asKnownDaemonEvent({ + id: 4, + v: 1, + type: 'session_metadata_updated', + data: { sessionId: 's-1', titleSource: 'unknown' }, + }), + ).toBeUndefined(); }); it('validates mid_turn_message_injected events', () => { diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 9ae3f3e205d..ab89a011036 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -46,6 +46,7 @@ type MockConnection = { context?: { sessionId: string }; clientId: string; displayName: string | undefined; + titleSource?: 'manual' | 'auto'; workspaceCwd: string; currentModel: string | undefined; currentMode: string; @@ -5534,6 +5535,7 @@ beforeEach(() => { mockConnection.workspaceCwd = '/tmp/project'; mockConnection.status = 'connected'; mockConnection.displayName = 'Session One'; + mockConnection.titleSource = undefined; mockConnection.currentMode = 'default'; mockConnection.currentModel = 'qwen'; mockConnection.models = [{ id: 'qwen', label: 'Qwen' }]; @@ -14587,6 +14589,200 @@ describe('App session callbacks', () => { expect(editorFocus).toHaveBeenCalledOnce(); }); + it('renames a /clear successor from persisted manual title provenance', async () => { + mockWorkspace.client.listWorkspaceSessions.mockResolvedValue([ + { + sessionId: 'session-1', + workspaceCwd: '/tmp/project', + displayName: 'Bug hunt', + titleSource: 'manual', + }, + ]); + const { container, rerender } = renderApp(); + await flush(); + await flush(); + + testState.prompt = '/clear'; + await clickSubmit(container); + await vi.waitFor(() => { + expect(mockSessionActions.clearSession).toHaveBeenCalledOnce(); + }); + + act(() => { + mockConnection.sessionId = undefined; + mockConnection.displayName = undefined; + rerender(); + }); + act(() => { + testState.latestChatEditorProps?.onSubmit('first prompt'); + }); + await vi.waitFor(() => { + expect(mockSessionActions.sendPrompt).toHaveBeenCalled(); + }); + + expect(mockSessionActions.renameSession).toHaveBeenCalledWith('Bug hunt'); + expect( + mockSessionActions.renameSession.mock.invocationCallOrder[0], + ).toBeLessThan( + mockSessionActions.attachSession.mock.invocationCallOrder[0], + ); + expect( + mockSessionActions.renameSession.mock.invocationCallOrder[0], + ).toBeLessThan(mockSessionActions.sendPrompt.mock.invocationCallOrder[0]); + }); + + it('attaches a /clear successor when carrying its title fails', async () => { + mockConnection.titleSource = 'manual'; + mockConnection.displayName = 'Bug hunt'; + mockSessionActions.renameSession.mockRejectedValueOnce( + new Error('rename failed'), + ); + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/clear'; + await clickSubmit(container); + await vi.waitFor(() => { + expect(mockSessionActions.clearSession).toHaveBeenCalledOnce(); + }); + + act(() => { + mockConnection.sessionId = undefined; + mockConnection.displayName = undefined; + mockConnection.titleSource = undefined; + rerender(); + }); + act(() => { + testState.latestChatEditorProps?.onSubmit('first prompt'); + }); + + await vi.waitFor(() => { + expect(mockSessionActions.attachSession).toHaveBeenCalled(); + expect(mockSessionActions.sendPrompt).toHaveBeenCalled(); + }); + }); + + it('does not carry an automatic title across /clear', async () => { + mockConnection.titleSource = 'auto'; + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/clear'; + await clickSubmit(container); + await vi.waitFor(() => { + expect(mockSessionActions.clearSession).toHaveBeenCalledOnce(); + }); + + act(() => { + mockConnection.sessionId = undefined; + mockConnection.displayName = undefined; + mockConnection.titleSource = undefined; + rerender(); + }); + act(() => { + testState.latestChatEditorProps?.onSubmit('first prompt'); + }); + await vi.waitFor(() => { + expect(mockSessionActions.sendPrompt).toHaveBeenCalled(); + }); + + expect(mockSessionActions.renameSession).not.toHaveBeenCalled(); + }); + + it('discards an armed /clear carry when a shrink-fold lands on a split pane', async () => { + let large = true; + let changeHandler: ((event: { matches: boolean }) => void) | undefined; + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + get matches() { + return query.includes('min-width') ? large : false; + }, + media: query, + addEventListener: ( + _type: string, + cb: (event: { matches: boolean }) => void, + ) => { + if (query.includes('1024')) changeHandler = cb; + }, + removeEventListener: vi.fn(), + })), + }); + mockConnection.sessionId = 'session-1'; + mockConnection.titleSource = 'manual'; + mockConnection.displayName = 'Bug hunt'; + + const { container, rerender } = renderApp(); + await flush(); + + // Split view with the chat on session-1; /clear arms the carry and + // leaves the chat as a sessionless draft. + await act(async () => { + container + .querySelector('[data-testid="open-split-view"]') + ?.click(); + await Promise.resolve(); + }); + testState.prompt = '/clear'; + await clickSubmit(container); + await vi.waitFor(() => { + expect(mockSessionActions.clearSession).toHaveBeenCalledOnce(); + }); + act(() => { + mockConnection.sessionId = undefined; + mockConnection.displayName = undefined; + mockConnection.titleSource = undefined; + rerender(); + }); + + // Re-enter the split (the draft chat stays sessionless), then shrink: + // the fold lands the first pane on the chat connection while the carry + // is still armed. + await act(async () => { + container + .querySelector('[data-testid="open-split-view"]') + ?.click(); + await Promise.resolve(); + }); + mockSessionActions.loadSession.mockImplementationOnce(async () => { + // Mirrors the real loadSession(): the pane session binds the chat + // connection; an automatic title carries no provenance. + mockConnection.sessionId = 'session-1'; + mockConnection.displayName = 'Pane task'; + }); + await act(async () => { + large = false; + changeHandler?.({ matches: false }); + await Promise.resolve(); + }); + await vi.waitFor(() => { + expect(mockSessionActions.loadSession).toHaveBeenCalledWith('session-1'); + }); + rerender(); + + // The pane session has no manual provenance and no manual summary; a + // second /clear must not read the stale armed carry from the first + // session and rename the pane session's successor with it. + testState.prompt = '/clear'; + await clickSubmit(container); + await vi.waitFor(() => { + expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(2); + }); + act(() => { + mockConnection.sessionId = undefined; + mockConnection.displayName = undefined; + rerender(); + }); + act(() => { + testState.latestChatEditorProps?.onSubmit('first prompt'); + }); + await vi.waitFor(() => { + expect(mockSessionActions.sendPrompt).toHaveBeenCalled(); + }); + + expect(mockSessionActions.renameSession).not.toHaveBeenCalled(); + }); + it('focuses a cleared new session without waiting for detach', async () => { const clear = deferred(); mockSessionActions.clearSession.mockReturnValueOnce(clear.promise); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 32b2ea3c54d..b78f2831ffe 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -2804,6 +2804,8 @@ export function App({ const [currentSessionSummary, setCurrentSessionSummary] = useState< DaemonSessionSummary | undefined >(undefined); + const currentSessionSummaryRef = useRef(currentSessionSummary); + currentSessionSummaryRef.current = currentSessionSummary; // Tracks the logical session from the latest effect run. In-flight fetches // compare their captured key against this ref on resolve: a match means // the response is still relevant and may set OR clear the worktree state; @@ -2920,6 +2922,7 @@ export function App({ setSessionStatusDisplayName( listedSession?.displayName ?? summary.displayName, ); + setCurrentSessionSummary(listedSession ?? summary); }) .catch(() => undefined); }) @@ -6161,6 +6164,14 @@ export function App({ // window is narrower than the large-screen breakpoint. Growing back past the // breakpoint restores it, so a transient resize doesn't drop the user's panes. const splitFoldedByShrinkRef = useRef(false); + // The manual title an armed `/clear` carries into the next created session + // (consumed by the deferred creation in ensureSessionForPrompt). Session + // binding outside the carry flow — sidebar navigation, workspace switches, + // the shrink-fold landing, workspace-resolver creation — must discard it, + // or a cleared title resurfaces on an unrelated session's successor. + const pendingManualTitleRef = useRef<{ displayName: string } | undefined>( + undefined, + ); useEffect(() => { if (isLargeScreen) { // Grew back above the breakpoint: restore a split that a shrink folded @@ -6194,6 +6205,10 @@ export function App({ // pane the single connection can't own) just leaves the empty chat. const firstPane = splitSessionIdsRef.current[0]; if (firstPane && !currentSessionIdRef.current) { + // Landing on a pane is session navigation: discard an armed + // `/clear` carry so the pane session's lineage never inherits the + // cleared session's title. + pendingManualTitleRef.current = undefined; void sessionActions.loadSession(firstPane).catch(() => undefined); } } @@ -6710,6 +6725,7 @@ export function App({ return Promise.resolve(undefined); } if (currentSessionId) return Promise.resolve(undefined); + const pendingManualTitle = pendingManualTitleRef.current; const promise = (async () => { let allocatedSessionId: string | undefined; const modelId = @@ -6797,7 +6813,21 @@ export function App({ ? { name: gitModeIntentRef.current.name } : undefined, sessionSourceType: sessionSourceTypeRef.current, - onSessionCreated: onSessionCreatedRef.current, + onSessionCreated: async (sessionId) => { + if ( + pendingManualTitle && + pendingManualTitleRef.current === pendingManualTitle + ) { + try { + await sessionActions.renameSession( + pendingManualTitle.displayName, + ); + } catch { + pendingManualTitleRef.current = undefined; + } + } + await onSessionCreatedRef.current?.(sessionId); + }, onSessionAllocated: (sessionId) => { preparingSessionIdRef.current = sessionId; allocatedSessionId = sessionId; @@ -6814,6 +6844,9 @@ export function App({ }, getCurrentSessionId: () => connectionRef.current.sessionId, }).then((result) => { + if (pendingManualTitleRef.current === pendingManualTitle) { + pendingManualTitleRef.current = undefined; + } if (result.worktree) { setSessionWorktree(result.worktree); } @@ -7247,7 +7280,10 @@ export function App({ ); if (page.sessions.length > 0) return page.sessions[0].sessionId; } - // No session exists or forced: create one. + // No session exists or forced: create one. Creating binds the chat + // connection — session navigation that must discard an armed + // `/clear` carry, mirroring loadSidebarSession. + pendingManualTitleRef.current = undefined; const result = await ( sessionActions as typeof sessionActions & SessionActionsWithCreate ).createSession({ workspaceCwd: cwd }); @@ -9427,6 +9463,7 @@ export function App({ * prompt sees it even while the clear is still in flight. */ gitIntent?: SessionGitIntent; + carryManualTitle?: string; }, ) => { if ( @@ -9436,6 +9473,9 @@ export function App({ pushToast('warning', t('session.recoveryBlocksAction')); return false; } + pendingManualTitleRef.current = opts?.carryManualTitle + ? { displayName: opts.carryManualTitle } + : undefined; splitClassificationGenerationRef.current += 1; const invocation = ++sessionOpenInvocationRef.current; let nextContext: DaemonProductSessionContext | undefined; @@ -9455,6 +9495,7 @@ export function App({ } : undefined); if (nextContext?.kind === 'live') { + pendingManualTitleRef.current = undefined; gitModeIntentRef.current = { mode: 'current' }; setGitModeIntent({ mode: 'current' }); try { @@ -9627,6 +9668,7 @@ export function App({ // intent. Compared before the ref is overwritten below. `undefined` // is the primary selection on both sides. const sameTarget = workspaceCwd === selectedWorkspaceCwdRef.current; + if (!sameTarget) pendingManualTitleRef.current = undefined; composerSourceVersionRef.current += 1; selectedWorkspaceCwdRef.current = workspaceCwd; setSelectedWorkspaceCwd(workspaceCwd); @@ -10123,6 +10165,7 @@ export function App({ workspaceCwd?: string, sessionContext?: DaemonProductSessionContext, ) => { + pendingManualTitleRef.current = undefined; splitClassificationGenerationRef.current += 1; const invocation = ++sessionOpenInvocationRef.current; const previousPendingContext = pendingSessionContextRef.current; @@ -11899,7 +11942,23 @@ export function App({ return true; } if (cmd === 'clear') { - void createNewSession({ kind: 'inherit' }); + const current = connectionRef.current; + const summary = currentSessionSummaryRef.current; + const carryManualTitle = current.sessionId + ? current.titleSource === 'manual' + ? current.displayName + : current.titleSource === undefined && + summary?.sessionId === current.sessionId && + summary.titleSource === 'manual' + ? summary.displayName + : current.titleSource === undefined + ? pendingManualTitleRef.current?.displayName + : undefined + : pendingManualTitleRef.current?.displayName; + void createNewSession( + { kind: 'inherit' }, + carryManualTitle?.trim() ? { carryManualTitle } : undefined, + ); return true; } if (cmd === 'new' || cmd === 'reset') { @@ -11907,6 +11966,7 @@ export function App({ return true; } if (cmd === 'rename') { + pendingManualTitleRef.current = undefined; const renameArg = parseRenameArgument(text.slice(match[0].length)); if (renameArg.type === 'auto' || renameArg.type === 'delegate') { if (commandBlocked) { diff --git a/packages/web-shell/client/daemon/session/actions.test.ts b/packages/web-shell/client/daemon/session/actions.test.ts index fd7bbd880cc..ac0e436a1b4 100644 --- a/packages/web-shell/client/daemon/session/actions.test.ts +++ b/packages/web-shell/client/daemon/session/actions.test.ts @@ -31,6 +31,7 @@ describe('getConnectionAfterSessionClear', () => { sessionId: 'session-a', clientId: 'client-a', displayName: 'Session A', + titleSource: 'manual', tokenCount: 42, goalState: { v: 2, goal: null, activity: 'idle' }, commands: [commandInfo('old-command')], @@ -58,6 +59,7 @@ describe('getConnectionAfterSessionClear', () => { expect(next).not.toHaveProperty('sessionId'); expect(next).not.toHaveProperty('clientId'); expect(next).not.toHaveProperty('displayName'); + expect(next).not.toHaveProperty('titleSource'); expect(next).not.toHaveProperty('tokenCount'); expect(next).not.toHaveProperty('goalState'); expect(next).not.toHaveProperty('supportedCommands'); diff --git a/packages/web-shell/client/daemon/session/actions.ts b/packages/web-shell/client/daemon/session/actions.ts index f2fcad831dd..3affec8d4bf 100644 --- a/packages/web-shell/client/daemon/session/actions.ts +++ b/packages/web-shell/client/daemon/session/actions.ts @@ -283,6 +283,7 @@ export function getConnectionAfterSessionClear( delete next.sessionId; delete next.clientId; delete next.displayName; + delete next.titleSource; delete next.tokenUsage; delete next.tokenCount; delete next.goalState; @@ -776,6 +777,7 @@ export function createDaemonSessionActions({ standaloneSession: undefined, clientId: undefined, displayName: undefined, + titleSource: undefined, goalState: undefined, error: undefined, errorStatus: undefined, diff --git a/packages/web-shell/client/daemon/session/mappers.test.ts b/packages/web-shell/client/daemon/session/mappers.test.ts index 97e3f610209..bd9b0058bf5 100644 --- a/packages/web-shell/client/daemon/session/mappers.test.ts +++ b/packages/web-shell/client/daemon/session/mappers.test.ts @@ -77,6 +77,95 @@ const turnComplete: DaemonEvent = { data: { stopReason: 'end_turn' }, }; +describe('session title metadata', () => { + it('keeps manual provenance with a renamed session', () => { + expect( + applyEvent( + { status: 'connected' }, + { + id: 1, + v: 1, + type: 'session_metadata_updated', + data: { + sessionId: 'session-1', + displayName: 'Bug hunt', + titleSource: 'manual', + }, + }, + ), + ).toMatchObject({ + displayName: 'Bug hunt', + titleSource: 'manual', + }); + }); + + it('keeps manual provenance when a pr-only event echoes the same name', () => { + const renamed = applyEvent( + { status: 'connected' }, + { + id: 1, + v: 1, + type: 'session_metadata_updated', + data: { + sessionId: 'session-1', + displayName: 'Bug hunt', + titleSource: 'manual', + }, + }, + ); + expect(renamed).toMatchObject({ + displayName: 'Bug hunt', + titleSource: 'manual', + }); + expect( + applyEvent(renamed, { + id: 2, + v: 1, + type: 'session_metadata_updated', + // The bridge's pr-binding publish echoes the name without a + // provenance: binding a PR must not wipe the manual title. + data: { + sessionId: 'session-1', + displayName: 'Bug hunt', + prs: [ + { + number: 9260, + url: 'https://github.com/QwenLM/qwen-code/pull/9260', + }, + ], + }, + }), + ).toMatchObject({ + displayName: 'Bug hunt', + titleSource: 'manual', + }); + }); + + it('drops provenance when an unstamped event changes the name', () => { + const renamed = applyEvent( + { status: 'connected' }, + { + id: 1, + v: 1, + type: 'session_metadata_updated', + data: { + sessionId: 'session-1', + displayName: 'Bug hunt', + titleSource: 'manual', + }, + }, + ); + const next = applyEvent(renamed, { + id: 2, + v: 1, + type: 'session_metadata_updated', + data: { sessionId: 'session-1', displayName: 'New name' }, + }); + expect(next.displayName).toBe('New name'); + expect(next.titleSource).toBeUndefined(); + }); +}); + describe('mapReasoningControls', () => { it('maps toggle-only reasoning without exposing an effort list', () => { expect( diff --git a/packages/web-shell/client/daemon/session/mappers.ts b/packages/web-shell/client/daemon/session/mappers.ts index c135d87eaab..5618ed4abe1 100644 --- a/packages/web-shell/client/daemon/session/mappers.ts +++ b/packages/web-shell/client/daemon/session/mappers.ts @@ -380,9 +380,22 @@ export function updateConnectionFromDaemonEvent( case 'session_metadata_updated': { const data = getRecord(event.data); if (Object.prototype.hasOwnProperty.call(data ?? {}, 'displayName')) { + const displayName = getString(data, 'displayName'); + const titleSource = getString(data, 'titleSource'); setConnection((current) => ({ ...current, - displayName: getString(data, 'displayName'), + displayName, + titleSource: + displayName && (titleSource === 'manual' || titleSource === 'auto') + ? titleSource + : // A metadata event that echoes the unchanged name without an + // explicit provenance (the bridge's pr-only publish) does not + // change the title, so it must not strip the provenance the + // `/clear` carry reads. Only a changed name of unknown + // provenance resets it. + displayName && displayName === current.displayName + ? current.titleSource + : undefined, })); } break; diff --git a/packages/web-shell/client/daemon/session/types.ts b/packages/web-shell/client/daemon/session/types.ts index ea834132dea..d2c1ca96b67 100644 --- a/packages/web-shell/client/daemon/session/types.ts +++ b/packages/web-shell/client/daemon/session/types.ts @@ -114,6 +114,7 @@ export interface DaemonConnectionState { reasoning?: DaemonReasoningControls; currentMode?: string; displayName?: string; + titleSource?: 'manual' | 'auto'; /** Latest main-conversation model usage event. */ tokenUsage?: DaemonTokenUsage; /** Authoritative Goal v2 state for the current session. */