Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7507,6 +7507,28 @@ ipcMain.handle('hermes:render-cache:read', (_event, gatewayUrl, activeStoredSess
}
})

// Per-session transcript read (switch-paint; Ace 2026-07-11): lets the click
// path paint a cached transcript instantly while the live prefetch runs. Same
// gateway-URL resolution + fail-open contract as the boot read above.
ipcMain.handle('hermes:render-cache:read-transcript', (_event, gatewayUrl, storedSessionId) => {
try {
let url = String(gatewayUrl || '').trim()
if (!url) {
const config = readDesktopConnectionConfig()
if (config.mode === 'remote' && config.remote?.url) {
url = String(config.remote.url).trim()
}
}
const cache = getRenderCache(url)
if (!cache || !storedSessionId) {
return null
}
return cache.readTranscript(String(storedSessionId))
} catch {
return null
}
})

// Reconcile observability (Phase 0 RC4): the renderer reports how many rows
// differed between the cached paint and the first live snapshot. rows=0 means
// the cache matched live exactly — I1's "reconciles within one cycle" held.
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
renderCache: {
read: (gatewayUrl, activeStoredSessionId) =>
ipcRenderer.invoke('hermes:render-cache:read', gatewayUrl, activeStoredSessionId),
// Per-session transcript read (switch-paint): cheap, returns {rows}|null.
readTranscript: (gatewayUrl, storedSessionId) =>
ipcRenderer.invoke('hermes:render-cache:read-transcript', gatewayUrl, storedSessionId),
putSessions: (gatewayUrl, data) => ipcRenderer.send('hermes:render-cache:put-sessions', gatewayUrl, data),
putStatus: (gatewayUrl, data) => ipcRenderer.send('hermes:render-cache:put-status', gatewayUrl, data),
putTranscript: (gatewayUrl, storedSessionId, rows) =>
Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/src/app/render-cache-hydration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
computeListDivergence,
cullRenderCacheSession,
hydrateFromRenderCache,
normalizeCachedTranscriptRows,
pushStatusToRenderCache,
pushTranscriptToRenderCache,
reconcileRenderCache
Expand Down Expand Up @@ -226,7 +227,7 @@ describe('transcript paint (chat pane)', () => {
gatewayUrl: 'http://studio:9119',
sessions: null,
status: null,
transcript: { storedSessionId: 'sid', rows: [{ text: 'hello' }] }
transcript: { storedSessionId: 'sid', rows: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }] }
})
})
const setMessages = vi.fn()
Expand All @@ -239,7 +240,7 @@ describe('transcript paint (chat pane)', () => {
setMessages
})
expect(result.transcriptPainted).toBe(true)
expect(setMessages).toHaveBeenCalledWith([{ text: 'hello' }])
expect(setMessages).toHaveBeenCalledWith([{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }])
})

it('never paints into a NON-empty messages store, and never without a remembered session', async () => {
Expand All @@ -249,7 +250,7 @@ describe('transcript paint (chat pane)', () => {
gatewayUrl: 'http://studio:9119',
sessions: null,
status: null,
transcript: { storedSessionId: 'sid', rows: [{ text: 'hello' }] }
transcript: { storedSessionId: 'sid', rows: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }] }
})
})
const setMessages = vi.fn()
Expand Down
56 changes: 52 additions & 4 deletions apps/desktop/src/app/render-cache-hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
* Everything here is fail-open: cache errors degrade to today's boot (I3).
*/

import type { SessionInfo } from '@/types/hermes'
import { toChatMessages } from '@/lib/chat-messages'
import type { ChatMessage } from '@/lib/chat-messages'
import type { SessionInfo, SessionMessage } from '@/types/hermes'

export interface CachedSessionList {
sessions: SessionInfo[]
Expand Down Expand Up @@ -69,15 +71,17 @@ export async function hydrateFromRenderCache(deps: {
// thread-switch branch, which publishes pending.state.messages unmerged).
let transcriptPainted = false
const cachedTranscript = result.transcript as { rows?: unknown[] } | null
const transcriptRows = Array.isArray(cachedTranscript?.rows)
? normalizeCachedTranscriptRows(cachedTranscript!.rows)
: []
if (
deps.setMessages &&
deps.getMessages &&
deps.rememberedSessionId &&
Array.isArray(cachedTranscript?.rows) &&
cachedTranscript!.rows.length > 0 &&
transcriptRows.length > 0 &&
deps.getMessages().length === 0
) {
deps.setMessages(cachedTranscript!.rows)
deps.setMessages(transcriptRows)
transcriptPainted = true
}

Expand Down Expand Up @@ -159,6 +163,50 @@ export function reconcileRenderCache(opts: {
}
}

/**
* Read a cached transcript for ANY session (the switch-paint path; Ace
* 2026-07-11: "cache them so we never wait when we click on a session").
* Returns the cached rows or null. Fail-open: any error is a miss. Rows come
* back in whichever shape the writer stored (raw SessionMessage from the
* preloader, converted ChatMessage from the write-through) — callers paint
* via normalizeCachedTranscriptRows.
*/
export async function readCachedTranscript(storedSessionId: string | null): Promise<unknown[] | null> {
try {
const api = renderCacheApi()
if (!api?.readTranscript || !storedSessionId) {
return null
}
const result = (await api.readTranscript(null, storedSessionId)) as { rows?: unknown[] } | null
const rows = Array.isArray(result?.rows) ? result!.rows : null
return rows && rows.length > 0 ? rows : null
} catch {
return null
}
}

/**
* Normalize cached transcript rows to ChatMessage[] for painting. Two writer
* shapes exist: the transcript preloader stores raw SessionMessage rows; the
* active-session write-through stores converted ChatMessage rows (they carry
* `parts: []`). Sniff and convert; anything unconvertible comes back empty
* (callers treat empty as a miss and fall through to the network).
*/
export function normalizeCachedTranscriptRows(rows: unknown[]): ChatMessage[] {
try {
if (!Array.isArray(rows) || rows.length === 0) {
return []
}
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[])
Comment on lines +200 to +204

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.

} catch {
return []
}
}

/** Push a live status snapshot into the cache (fire-and-forget). */
export function pushStatusToRenderCache(gatewayUrl: string | null, status: unknown): void {
try {
Expand Down
45 changes: 44 additions & 1 deletion apps/desktop/src/app/session/hooks/use-session-actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { resolveNewSessionCwd, tombstoneSessions, untombstoneSessions } from '@/store/projects'

import { cullRenderCacheSession } from '../../../render-cache-hydration'
import { cullRenderCacheSession, normalizeCachedTranscriptRows, readCachedTranscript } from '../../../render-cache-hydration'
import {
$connection,
$currentCwd,
Expand Down Expand Up @@ -339,6 +339,24 @@ export function useSessionActions({
setActiveSessionId(null)
activeSessionIdRef.current = null
setMessages([])

// Switch-paint from the render cache (Ace 2026-07-11): for a cold
// target, paint the cached transcript instantly while the live
// prefetch below runs. Read is async (~ms IPC + disk); guarded so it
// only applies while THIS resume is current and nothing live (or a
// newer click) has painted yet — the live prefetch/resume wholesale-
// replaces these rows when it lands (same SWR contract as boot).
void readCachedTranscript(storedSessionId)
.then(cachedRows => {
if (!cachedRows || !isCurrentResume() || $messages.get().length > 0) {
return
}
const painted = normalizeCachedTranscriptRows(cachedRows)
if (painted.length > 0) {
setMessages(painted)
}
})
.catch(() => undefined)
}

// Swap the single live gateway to this session's profile before any
Expand Down Expand Up @@ -459,6 +477,31 @@ export function useSessionActions({
// Watch windows skip the prefetch — lazy resume attaches the live mirror.
const prefetchPromise = watchWindow ? null : getSessionMessages(storedSessionId, sessionProfile)

// Switch-paint from the render cache (Ace 2026-07-11: first click on a
// session shouldn't wait on the network). If this session's transcript
// is cached (transcript preloader / write-through), paint it NOW while
// the prefetch + resume run. Interim paint only: it never feeds
// localSnapshot, so the live prefetch/resume below wholesale-replaces
// it (same I5 discipline as the boot paint).
if (!watchWindow && $messages.get().length === 0) {
readCachedTranscript(storedSessionId)
.then(rows => {
if (!rows || !isCurrentResume()) {
return
}
// A live payload may already have landed while the cache read
// resolved; never clobber it with the cached copy.
if ($messages.get().length > 0) {
return
}
const painted = normalizeCachedTranscriptRows(rows)
if (painted.length > 0) {
setMessages(painted)
}
})
.catch(() => undefined)
}

const resumePromise = requestGateway<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
cols: 96,
Expand Down
86 changes: 86 additions & 0 deletions apps/desktop/src/app/switch-cache-paint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Switch-paint from the render cache (Ace 2026-07-11: "clicking a session for
// the first time doesn't feel snappy"). Contract under test:
// 1. readCachedTranscript returns rows for a cached session, null otherwise;
// it NEVER throws (fail-open) — a broken preload API is a miss.
// 2. normalizeCachedTranscriptRows handles BOTH writer shapes: raw
// SessionMessage rows (transcript preloader) convert via toChatMessages;
// already-converted ChatMessage rows (write-through) pass through.
// 3. Empty/garbage normalizes to [] — the switch falls through to the
// network, never paints a blank.
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { normalizeCachedTranscriptRows, readCachedTranscript } from './render-cache-hydration'

function installApi(readTranscript: unknown) {
;(window as any).hermesDesktop = { renderCache: { readTranscript } }
}

describe('readCachedTranscript (switch-paint)', () => {
beforeEach(() => {
delete (window as any).hermesDesktop
})

it('returns cached rows for a hit', async () => {
installApi(vi.fn().mockResolvedValue({ rows: [{ role: 'user', content: 'hi' }] }))
const rows = await readCachedTranscript('s1')
expect(rows).toEqual([{ role: 'user', content: 'hi' }])
})

it('passes a null gatewayUrl (main resolves it) and the session id', async () => {
const read = vi.fn().mockResolvedValue({ rows: [1] })
installApi(read)
await readCachedTranscript('s42')
expect(read).toHaveBeenCalledWith(null, 's42')
})

it('misses on null payload, empty rows, and missing id', async () => {
installApi(vi.fn().mockResolvedValue(null))
expect(await readCachedTranscript('s1')).toBeNull()

installApi(vi.fn().mockResolvedValue({ rows: [] }))
expect(await readCachedTranscript('s1')).toBeNull()

installApi(vi.fn().mockResolvedValue({ rows: [1] }))
expect(await readCachedTranscript(null)).toBeNull()
expect(await readCachedTranscript('')).toBeNull()
})

it('fail-open: missing bridge, missing method, rejecting IPC are all misses', async () => {
expect(await readCachedTranscript('s1')).toBeNull() // no hermesDesktop at all

;(window as any).hermesDesktop = { renderCache: {} } // no readTranscript method
expect(await readCachedTranscript('s1')).toBeNull()

installApi(vi.fn().mockRejectedValue(new Error('ipc down')))
expect(await readCachedTranscript('s1')).toBeNull()
})

it('tolerates a malformed payload (rows not an array)', async () => {
installApi(vi.fn().mockResolvedValue({ rows: 'garbage' }))
expect(await readCachedTranscript('s1')).toBeNull()
})
})

describe('normalizeCachedTranscriptRows (dual writer shapes)', () => {
it('passes ChatMessage-shaped rows (write-through) straight through', () => {
const rows = [{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'hi' }] }]
expect(normalizeCachedTranscriptRows(rows)).toBe(rows)
})

it('converts raw SessionMessage rows (preloader) via toChatMessages', () => {
const raw = [
{ role: 'user', content: 'hello' },
{ role: 'assistant', content: 'hey there' }
]
const out = normalizeCachedTranscriptRows(raw)
expect(out.length).toBeGreaterThan(0)
expect(out[0]).toHaveProperty('parts')
expect(out[0].role).toBe('user')
})

it('empty/garbage input normalizes to [] (a miss, never a blank paint)', () => {
expect(normalizeCachedTranscriptRows([])).toEqual([])
expect(normalizeCachedTranscriptRows(null as never)).toEqual([])
expect(normalizeCachedTranscriptRows('nope' as never)).toEqual([])
})
})
28 changes: 24 additions & 4 deletions apps/desktop/src/app/transcript-preload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ describe('preloadTranscripts', () => {
gatewayUrl: 'http://g',
sessions: [s('cached'), s('empty'), s('real')],
freshCached: new Set(['cached']),
fetchMessages: (async (id: string) => ({ messages: id === 'empty' ? [] : [{ x: 1 }] })) as never,
fetchMessages: (async (id: string) => ({ messages: id === 'empty' ? [] : [{ role: 'user', content: 'hi' }] })) as never,
push: ((_u: string, id: string) => pushed.push(id)) as never,
sleep: noSleep
})
Expand All @@ -71,7 +71,7 @@ describe('preloadTranscripts', () => {
sessions: [s('bad'), s('good')],
fetchMessages: (async (id: string) => {
if (id === 'bad') throw new Error('boom')
return { messages: [{ x: 1 }] }
return { messages: [{ role: 'user', content: 'hi' }] }
}) as never,
push: ((_u: string, id: string) => pushed.push(id)) as never,
sleep: noSleep
Expand All @@ -87,7 +87,7 @@ describe('preloadTranscripts', () => {
sessions: [s('a'), s('b'), s('c')],
fetchMessages: (async () => {
calls += 1
return { messages: [{ x: 1 }] }
return { messages: [{ role: 'user', content: 'hi' }] }
}) as never,
push: (() => undefined) as never,
sleep: noSleep,
Expand All @@ -107,11 +107,31 @@ describe('preloadTranscripts', () => {
sessions: [s('x', { profile: 'daedalus' } as never)],
fetchMessages: (async (id: string, profile?: string | null) => {
seen.push([id, profile])
return { messages: [{ x: 1 }] }
return { messages: [{ role: 'user', content: 'hi' }] }
}) as never,
push: (() => undefined) as never,
sleep: noSleep
})
expect(seen).toEqual([['x', 'daedalus']])
})

it('stores ChatMessage-shaped rows (parts[]), not raw SessionMessage rows', async () => {
const pushedRows: unknown[][] = []
await preloadTranscripts({
gatewayUrl: 'http://g',
sessions: [s('a')],
fetchMessages: (async () => ({
messages: [
{ role: 'user', content: 'hi' },
{ role: 'assistant', content: 'yo' }
]
})) as never,
push: ((_u: string, _id: string, rows: unknown[]) => pushedRows.push(rows)) as never,
sleep: noSleep
})
expect(pushedRows).toHaveLength(1)
for (const row of pushedRows[0] as Array<{ parts?: unknown }>) {
expect(Array.isArray(row.parts)).toBe(true)
}
})
})
7 changes: 6 additions & 1 deletion apps/desktop/src/app/transcript-preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
*/

import { getSessionMessages } from '@/hermes'
import { toChatMessages } from '@/lib/chat-messages'
import type { SessionInfo } from '@/types/hermes'

import { pushTranscriptToRenderCache } from './render-cache-hydration'
Expand Down Expand Up @@ -76,7 +77,11 @@ export async function preloadTranscripts(deps: PreloadDeps): Promise<number> {
}
try {
const result = await fetchMessages(session.id, session.profile)
const rows = Array.isArray(result?.messages) ? result.messages : []
const raw = Array.isArray(result?.messages) ? result.messages : []
// Store in ChatMessage shape (what setMessages renders) so the paint
// path never converts on click. toChatMessages is the same conversion
// the live prefetch applies.
const rows = raw.length > 0 ? toChatMessages(raw) : []
if (rows.length > 0) {
push(gatewayUrl, session.id, rows)
cached += 1
Expand Down
Loading
Loading