feat(web): render durable session titles

This commit is contained in:
Tianyi Cui
2026-07-22 23:43:53 +08:00
parent 690c53dc03
commit a9ea193e31
39 changed files with 481 additions and 57 deletions

View File

@@ -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,

View File

@@ -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')

View File

@@ -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

View File

@@ -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

View File

@@ -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 } : {}),
}

View File

@@ -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', () => {

View File

@@ -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 () => {

View File

@@ -54,7 +54,7 @@ export function ConversationRoot({
disabled={last}
onClick={() => { actions.open(s.id) }}
>
{s.title}
{s.displayTitle}
</button>
</span>
)

View File

@@ -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 }
}

View File

@@ -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 = {

View File

@@ -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 },
},
}),
})

View File

@@ -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() })

View File

@@ -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()

View File

@@ -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 = {

View File

@@ -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(

View File

@@ -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() })

View File

@@ -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.

View File

@@ -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()

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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}

View File

@@ -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'

View File

@@ -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() })

View File

@@ -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')
})
})