diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 1326271735..4fd5d15905 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -4,7 +4,7 @@ Client cordis boot + core services: SlotsService (Service wrapper over SlotCore ## Session title projection -`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and explicit session removal clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. +`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. ## Model Experience diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 950b03b517..a207fdc0c2 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -177,6 +177,13 @@ export class SessionManager { this.notifier.markDirty() return } + if (frame.type === 'session/subscribed') { + const current = this.titleSnapshots.get(frame.sessionId) + if (current !== undefined && current.eventSeq > frame.lastSeq) { + this.titleSnapshots.delete(frame.sessionId) + this.notifier.markDirty() + } + } const session = this.sessions.get(frame.sessionId) if (session === undefined) { // Approval/question frames never hit history: buffer for replay on instantiation; diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index edf326bd31..87c80c2dce 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -119,6 +119,36 @@ describe('list lifecycle', () => { manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', sessionId: S1 } }) expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined() }) + + it('drops a retained title beyond the subscription baseline before accepting its durable replay', async () => { + const api = new FakeApiClient() + api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] })) + const manager = new SessionManager(api) + await manager.refreshList() + manager.handleMuxEnvelope({ + rpcId: 'title-unflushed' as never, + payload: { type: 'session/title', sessionId: S1, title: 'Unflushed', eventSeq: 4, updatedAt: 400 }, + }) + + manager.handleMuxEnvelope({ + rpcId: 'subscribed-recovered' as never, + payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 }, + }) + expect(manager.getListSnapshot().items[0]?.title).toBeUndefined() + expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(100) + + manager.handleMuxEnvelope({ + rpcId: 'title-durable' as never, + payload: { type: 'session/title', sessionId: S1, title: 'Durable', eventSeq: 2, updatedAt: 200 }, + }) + expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 }) + + manager.handleMuxEnvelope({ + rpcId: 'subscribed-current' as never, + payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 2 }, + }) + expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'Durable', updatedAt: 200 }) + }) }) describe('host frame routing', () => {