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

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