feat(desktop): paint cached transcripts on session click (switch-paint) - #296
Conversation
Ace's report: first click on a session still doesn't feel snappy despite the transcript preloader. Root cause: the preloader WRITES transcripts into the render cache, but the only cache READ was the boot path (last-open session). The click path (resumeStoredSession) always waited on the network prefetch. Fix: - main: 'hermes:render-cache:read-transcript' IPC (per-session cache read, same gateway-URL resolution + fail-open contract as the boot read) - preload/global.d.ts: renderCache.readTranscript - render-cache-hydration: readCachedTranscript + normalizeCachedTranscriptRows (two writer shapes exist: preloader stores raw SessionMessage rows, the write-through stores converted ChatMessage rows — sniff and convert; boot paint now normalizes too instead of assuming shape) - use-session-actions: on switch, paint the cached transcript immediately while the REST prefetch + resume RPC run; interim paint only (never feeds localSnapshot, live payload wholesale-replaces; guarded by isCurrentResume and an empty-store check so a faster live payload is never clobbered) Tests: 33 vitest (switch-paint + hydration + preload), full UI suite 1266/0, electron node:test 31/0, typecheck clean.
The boot cache-paint only covered the restored session; clicking any other sidebar row always waited on the network even when its transcript was warm in the render cache (Ace: 'not super snappy to click sessions the first time'). Close the loop so every visible session paints instantly: - electron: per-session 'render-cache:read-transcript' IPC (same gateway-URL resolution + fail-open contract as the boot read) + preload bridge - renderer: readCachedTranscript() + switch-paint in resumeSession's cold path — cached rows paint while the live prefetch runs; the live response wholesale-replaces them (same SWR contract as boot); guarded by isCurrentResume + empty-store so a late cache read never clobbers live data - preloader now stores ChatMessage-shaped rows (toChatMessages at fetch time) so the click path never converts on paint; normalizeCachedTranscriptRows() sniffs and converts legacy raw-shape files (treats garbage as a miss) - boot hydrate paints via the same normalizer (raw files painted wrong before) Tests: switch-cache-paint.test.ts (8), preload shape test, hydration paint tests updated to ChatMessage fixtures. test:ui 1261 pass / typecheck clean / platforms 351 pass.
e6baecf to
9a5546d
Compare
|
| Filename | Overview |
|---|---|
| apps/desktop/src/app/session/hooks/use-session-actions/index.ts | Two switch-paint blocks added to resumeSession; Block 1's async paint can populate $messages before localSnapshot = $messages.get() is captured, causing live resume messages to be silently bypassed when the REST prefetch fails |
| apps/desktop/src/app/render-cache-hydration.ts | Adds readCachedTranscript (per-session IPC read) and normalizeCachedTranscriptRows (dual-shape normalizer); shape detection sniffs only first element's parts array, which is correct for the two defined writer shapes |
| apps/desktop/src/app/transcript-preload.ts | Preloader now converts raw SessionMessage rows to ChatMessage shape before caching, unifying the two writer shapes and making the cache read path cheaper at switch time |
| apps/desktop/electron/main.ts | New hermes:render-cache:read-transcript IPC handler mirrors the existing boot-read pattern; fail-open with gateway URL fallback, consistent with the rest of the render cache API surface |
| apps/desktop/electron/preload.ts | Exposes readTranscript on the context bridge alongside the existing renderCache methods; correctly passes gatewayUrl and storedSessionId to the main process handler |
| apps/desktop/src/app/switch-cache-paint.test.ts | New test file covering readCachedTranscript fail-open contract and normalizeCachedTranscriptRows dual-shape handling; good coverage of null/empty/malformed inputs |
| apps/desktop/src/app/render-cache-hydration.test.ts | Updated to use ChatMessage-shaped rows with parts[] in transcript paint tests; correctly reflects the new normalized shape flowing into setMessages |
| apps/desktop/src/app/transcript-preload.test.ts | Updated stub messages to realistic shape and adds a new test asserting the preloader stores ChatMessage-shaped rows (parts[]) rather than raw SessionMessage rows |
| apps/desktop/src/global.d.ts | Type declaration for readTranscript added with nullable gatewayUrl and storedSessionId; matches the preload.ts and IPC handler signatures |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant UI as UI Click
participant Hook as useSessionActions
participant Cache as renderCache IPC
participant REST as REST prefetch
participant GW as Gateway resume RPC
UI->>Hook: resumeSession(storedSessionId)
Note over Hook: setMessages([]) — clear previous transcript
Hook-->>Cache: Block 1: readCachedTranscript() [void/fire-and-forget]
Hook->>Hook: await resolveStoredSession()
Hook->>Hook: await ensureGatewayProfile()
Note over Cache,Hook: IPC (~1-5ms) may resolve HERE, painting $messages before localSnapshot capture
Hook->>Hook: "localSnapshot = $messages.get() <- could be cached rows"
Hook-->>Cache: Block 2: readCachedTranscript() [if $messages still empty]
Hook->>REST: "prefetchPromise = getSessionMessages()"
Hook->>GW: "resumePromise = session.resume()"
alt REST prefetch succeeds
REST-->>Hook: storedMessages
Note over Hook: localSnapshot = toChatMessages(storedMessages)
else REST prefetch fails
Note over Hook: localSnapshot stays as captured value
Note over Hook: If localSnapshot = cached rows, preferredMessages = cached rows, resumed.messages ignored
end
GW-->>Hook: resumed (live messages)
Note over Hook: preferredMessages = localSnapshot.length > 0 ? localSnapshot : reconcile(resumed.messages)
Hook->>UI: setMessages(preferredMessages)
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant UI as UI Click
participant Hook as useSessionActions
participant Cache as renderCache IPC
participant REST as REST prefetch
participant GW as Gateway resume RPC
UI->>Hook: resumeSession(storedSessionId)
Note over Hook: setMessages([]) — clear previous transcript
Hook-->>Cache: Block 1: readCachedTranscript() [void/fire-and-forget]
Hook->>Hook: await resolveStoredSession()
Hook->>Hook: await ensureGatewayProfile()
Note over Cache,Hook: IPC (~1-5ms) may resolve HERE, painting $messages before localSnapshot capture
Hook->>Hook: "localSnapshot = $messages.get() <- could be cached rows"
Hook-->>Cache: Block 2: readCachedTranscript() [if $messages still empty]
Hook->>REST: "prefetchPromise = getSessionMessages()"
Hook->>GW: "resumePromise = session.resume()"
alt REST prefetch succeeds
REST-->>Hook: storedMessages
Note over Hook: localSnapshot = toChatMessages(storedMessages)
else REST prefetch fails
Note over Hook: localSnapshot stays as captured value
Note over Hook: If localSnapshot = cached rows, preferredMessages = cached rows, resumed.messages ignored
end
GW-->>Hook: resumed (live messages)
Note over Hook: preferredMessages = localSnapshot.length > 0 ? localSnapshot : reconcile(resumed.messages)
Hook->>UI: setMessages(preferredMessages)
Comments Outside Diff (1)
-
apps/desktop/src/app/session/hooks/use-session-actions/index.ts, line 471 (link)Interim cache paint can corrupt
localSnapshot, silently dropping live resume messagesBlock 1's
readCachedTranscript(line 349) fires whileresolveStoredSessionandensureGatewayProfileare awaiting — the IPC read typically resolves in ~1–5 ms, well within those two round-trips. If it resolves before this line,$messages.get()returns the cached rows, solocalSnapshotis non-empty. Then at thepreferredMessagesselection (line 552),localSnapshot.length > 0short-circuits tolocalSnapshot, and the liveresumed.messagesis completely bypassed. When the REST prefetch also fails (independently plausible on a slow/flaky gateway), the user sees the stale cached transcript even though the resume RPC succeeded with the full live history. Before this PR,$messageswas always empty here because nothing betweensetMessages([])(line 341) and this read was async.
Reviews (1): Last reviewed commit: "feat(desktop): paint cached transcripts ..." | Re-trigger Greptile
| const first = rows[0] as { parts?: unknown } | ||
| if (first != null && typeof first === 'object' && Array.isArray(first.parts)) { | ||
| return rows as ChatMessage[] | ||
| } | ||
| return toChatMessages(rows as SessionMessage[]) |
There was a problem hiding this comment.
Shape detection sniffs only the first element's
parts field
If the cache ever contains a heterogeneous array — e.g., a first ChatMessage whose parts field is null or undefined (possible if a write-through stored a malformed row) — the entire array falls through to toChatMessages, which treats it as SessionMessage[]. Conversely, a first row that happens to carry a parts key for an unrelated reason would bypass toChatMessages entirely. Since rows is typed as unknown[], nothing enforces homogeneity at the call site. After this PR both writers produce ChatMessage-shaped rows, so this is a backward-compat path for old cache files — but the sniff-by-first-element assumption is worth a clarifying comment.
Ace: 'clicking sessions for the first time doesn't feel snappy — are we sure they're preloaded?' They WERE preloaded (12 transcripts in the render cache on the MBP, verified) — but nothing READ the cache on click. The only reader was the boot path (last-open session). This wires the missing read: on switch, paint the cached transcript instantly while the REST prefetch + resume RPC run in the background; the live payload wholesale-replaces the interim paint (same I5 discipline as boot).
Includes a shape normalizer: the preloader stores raw SessionMessage rows while the active-session write-through stores converted ChatMessage rows — both now paint correctly on boot and switch (verified against the actual cached files on the MBP: preloaded rows already carry parts[], both shapes covered).
Gates: 33 vitest (new+touched), full UI suite 1266/0, electron node:test 31/0, typecheck clean.