Skip to content

feat(desktop): paint cached transcripts on session click (switch-paint) - #296

Merged
Kyzcreig merged 2 commits into
mainfrom
feat/switch-cache-paint
Jul 11, 2026
Merged

Kyzcreig merged 2 commits into
mainfrom
feat/switch-cache-paint

Conversation

@Kyzcreig

Copy link
Copy Markdown
Collaborator

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.

Kyzcreig added 2 commits July 11, 2026 12:12
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.
@Kyzcreig
Kyzcreig force-pushed the feat/switch-cache-paint branch from e6baecf to 9a5546d Compare July 11, 2026 19:12
@Kyzcreig
Kyzcreig merged commit e545fe6 into main Jul 11, 2026
24 checks passed
@Kyzcreig
Kyzcreig deleted the feat/switch-cache-paint branch July 11, 2026 19:15
@greptile-apps

greptile-apps Bot commented Jul 11, 2026 •

Copy link
Copy Markdown

Greptile Summary

This PR wires the missing render-cache read on session click: for a cold session switch, it fires readCachedTranscript immediately (while the REST prefetch and resume RPC run in parallel), painting the cached transcript from the preloader or write-through as an instant interim view. A normalizeCachedTranscriptRows shape normalizer bridges the two writer formats, and transcript-preload.ts is updated to store rows already in ChatMessage shape so the paint path is zero-conversion at switch time.

  • New hermes:render-cache:read-transcript IPC handler and readTranscript bridge method enable per-session cache reads beyond the existing boot-path read.
  • normalizeCachedTranscriptRows detects ChatMessage vs SessionMessage shapes by sniffing rows[0].parts, converting only the latter via toChatMessages for backward compatibility with old cache files.
  • Two fire-and-forget switch-paint blocks are placed in resumeSession: one at the initial cold-miss check (Block 1, line 349) and one inside the full resume path (Block 2, line 487); both are guarded by isCurrentResume() and $messages.get().length > 0 to prevent clobbering live data.

Confidence Score: 3/5

The cache-painting path is safe under the happy path but has a subtle correctness hole under REST-prefetch failure that can leave a live session showing a stale transcript.

Block 1's fire-and-forget readCachedTranscript can resolve and write to $messages during the two await statements (resolveStoredSession + ensureGatewayProfile) that precede localSnapshot = $messages.get(). Before this PR, $messages was always empty at that line because nothing between setMessages([]) and the capture was async. Now, when the IPC read wins the race (very likely given ~1–5 ms IPC vs two network/disk round-trips), localSnapshot holds cached rows. The preferredMessages selection then commits to those cached rows and never consults resumed.messages. If the REST prefetch also fails — a plausible combination when the gateway is slow — the user sees a stale transcript for a live session that the resume RPC hydrated correctly. The failure is silent: no error is surfaced, and the session remains interactive.

apps/desktop/src/app/session/hooks/use-session-actions/index.ts — specifically the interaction between the Block 1 fire-and-forget (line 349) and the localSnapshot capture (line 471) in the cold resume path.

Important Files Changed

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)
Loading
%%{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)
Loading

Comments Outside Diff (1)

  1. apps/desktop/src/app/session/hooks/use-session-actions/index.ts, line 471 (link)

    P1 Interim cache paint can corrupt localSnapshot, silently dropping live resume messages

    Block 1's readCachedTranscript (line 349) fires while resolveStoredSession and ensureGatewayProfile are 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, so localSnapshot is non-empty. Then at the preferredMessages selection (line 552), localSnapshot.length > 0 short-circuits to localSnapshot, and the live resumed.messages is 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, $messages was always empty here because nothing between setMessages([]) (line 341) and this read was async.

Reviews (1): Last reviewed commit: "feat(desktop): paint cached transcripts ..." | Re-trigger Greptile

Comment on lines +200 to +204
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[])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant