From a9ea193e31adba8dfc3418bb1ee0822300c37dfc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:43:53 +0800 Subject: [PATCH 1/9] feat(web): render durable session titles --- apps/web/tests/session-title.snapshot.ts | 114 ++++++++++++++++++ apps/web/tests/snapshots/session-title.json | 12 ++ knip.json | 2 +- .../client/connection/src/client/fixture.ts | 34 +++++- .../client/connection/tests/fixture.spec.ts | 13 +- .../runtime/src/client/sessions/lineage.ts | 18 ++- .../runtime/src/client/sessions/manager.ts | 33 ++++- .../runtime/src/client/sessions/service.ts | 15 ++- packages/client/runtime/tests/manager.spec.ts | 30 +++++ .../runtime/tests/sessions-service.spec.ts | 18 ++- .../src/client/skeleton/ConversationRoot.tsx | 2 +- .../tests/apply-inject.spec.tsx | 4 +- .../ui-conversation/tests/chat-apply.spec.tsx | 4 +- .../tests/chat-stats-bash-sample.spec.tsx | 4 +- .../tests/gate-branch-tails.spec.tsx | 6 +- .../tests/selection-survival.spec.ts | 12 +- .../tests/skeleton-branches.spec.tsx | 2 +- .../ui-conversation/tests/skeleton.spec.tsx | 4 +- .../client/ui-layout/tests/service.spec.ts | 2 +- packages/client/ui-sidebar/src/client/tree.ts | 8 +- .../client/ui-sidebar/tests/apply.spec.tsx | 4 +- .../ui-sidebar/tests/sidebar-root.spec.tsx | 1 + .../client/ui-sidebar/tests/store.spec.ts | 1 + packages/client/ui-sidebar/tests/tree.spec.ts | 13 +- .../client/ui-trajectory/tests/views.spec.tsx | 2 +- packages/client/web/src/DocumentTitle.tsx | 22 ++++ packages/client/web/src/app.tsx | 7 ++ packages/client/web/src/index.ts | 1 + packages/client/web/tests/boot.spec.tsx | 8 +- .../client/web/tests/document-title.spec.tsx | 28 +++++ .../host/apiproxy/src/api/events.schema.ts | 1 + packages/host/apiproxy/src/api/events.ts | 6 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 8 ++ packages/host/runtime/package.json | 1 + packages/host/runtime/src/api-proxy.ts | 31 ++++- .../host/runtime/tests/host-runtime.spec.ts | 60 +++++++++ packages/host/runtime/tsconfig.json | 3 + pnpm-lock.yaml | 3 + vitest.snapshot.config.ts | 1 + 39 files changed, 481 insertions(+), 57 deletions(-) create mode 100644 apps/web/tests/session-title.snapshot.ts create mode 100644 apps/web/tests/snapshots/session-title.json create mode 100644 packages/client/web/src/DocumentTitle.tsx create mode 100644 packages/client/web/tests/document-title.spec.tsx diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts new file mode 100644 index 0000000000..a1b5f45839 --- /dev/null +++ b/apps/web/tests/session-title.snapshot.ts @@ -0,0 +1,114 @@ +// @vitest-environment jsdom +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { BootPluginEntry } from '@deepseek-ai/dsh-client-runtime/client' +import { bootWebShell } from '@deepseek-ai/dsh-client-web' + +const PLUGINS: readonly (BootPluginEntry & { dir: string })[] = [ + { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', url: '/plugins/i18n.js', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, +] + +const bundles = new Map(PLUGINS.map(plugin => [ + plugin.url, + readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'), +])) + +interface FixtureTiming { + appendTitle(id: string, title: string): void +} + +interface FixtureWindow extends Window { + __DSH_BOOT__?: { plugins: BootPluginEntry[] } + DSHClientProxy?: unknown +} + +class ResizeObserverStub { + observe(): void {} + disconnect(): void {} + unobserve(): void {} +} + +const win = window as FixtureWindow +let unmount: (() => void) | undefined + +beforeEach(() => { + localStorage.clear() + history.replaceState(null, '', '/?fixture') + document.title = 'DeepSeek Harness' + const root = document.createElement('div') + root.id = 'root' + document.body.appendChild(root) + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => { callback(0) }, 0) as unknown as number) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) }) + win.__DSH_BOOT__ = { plugins: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } +}) + +afterEach(() => { + act(() => { unmount?.() }) + unmount = undefined + cleanup() + delete win.__DSH_BOOT__ + delete win.DSHClientProxy + delete (globalThis as Record).__fxTiming + document.body.innerHTML = '' + document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) + document.title = '' + history.replaceState(null, '', '/') + vi.unstubAllGlobals() +}) + +/** Read only the stable, user-facing title surfaces from the assembled app. */ +function titleSurfaces(label: string): { sidebar: string; breadcrumb: string; documentTitle: string } { + const tree = screen.getByRole('tree', { name: 'Sessions' }) + const sidebar = within(tree).getByText(label).textContent ?? '' + const breadcrumb = within(screen.getByRole('navigation', { name: '会话层级' })) + .getByRole('button', { name: label }).textContent ?? '' + return { sidebar, breadcrumb, documentTitle: document.title } +} + +it('projects initial and revised durable titles through the built eight-plugin fixture app', async () => { + const root = document.querySelector('#root') + if (root === null) throw new Error('snapshot root missing') + act(() => { + unmount = bootWebShell(root, { + fetchBundle: (url) => { + const code = bundles.get(url) + return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code) + }, + executeBundle: (code) => { (0, eval)(code) }, + }) + }) + + const projectLabel = await screen.findByText('fixture', {}, { timeout: 10_000 }) + const projectRow = projectLabel.closest('[role="treeitem"]') + if (projectRow === null) throw new Error('fixture project row missing') + fireEvent.click(projectRow) + + const initialLabel = 'Fixture 历史会话' + const initialRowLabel = await screen.findByText(initialLabel) + const initialRow = initialRowLabel.closest('[role="treeitem"]') + if (initialRow === null) throw new Error('fixture session row missing') + fireEvent.click(initialRow) + await waitFor(() => { expect(document.title).toBe(`${initialLabel} — DeepSeek Harness`) }) + const initial = titleSurfaces(initialLabel) + + const revisedLabel = 'Fixture 修订标题' + const timing = (globalThis as Record).__fxTiming as FixtureTiming + act(() => { timing.appendTitle('fx-alpha', revisedLabel) }) + await waitFor(() => { expect(document.title).toBe(`${revisedLabel} — DeepSeek Harness`) }) + const revised = titleSurfaces(revisedLabel) + + await expect(`${JSON.stringify({ initial, revised }, null, 2)}\n`) + .toMatchFileSnapshot('./snapshots/session-title.json') +}) diff --git a/apps/web/tests/snapshots/session-title.json b/apps/web/tests/snapshots/session-title.json new file mode 100644 index 0000000000..2063036803 --- /dev/null +++ b/apps/web/tests/snapshots/session-title.json @@ -0,0 +1,12 @@ +{ + "initial": { + "sidebar": "Fixture 历史会话", + "breadcrumb": "Fixture 历史会话", + "documentTitle": "Fixture 历史会话 — DeepSeek Harness" + }, + "revised": { + "sidebar": "Fixture 修订标题", + "breadcrumb": "Fixture 修订标题", + "documentTitle": "Fixture 修订标题 — DeepSeek Harness" + } +} diff --git a/knip.json b/knip.json index dbc1e585de..e89f1908fe 100644 --- a/knip.json +++ b/knip.json @@ -545,6 +545,7 @@ "apps/web": { "entry": [ "tests/**/*.e2e.ts", + "tests/**/*.snapshot.ts", "tests/support.ts" ], "project": [ @@ -552,7 +553,6 @@ "tests/**/*.ts" ], "ignoreDependencies": [ - "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-primitives", "@deepseek-ai/dsh-client-ui-slots", "@deepseek-ai/dsh-client-web-react", diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 7d4a93e888..f831519e25 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -40,7 +40,13 @@ function buildAlphaLog(): SessionEvent[] { } for (let turn = 0; turn < 60; turn++) { push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) - push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`), source: { kind: 'user' } } }) + const userSeq = push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`), source: { kind: 'user' } } }) + if (turn === 0) { + push({ + type: 'session/title', + data: { title: 'Fixture 历史会话', messageSeqs: [userSeq], source: { kind: 'fallback' } }, + }) + } if (turn % 9 === 4) { push({ type: 'context/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } }) } @@ -155,6 +161,20 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi return undefined } +/** Fold the latest fixture title into the host's control-frame projection. */ +function titleFrameOf(id: SessionId, log: readonly SessionEvent[]): Extract | undefined { + const event = log.findLast(item => (item as { type: string }).type === 'session/title') + if (event === undefined) return undefined + const titleEvent = event as unknown as { seq: number; time: number; data: { title: string } } + return { + type: 'session/title', + sessionId: id, + title: titleEvent.data.title, + eventSeq: titleEvent.seq, + updatedAt: titleEvent.time, + } +} + /** * Message-boundary paging (mirrors the host's paging contract): count * maxMessages messages @@ -294,6 +314,10 @@ export function createFixtureApi(): ApiProxy { emitMux(view === undefined ? { type: 'session/event', sessionId: id, event } : { type: 'session/event', sessionId: id, event, view }) + if ((event as { type: string }).type === 'session/title') { + // The raw title is already in this log, so the latest-title fold must find it. + emitMux(titleFrameOf(id, log) as Extract) + } } /** At most one in-flight replay per session; cancel clears it. */ @@ -322,6 +346,12 @@ export function createFixtureApi(): ApiProxy { appendUser(id: string, msg: string): void { append(sid(id), { type: 'user/message', surfaceOp: 'append', data: { content: text(msg), source: { kind: 'user' } } }) }, + /** Append a later durable title revision through the normal raw-event + control-frame path. */ + appendTitle(id: string, title: string): void { + const log = logOf(sid(id)) + const messageSeqs = log.filter(event => event.type === 'user/message').map(event => event.seq) + append(sid(id), { type: 'session/title', data: { title, messageSeqs, source: { kind: 'provider', provider: 'fixture' } } }) + }, /** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */ appendSilent(id: string, msg: string): void { const log = logOf(sid(id)) @@ -433,6 +463,8 @@ export function createFixtureApi(): ApiProxy { for (const s of sessions) { if (!s.running) continue conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: (logs.get(s.sessionId)?.length ?? 0) - 1 } }) + const title = titleFrameOf(s.sessionId, logs.get(s.sessionId) ?? []) + if (title !== undefined) conn.push({ rpcId: mint(), payload: title }) } conn.push({ rpcId: pendingApprovalRpcId, diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index c50921a44d..440c90fc05 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -18,6 +18,7 @@ interface TimingHooks { setHistoryDelay(ms: number): void failNextHistory(): void appendUser(id: string, msg: string): void + appendTitle(id: string, title: string): void appendSilent(id: string, msg: string): void breakStreams(): void } @@ -155,7 +156,7 @@ describe('createFixtureApi', () => { const envelopes: RpcRequest[] = [] for await (const envelope of api.events.mux(req({}), abort.signal)) { envelopes.push(envelope) - if (envelopes.length >= 2) abort.abort() + if (envelopes.length >= 3) abort.abort() } return envelopes } @@ -163,8 +164,9 @@ describe('createFixtureApi', () => { const second = await openOnce() expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' }) expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0) - expect(first[1]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) - expect(second[1]?.rpcId).toBe(first[1]?.rpcId) // stable rpcId across replays (host replay semantics) + expect(first[1]?.payload).toMatchObject({ type: 'session/title', sessionId: 'fx-alpha', title: 'Fixture 历史会话' }) + expect(first[2]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) + expect(second[2]?.rpcId).toBe(first[2]?.rpcId) // stable rpcId across replays (host replay semantics) }) it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => { @@ -248,10 +250,15 @@ describe('createFixtureApi', () => { await new Promise(resolve => setTimeout(resolve, 10)) hooks.appendSilent('fx-alpha', '静默丢帧') hooks.appendUser('fx-alpha', '正常直播') + hooks.appendTitle('fx-alpha', 'Fixture 修订标题') await vi.waitFor(() => { expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true) + expect(seen.some(f => f.type === 'session/title' && f.title === 'Fixture 修订标题')).toBe(true) }) expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false) + const rawTitleIndex = seen.findIndex(f => f.type === 'session/event' && (f.event as { type: string }).type === 'session/title') + const titleControlIndex = seen.findIndex(f => f.type === 'session/title' && f.title === 'Fixture 修订标题') + expect(titleControlIndex).toBe(rawTitleIndex + 1) // But history serves the silent event (the client's repull finds it). const repull = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 })) if (!repull.result.ok) throw new Error('repull failed') diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index 4f67f33343..c6bd572ea7 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -4,9 +4,15 @@ import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client' +/** Host list summary enriched with the latest mux-projected durable title. */ +export interface TitledSessionSummary extends SessionSummary { + title?: string +} + /** One flattened session-list row (summary + lineage indent depth). */ export interface SessionListEntry { sessionId: SessionId + title?: string updatedAt: number running: boolean parentSessionId?: SessionId @@ -21,12 +27,12 @@ export interface SessionListEntry { * @param summaries - the host's session.list items. * @returns display rows in render order. */ -export function flattenLineage(summaries: readonly SessionSummary[]): SessionListEntry[] { - const byId = new Map() +export function flattenLineage(summaries: readonly TitledSessionSummary[]): SessionListEntry[] { + const byId = new Map() for (const s of summaries) byId.set(s.sessionId, s) - const children = new Map() - const roots: SessionSummary[] = [] + const children = new Map() + const roots: TitledSessionSummary[] = [] for (const s of summaries) { if (s.parentSessionId !== undefined && byId.has(s.parentSessionId)) { const list = children.get(s.parentSessionId) ?? [] @@ -37,12 +43,12 @@ export function flattenLineage(summaries: readonly SessionSummary[]): SessionLis } } - const byUpdatedDesc = (a: SessionSummary, b: SessionSummary): number => b.updatedAt - a.updatedAt + const byUpdatedDesc = (a: TitledSessionSummary, b: TitledSessionSummary): number => b.updatedAt - a.updatedAt roots.sort(byUpdatedDesc) const out: SessionListEntry[] = [] const visited = new Set() - const walk = (s: SessionSummary, depth: number): void => { + const walk = (s: TitledSessionSummary, depth: number): void => { if (visited.has(s.sessionId)) { console.warn(`[web-runtime] lineage cycle at ${s.sessionId}; emitting as root`) return diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 2881fea468..950b03b517 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -4,7 +4,7 @@ import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client' import { transportError } from '@deepseek-ai/dsh-client-connection/client' -import type { SessionListEntry } from './lineage.ts' +import type { SessionListEntry, TitledSessionSummary } from './lineage.ts' import { flattenLineage } from './lineage.ts' import { Notifier } from './notifier.ts' import { Session } from './session.ts' @@ -19,6 +19,13 @@ export interface SessionListSnapshot { /** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */ const PENDING_BUFFER_CAP = 32 +/** Latest title control snapshot retained independently of list/instance arrival. */ +interface SessionTitleSnapshot { + title: string + eventSeq: number + updatedAt: number +} + /** Instance cluster + frame entry + the session list (see the web client architecture RFC). */ export class SessionManager { private readonly sessions = new Map() @@ -27,6 +34,7 @@ export class SessionManager { * drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these * frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */ private readonly pendingBuffers = new Map[]>() + private readonly titleSnapshots = new Map() private summaries: SessionSummary[] = [] private listState: 'idle' | 'loading' | 'error' = 'idle' private listError: RpcError | null = null @@ -158,6 +166,17 @@ export class SessionManager { handleMuxEnvelope(envelope: RpcRequest): void { const frame = envelope.payload if (frame.type === 'stream/error') return // Controller already treats this as stream failure + if (frame.type === 'session/title') { + const current = this.titleSnapshots.get(frame.sessionId) + if (current !== undefined && current.eventSeq >= frame.eventSeq) return + this.titleSnapshots.set(frame.sessionId, { + title: frame.title, + eventSeq: frame.eventSeq, + updatedAt: frame.updatedAt, + }) + this.notifier.markDirty() + return + } const session = this.sessions.get(frame.sessionId) if (session === undefined) { // Approval/question frames never hit history: buffer for replay on instantiation; @@ -204,6 +223,7 @@ export class SessionManager { this.summaries = this.summaries.filter(s => s.sessionId !== frame.sessionId) this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation + this.titleSnapshots.delete(frame.sessionId) this.notifier.markDirty() return } @@ -230,12 +250,19 @@ export class SessionManager { } private buildListSnapshot(): SessionListSnapshot { - const fresh = flattenLineage(this.summaries) + const merged: TitledSessionSummary[] = this.summaries.map((summary) => { + const title = this.titleSnapshots.get(summary.sessionId) + return title === undefined + ? summary + : { ...summary, title: title.title, updatedAt: Math.max(summary.updatedAt, title.updatedAt) } + }) + const fresh = flattenLineage(merged) const items = fresh.map((entry) => { const prev = this.entryCache.get(entry.sessionId) if ( prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running - && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.depth === entry.depth + && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd + && prev.title === entry.title && prev.depth === entry.depth ) return prev this.entryCache.set(entry.sessionId, entry) return entry diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 2b4cf9677e..25c6579c3f 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -21,7 +21,10 @@ import type { Session } from './session.ts' /** Session list row projected from the host list RPC plus live stream increments. */ export interface SessionSummary { id: SessionId - title: string + /** Latest durable log-backed title, absent until the host projects one. */ + title?: string + /** Human-facing label: durable title, project basename, then session id. */ + displayTitle: string cwd?: string parentId?: SessionId running: boolean @@ -54,10 +57,11 @@ export function scopeOf(ctx: Context): SessionId | undefined { function sessionScope(): void {} /** - * Display title projection. The wire summary carries no title yet (P-I - * ledger): the project directory's basename stands in, then the raw id. + * Display title projection: durable title, project directory basename, then + * the raw id. */ -function titleOf(cwd: string | undefined, id: SessionId): string { +function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string { + if (title !== undefined) return title if (cwd !== undefined && cwd !== '') { const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() if (base !== undefined && base !== '') return base @@ -176,9 +180,10 @@ export class SessionsService { ids.push(entry.sessionId) byId[entry.sessionId] = { id: entry.sessionId, - title: titleOf(entry.cwd, entry.sessionId), + displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId), running: entry.running, updatedAt: entry.updatedAt, + ...(entry.title !== undefined ? { title: entry.title } : {}), ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), } diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index af7bc60fd2..edf326bd31 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -89,6 +89,36 @@ describe('list lifecycle', () => { expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } }) expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2]) }) + + it('retains monotonic title snapshots before list arrival, merges recency, and clears them on removal', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(api) + manager.handleMuxEnvelope({ + rpcId: 'title-new' as never, + payload: { type: 'session/title', sessionId: S1, title: 'Newest', eventSeq: 4, updatedAt: 300 }, + }) + manager.handleMuxEnvelope({ + rpcId: 'title-stale' as never, + payload: { type: 'session/title', sessionId: S1, title: 'Stale', eventSeq: 3, updatedAt: 900 }, + }) + manager.handleMuxEnvelope({ + rpcId: 'title-equal' as never, + payload: { type: 'session/title', sessionId: S1, title: 'Equal', eventSeq: 4, updatedAt: 901 }, + }) + api.onList = () => Promise.resolve(ok({ + items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[], + })) + await manager.refreshList() + + const titled = manager.getListSnapshot() + expect(titled.items.map(item => item.sessionId)).toEqual([S1, S2]) + expect(titled.items[0]).toMatchObject({ title: 'Newest', updatedAt: 300 }) + expect(titled.items[1]?.title).toBeUndefined() + + manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) + manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', sessionId: S1 } }) + expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined() + }) }) describe('host frame routing', () => { diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index f3989c4532..4a8b68ed73 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -38,16 +38,21 @@ async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: s } describe('list store projection', () => { - it('projects ids/byId with cwd-basename titles (id fallback) and parent links', async () => { + it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => { const b = bench() + b.svc.manager.handleMuxEnvelope({ + rpcId: 'title' as never, + payload: { type: 'session/title', sessionId: sid('s1'), title: 'Durable title', eventSeq: 2, updatedAt: 3 }, + }) await feedList(b, [ { id: 's1', cwd: '/home/u/proj-a/' }, { id: 's2', parentId: 's1', running: true }, ]) const state = b.svc.list.getSnapshot() expect(state.ids).toEqual(['s1', 's2']) - expect(state.byId[sid('s1')]).toMatchObject({ title: 'proj-a', cwd: '/home/u/proj-a/' }) - expect(state.byId[sid('s2')]).toMatchObject({ title: 's2', parentId: 's1', running: true }) + expect(state.byId[sid('s1')]).toMatchObject({ title: 'Durable title', displayTitle: 'Durable title', cwd: '/home/u/proj-a/' }) + expect(state.byId[sid('s2')]).toMatchObject({ displayTitle: 's2', parentId: 's1', running: true }) + expect(state.byId[sid('s2')]?.title).toBeUndefined() }) it('reflects live increments (host stream via manager) into the store', async () => { @@ -141,12 +146,13 @@ describe('create', () => { }) describe('coverage tails (branch duals)', () => { - it('titleOf falls back to the id for empty and separator-only cwd', async () => { + it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => { const b = bench() await feedList(b, [{ id: 'no-base', cwd: '///' }, { id: 'empty-cwd', cwd: '' }]) const { byId } = b.svc.list.getSnapshot() - expect(byId[sid('no-base')]?.title).toBe('no-base') - expect(byId[sid('empty-cwd')]?.title).toBe('empty-cwd') + expect(byId[sid('no-base')]?.displayTitle).toBe('no-base') + expect(byId[sid('empty-cwd')]?.displayTitle).toBe('empty-cwd') + expect(byId[sid('no-base')]?.title).toBeUndefined() }) it('binding for an unknown session returns undefined without moving the watch', async () => { diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 540b76bda7..3f9aaf8bf1 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -54,7 +54,7 @@ export function ConversationRoot({ disabled={last} onClick={() => { actions.open(s.id) }} > - {s.title} + {s.displayTitle} ) diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 2c0166c7de..b343cf1bca 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -50,7 +50,7 @@ async function bench() { const listStore = createSnapshotStore({ ids: [ROOT], - byId: { [ROOT]: { id: ROOT, title: 'R', cwd: '/proj', running: false, updatedAt: 1 } }, + byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, updatedAt: 1 } }, }) const snap = snapshotBase() const sessionFake = { @@ -208,7 +208,7 @@ describe('conversation slot inject surface', () => { // Ancestry and draft/active-view hooks execute inside a component tree. const HookProbe = () => { const injected2 = b.entryOf('conversation').options.inject(b.binding) as { - useAncestry: () => readonly { title: string }[] + useAncestry: () => readonly { displayTitle: string }[] useActiveView: () => string | undefined composer: { useDraft: () => string } } diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index e6ce83edc5..72d31560b9 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -26,8 +26,8 @@ async function bench() { const listStore = createSnapshotStore({ ids: [ROOT, CHILD], byId: { - [ROOT]: { id: ROOT, title: 'R', running: false, updatedAt: 1 }, - [CHILD]: { id: CHILD, title: 'C', parentId: ROOT, running: false, updatedAt: 2 }, + [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, updatedAt: 1 }, + [CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, updatedAt: 2 }, }, }) const sessionsFake = { diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 63b76c86df..3870e7eb09 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -155,8 +155,8 @@ describe('bash toolview samples', () => { getSnapshot: () => ({ ids: [root, child], byId: { - [root]: { id: root, title: 'r', running: false, updatedAt: 0 }, - [child]: { id: child, title: 'c', parentId: root, running: false, updatedAt: 0 }, + [root]: { id: root, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 }, + [child]: { id: child, title: 'c', displayTitle: 'c', parentId: root, running: false, updatedAt: 0 }, }, }), }) diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 3ff148f5d6..61aa499b5b 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -49,9 +49,9 @@ describe('apply need() and cwd cache', () => { const listStore = createSnapshotStore({ ids: [SID, 'x2' as SessionId, 'x3' as SessionId], byId: { - [SID]: { id: SID, title: 'a', cwd: '/proj', running: false, updatedAt: 1 }, - ['x2' as SessionId]: { id: 'x2' as SessionId, title: 'b', cwd: '', running: false, updatedAt: 1 }, - ['x3' as SessionId]: { id: 'x3' as SessionId, title: 'c', running: false, updatedAt: 1 }, + [SID]: { id: SID, title: 'a', displayTitle: 'a', cwd: '/proj', running: false, updatedAt: 1 }, + ['x2' as SessionId]: { id: 'x2' as SessionId, title: 'b', displayTitle: 'b', cwd: '', running: false, updatedAt: 1 }, + ['x3' as SessionId]: { id: 'x3' as SessionId, title: 'c', displayTitle: 'c', running: false, updatedAt: 1 }, }, }) ctx.provide('sessions', { list: listStore, manager: { get: vi.fn() }, ancestry: () => [], scope: () => undefined, create: vi.fn() }) diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.ts b/packages/client/ui-conversation/tests/selection-survival.spec.ts index f12cb63617..c1a5846d48 100644 --- a/packages/client/ui-conversation/tests/selection-survival.spec.ts +++ b/packages/client/ui-conversation/tests/selection-survival.spec.ts @@ -49,13 +49,14 @@ function feed(b: Bench, rows: { id: string; cwd?: string; running?: boolean }[]) } describe('selection survives list refreshes (M1a)', () => { - it('create → select → title-upgrading refresh keeps scope, binding, store and value', async () => { + it('create → select → display-title-upgrading refresh keeps scope, binding, store and value', async () => { const b = bench() // First-send shape: client-side create inserts the row without cwd (title = bare id). b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s1') })) const id = await b.sessions.create({}) await flush() - expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('s1') + expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 's1' }) + expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined() const binding = b.sessions.binding(id) expect(binding).toBeDefined() @@ -63,11 +64,12 @@ describe('selection survives list refreshes (M1a)', () => { const store = (scoped.get('conversation') as ConversationService).selection store.set({ turnSeq: 3, callId: 'c1' }) - // The late list refresh lands (host knows the cwd → formal title). + // The late list refresh lands (host knows the cwd → better fallback label). feed(b, [{ id: 's1', cwd: '/w/proj-a' }]) await b.sessions.manager.refreshList() await flush() - expect(b.sessions.list.getSnapshot().byId[id]?.title).toBe('proj-a') + expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 'proj-a' }) + expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined() // Scope, binding and the selection account must all be identity-stable. expect(b.sessions.scope(id)).toBe(scoped) @@ -87,7 +89,7 @@ describe('selection survives list refreshes (M1a)', () => { const store = (scoped.get('conversation') as ConversationService).selection store.set({ turnSeq: 1, callId: 'c9' }) - // Reconnect generation: title upgrade arrives with the re-pull. + // Reconnect generation: display-title fallback upgrade arrives with the re-pull. feed(b, [{ id: 's1', cwd: '/w/proj-a', running: true }]) b.sessions.manager.handleConnected() await flush() diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index 25808e17c5..e7db208c17 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -33,7 +33,7 @@ function sessionSource(over?: Partial) { } const summary = (id: string, title: string): SessionSummary => - ({ id: id as SessionId, title, running: false, updatedAt: 1 }) + ({ id: id as SessionId, title: `durable ${title}`, displayTitle: title, running: false, updatedAt: 1 }) describe('ConversationRoot branches', () => { const chatEntry: ViewEntry = { diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index ab23a64389..d480744a0d 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -76,8 +76,8 @@ describe('ConversationRoot', () => { const send = vi.fn() const stop = vi.fn() const ancestry: SessionSummary[] = [ - { id: sid('root'), title: 'proj', running: false, updatedAt: 1 }, - { id: sid('s1'), title: 'child', running: false, updatedAt: 1, parentId: sid('root') }, + { id: sid('root'), title: 'proj', displayTitle: 'proj', running: false, updatedAt: 1 }, + { id: sid('s1'), title: 'child', displayTitle: 'child', running: false, updatedAt: 1, parentId: sid('root') }, ] const rendered: string[] = [] const ui = render( diff --git a/packages/client/ui-layout/tests/service.spec.ts b/packages/client/ui-layout/tests/service.spec.ts index 85d458cf7b..1e1d13b80c 100644 --- a/packages/client/ui-layout/tests/service.spec.ts +++ b/packages/client/ui-layout/tests/service.spec.ts @@ -20,7 +20,7 @@ function makeCtx() { /** Test-side brand: specs mint ids the wire would normally brand. */ const sid = (s: string): SessionId => s as SessionId -const summary = (id: SessionId) => ({ id, title: id as string, running: false, updatedAt: 1 }) +const summary = (id: SessionId) => ({ id, title: id as string, displayTitle: id as string, running: false, updatedAt: 1 }) beforeEach(() => { localStorage.clear() }) diff --git a/packages/client/ui-sidebar/src/client/tree.ts b/packages/client/ui-sidebar/src/client/tree.ts index 1858643d43..5b855005aa 100644 --- a/packages/client/ui-sidebar/src/client/tree.ts +++ b/packages/client/ui-sidebar/src/client/tree.ts @@ -153,7 +153,7 @@ function sessionRow(g: Group, s: SessionSummary, depth: number, hasChildren: boo type: 'session', id: s.id, groupKey: g.key, - title: s.title, + title: s.displayTitle, depth, hasChildren, expanded, @@ -182,7 +182,7 @@ function flattenVisible(g: Group, expandedSessions: ReadonlySet, rows: S function searchVisible(g: Group, q: string): Set { const visible = new Set() for (const m of g.summaries.values()) { - if (!m.title.toLowerCase().includes(q)) continue + if (!m.displayTitle.toLowerCase().includes(q)) continue let cur: SessionSummary | undefined = m while (cur !== undefined && !visible.has(cur.id)) { visible.add(cur.id) @@ -212,9 +212,9 @@ function flattenSearch(g: Group, visible: ReadonlySet, rows: SidebarR * * Normal mode: every project row shows; sessions show under expanded * projects, descending only into expanded sessions. Search mode (non-blank - * query, case-insensitive title substring): expansion state is ignored — + * query, case-insensitive display-title substring): expansion state is ignored — * matched sessions and their ancestor chains are forced visible, groups - * without a title or label hit are dropped, and a label-only hit keeps the + * without a display-title or label hit are dropped, and a label-only hit keeps the * bare project row. * @param list - sessions list snapshot. * @param view - expansion sets and search query. diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.spec.tsx index ea029c6709..9e57771a27 100644 --- a/packages/client/ui-sidebar/tests/apply.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.spec.tsx @@ -28,7 +28,7 @@ async function bench() { await ctx.plugin(SlotsService).await() const list = createSnapshotStore({ ids: [sid('a')], - byId: { [sid('a')]: { id: sid('a'), title: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } }, + byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } }, }) const sessions = { list, create: vi.fn(async () => sid('minted')) } const layout = { @@ -132,7 +132,7 @@ describe('apply', () => { sessions.list.update((draft) => { draft.ids.push(sid('kid')) draft.byId[sid('kid')] = { - id: sid('kid'), title: 'child', cwd: '/proj', parentId: sid('a'), running: false, updatedAt: 2, + id: sid('kid'), title: 'child', displayTitle: 'child', cwd: '/proj', parentId: sid('a'), running: false, updatedAt: 2, } }) await ctx.plugin({ inject: [...inject], apply }).await() diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index db03c672dd..077cdc9c56 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -31,6 +31,7 @@ function summary(init: SummaryInit): SessionSummary { const s: SessionSummary = { id: sid(init.id), title: init.title ?? init.id, + displayTitle: init.title ?? init.id, running: init.running ?? false, updatedAt: init.updatedAt ?? 0, } diff --git a/packages/client/ui-sidebar/tests/store.spec.ts b/packages/client/ui-sidebar/tests/store.spec.ts index d0bb3b386b..fbdb64c993 100644 --- a/packages/client/ui-sidebar/tests/store.spec.ts +++ b/packages/client/ui-sidebar/tests/store.spec.ts @@ -19,6 +19,7 @@ function summary(init: SummaryInit): SessionSummary { const s: SessionSummary = { id: sid(init.id), title: init.title ?? init.id, + displayTitle: init.title ?? init.id, running: init.running ?? false, updatedAt: init.updatedAt ?? 0, } diff --git a/packages/client/ui-sidebar/tests/tree.spec.ts b/packages/client/ui-sidebar/tests/tree.spec.ts index 1b29d460cf..ece5c769c6 100644 --- a/packages/client/ui-sidebar/tests/tree.spec.ts +++ b/packages/client/ui-sidebar/tests/tree.spec.ts @@ -11,6 +11,7 @@ const sid = (s: string) => s as SessionId interface SummaryInit { id: string title?: string + displayTitle?: string cwd?: string parentId?: string running?: boolean @@ -20,10 +21,11 @@ interface SummaryInit { function summary(init: SummaryInit): SessionSummary { const s: SessionSummary = { id: sid(init.id), - title: init.title ?? init.id, + displayTitle: init.displayTitle ?? init.title ?? init.id, running: init.running ?? false, updatedAt: init.updatedAt ?? 0, } + if (init.title !== undefined) s.title = init.title if (init.cwd !== undefined) s.cwd = init.cwd if (init.parentId !== undefined) s.parentId = sid(init.parentId) return s @@ -211,6 +213,15 @@ describe('deriveRows search', () => { const rows = deriveRows(list, view({ query: ' ' })) expect(rows.every(r => r.type === 'project')).toBe(true) }) + + it('matches the effective display title when no durable title is available', () => { + const fallback = listOf(summary({ id: 'raw-id', displayTitle: 'project fallback', cwd: '/elsewhere' })) + const rows = deriveRows(fallback, view({ query: 'fallback' })) + expect(rows).toEqual([ + expect.objectContaining({ type: 'project', key: '/elsewhere' }), + expect.objectContaining({ type: 'session', id: 'raw-id', title: 'project fallback' }), + ]) + }) }) describe('formatRelativeTime', () => { diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 41a3c0c95b..a020431698 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -52,7 +52,7 @@ async function bench() { function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] = NODES) { const { useSession } = fakeSession(nodes) const activeStore = createSnapshotStore(undefined) - const ancestry: SessionSummary[] = [{ id: SID, title: 'self', running: false, updatedAt: 1 }] + const ancestry: SessionSummary[] = [{ id: SID, title: 'self', displayTitle: 'self', running: false, updatedAt: 1 }] const viewProps = { sessionId: SID, useSession, useSelection: () => null, diff --git a/packages/client/web/src/DocumentTitle.tsx b/packages/client/web/src/DocumentTitle.tsx new file mode 100644 index 0000000000..608f97497d --- /dev/null +++ b/packages/client/web/src/DocumentTitle.tsx @@ -0,0 +1,22 @@ +import { useEffect, useRef } from 'react' + +/** Props for the shell-owned browser title projection. */ +export interface DocumentTitleProps { + /** Durable title of the selected session, or undefined for the product title. */ + title?: string +} + +/** + * Project the selected durable session title into the browser title and + * restore the shell's original product title when unmounted. + * @param props - selected session title projection. + * @returns no rendered content. + */ +export function DocumentTitle({ title }: DocumentTitleProps): null { + const original = useRef(document.title) + useEffect(() => { + document.title = title === undefined ? original.current : `${title} — ${original.current}` + return () => { document.title = original.current } + }, [title]) + return null +} diff --git a/packages/client/web/src/app.tsx b/packages/client/web/src/app.tsx index e8c25acc3d..581cd4c98f 100644 --- a/packages/client/web/src/app.tsx +++ b/packages/client/web/src/app.tsx @@ -11,6 +11,7 @@ import { createSessionProvider, RootBindingProvider, scopedSlots, } from '@deepseek-ai/dsh-client-web-react' import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import { DocumentTitle } from './DocumentTitle.tsx' type LayoutExports = typeof import('@deepseek-ai/dsh-client-ui-layout/client') @@ -47,6 +48,11 @@ export function buildRenderApp(deps: AssemblyDeps): () => ReactNode { const useDetails = layout.details.useSelector const setSidebarWidth = (px: number): void => { layout.setSidebarWidth(px) } const setDetailsWidth = (px: number): void => { layout.setDetailsWidth(px) } + const SessionDocumentTitle = (): ReactNode => { + const id = useCurrent() + const title = sessions.list.useSelector(state => id === undefined ? undefined : state.byId[id]?.title) + return + } const renderBody = (id: SessionId): ReactNode => ( <> @@ -75,6 +81,7 @@ export function buildRenderApp(deps: AssemblyDeps): () => ReactNode { return () => ( + (id === 's1' ? binding : undefined), }) }, @@ -119,6 +119,7 @@ afterEach(() => { delete win.__TEST_NAV__ document.body.innerHTML = '' document.head.querySelectorAll('script').forEach((s) => { s.remove() }) + document.title = '' }) describe('bootWebShell (real loader + real script execution)', () => { @@ -130,6 +131,7 @@ describe('bootWebShell (real loader + real script execution)', () => { ], } const el = mountPoint() + document.title = 'DeepSeek Harness' let unmount: (() => void) | undefined const s = seams({ '/plugins/fake-runtime.js': RUNTIME_STUB, @@ -145,9 +147,11 @@ describe('bootWebShell (real loader + real script execution)', () => { // Selected session: SessionProvider resolved the binding and renderBody // mounted the conversation slot content into the center column. expect(el.querySelector('[data-testid="conv-body"]')).not.toBeNull() + expect(document.title).toBe('S1 — DeepSeek Harness') act(() => { unmount!() }) expect(el.childElementCount).toBe(0) + expect(document.title).toBe('DeepSeek Harness') }) it('no selected session: renderEmpty keeps the grid and forwards width setters', async () => { @@ -159,6 +163,7 @@ describe('bootWebShell (real loader + real script execution)', () => { ], } const el = mountPoint() + document.title = 'DeepSeek Harness' const s = seams({ '/plugins/fake-runtime.js': RUNTIME_STUB, '/plugins/fake-layout.js': LAYOUT_STUB.replace("id: 'fake-layout'", `id: '${LAYOUT_ID}'`), @@ -169,6 +174,7 @@ describe('bootWebShell (real loader + real script execution)', () => { expect(frame).not.toBeNull() // Empty path: no conversation body (nothing registered into conversation.empty → fallback null). expect(el.querySelector('[data-testid="conv-body"]')).toBeNull() + expect(document.title).toBe('DeepSeek Harness') // Width setter/selector pass-through (assembly closures over ctx.layout). expect((frame as HTMLElement).dataset['widths']).toBe('300x360') act(() => { (frame as HTMLElement).click() }) diff --git a/packages/client/web/tests/document-title.spec.tsx b/packages/client/web/tests/document-title.spec.tsx new file mode 100644 index 0000000000..ed336a1ccc --- /dev/null +++ b/packages/client/web/tests/document-title.spec.tsx @@ -0,0 +1,28 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { DocumentTitle } from '../src/DocumentTitle.tsx' + +afterEach(() => { + cleanup() + document.title = '' +}) + +describe('DocumentTitle', () => { + it('preserves the product title without a durable title and restores it on unmount', () => { + document.title = 'DeepSeek Harness' + const mounted = render() + expect(document.title).toBe('DeepSeek Harness') + + mounted.rerender() + expect(document.title).toBe('First title — DeepSeek Harness') + + mounted.rerender() + expect(document.title).toBe('Revised title — DeepSeek Harness') + + mounted.rerender() + expect(document.title).toBe('DeepSeek Harness') + mounted.unmount() + expect(document.title).toBe('DeepSeek Harness') + }) +}) diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index a46cdfe09e..050063d5e9 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -25,6 +25,7 @@ export const askUserQuestionItemSchema = z.object({ export const muxFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }), z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }), + z.object({ type: z.literal('session/title'), sessionId: sessionIdSchema, title: z.string().min(1), eventSeq: z.number().int().nonnegative(), updatedAt: z.number() }), z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }), z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }), z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema) }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index eac4f0e65c..c03877c31d 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -33,8 +33,9 @@ export type ToolEventView = export interface EventsApi { /** * All-session aggregated mux stream. On open, emits a subscribed control frame for every - * attached session and replays each session's still-pending approval/question requested - * frames (rpcId reused verbatim — the refresh-recovery baseline). + * attached session followed by its optional latest title snapshot, then replays each + * session's still-pending approval/question requested frames (rpcId reused verbatim — the + * refresh-recovery baseline). * since: resume seam, unimplemented in v1 (ignored if passed); reconnection = reopen the * stream + refetch history. */ @@ -54,6 +55,7 @@ export interface EventsApi { export type MuxFrame = | { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView } | { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number } + | { type: 'session/title'; sessionId: SessionId; title: string; eventSeq: number; updatedAt: number } | { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string } | { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome } | { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] } diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 764c9673b9..23cda690ec 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -123,6 +123,7 @@ describe('events frame schemas', () => { const frames = [ { type: 'session/event', sessionId: 's', event: { type: 't', seq: 0, time: 1, data: null } }, { type: 'session/subscribed', sessionId: 's', lastSeq: -1 }, + { type: 'session/title', sessionId: 's', title: 'Durable title', eventSeq: 2, updatedAt: 3 }, { type: 'approval/requested', sessionId: 's', approvalId: 'a', toolName: 'bash', callId: 'c', reason: 'r' }, { type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' }, { type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] }, @@ -131,6 +132,13 @@ describe('events frame schemas', () => { ] for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type }) expect(() => muxFrameSchema.parse({ type: 'unknown/frame' })).toThrow() + for (const invalid of [ + { type: 'session/title', sessionId: 's', title: '', eventSeq: 0, updatedAt: 1 }, + { type: 'session/title', sessionId: 's', title: 'x', eventSeq: -1, updatedAt: 1 }, + { type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0.5, updatedAt: 1 }, + { type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0, updatedAt: 'now' }, + { type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0, updatedAt: Number.NaN }, + ]) expect(() => muxFrameSchema.parse(invalid)).toThrow() expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q') }) diff --git a/packages/host/runtime/package.json b/packages/host/runtime/package.json index ea32040012..62e19f6635 100644 --- a/packages/host/runtime/package.json +++ b/packages/host/runtime/package.json @@ -49,6 +49,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-spill-local": "workspace:^", diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 0621fbecb9..901e74dcf5 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -12,6 +12,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, SessionSummary, ToolEventView } from '@deepseek-ai/dsh-host-apiproxy/api' import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' @@ -104,6 +105,28 @@ function frame(payload: F): RpcRequest { return { rpcId: RpcId(randomUUID()), payload } } +type SessionTitleFrame = Extract + +/** Project the latest durable title without exposing title-generation policy. */ +function titleFrame(session: Session): SessionTitleFrame | undefined { + const title = foldSessionTitle(session.events) + if (title === undefined) return undefined + return { + type: 'session/title', + sessionId: session.id, + title: title.title, + eventSeq: title.eventSeq, + updatedAt: title.updatedAt, + } +} + +/** Queue the subscription baseline followed by its optional title snapshot. */ +function subscribeSession(queue: FrameQueue>, session: Session): void { + queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 })) + const title = titleFrame(session) + if (title !== undefined) queue.push(frame(title)) +} + /** SessionSummary projection for attached (in-memory) sessions. */ function summarize(session: Session, running: boolean): SessionSummary { return { @@ -362,7 +385,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro mux(_request, signal) { const queue = new FrameQueue>() for (const session of ctx.sessions.list()) { - queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 })) + subscribeSession(queue, session) } // Per-session open-call table for result-view pairing. Bounded by the // per-turn call count: entries clear on turn/end; a table miss (stream @@ -385,9 +408,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const view = viewFor(ctx, event, callId => openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId)) queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } })) + if (event.type === 'session/title') { + // The accepted raw event is already in session.events, so the fold must find it. + queue.push(frame(titleFrame(session) as SessionTitleFrame)) + } }), ctx.on('session/created', (session: Session) => { - queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 })) + subscribeSession(queue, session) }), ctx.on('session/disposed', (session: Session) => { openCalls.delete(session.id) diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index f3a2dc9a82..81bc4fc009 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -65,6 +65,21 @@ function expectOk(response: RpcResponse): T { return response.result.value } +async function nextMux(iterator: AsyncIterator>): Promise> { + const next = await iterator.next() + if (next.done === true) throw new Error('mux ended before the expected frame') + return next.value +} + +/** Durably append a title event without mounting title-generation policy. */ +function appendTitle(ctx: Context, agent: Agent, title: string) { + return ctx.sessions.appendOutOfBand(agent.session, 'session/title', { + title, + messageSeqs: [1], + source: { kind: 'fallback' }, + }, { kind: 'session-title' }) +} + let host: RunningHost | undefined beforeEach(() => { @@ -203,11 +218,14 @@ describe('sessions.history', () => { const idle = waitForIdle(first.ctx, agent) agent.send([{ type: 'text', text: 'save me' }]) await idle + const titleEvent = await appendTitle(first.ctx, agent, 'Persisted title') await first.dispose() host = await startHost({ boot: { persistenceRoot, provider: 'scripted', model: 'test-model' } }) host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([])) expect(host.ctx.agents.get(sessionId)).toBeUndefined() + const abort = new AbortController() + const mux = host.api.events.mux(request({}), abort.signal)[Symbol.asyncIterator]() const [a, b] = await Promise.all([ host.api.sessions.history(request({ sessionId })), host.api.sessions.history(request({ sessionId })), @@ -218,6 +236,11 @@ describe('sessions.history', () => { } expect(host.ctx.agents.get(sessionId)).toBeDefined() expect(host.ctx.agents.list()).toHaveLength(1) + expect((await nextMux(mux)).payload).toMatchObject({ type: 'session/subscribed', sessionId }) + expect((await nextMux(mux)).payload).toEqual(expect.objectContaining({ + type: 'session/title', sessionId, title: 'Persisted title', eventSeq: titleEvent.seq, + })) + abort.abort() }) it('errors session-not-found when resume fails, deduplicating concurrent resumes', async () => { @@ -325,6 +348,43 @@ describe('events streams', () => { expect((await stream.next()).done).toBe(true) }) + it('mux: projects durable titles after open baselines and immediately after live raw events', async () => { + const running = await boot() + const { api, ctx } = running + const { sessionId } = expectOk(await api.sessions.create(request({}))) + const agent = ctx.agents.get(sessionId) as Agent + const initial = await appendTitle(ctx, agent, 'Initial title') + + const ac = new AbortController() + const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]() + expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId }) + expect((await nextMux(stream)).payload).toEqual(expect.objectContaining({ + type: 'session/title', sessionId, title: 'Initial title', eventSeq: initial.seq, updatedAt: initial.time, + })) + + const revised = await appendTitle(ctx, agent, 'Revised title') + let raw: RpcRequest + do raw = await nextMux(stream) + while (!(raw.payload.type === 'session/event' && raw.payload.event.type === 'session/title')) + expect(raw.payload).toMatchObject({ type: 'session/event', sessionId, event: { seq: revised.seq } }) + expect((await nextMux(stream)).payload).toEqual(expect.objectContaining({ + type: 'session/title', sessionId, title: 'Revised title', eventSeq: revised.seq, updatedAt: revised.time, + })) + ac.abort() + }) + + it('mux: emits no title control for untitled subscriptions', async () => { + const { api } = await boot() + const first = expectOk(await api.sessions.create(request({}))).sessionId + const ac = new AbortController() + const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]() + expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId: first }) + + const second = expectOk(await api.sessions.create(request({}))).sessionId + expect((await nextMux(stream)).payload).toMatchObject({ type: 'session/subscribed', sessionId: second }) + ac.abort() + }) + it('host: session lifecycle, status flips (disposed suppressed), and agent errors', async () => { const running = await boot([textResponse('x')]) const { api, ctx } = running diff --git a/packages/host/runtime/tsconfig.json b/packages/host/runtime/tsconfig.json index 9836e0b5fd..72789891fb 100644 --- a/packages/host/runtime/tsconfig.json +++ b/packages/host/runtime/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/session" }, + { + "path": "../../session-title/session-title" + }, { "path": "../../core/system-prompt" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 968891f28d..28ee8bfebd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2032,6 +2032,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../../session-title/session-title '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../skill/skill diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index 142528e604..858a3fd9a1 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -41,6 +41,7 @@ export default defineConfig({ test: { setupFiles: ['./scripts/test-invariants.ts'], include: [ + 'apps/web/tests/**/*.snapshot.ts', 'examples/*/tests/**/*.snapshot.ts', 'packages/sdk/*/tests/**/*.snapshot.ts', 'packages/ui/tui/tests/**/*.snapshot.ts', From 2e91db9271f953c6dde8f0b18e31f0cce5eb79ad Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:44:16 +0800 Subject: [PATCH 2/9] docs(web): describe session title projection --- ...026-07-21-log-backed-session-titles.i18n.yaml | 4 ++-- .../2026-07-21-log-backed-session-titles.md | 3 ++- .../2026-07-21-log-backed-session-titles.zh.md | 3 ++- .../2026-07-20-gui-testing-system.i18n.yaml | 4 ++-- .../process/2026-07-20-gui-testing-system.md | 16 ++++++++-------- .../process/2026-07-20-gui-testing-system.zh.md | 16 ++++++++-------- packages/client/runtime/README.md | 5 ++++- packages/client/web/README.md | 2 ++ packages/host/apiproxy/README.md | 2 ++ 9 files changed, 32 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml index 32b0b3a218..17f4515c1d 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-log-backed-session-titles.md: cd0d2a4bab9b6504c65e942c0e03bce79488364e -2026-07-21-log-backed-session-titles.zh.md: b90ac6c59677e6542733210b91de38ef1169c760 +2026-07-21-log-backed-session-titles.md: 494187a73c58fb2313d802825c3ec9f9994d6f2b +2026-07-21-log-backed-session-titles.zh.md: cae51cca920fad748cb1944d35cc1b80850eb6ee diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md index cd0d2a4bab..494187a73c 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md @@ -40,7 +40,7 @@ Automatic provider failures are nonfatal warnings and retain the latest title. E A fork inherits seed title events unchanged, like the rest of its source log. The first-message provider does not automatically retitle a fork. The all-messages provider may append a child-owned revision after a later child prompt, using inherited and new eligible messages. -`ctx.sessionQuery.readTitle()` folds one live-preferred or persisted log without loading titles during `listSessions()`. ACP maps the event to `session_info_update` during both live streaming and load replay, using the event timestamp for `updatedAt`. The TUI uses the latest title as its header subtitle and sets the terminal window title to `` after terminal-safe rendering. A synthetic title turn remains a completed durability boundary for the metadata write; consumers reporting agent completion use the core `findLastMessageTurnEnd()` fold so a later title, injection, or other plugin-owned turn cannot replace the preceding message-triggered outcome. +`ctx.sessionQuery.readTitle()` folds one live-preferred or persisted log without loading titles during `listSessions()`. ACP maps the event to `session_info_update` during both live streaming and load replay, using the event timestamp for `updatedAt`. The TUI uses the latest title as its header subtitle and sets the terminal window title to `` after terminal-safe rendering. The Web host folds the same log state into a validated mux control frame after each attached-session subscription baseline and immediately after forwarding a live raw title event. The browser retains only newer title event seqs even when the control frame precedes list or session-instance creation; sidebar labels, search, breadcrumbs, and the browser title then react to the projected revision. `session.list` remains metadata-only, so a cold persisted session uses the cwd basename or id until opening or resuming it attaches the log. The browser title uses `` only for a selected titled session and otherwise preserves the product title. A synthetic title turn remains a completed durability boundary for the metadata write; consumers reporting agent completion use the core `findLastMessageTurnEnd()` fold so a later title, injection, or other plugin-owned turn cannot replace the preceding message-triggered outcome. ## Alternatives considered @@ -54,6 +54,7 @@ A fork inherits seed title events unchanged, like the rest of its source log. Th ## Consequences - Titles survive JSONL and SQLite persistence, replay through ACP, and follow fork inheritance without a separate mutable record. +- Web title delivery stays incremental and log-backed without a title index or persisted-list scan; cold list rows improve after attach. - A fallback appears without an auxiliary call; deployments choose whether better titles justify model cost and whether later prompts should retitle a session. - Auxiliary request records and late accepted titles consume event seqs and may create balanced zero-step turns, so persistence exposes both attempted dispatches and accepted updates even though model history and KV-cache identity do not change. - One provider and monotonic per-session revisions make disposal, supersession, and stale-result rejection explicit, at the cost of leaving multi-strategy precedence to a composite provider. diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md index b90ac6c596..cae51cca92 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md @@ -40,7 +40,7 @@ Status: implemented 与源日志的其他部分相同,fork 会原样继承作为种子的标题事件。首消息提供方不会自动为 fork 重新生成标题。全部消息提供方可以在子会话出现后续提示词后追加一项归子会话所有的修订,并使用继承的合格消息和新增的合格消息。 -`ctx.sessionQuery.readTitle()` 会折叠一份实时优先或已持久化的日志,而不会在 `listSessions()` 期间加载标题。ACP(Agent Client Protocol)会在实时流式输出和加载回放期间把该事件映射到 `session_info_update`,并使用事件时间戳作为 `updatedAt`。TUI 使用最新标题作为其标题栏副标题,并在完成终端安全渲染后,将终端窗口标题设置为 ``。合成标题轮次本身仍会完成,并作为元数据写入的持久性边界;报告 agent 完成情况的消费方使用核心的 `findLastMessageTurnEnd()` 折叠逻辑,因此后续的标题轮次、注入轮次或其他归插件所有的轮次无法取代此前由消息触发的结果。 +`ctx.sessionQuery.readTitle()` 会折叠一份实时优先或已持久化的日志,而不会在 `listSessions()` 期间加载标题。ACP(Agent Client Protocol)会在实时流式输出和加载回放期间把该事件映射到 `session_info_update`,并使用事件时间戳作为 `updatedAt`。TUI 使用最新标题作为其标题栏副标题,并在完成终端安全渲染后,将终端窗口标题设置为 ``。Web host 会在每个已附加会话的订阅基线之后,以及转发实时原始标题事件后立即,将同一份日志状态折叠为经过校验的 mux 控制帧。即使控制帧先于列表或会话实例创建抵达,浏览器也只保留标题事件 seq 较新的版本;侧边栏标签、搜索、面包屑和浏览器标题会随投影后的修订更新。`session.list` 仍只包含元数据,因此尚未打开的持久化会话会继续以 cwd 基名或 id 作为回退,直至打开或恢复会话时附加其日志。浏览器仅在选中已有标题的会话时将标题设置为 ``,否则保留产品标题。合成标题轮次本身仍会完成,并作为元数据写入的持久性边界;报告 agent 完成情况的消费方使用核心的 `findLastMessageTurnEnd()` 折叠逻辑,因此后续的标题轮次、注入轮次或其他归插件所有的轮次无法取代此前由消息触发的结果。 ## 考虑过的替代方案 @@ -54,6 +54,7 @@ Status: implemented ## 后果 - 标题可以在 JSONL 和 SQLite 持久化中存续,通过 ACP 回放,并遵循 fork 继承语义,而无需单独的可变记录。 +- Web 标题仍以增量方式从日志交付,无需标题索引或扫描持久化列表;冷启动列表项会在会话附加后改用标题。 - 回退标题无需辅助调用即可出现;部署方可以自行决定更优标题是否值得模型成本,以及后续提示词是否需要重新生成会话标题。 - 辅助请求记录和延迟接受的标题会占用事件 seq,并可能创建平衡的零步骤轮次,因此持久化会同时呈现尝试发起的调用与已接受的更新,尽管模型历史和 KV 缓存标识保持不变。 - 单个提供方和每会话单调递增的修订号让释放、取代和陈旧结果拒绝行为明确可见,但多策略优先级必须由复合提供方负责。 diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml index 6b2f1de1e0..9c7fff8b8b 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-20-gui-testing-system.md: db1b47566f5aa089ffcb10d130ecde1851b93112 -2026-07-20-gui-testing-system.zh.md: 691c6baf50c1025a09461effd28ac0f1650fb933 +2026-07-20-gui-testing-system.md: 28652f97d5c8d4968e2beef0ccffa7a39dcd7359 +2026-07-20-gui-testing-system.zh.md: e07d23ded9613251b71808d56e05365120d9c0a7 diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md index db1b47566f..28652f97d5 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md @@ -19,24 +19,24 @@ Cut along the architecture's natural test seams into three tiers, bottom-up: | Tier | Under test | Key technique | File location | |---|---|---|---| | 1 Protocol isomorphism | `AbstractApiClient` + `toFetchHandler` (bidirectional data / rpcId / zod types / SSE streams / batching / timeouts) | **The full chain at the isomorphic point**: `InProcessApiClient(toFetchHandler(脚本化 impl))` skips the network but genuinely runs the wire serialization — zero browser, pure node env | `packages/host/apiproxy/tests/client-handler.spec.ts` | -| 2 Object-layer orchestration | `Session`/`SessionManager`/`ConnectionController` (state machines and timing: stitching / dedup / paging / optimistic draft clearing / pendingBuffers / reconnect / backoff) | **The "event sequence in → snapshot out" golden path**: programmable fakes + deferreds controlling timing + fake timers controlling backoff | `packages/client/web-runtime/tests/{session,manager,connection,…}.spec.ts` | -| 3 Browser smoke | Build artifacts × a real browser (the page boots, one conversation round-trips) | Bare playwright library (chromium headless, no @playwright/test framework), minimal pass-through; fixture level + real-host level (self-skips without a key) | `apps/web/tests/smoke-{fixture,real}.e2e.ts` | +| 2 Object-layer orchestration | `Session`/`SessionManager`/`ConnectionController` (state machines and timing: stitching / dedup / paging / optimistic draft clearing / pendingBuffers / reconnect / backoff) | **The "event sequence in → snapshot out" golden path**: programmable fakes + deferreds controlling timing + fake timers controlling backoff | `packages/client/{runtime,connection}/tests/` | +| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts` | -Inter-tier discipline: **each tier tests its own layer, upper tiers never re-test lower ones** — smoke only proves the wiring is alive (the fixture level asserts zero `/api` requests and zero pageerror), interaction detail belongs to the verify scripts (see the lane map), wire semantics to tier 1, data semantics to tier 2. Pure-function layers (lineage/partial/notifier/fold-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2. +Inter-tier discipline: **each tier tests its own layer, upper tiers never re-test lower ones** — an app semantic snapshot pins only user-visible projection across the assembled plugin boundary, while Playwright smoke proves browser and carrier liveness; wire semantics belong to tier 1 and data semantics to tier 2. Pure-function layers (lineage/partial/notifier/fold-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2. -- **Host side** (apiproxy/runtime/webserver): under the repo-wide `test:coverage` gate, per-file 100%. -- **Client side**: web-runtime **is already under the per-file 100% gate** (12 defensive unreachable arms carry reasoned `/* v8 ignore */` comments); the `vitest.config.ts` coverage.exclude is down to `packages/client/web-ui/src/**` (temporary — lifted progressively as component specs fill in after the component redo); tests still run, the exclusion only keeps web-ui src out of the thresholds. web-ui takes the **jsdom route (landed)**: jsdom + @testing-library/react entered root devDependencies (dev-only), first spec `web-ui/tests/utils.spec.tsx` (utils pure functions + component RTL render + hook uSES probe); the environment uses the per-file `// @vitest-environment jsdom` pragma, zero impact on the other node-env packages. -- The exclusion is an **explicitly annotated ruling**, not a silent waiver; the lift path = delete the exclude line + add a justified exclusion or the missing tests. +- **Host and client source** are under the repo-wide per-file 100% coverage gate except the narrow browser-grade exclusions annotated in `vitest.config.ts`; component suites use per-file jsdom pragmas and Testing Library without changing Node suites. +- **App-owned semantic snapshots** read built client bundles, execute them through the real loader, and drive only deterministic fixture hooks. They own stable visible state such as sidebar labels, breadcrumbs, and `document.title`, not CSS pixels or lower-layer state-machine details. ## Lane map | Scenario | Command | Content | When to run | |---|---|---|---| | Baseline | `pnpm run test:gui` | Tier 1+2 vitest (`packages/client packages/host`), seconds-fast, no browser, no server | Casually, after touching any GUI source | +| Semantic snapshot | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | Keyless assembled-application semantics plus the repo's transport-specific expected outputs | After a human-visible GUI change; before delivery | | Browser end-to-end | `pnpm run test:web` | Rebuilds the front-end dist first, then runs the tier-3 two-level smoke (fixture level + real-host level self-skip) | After touching the build surface/boot/carriage; before delivery | | Gate | `pnpm run test:coverage` | The repo-wide gate (host-side GUI packages included, client side excluded) | The PR window | -**Division of labor between the verify scripts and vitest**: verify owns browser black-box regression (sequential steps = a user-operation script, one shared browser session, streaming PASS/FAIL output for the agent to locate the break), vitest owns first-class data-layer semantic assertions (reference stability `toBe`, state-machine timing, wire shapes). The two lanes complement each other, neither absorbs the other — scripts do not migrate to vitest (tearing apart an ordered script is a net loss); promoting one means wrapping a spawn shell hooked into the e2e lane, never rewriting the script body. +**Division of labor between the browser scripts and vitest**: Playwright owns browser/carrier black-box regression and long sequential user journeys; ordinary vitest owns data-layer semantics such as reference stability, timing, and wire shapes; snapshot vitest owns stable app-level semantic output through the built composition. These lanes complement each other rather than duplicating assertions. ## Anti-regression discipline @@ -46,7 +46,7 @@ Inter-tier discipline: **each tier tests its own layer, upper tiers never re-tes ## Consequences -Each lane tests its own tier: touching any GUI source gets seconds-fast `test:gui` feedback, wire/object-layer semantics assert in milliseconds in node env, and the browser carries only wiring-liveness smoke. On the gate surface, the host side is fully under per-file 100%; on the client side web-runtime is under the gate while web-ui waits behind the explicitly annotated exclude. The accepted cost: the inter-tier discipline (upper tiers never re-test lower ones) is upheld by review rather than a machine gate, and web-ui's coverage gap persists until component specs fill in after the component redo. +Each lane tests its own tier: touching any GUI source gets seconds-fast `test:gui` feedback, wire/object-layer semantics assert in milliseconds in Node, built-composition snapshots pin deterministic user-visible projection, and the browser carries wiring and carrier acceptance. The accepted cost is that inter-tier discipline is upheld by review rather than a machine gate and every new app snapshot must avoid unstable layout or clock output. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md index 691c6baf50..e07d23ded9 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md @@ -19,24 +19,24 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 | 层 | 被测物 | 关键手段 | 文件落点 | |---|---|---|---| | 1 协议同构层 | `AbstractApiClient` + `toFetchHandler`(双向数据/rpcId/ZOD类型/SSE 流/合批/超时) | **同构点全链**:`InProcessApiClient(toFetchHandler(脚本化 impl))` 不过网络但真跑 wire 序列化——零浏览器、纯 node env | `packages/host/apiproxy/tests/client-handler.spec.ts` | -| 2 对象层编排 | `Session`/`SessionManager`/`ConnectionController`(状态机与时序:缝合/去重/翻页/乐观清稿/pendingBuffers/重连/退避) | **「事件序列进→快照出」黄金路径**:可编程假体 + deferred 控时序 + fake timers 控退避 | `packages/client/web-runtime/tests/{session,manager,connection,…}.spec.ts` | -| 3 浏览器 smoke | 构建产物 × 真浏览器(页面起得来、一轮对话跑得通) | playwright 裸库(chromium headless,无 @playwright/test 框架)最简跑通;fixture 级 + 真 host 级(无 key self-skip) | `apps/web/tests/smoke-{fixture,real}.e2e.ts` | +| 2 对象层编排 | `Session`/`SessionManager`/`ConnectionController`(状态机与时序:缝合/去重/翻页/乐观清稿/pendingBuffers/重连/退避) | **「事件序列进→快照出」黄金路径**:可编程假体 + deferred 控时序 + fake timers 控退避 | `packages/client/{runtime,connection}/tests/` | +| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以固定确定性的跨插件状态变化;独立使用 Playwright 裸库的冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过 | `apps/web/tests/*.snapshot.ts`、`apps/web/tests/smoke-{fixture,real}.e2e.ts` | -层间纪律:**下层各测各的,上层不重测下层**——smoke 只证接线活着(fixture 级断零 `/api` 请求、零 pageerror),交互细节归 verify 脚本(见车道地图),wire 语义归 1 层,数据语义归 2 层。纯函数层(lineage/partial/notifier/fold-adapter)随 2 层同包 tests/ 零假体直测。 +层间纪律:**下层各测各的,上层不重测下层**:应用语义快照只固定组装后插件边界上的用户可见投影,Playwright 冒烟测试负责验证浏览器与承载层是否存活;wire 语义归 1 层,数据语义归 2 层。纯函数层(lineage/partial/notifier/fold-adapter)随 2 层同包 tests/ 零假体直测。 -- **host 侧**(apiproxy/runtime/webserver):进全仓 `test:coverage` 门禁,per-file 100%。 -- **client 侧**:web-runtime **已进 per-file 100% 门禁**(12 处防御性不可达臂带理由 `/* v8 ignore */` 注释);`vitest.config.ts` coverage.exclude 只剩 `packages/client/web-ui/src/**`(暂时——组件重做后随组件 specs 铺满逐步解除),测试照跑,只是不拉 web-ui src 进阈值。web-ui 走 **jsdom 路线(已落地)**:jsdom + @testing-library/react 入 root devDeps(dev-only),首个 spec `web-ui/tests/utils.spec.tsx`(utils 纯函数 + 组件 RTL render + hook uSES 探针);环境用 per-file `// @vitest-environment jsdom` pragma,node env 的其他包零影响。 -- 排除是**显式注释的裁决**不是静默豁免;解除路径=删 exclude 行 + 补 justified 排除或补测。 +- **host 与 client 源码**均纳入全仓 per-file 100% 覆盖率门禁,仅排除 `vitest.config.ts` 中带注释的少量浏览器级例外;组件套件通过逐文件 jsdom pragma 和 Testing Library 运行,不会改变 Node 套件。 +- **归应用所有的语义快照**读取已构建的 client bundle,通过真实 loader 执行它们,并且只驱动确定性的 fixture 钩子。它们负责固定侧边栏标签、面包屑和 `document.title` 等稳定可见状态,而不固定 CSS 像素或下层状态机细节。 ## 车道地图 | 场景 | 命令 | 内容 | 何时跑 | |---|---|---|---| | 基础 | `pnpm run test:gui` | 1+2 层 vitest(`packages/client packages/host`),秒级、无浏览器无 server | 改 GUI 任意源码后随手跑 | +| 语义快照 | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | 无需密钥的组装应用语义,以及仓库按传输形态划分的预期输出 | 用户可见的 GUI 变更后;交付前 | | 浏览器端到端 | `pnpm run test:web` | 先重建前端 dist,再跑 3 层双级 smoke(fixture 级 + 真 host 级 self-skip) | 改构建面/boot/承载后;交付前 | | 门禁 | `pnpm run test:coverage` | 全仓 gate(host 侧 GUI 包在内,client 侧 excluded) | PR 窗口 | -**verify 脚本与 vitest 的分工**:verify 管浏览器黑盒回归(顺序步骤=用户操作剧本,共享一次浏览器会话,PASS/FAIL 流式输出供 agent 定位断点),vitest 管数据层语义一等断言(引用稳定性 `toBe`、状态机时序、wire 形)。两车道互补不收编——脚本不迁 vitest(拆散有序剧本是负收益),转正时包一层 spawn 壳挂 e2e 车道即可,脚本本体不改写。 +**浏览器脚本与 vitest 的分工**:Playwright 负责浏览器/承载层黑盒回归和较长的连续用户操作流程;普通 vitest 负责引用稳定性、时序和 wire 结构等数据层语义;快照 vitest 通过构建后的组合负责稳定的应用层语义输出。这些车道彼此互补,而不重复断言。 ## 防回归纪律 @@ -46,7 +46,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 ## Consequences -各车道各测各层:改任意 GUI 源码有秒级 `test:gui` 反馈,wire/对象层语义在 node env 毫秒级断言,浏览器只承担接线存活冒烟。门禁面上 host 侧全量进 per-file 100%;client 侧 web-runtime 已进门,web-ui 暂留显式注释的 exclude 之后。接受的代价:层间纪律(上层不重测下层)靠 review 而非机器门禁维持;web-ui 的覆盖缺口持续到组件重做后组件 specs 铺满为止。 +各车道各测各层:改动任意 GUI 源码后都能获得秒级 `test:gui` 反馈,wire/对象层语义在 Node 环境中进行毫秒级断言,基于构建后组合的快照固定确定性的用户可见投影,浏览器负责接线与承载层验收。接受的代价是层间纪律由评审而非机器门禁维持,而且每个新的应用快照都必须避开不稳定的布局或时钟输出。 ## Alternatives considered diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 01c4172902..7c200ad202 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,6 +2,10 @@ Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), Session object layer, ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4. +## Session title projection + +`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and explicit session removal clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. + ## Model Experience None, as the client runtime hosts browser-side services and the session object layer; nothing here reaches a model request. @@ -15,4 +19,3 @@ None; this package neither assembles nor sends a provider request. - **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project. - **Scope teardown is watch-approximated** — the most recently resolved binding stands in for "who is watching"; a removed-while-watched session's scope survives until the watch moves away, not until true observer count reaches zero. - **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem). -- **`SessionSummary.title` is a display projection** — the wire summary carries no title yet; the cwd basename stands in, then the raw id. diff --git a/packages/client/web/README.md b/packages/client/web/README.md index 0501cb0384..c405aca3bc 100644 --- a/packages/client/web/README.md +++ b/packages/client/web/README.md @@ -4,6 +4,8 @@ Web shell library: `bootWebShell(el, seams?)` mounts the whole client — loader The optional `seams` parameter forwards the client loader's `fetchBundle`/`executeBundle` transport overrides (`BootSeams`); production callers omit it — it exists for test environments where `