Browse Source

feat(web): render durable session titles

Tianyi Cui 2 months ago
parent
commit
a9ea193e31
39 changed files with 481 additions and 57 deletions
  1. 114 0
      apps/web/tests/session-title.snapshot.ts
  2. 12 0
      apps/web/tests/snapshots/session-title.json
  3. 1 1
      knip.json
  4. 33 1
      packages/client/connection/src/client/fixture.ts
  5. 10 3
      packages/client/connection/tests/fixture.spec.ts
  6. 12 6
      packages/client/runtime/src/client/sessions/lineage.ts
  7. 30 3
      packages/client/runtime/src/client/sessions/manager.ts
  8. 10 5
      packages/client/runtime/src/client/sessions/service.ts
  9. 30 0
      packages/client/runtime/tests/manager.spec.ts
  10. 12 6
      packages/client/runtime/tests/sessions-service.spec.ts
  11. 1 1
      packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
  12. 2 2
      packages/client/ui-conversation/tests/apply-inject.spec.tsx
  13. 2 2
      packages/client/ui-conversation/tests/chat-apply.spec.tsx
  14. 2 2
      packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx
  15. 3 3
      packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx
  16. 7 5
      packages/client/ui-conversation/tests/selection-survival.spec.ts
  17. 1 1
      packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
  18. 2 2
      packages/client/ui-conversation/tests/skeleton.spec.tsx
  19. 1 1
      packages/client/ui-layout/tests/service.spec.ts
  20. 4 4
      packages/client/ui-sidebar/src/client/tree.ts
  21. 2 2
      packages/client/ui-sidebar/tests/apply.spec.tsx
  22. 1 0
      packages/client/ui-sidebar/tests/sidebar-root.spec.tsx
  23. 1 0
      packages/client/ui-sidebar/tests/store.spec.ts
  24. 12 1
      packages/client/ui-sidebar/tests/tree.spec.ts
  25. 1 1
      packages/client/ui-trajectory/tests/views.spec.tsx
  26. 22 0
      packages/client/web/src/DocumentTitle.tsx
  27. 7 0
      packages/client/web/src/app.tsx
  28. 1 0
      packages/client/web/src/index.ts
  29. 7 1
      packages/client/web/tests/boot.spec.tsx
  30. 28 0
      packages/client/web/tests/document-title.spec.tsx
  31. 1 0
      packages/host/apiproxy/src/api/events.schema.ts
  32. 4 2
      packages/host/apiproxy/src/api/events.ts
  33. 8 0
      packages/host/apiproxy/tests/rpc-schemas.spec.ts
  34. 1 0
      packages/host/runtime/package.json
  35. 29 2
      packages/host/runtime/src/api-proxy.ts
  36. 60 0
      packages/host/runtime/tests/host-runtime.spec.ts
  37. 3 0
      packages/host/runtime/tsconfig.json
  38. 3 0
      pnpm-lock.yaml
  39. 1 0
      vitest.snapshot.config.ts

+ 114 - 0
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<string, unknown>).__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<HTMLElement>('#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<HTMLElement>('[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<HTMLElement>('[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<string, unknown>).__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')
+})

+ 12 - 0
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"
+  }
+}

+ 1 - 1
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",

+ 33 - 1
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<MuxFrame, { type: 'session/title' }> | 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<MuxFrame, { type: 'session/title' }>)
+    }
   }
 
   /** 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,

+ 10 - 3
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<MuxFrame>[] = []
       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')

+ 12 - 6
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<SessionId, SessionSummary>()
+export function flattenLineage(summaries: readonly TitledSessionSummary[]): SessionListEntry[] {
+  const byId = new Map<SessionId, TitledSessionSummary>()
   for (const s of summaries) byId.set(s.sessionId, s)
 
-  const children = new Map<SessionId, SessionSummary[]>()
-  const roots: SessionSummary[] = []
+  const children = new Map<SessionId, TitledSessionSummary[]>()
+  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<SessionId>()
-  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

+ 30 - 3
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<SessionId, Session>()
@@ -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<SessionId, RpcRequest<MuxFrame>[]>()
+  private readonly titleSnapshots = new Map<SessionId, SessionTitleSnapshot>()
   private summaries: SessionSummary[] = []
   private listState: 'idle' | 'loading' | 'error' = 'idle'
   private listError: RpcError | null = null
@@ -158,6 +166,17 @@ export class SessionManager {
   handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): 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

+ 10 - 5
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 } : {}),
       }

+ 30 - 0
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', () => {

+ 12 - 6
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 () => {

+ 1 - 1
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}
                   </button>
                 </span>
               )

+ 2 - 2
packages/client/ui-conversation/tests/apply-inject.spec.tsx

@@ -50,7 +50,7 @@ async function bench() {
 
   const listStore = createSnapshotStore<SessionListState>({
     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 }
       }

+ 2 - 2
packages/client/ui-conversation/tests/chat-apply.spec.tsx

@@ -26,8 +26,8 @@ async function bench() {
   const listStore = createSnapshotStore<SessionListState>({
     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 = {

+ 2 - 2
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 },
         },
       }),
     })

+ 3 - 3
packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx

@@ -49,9 +49,9 @@ describe('apply need() and cwd cache', () => {
     const listStore = createSnapshotStore<SessionListState>({
       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() })

+ 7 - 5
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()

+ 1 - 1
packages/client/ui-conversation/tests/skeleton-branches.spec.tsx

@@ -33,7 +33,7 @@ function sessionSource(over?: Partial<ConversationSnapshot>) {
 }
 
 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 = {

+ 2 - 2
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(

+ 1 - 1
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() })
 

+ 4 - 4
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<string>, rows: S
 function searchVisible(g: Group, q: string): Set<SessionId> {
   const visible = new Set<SessionId>()
   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<SessionId>, 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.

+ 2 - 2
packages/client/ui-sidebar/tests/apply.spec.tsx

@@ -28,7 +28,7 @@ async function bench() {
   await ctx.plugin(SlotsService).await()
   const list = createSnapshotStore<SessionListState>({
     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()

+ 1 - 0
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,
   }

+ 1 - 0
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,
   }

+ 12 - 1
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', () => {

+ 1 - 1
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<string | undefined>(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,

+ 22 - 0
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
+}

+ 7 - 0
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 <DocumentTitle {...title === undefined ? {} : { title }} />
+  }
 
   const renderBody = (id: SessionId): ReactNode => (
     <>
@@ -75,6 +81,7 @@ export function buildRenderApp(deps: AssemblyDeps): () => ReactNode {
 
   return () => (
     <RootBindingProvider value={rootBinding}>
+      <SessionDocumentTitle />
       <AppFrame
         useSidebar={useSidebar}
         useDetails={useDetails}

+ 1 - 0
packages/client/web/src/index.ts

@@ -8,4 +8,5 @@
 export { bootWebShell } from './boot.tsx'
 export { AppRoot, type AppRootProps } from './AppRoot.tsx'
 export { buildRenderApp, type AssemblyDeps } from './app.tsx'
+export { DocumentTitle, type DocumentTitleProps } from './DocumentTitle.tsx'
 export { seedModules } from './seed.ts'

+ 7 - 1
packages/client/web/tests/boot.spec.tsx

@@ -38,7 +38,7 @@ window.DSHClientProxy.loadPlugin({
           ctx,
         }
         ctx.provide('sessions', {
-          list: createSnapshotStore({ ids: ['s1'], byId: { s1: { id: 's1', title: 'S1', running: false, updatedAt: 1 } } }),
+          list: createSnapshotStore({ ids: ['s1'], byId: { s1: { id: 's1', title: 'S1', displayTitle: 'S1', running: false, updatedAt: 1 } } }),
           binding: (id) => (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() })

+ 28 - 0
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(<DocumentTitle />)
+    expect(document.title).toBe('DeepSeek Harness')
+
+    mounted.rerender(<DocumentTitle title="First title" />)
+    expect(document.title).toBe('First title — DeepSeek Harness')
+
+    mounted.rerender(<DocumentTitle title="Revised title" />)
+    expect(document.title).toBe('Revised title — DeepSeek Harness')
+
+    mounted.rerender(<DocumentTitle />)
+    expect(document.title).toBe('DeepSeek Harness')
+    mounted.unmount()
+    expect(document.title).toBe('DeepSeek Harness')
+  })
+})

+ 1 - 0
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) }),

+ 4 - 2
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[] }

+ 8 - 0
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')
   })
 

+ 1 - 0
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:^",

+ 29 - 2
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<F>(payload: F): RpcRequest<F> {
   return { rpcId: RpcId(randomUUID()), payload }
 }
 
+type SessionTitleFrame = Extract<MuxFrame, { type: 'session/title' }>
+
+/** 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<RpcRequest<MuxFrame>>, 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<RpcRequest<MuxFrame>>()
         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)

+ 60 - 0
packages/host/runtime/tests/host-runtime.spec.ts

@@ -65,6 +65,21 @@ function expectOk<T>(response: RpcResponse<T>): T {
   return response.result.value
 }
 
+async function nextMux(iterator: AsyncIterator<RpcRequest<MuxFrame>>): Promise<RpcRequest<MuxFrame>> {
+  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<MuxFrame>
+    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

+ 3 - 0
packages/host/runtime/tsconfig.json

@@ -23,6 +23,9 @@
     {
       "path": "../../core/session"
     },
+    {
+      "path": "../../session-title/session-title"
+    },
     {
       "path": "../../core/system-prompt"
     },

+ 3 - 0
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

+ 1 - 0
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',