Browse Source

fix(web): reconcile title snapshots on recovery

Tianyi Cui 2 months ago
parent
commit
41a6a07833

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

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

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