`. Crucially, we update the text-
+ * node pointer AFTER the wrap, so the next search step sees the new node
+ * layout rather than the (now detached) original.
+ */
+function highlightMatches(root: Element, query: string): HTMLElement[] {
+ const marks: HTMLElement[] = []
+ const lowerQuery = query.toLowerCase()
+
+ if (!lowerQuery) {
+ return marks
+ }
+
+ let current: Node | null = root.firstChild
+ let textNode: Text | null = null
+
+ // Outer loop: walk element children until we have a text node to search.
+ while (current) {
+ if (current.nodeType === Node.TEXT_NODE) {
+ textNode = current as Text
+ } else if (current.nodeType === Node.ELEMENT_NODE) {
+ const el = current as Element
+
+ // Skip elements we never want to search into. The walker is
+ // deliberately flat (no recursion); a nested in a
+ // becomes a fresh descendant scan via the recursive call below.
+ if (shouldSkipElement(el)) {
+ current = el.nextSibling
+
+ continue
+ }
+
+ // Recurse into the element's descendants.
+ const inner = highlightMatches(el, query)
+
+ marks.push(...inner)
+ current = el.nextSibling
+
+ continue
+ } else {
+ current = current.nextSibling
+
+ continue
+ }
+
+ if (!textNode || !textNode.parentNode) {
+ current = textNode?.nextSibling ?? null
+
+ continue
+ }
+
+ // Search within this text node. Splits may invalidate `textNode`'s
+ // identity, so we re-read it from the parent each iteration.
+ let nodeValue = textNode.nodeValue ?? ''
+ // Capture the text node's own next sibling BEFORE any replaceChild:
+ // once the original node is detached, `.nextSibling` reads null and
+ // the outer walker would stop, skipping every following sibling
+ // subtree (`needleneedle
` searching "needle"
+ // matched only the first). `continueFrom` is the sibling AFTER this
+ // text node — for a fully-consumed match, the walker resumes there.
+ const continueFrom = textNode.nextSibling
+
+ while (true) {
+ const lower = nodeValue.toLowerCase()
+ const idx = lower.indexOf(lowerQuery)
+
+ if (idx === -1) {
+ break
+ }
+
+ const before = nodeValue.slice(0, idx)
+ const matchText = nodeValue.slice(idx, idx + lowerQuery.length)
+ const after = nodeValue.slice(idx + lowerQuery.length)
+ const parent = textNode.parentNode
+
+ if (!parent) {
+ break
+ }
+
+ const fragment = document.createDocumentFragment()
+
+ if (before) {
+ fragment.appendChild(document.createTextNode(before))
+ }
+
+ const mark = document.createElement('mark')
+
+ mark.className = HIGHLIGHT_CLASS
+ mark.textContent = matchText
+ fragment.appendChild(mark)
+
+ // Capture the after-sibling BEFORE replaceChild — replaceChild moves
+ // the fragment's children into the parent and empties the fragment,
+ // so looking at `fragment.lastChild` afterwards would point at a
+ // detached `` (or null if `before` was empty).
+ const afterNode = after ? document.createTextNode(after) : null
+
+ if (afterNode) {
+ fragment.appendChild(afterNode)
+ }
+
+ parent.replaceChild(fragment, textNode)
+
+ marks.push(mark)
+
+ if (!afterNode) {
+ // Whole text node consumed — nothing left to scan in this region.
+ textNode = null
+
+ break
+ }
+
+ textNode = afterNode
+ nodeValue = afterNode.nodeValue ?? ''
+ }
+
+ if (textNode) {
+ current = textNode.nextSibling
+ } else {
+ // The whole text node was consumed by matches — `textNode` was
+ // detached by replaceChild so its own `.nextSibling` is null. Resume
+ // the outer walker from the parent's next sibling (captured before
+ // the first replaceChild), so the sibling subtree is still searched.
+ current = continueFrom
+ }
+ }
+
+ return marks
+}
+
+/** Elements we never want to descend into during a search. Mirrors the
+ * filter the TreeWalker applied in the original design. */
+function shouldSkipElement(el: Element): boolean {
+ const tag = el.tagName
+
+ if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT') {
+ return true
+ }
+
+ if (el.closest(`mark.${HIGHLIGHT_CLASS}`)) {
+ return true
+ }
+
+ if (el.closest('[role="search"]')) {
+ return true
+ }
+
+ return false
+}
+
+/**
+ * True when `root` contains an occurrence of `query` that is NOT inside one
+ * of our find-hit marks. Read-only counterpart of `highlightMatches` with
+ * the identical skip rules, used by the findNext fast path to verify that
+ * the existing marks still cover every match — a stale mark from an earlier
+ * query can satisfy the per-mark text comparison yet leave live occurrences
+ * unwrapped, and stepping on that set would never highlight them.
+ */
+function hasUnmarkedMatch(root: Element, query: string): boolean {
+ const lowerQuery = query.toLowerCase()
+
+ if (!lowerQuery) {
+ return false
+ }
+
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
+
+ while (walker.nextNode()) {
+ const node = walker.currentNode as Text
+ const parent = node.parentElement
+
+ if (parent && shouldSkipElement(parent)) {
+ continue
+ }
+
+ if (node.nodeValue?.toLowerCase().includes(lowerQuery)) {
+ return true
+ }
+ }
+
+ return false
+}
+
+/** Remove every find-hit mark we previously added, restoring original text. */
+function clearHighlights(root: Element): void {
+ const marks = root.querySelectorAll(`mark.${HIGHLIGHT_CLASS}`)
+
+ for (const mark of marks) {
+ const parent = mark.parentNode
+
+ if (!parent) {
+ continue
+ }
+
+ while (mark.firstChild) {
+ parent.insertBefore(mark.firstChild, mark)
+ }
+
+ parent.removeChild(mark)
+ parent.normalize()
+ }
+}
+
+/** Mark one element as the active match and scroll it into view. */
+function setActiveMark(mark: HTMLElement | null): void {
+ document.querySelectorAll(`mark[${ACTIVE_ATTR}]`).forEach(el => el.removeAttribute(ACTIVE_ATTR))
+
+ if (!mark) {
+ return
+ }
+
+ mark.setAttribute(ACTIVE_ATTR, '')
+ // Block: 'nearest' so a match already on screen doesn't twitch, but a
+ // match below the fold scrolls into view instead of silently landing off-
+ // screen. Inline: 'nearest' for the same reason. Guarded: jsdom does not
+ // implement scrollIntoView, and the bar still needs to highlight even
+ // when the renderer side has no layout (tests, headless boot, …).
+ if (typeof mark.scrollIntoView === 'function') {
+ mark.scrollIntoView({ block: 'nearest', inline: 'nearest' })
+ }
+}
+
+/** Result of a find / step — the same shape the bar already shows. */
+export interface ScopedFindResult {
+ count: number
+ activeOrdinal: number
+}
+
+export interface ScopedFindOptions {
+ forward: boolean
+ findNext: boolean
+}
+
+const DEFAULT_RESULT: ScopedFindResult = { count: 0, activeOrdinal: 0 }
+
+/**
+ * Run a scoped find against `root`. When `findNext` is true, advance / step
+ * the active mark without re-highlighting (the query has not changed). When
+ * false, drop prior highlights and re-wrap matches for `query`.
+ *
+ * Returns the (count, activeOrdinal) the bar should display. Returning a
+ * plain object instead of pushing into the store keeps this helper testable
+ * without a nanostores harness — the store wires it up.
+ */
+export function performScopedFind(
+ root: Element,
+ query: string,
+ options: ScopedFindOptions
+): ScopedFindResult {
+ if (!query) {
+ clearHighlights(root)
+ setActiveMark(null)
+ // No query, no marks to maintain — stop watching and forget the query.
+ activeQuery = ''
+ lastActiveOrdinal = 0
+ stopObserver()
+
+ return DEFAULT_RESULT
+ }
+
+ const existingMarks = [...root.querySelectorAll(`mark.${HIGHLIGHT_CLASS}`)]
+ // The marks store the ORIGINAL-CASE source slice (`highlightMatches`
+ // writes `mark.textContent = matchText`), so byte-equality against the
+ // typed query fails on the first match whose casing differs — 'Hermes'
+ // for 'hermes', sentence-initial capitals, ALL-CAPS. Without the
+ // case-insensitive comparison, every Enter/⌘G step re-wraps all
+ // highlights, `data-find-active` is lost on the fresh DOM, and the
+ // active ordinal resets to 1 forever (triage finding on #81778).
+ // The per-mark comparison alone is not enough: a stale mark from an
+ // earlier query that survived a raced re-wrap (or external DOM writes)
+ // can match the current query case-insensitively while the mark set no
+ // longer covers every match — stepping would walk old highlights and
+ // never wrap the live ones. Only step when the marks match AND cover
+ // every occurrence (review finding on #81778).
+ const sameQuery =
+ options.findNext &&
+ existingMarks.length > 0 &&
+ existingMarks.every(mark => mark.textContent.toLowerCase() === query.toLowerCase()) &&
+ !hasUnmarkedMatch(root, query)
+
+ let marks = existingMarks
+
+ if (!sameQuery) {
+ // Mutate under the re-entrancy guard so the re-render watcher doesn't
+ // fire on the marks WE are replacing (it has nothing to repair yet).
+ applying = true
+
+ try {
+ clearHighlights(root)
+ marks = highlightMatches(root, query)
+ } finally {
+ applying = false
+ }
+ }
+
+ if (marks.length === 0) {
+ setActiveMark(null)
+ // A query that matches nothing has nothing to maintain.
+ activeQuery = ''
+ lastActiveOrdinal = 0
+ stopObserver()
+
+ return DEFAULT_RESULT
+ }
+
+ const previousActive = root.querySelector(`mark[${ACTIVE_ATTR}]`)
+ let nextIndex = 0
+
+ if (previousActive) {
+ const previousIndex = marks.indexOf(previousActive)
+
+ if (previousIndex !== -1) {
+ nextIndex = options.forward
+ ? (previousIndex + 1) % marks.length
+ : (previousIndex - 1 + marks.length) % marks.length
+ }
+ } else if (!options.forward) {
+ // Entering find mode backwards (Shift+Enter on first press): land on the
+ // last match, matching browser convention.
+ nextIndex = marks.length - 1
+ }
+
+ setActiveMark(marks[nextIndex] ?? null)
+
+ // A live search must survive React re-rendering the transcript under us.
+ // Remember the query + active position and start watching the scope; the
+ // observer re-wraps when a render detaches our marks and re-activates the
+ // same ordinal so the user's place doesn't reset to match #1 on the next
+ // streamed token.
+ activeQuery = query
+ lastActiveOrdinal = nextIndex + 1
+ ensureObserver(root)
+
+ return { count: marks.length, activeOrdinal: nextIndex + 1 }
+}
+
+// ── Re-apply on React re-render ─────────────────────────────────────────────
+/**
+ * Attach the scope watcher, if it isn't already attached. Only observes the
+ * current scope; a fresh `captureFindScope` (or close) detaches it.
+ */
+function ensureObserver(root: Element): void {
+ if (observer && scopeRoot === root) {
+ return
+ }
+
+ stopObserver()
+ observer = new MutationObserver(() => scheduleReapply())
+ observer.observe(root, { childList: true, subtree: true })
+}
+
+function stopObserver(): void {
+ observer?.disconnect()
+ observer = null
+}
+
+/**
+ * Coalesce a mutation burst (one streaming delta) into a single re-apply that
+ * runs on the next microtask. A no-op when the marks still cover the query —
+ * React reusing an untouched region doesn't need any work.
+ */
+function scheduleReapply(): void {
+ if (applying) {
+ // A mutation landed while we were mutating the tree. We'll look again
+ // once this apply finishes rather than recursing now.
+ pending = true
+
+ return
+ }
+
+ if (scheduled || !scopeRoot || !activeQuery) {
+ return
+ }
+
+ scheduled = true
+
+ queueMicrotask(() => {
+ scheduled = false
+
+ if (applying || !scopeRoot || !activeQuery) {
+ return
+ }
+
+ // Nothing to repair: every occurrence is still wrapped (React only
+ // touched a region that doesn't match the query).
+ if (!hasUnmarkedMatch(scopeRoot, activeQuery)) {
+ return
+ }
+
+ reapplying(scopeRoot)
+ })
+}
+
+/**
+ * Re-wrap every occurrence in the scope after React detached our previous
+ * marks, re-activating the position the user was on. Runs with `applying` set
+ * so the observer ignores the mutations WE make, then drains any `pending`
+ * mutation that arrived during the apply.
+ */
+function reapplying(root: HTMLElement): void {
+ const restoreOrdinal = lastActiveOrdinal
+
+ applying = true
+
+ try {
+ clearHighlights(root)
+ const marks = highlightMatches(root, activeQuery)
+
+ if (marks.length === 0) {
+ return
+ }
+
+ // Re-activate the same ordinal the user last stood on, clamped to the
+ // re-wrapped set, so a mid-stream re-render doesn't bounce their place
+ // back to match #1.
+ const nextIndex = Math.min(restoreOrdinal - 1, marks.length - 1)
+ setActiveMark(marks[nextIndex] ?? null)
+ lastActiveOrdinal = nextIndex + 1
+ } finally {
+ applying = false
+ }
+
+ if (pending) {
+ pending = false
+ scheduleReapply()
+ }
+}
+
+/** Tear down highlights and the scope marker — called when the bar closes. */
+export function releaseFindScope(): void {
+ const roots = document.querySelectorAll(`[${ROOT_ATTR}]`)
+
+ for (const root of roots) {
+ clearHighlights(root)
+ root.removeAttribute(ROOT_ATTR)
+ }
+
+ setActiveMark(null)
+ resetScopeState()
+}
diff --git a/apps/desktop/src/lib/gateway-events.test.ts b/apps/desktop/src/lib/gateway-events.test.ts
index 02c3f643ca..5ec527d0bc 100644
--- a/apps/desktop/src/lib/gateway-events.test.ts
+++ b/apps/desktop/src/lib/gateway-events.test.ts
@@ -1,8 +1,14 @@
import { describe, expect, it } from 'vitest'
-import { gatewayEventRequiresSessionId, resolveGatewayEventSessionId } from './gateway-events'
+import { approvalReplaySessionId, gatewayEventRequiresSessionId, resolveGatewayEventSessionId } from './gateway-events'
describe('gateway event routing', () => {
+ it('rehydrates pending approvals on reconnect ready and resumed session info', () => {
+ expect(approvalReplaySessionId('gateway.ready', 'active-1', null)).toBe('active-1')
+ expect(approvalReplaySessionId('session.info', 'active-1', 'routed-1')).toBe('routed-1')
+ expect(approvalReplaySessionId('message.delta', 'active-1', 'routed-1')).toBeNull()
+ })
+
it('drops only unscoped subagent events (genuinely background work)', () => {
expect(gatewayEventRequiresSessionId('subagent.progress')).toBe(true)
expect(gatewayEventRequiresSessionId('subagent.start')).toBe(true)
@@ -37,6 +43,7 @@ describe('gateway event routing', () => {
expect(started).toEqual({
drop: false,
nextUnscopedStreamSessionId: 'session-a',
+ pinned: false,
sessionId: 'session-a'
})
@@ -50,6 +57,7 @@ describe('gateway event routing', () => {
expect(delta).toEqual({
drop: false,
nextUnscopedStreamSessionId: 'session-a',
+ pinned: true,
sessionId: 'session-a'
})
@@ -63,6 +71,7 @@ describe('gateway event routing', () => {
expect(completed).toEqual({
drop: false,
nextUnscopedStreamSessionId: null,
+ pinned: true,
sessionId: 'session-a'
})
})
@@ -78,6 +87,27 @@ describe('gateway event routing', () => {
expect(routed).toEqual({
drop: false,
nextUnscopedStreamSessionId: 'session-b',
+ pinned: false,
+ sessionId: 'session-b'
+ })
+ })
+
+ it('attributes an unpinned stream event to the active session without the pin flag', () => {
+ // A late straggler (no pin left after the previous turn completed) falls
+ // back to the active session. The handler drops this case when the target
+ // session has no live turn — the straggler belongs to a turn that already
+ // ended elsewhere (#43142 family).
+ const routed = resolveGatewayEventSessionId({
+ activeSessionId: 'session-b',
+ eventType: 'thinking.delta',
+ explicitSessionId: '',
+ unscopedStreamSessionId: null
+ })
+
+ expect(routed).toEqual({
+ drop: false,
+ nextUnscopedStreamSessionId: null,
+ pinned: false,
sessionId: 'session-b'
})
})
@@ -93,6 +123,7 @@ describe('gateway event routing', () => {
expect(routed).toEqual({
drop: false,
nextUnscopedStreamSessionId: null,
+ pinned: true,
sessionId: 'session-a'
})
})
diff --git a/apps/desktop/src/lib/gateway-events.ts b/apps/desktop/src/lib/gateway-events.ts
index 4b09ba30d7..6375955510 100644
--- a/apps/desktop/src/lib/gateway-events.ts
+++ b/apps/desktop/src/lib/gateway-events.ts
@@ -17,7 +17,12 @@ function asRecord(payload: unknown): Record {
* Without this, ``explicitSid || activeSessionId`` reattributes live deltas to
* the newly focused chat.
*/
-const UNSCOPED_STREAM_EVENT_TYPES = new Set([
+/** Unscoped stream events that must stay pinned to the session that received
+ * ``message.start`` after the user switches chats mid-turn (#47709 / #48281).
+ * Without this, ``explicitSid || activeSessionId`` reattributes live deltas to
+ * the newly focused chat. Exported so the event handler can tell which events
+ * are pin-eligible when deciding whether an unpinned straggler is legitimate. */
+export const UNSCOPED_STREAM_EVENT_TYPES = new Set([
'approval.request',
'browser.progress',
'clarify.request',
@@ -67,9 +72,30 @@ export interface GatewayEventSessionRouteInput {
export interface GatewayEventSessionRoute {
drop: boolean
nextUnscopedStreamSessionId: null | string
+ /** True when the event was attributed via the pinned stream session rather
+ * than the active-session fallback. The caller uses this to drop late
+ * stragglers: an unpinned stream event landing on a session that has no
+ * live turn belongs to a turn that already ended elsewhere. */
+ pinned: boolean
sessionId: null | string
}
+export function approvalReplaySessionId(
+ eventType: string | undefined,
+ activeSessionId: null | string,
+ routedSessionId: null | string
+): null | string {
+ if (eventType === 'gateway.ready') {
+ return activeSessionId
+ }
+
+ if (eventType === 'session.info') {
+ return routedSessionId
+ }
+
+ return null
+}
+
/**
* Resolve which runtime session owns a gateway event.
*
@@ -92,6 +118,7 @@ export function resolveGatewayEventSessionId({
return {
drop: false,
nextUnscopedStreamSessionId,
+ pinned: true,
sessionId: explicitSessionId
}
}
@@ -100,6 +127,7 @@ export function resolveGatewayEventSessionId({
return {
drop: true,
nextUnscopedStreamSessionId: unscopedStreamSessionId,
+ pinned: false,
sessionId: null
}
}
@@ -124,6 +152,7 @@ export function resolveGatewayEventSessionId({
return {
drop: false,
nextUnscopedStreamSessionId,
+ pinned: streamEvent && eventType !== 'message.start' && Boolean(unscopedStreamSessionId),
sessionId
}
}
diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts
index 6b3d5f80cc..943dd1870a 100644
--- a/apps/desktop/src/lib/icons.ts
+++ b/apps/desktop/src/lib/icons.ts
@@ -80,6 +80,7 @@ import {
IconDots as MoreHorizontal,
IconDots as MoreHorizontalIcon,
IconDotsVertical as MoreVertical,
+ IconNetwork as Network,
IconNotebook as NotebookTabs,
IconPackage as Package,
IconPalette as Palette,
@@ -207,6 +208,7 @@ export {
MoreHorizontal,
MoreHorizontalIcon,
MoreVertical,
+ Network,
NotebookTabs,
Package,
Palette,
diff --git a/apps/desktop/src/lib/image-resize.test.ts b/apps/desktop/src/lib/image-resize.test.ts
new file mode 100644
index 0000000000..cc23de1ff2
--- /dev/null
+++ b/apps/desktop/src/lib/image-resize.test.ts
@@ -0,0 +1,199 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { downscaleDataUrlForPreview } from './image-resize'
+
+// A minimal valid 1x1 red PNG (67 bytes) for testing
+const TINY_PNG_B64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+GkZcAAAAASUVORK5CYII='
+
+const TINY_PNG_DATA_URL = `data:image/png;base64,${TINY_PNG_B64}`
+
+// A 1×1 transparent PNG used as the fallback placeholder
+const FALLBACK_PLACEHOLDER =
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+GkZcAAAAASUVORK5CYII='
+
+describe('downscaleDataUrlForPreview', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ vi.unstubAllGlobals()
+ })
+
+ describe('without createImageBitmap (jsdom default)', () => {
+ it('fails closed when createImageBitmap is unavailable', async () => {
+ await expect(downscaleDataUrlForPreview('data:image/png;base64,ZmFrZQ==')).resolves.toBe(FALLBACK_PLACEHOLDER)
+ })
+
+ it('preserves an external source when resize APIs are unavailable', async () => {
+ await expect(downscaleDataUrlForPreview('not-a-data-url')).resolves.toBe('not-a-data-url')
+ })
+
+ it('preserves a non-image data URL when resize APIs are unavailable', async () => {
+ await expect(downscaleDataUrlForPreview('data:text/plain')).resolves.toBe('data:text/plain')
+ })
+ })
+
+ describe('with mocked createImageBitmap + OffscreenCanvas', () => {
+ /**
+ * Set up the full mock chain: fetch → createImageBitmap → OffscreenCanvas → FileReader.
+ * Mocks a bitmap of the given dimensions so the downscaling logic is exercised.
+ */
+ function setupMocks(bitmapWidth: number, bitmapHeight: number) {
+ let activeBitmaps = 0
+ let maxActiveBitmaps = 0
+
+ const close = vi.fn(() => {
+ activeBitmaps -= 1
+ })
+
+ const drawImage = vi.fn()
+ const convertToBlob = vi.fn(async () => new Blob(['x'], { type: 'image/png' }))
+ const canvasSizes: [number, number][] = []
+
+ const bitmap = { width: bitmapWidth, height: bitmapHeight, close }
+ const ctx = { drawImage }
+
+ class MockOffscreenCanvas {
+ getContext = vi.fn(() => ctx)
+ convertToBlob = convertToBlob
+ constructor(width: number, height: number) {
+ canvasSizes.push([width, height])
+ }
+ }
+
+ // Mock fetch to return a Blob from the data URL
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => ({
+ blob: async () => new Blob([new Uint8Array([0])], { type: 'image/png' })
+ }))
+ )
+ vi.stubGlobal(
+ 'createImageBitmap',
+ vi.fn(async () => {
+ activeBitmaps += 1
+ maxActiveBitmaps = Math.max(maxActiveBitmaps, activeBitmaps)
+
+ return bitmap
+ })
+ )
+ vi.stubGlobal('OffscreenCanvas', MockOffscreenCanvas)
+
+ return { bitmap, close, drawImage, convertToBlob, ctx, canvasSizes, maxActiveBitmaps: () => maxActiveBitmaps }
+ }
+
+ it('returns the original when image is smaller than maxLongEdge', async () => {
+ setupMocks(500, 300)
+
+ const result = await downscaleDataUrlForPreview(TINY_PNG_DATA_URL, 2048)
+ expect(result).toBe(TINY_PNG_DATA_URL)
+ })
+
+ it('downscales when image exceeds the default preview edge', async () => {
+ const { drawImage, close } = setupMocks(4000, 3000)
+
+ // In jsdom, FileReader.readAsDataURL won't actually produce a data URL,
+ // so the function falls through to the fallback placeholder. But the
+ // important thing is that drawImage was called with scaled dimensions
+ // and the bitmap was closed.
+ const result = await downscaleDataUrlForPreview(TINY_PNG_DATA_URL)
+
+ // scale = 512 / 4000 = 0.128 → width=512, height=384
+ expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 512, 384)
+ expect(close).toHaveBeenCalled()
+
+ // Result should be the downscaled data URL (from our mocked blob),
+ // NOT the original tiny PNG — the function attempted downscaling.
+ expect(result).not.toBe(TINY_PNG_DATA_URL)
+ // The drawImage was called with correct dimensions and bitmap was cleaned up
+ expect(drawImage).toHaveBeenCalled()
+ expect(close).toHaveBeenCalled()
+ })
+
+ it('keeps every thumbnail bounded for a 72-image composer prompt', async () => {
+ const { canvasSizes, drawImage, maxActiveBitmaps } = setupMocks(6000, 6000)
+
+ await Promise.all(Array.from({ length: 72 }, () => downscaleDataUrlForPreview(TINY_PNG_DATA_URL)))
+
+ expect(canvasSizes).toHaveLength(72)
+ expect(canvasSizes.every(([width, height]) => width === 512 && height === 512)).toBe(true)
+ expect(drawImage).toHaveBeenCalledTimes(72)
+ expect(maxActiveBitmaps()).toBe(1)
+ })
+
+ it('returns placeholder when createImageBitmap throws', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => ({
+ blob: async () => new Blob([new Uint8Array([0])], { type: 'image/png' })
+ }))
+ )
+ vi.stubGlobal(
+ 'createImageBitmap',
+ vi.fn(async () => {
+ throw new Error('decode failed')
+ })
+ )
+ vi.stubGlobal(
+ 'OffscreenCanvas',
+ vi.fn(() => ({}))
+ )
+
+ const result = await downscaleDataUrlForPreview(TINY_PNG_DATA_URL, 2048)
+ expect(result).toBe(FALLBACK_PLACEHOLDER)
+ })
+
+ it('closes bitmap even when canvas getContext returns null', async () => {
+ const close = vi.fn()
+ const bitmap = { width: 4000, height: 3000, close }
+
+ class NullCanvas {
+ getContext = () => null
+ constructor(_w: number, _h: number) {}
+ }
+
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => ({
+ blob: async () => new Blob([new Uint8Array([0])], { type: 'image/png' })
+ }))
+ )
+ vi.stubGlobal(
+ 'createImageBitmap',
+ vi.fn(async () => bitmap)
+ )
+ vi.stubGlobal('OffscreenCanvas', NullCanvas)
+
+ const result = await downscaleDataUrlForPreview(TINY_PNG_DATA_URL, 2048)
+ expect(result).toBe(FALLBACK_PLACEHOLDER)
+ expect(close).toHaveBeenCalled()
+ })
+
+ it('uses placeholder instead of original on convertToBlob failure', async () => {
+ const close = vi.fn()
+ const bitmap = { width: 4000, height: 3000, close }
+
+ class BrokenCanvas {
+ getContext = () => ({ drawImage: vi.fn() })
+ convertToBlob = vi.fn(async () => {
+ throw new Error('blob failed')
+ })
+ constructor(_w: number, _h: number) {}
+ }
+
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => ({
+ blob: async () => new Blob([new Uint8Array([0])], { type: 'image/png' })
+ }))
+ )
+ vi.stubGlobal(
+ 'createImageBitmap',
+ vi.fn(async () => bitmap)
+ )
+ vi.stubGlobal('OffscreenCanvas', BrokenCanvas)
+
+ const result = await downscaleDataUrlForPreview(TINY_PNG_DATA_URL, 2048)
+ expect(result).toBe(FALLBACK_PLACEHOLDER)
+ expect(close).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/apps/desktop/src/lib/image-resize.ts b/apps/desktop/src/lib/image-resize.ts
new file mode 100644
index 0000000000..1639488363
--- /dev/null
+++ b/apps/desktop/src/lib/image-resize.ts
@@ -0,0 +1,95 @@
+/**
+ * Downscale a data URL for preview rendering.
+ *
+ * Large images (Retina screenshots, 6000×4000+) cause Chromium to build very
+ * large paint operations when the original is assigned to an
. This
+ * utility uses createImageBitmap + OffscreenCanvas to resize before display.
+ *
+ * Calls share a one-at-a-time queue. Multi-image attach flows must not keep
+ * dozens of decoded full-resolution bitmaps alive concurrently while their
+ * 512px thumbnails are produced.
+ *
+ * @param dataUrl The full-resolution data URL (data:image/...;base64,...)
+ * @param maxLongEdge Maximum pixel dimension on the longest side (default 512)
+ * @returns A downscaled PNG data URL, the original if already small enough, or
+ * a 1×1 transparent PNG placeholder if downscaling fails.
+ */
+
+/** 1×1 transparent PNG — fail closed so the pill never receives the original. */
+const FALLBACK_PLACEHOLDER =
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+GkZcAAAAASUVORK5CYII='
+
+// Composer pills render at 32 CSS pixels. A 512px source stays sharp on
+// high-density displays while keeping a square RGBA decode to 1 MiB. A 2048px
+// thumbnail can decode to 16 MiB and reproduce Chromium's paint-op pressure.
+const DEFAULT_MAX_LONG_EDGE = 512
+
+let resizeQueue = Promise.resolve()
+
+async function downscaleDataUrl(dataUrl: string, maxLongEdge: number): Promise {
+ try {
+ // fetch(data:) decodes base64 natively in C++ and avoids an O(n) atob loop.
+ const blob = await fetch(dataUrl).then(response => response.blob())
+ const bitmap = await createImageBitmap(blob)
+
+ try {
+ const { width, height } = bitmap
+ const longEdge = Math.max(width, height)
+
+ if (longEdge <= maxLongEdge) {
+ return dataUrl
+ }
+
+ const scale = maxLongEdge / longEdge
+ const newWidth = Math.max(1, Math.round(width * scale))
+ const newHeight = Math.max(1, Math.round(height * scale))
+ const canvas = new OffscreenCanvas(newWidth, newHeight)
+ const ctx = canvas.getContext('2d')
+
+ if (!ctx) {
+ return FALLBACK_PLACEHOLDER
+ }
+
+ ctx.drawImage(bitmap, 0, 0, newWidth, newHeight)
+
+ const resultBlob = await canvas.convertToBlob({ type: 'image/png' })
+ const reader = new FileReader()
+
+ return await new Promise(resolve => {
+ reader.onloadend = () => resolve(typeof reader.result === 'string' ? reader.result : FALLBACK_PLACEHOLDER)
+ reader.onabort = () => resolve(FALLBACK_PLACEHOLDER)
+ reader.onerror = () => resolve(FALLBACK_PLACEHOLDER)
+ reader.readAsDataURL(resultBlob)
+ })
+ } finally {
+ bitmap.close()
+ }
+ } catch {
+ return FALLBACK_PLACEHOLDER
+ }
+}
+
+export async function downscaleDataUrlForPreview(
+ dataUrl: string,
+ maxLongEdge = DEFAULT_MAX_LONG_EDGE
+): Promise {
+ if (!dataUrl.startsWith('data:image/') || dataUrl.indexOf(',') === -1) {
+ return dataUrl
+ }
+
+ // Never hand a full-resolution image to the pill if resize support is absent.
+ // A tiny placeholder is preferable to recreating the renderer failure this
+ // helper exists to prevent.
+ if (typeof createImageBitmap !== 'function' || typeof OffscreenCanvas !== 'function') {
+ return FALLBACK_PLACEHOLDER
+ }
+
+ const task = resizeQueue.then(() => downscaleDataUrl(dataUrl, maxLongEdge))
+
+ resizeQueue = task.then(
+ () => undefined,
+ () => undefined
+ )
+
+ return task
+}
diff --git a/apps/desktop/src/lib/inflight-turn-journal.test.ts b/apps/desktop/src/lib/inflight-turn-journal.test.ts
index 206a122288..8041db1059 100644
--- a/apps/desktop/src/lib/inflight-turn-journal.test.ts
+++ b/apps/desktop/src/lib/inflight-turn-journal.test.ts
@@ -7,11 +7,15 @@ import {
mergeInFlightMessages,
persistInFlightTurnState,
readInFlightTurnJournal,
- recoverInFlightTurnJournal
+ recoverInFlightTurnJournal,
+ resetInFlightTurnJournalStateForTests
} from '@/lib/inflight-turn-journal'
+const STORAGE_KEY = 'hermes.desktop.inflightTurnJournal.v1'
const STORAGE_PREFIX = 'hermes.desktop.inflightTurnJournal.v2:'
-const LEGACY_STORAGE_KEY = 'hermes.desktop.inflightTurnJournal.v1'
+const MIGRATION_KEY = 'hermes.desktop.inflightTurnJournal.v2.migrated'
+
+const sessionStorageKey = (storedSessionId: string) => `${STORAGE_PREFIX}${encodeURIComponent(storedSessionId)}`
function user(id: string, text: string): ChatMessage {
return { id, role: 'user', parts: [{ type: 'text', text }] }
@@ -46,16 +50,94 @@ function journalState(overrides: Partial = {}): Journal
}
beforeEach(() => {
+ resetInFlightTurnJournalStateForTests()
vi.useFakeTimers()
window.localStorage.clear()
})
afterEach(() => {
+ vi.restoreAllMocks()
clearInFlightTurnJournal('stored-1')
vi.useRealTimers()
})
describe('persistInFlightTurnState', () => {
+ it('sweeps expired and oldest session entries once before the first write', () => {
+ const now = Date.now()
+
+ for (let index = 0; index < 25; index += 1) {
+ const sessionId = `old-${index}`
+
+ const snapshot = {
+ messages: [
+ user(`u-${index}`, `prompt-${index}`),
+ assistant(`a-${index}`, `partial-${index}`, { pending: true })
+ ],
+ streamId: `a-${index}`,
+ turnStartedAt: index,
+ updatedAt: now - index * 1_000
+ }
+
+ window.localStorage.setItem(sessionStorageKey(sessionId), JSON.stringify(snapshot))
+ }
+
+ window.localStorage.setItem(
+ sessionStorageKey('expired'),
+ JSON.stringify({
+ messages: [user('expired-u', 'expired'), assistant('expired-a', 'expired', { pending: true })],
+ streamId: 'expired-a',
+ turnStartedAt: 0,
+ updatedAt: now - 8 * 24 * 60 * 60 * 1_000
+ })
+ )
+
+ persistInFlightTurnState(journalState())
+ vi.advanceTimersByTime(400)
+
+ const sessionKeys = Array.from({ length: window.localStorage.length }, (_, index) =>
+ window.localStorage.key(index)
+ ).filter((key): key is string => key?.startsWith(STORAGE_PREFIX) === true)
+
+ expect(sessionKeys).toHaveLength(24)
+ expect(window.localStorage.getItem(sessionStorageKey('expired'))).toBeNull()
+ expect(window.localStorage.getItem(sessionStorageKey('old-24'))).toBeNull()
+ expect(window.localStorage.getItem(sessionStorageKey('stored-1'))).not.toBeNull()
+ })
+
+ it('writes only the current session instead of reading and rewriting the aggregate journal', () => {
+ const localStorage = window.localStorage
+ const storageConstructor = window.Storage
+
+ const spyTarget =
+ typeof storageConstructor === 'function' && localStorage instanceof storageConstructor
+ ? storageConstructor.prototype
+ : localStorage
+
+ const getItem = vi.spyOn(spyTarget, 'getItem')
+ const setItem = vi.spyOn(spyTarget, 'setItem')
+
+ persistInFlightTurnState(journalState())
+ vi.advanceTimersByTime(400)
+
+ expect(getItem).not.toHaveBeenCalledWith(STORAGE_KEY)
+ expect(setItem).not.toHaveBeenCalledWith(STORAGE_KEY, expect.any(String))
+ expect(setItem).toHaveBeenCalledWith(sessionStorageKey('stored-1'), expect.any(String))
+ })
+
+ it('keeps another session snapshot when one session settles', () => {
+ persistInFlightTurnState(journalState())
+ persistInFlightTurnState(journalState({ storedSessionId: 'stored-2' }))
+ vi.advanceTimersByTime(400)
+
+ expect(window.localStorage.getItem(sessionStorageKey('stored-1'))).not.toBeNull()
+ expect(window.localStorage.getItem(sessionStorageKey('stored-2'))).not.toBeNull()
+
+ clearInFlightTurnJournal('stored-2')
+
+ expect(readInFlightTurnJournal('stored-1')).not.toBeNull()
+ expect(readInFlightTurnJournal('stored-2')).toBeNull()
+ })
+
it('journals the running turn tail after the throttle window', () => {
persistInFlightTurnState(journalState())
@@ -88,6 +170,182 @@ describe('persistInFlightTurnState', () => {
expect(tail?.parts).toEqual([{ type: 'text', text: 'partial answer grew' }])
})
+ it('preserves a long user prompt exactly so recovery still matches its transcript row', () => {
+ const prompt = 'prompt '.repeat(8_000)
+
+ persistInFlightTurnState(
+ journalState({
+ messages: [user('u1', prompt), assistant('assistant-stream-1', 'partial', { pending: true })]
+ })
+ )
+ vi.advanceTimersByTime(400)
+
+ const result = recoverInFlightTurnJournal('stored-1', [user('db-u1', prompt)])
+
+ expect(result.messages.map(message => message.id)).toEqual(['db-u1', 'assistant-stream-1'])
+ })
+
+ it('preserves user attachment refs exactly so recovery still matches its transcript row', () => {
+ const attachmentRefs = Array.from({ length: 25 }, (_, index) => `@file:/tmp/input-${index}.txt`)
+ const prompt = user('u1', 'inspect these files')
+ prompt.attachmentRefs = attachmentRefs
+
+ persistInFlightTurnState(
+ journalState({ messages: [prompt, assistant('assistant-stream-1', 'partial', { pending: true })] })
+ )
+ vi.advanceTimersByTime(400)
+
+ const restoredPrompt = user('db-u1', 'inspect these files')
+ restoredPrompt.attachmentRefs = attachmentRefs
+ const result = recoverInFlightTurnJournal('stored-1', [restoredPrompt])
+
+ expect(result.messages.map(message => message.id)).toEqual(['db-u1', 'assistant-stream-1'])
+ })
+
+ it('trims oldest sealed rows when bounded parts exceed the entry cap', () => {
+ const text = 'x'.repeat(60 * 1024)
+
+ const messages = [
+ user('u1', 'do the thing'),
+ assistant('a1', text, { pending: false }),
+ assistant('a2', text, { pending: false }),
+ assistant('a3', text, { pending: false }),
+ assistant('a4', text, { pending: true })
+ ]
+
+ persistInFlightTurnState(journalState({ messages, streamId: 'a4' }))
+ vi.advanceTimersByTime(400)
+
+ const raw = window.localStorage.getItem(sessionStorageKey('stored-1'))
+ const snapshot = JSON.parse(raw!)
+
+ expect(raw?.length).toBeLessThanOrEqual(160 * 1024)
+ expect(snapshot.messages.map((message: ChatMessage) => message.id)).toEqual(['u1', 'a3', 'a4'])
+ })
+
+ it('keeps an older recoverable snapshot when the newest row alone is too large', () => {
+ persistInFlightTurnState(journalState())
+ vi.advanceTimersByTime(400)
+
+ const hugeAssistant: ChatMessage = {
+ id: 'assistant-stream-1',
+ role: 'assistant',
+ parts: Array.from({ length: 3 }, () => ({ type: 'text' as const, text: 'x'.repeat(64 * 1024) })),
+ pending: true
+ }
+
+ persistInFlightTurnState(
+ journalState({ messages: [user('u1', 'do the thing'), hugeAssistant], streamId: hugeAssistant.id })
+ )
+ vi.advanceTimersByTime(400)
+
+ const snapshot = JSON.parse(window.localStorage.getItem(sessionStorageKey('stored-1'))!)
+
+ expect(snapshot.messages[1].parts).toEqual([{ type: 'text', text: 'partial answer' }])
+ })
+
+ it('skips a pathological user prompt instead of truncating its recovery join key', () => {
+ const prompt = 'x'.repeat(64 * 1024 + 1)
+
+ persistInFlightTurnState(
+ journalState({
+ messages: [user('u1', prompt), assistant('assistant-stream-1', 'partial', { pending: true })]
+ })
+ )
+ vi.advanceTimersByTime(400)
+
+ expect(readInFlightTurnJournal('stored-1')).toBeNull()
+ })
+
+ it('does not parse the legacy aggregate when a pathological write discards its session', () => {
+ const legacy = {
+ messages: [user('legacy-u1', 'old prompt'), assistant('legacy-a1', 'old partial', { pending: true })],
+ streamId: 'legacy-a1',
+ turnStartedAt: 1,
+ updatedAt: Date.now()
+ }
+
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ entries: { 'stored-1': legacy }, version: 1 }))
+ const getItem = vi.spyOn(Storage.prototype, 'getItem')
+ const prompt = 'x'.repeat(64 * 1024 + 1)
+
+ persistInFlightTurnState(
+ journalState({
+ messages: [user('u1', prompt), assistant('assistant-stream-1', 'partial', { pending: true })]
+ })
+ )
+ vi.advanceTimersByTime(400)
+
+ expect(getItem).not.toHaveBeenCalledWith(STORAGE_KEY)
+ expect(readInFlightTurnJournal('stored-1')).toBeNull()
+ })
+
+ it('preserves a tombstone while sweeping before legacy migration', () => {
+ const legacy = {
+ messages: [user('legacy-u1', 'old prompt'), assistant('legacy-a1', 'old partial', { pending: true })],
+ streamId: 'legacy-a1',
+ turnStartedAt: 1,
+ updatedAt: Date.now()
+ }
+
+ window.localStorage.setItem(sessionStorageKey('stored-1'), '0')
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ entries: { 'stored-1': legacy }, version: 1 }))
+
+ expect(readInFlightTurnJournal('stored-1')).toBeNull()
+ expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull()
+ expect(window.localStorage.getItem(sessionStorageKey('stored-1'))).toBeNull()
+ })
+
+ it('removes tombstones after the one-shot legacy migration has completed', () => {
+ const key = sessionStorageKey('stored-1')
+
+ window.localStorage.setItem(MIGRATION_KEY, '1')
+ window.localStorage.setItem(key, '0')
+
+ expect(readInFlightTurnJournal('stored-1')).toBeNull()
+ expect(window.localStorage.getItem(key)).toBeNull()
+ })
+
+ it('strips pathological 5 MiB tool payloads before attempting a storage write', () => {
+ const setItem = vi.spyOn(Storage.prototype, 'setItem')
+
+ const oversized: ChatMessage = {
+ id: 'assistant-stream-1',
+ role: 'assistant',
+ parts: [
+ {
+ type: 'tool-call',
+ toolCallId: 'tc-1',
+ toolName: 'terminal',
+ args: { command: 'x'.repeat(5 * 1024 * 1024) },
+ result: 'x'.repeat(5 * 1024 * 1024),
+ isError: true
+ },
+ { type: 'text', text: 'still useful' }
+ ],
+ pending: true
+ }
+
+ persistInFlightTurnState(journalState({ messages: [user('u1', 'do the thing'), oversized] }))
+ vi.advanceTimersByTime(400)
+
+ const raw = window.localStorage.getItem(sessionStorageKey('stored-1'))
+ const snapshot = JSON.parse(raw!)
+ const persistedTool = snapshot.messages[1].parts[0]
+
+ expect(raw?.length).toBeLessThan(256 * 1024)
+ expect(persistedTool).toEqual({
+ args: {},
+ isError: true,
+ result: {},
+ toolCallId: 'tc-1',
+ toolName: 'terminal',
+ type: 'tool-call'
+ })
+ expect(snapshot.messages[1].parts[1]).toEqual({ type: 'text', text: 'still useful' })
+ expect(setItem.mock.calls.every(([, value]) => value.length <= 256 * 1024)).toBe(true)
+ })
+
it('clears the entry the moment the turn settles, cancelling pending writes', () => {
persistInFlightTurnState(journalState())
vi.advanceTimersByTime(400)
@@ -113,52 +371,170 @@ describe('persistInFlightTurnState', () => {
persistInFlightTurnState(journalState())
vi.advanceTimersByTime(400)
- const raw = JSON.parse(window.localStorage.getItem(`${STORAGE_PREFIX}stored-1`)!)
+ const key = sessionStorageKey('stored-1')
+ const raw = JSON.parse(window.localStorage.getItem(key)!)
raw.updatedAt = Date.now() - 8 * 24 * 60 * 60 * 1000
- window.localStorage.setItem(`${STORAGE_PREFIX}stored-1`, JSON.stringify(raw))
+ window.localStorage.setItem(key, JSON.stringify(raw))
expect(readInFlightTurnJournal('stored-1')).toBeNull()
})
- it('writes each session under its own key, untouched by other sessions settling', () => {
- persistInFlightTurnState(journalState())
- persistInFlightTurnState(journalState({ storedSessionId: 'stored-2' }))
- vi.advanceTimersByTime(400)
+ it('isolates storage read, write, and removal failures', () => {
+ const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+ throw new Error('read denied')
+ })
- expect(window.localStorage.getItem(`${STORAGE_PREFIX}stored-1`)).not.toBeNull()
- expect(window.localStorage.getItem(`${STORAGE_PREFIX}stored-2`)).not.toBeNull()
+ expect(() => readInFlightTurnJournal('stored-1')).not.toThrow()
+ getItem.mockRestore()
- clearInFlightTurnJournal('stored-2')
+ const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+ throw new Error('quota')
+ })
- expect(readInFlightTurnJournal('stored-1')).not.toBeNull()
- expect(readInFlightTurnJournal('stored-2')).toBeNull()
+ expect(() => {
+ persistInFlightTurnState(journalState())
+ vi.advanceTimersByTime(400)
+ }).not.toThrow()
+ setItem.mockRestore()
+
+ const removeItem = vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
+ throw new Error('remove denied')
+ })
+
+ expect(() => clearInFlightTurnJournal('stored-1')).not.toThrow()
})
- it('recovers entries journaled by the v1 single-key store', () => {
- // A pre-upgrade crash leaves a v1 store behind; the first journal touch
- // after the upgrade must still recover its turns.
+ it('discards malformed optional message metadata instead of throwing during recovery', () => {
window.localStorage.setItem(
- LEGACY_STORAGE_KEY,
+ sessionStorageKey('stored-1'),
JSON.stringify({
- entries: {
- 'stored-legacy': {
- messages: [user('u1', 'legacy prompt'), assistant('a1', 'legacy partial', { pending: true })],
- streamId: 'a1',
- turnStartedAt: 500,
- updatedAt: Date.now()
- }
- },
- version: 1
+ messages: [
+ { id: 'u1', role: 'user', parts: [{ type: 'text', text: 'prompt' }], attachmentRefs: '@file:bad' },
+ { id: 'a1', role: 'assistant', parts: [{ type: 'text', text: 'partial' }], pending: true }
+ ],
+ streamId: 'a1',
+ turnStartedAt: 1,
+ updatedAt: Date.now()
})
)
+ const base = [user('db-u1', 'prompt')]
- const entry = readInFlightTurnJournal('stored-legacy')
+ expect(() => recoverInFlightTurnJournal('stored-1', base)).not.toThrow()
+ expect(readInFlightTurnJournal('stored-1')).toBeNull()
+ })
+})
- expect(entry?.streamId).toBe('a1')
- expect(entry?.messages).toHaveLength(2)
- expect(window.localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull()
+describe('legacy journal migration', () => {
+ it('migrates the bounded v1 aggregate once and recovers its sessions', () => {
+ const first = {
+ messages: [user('u1', 'one'), assistant('a1', 'partial one', { pending: true })],
+ streamId: 'a1',
+ turnStartedAt: 1,
+ updatedAt: Date.now()
+ }
- clearInFlightTurnJournal('stored-legacy')
+ const second = {
+ messages: [
+ user('u2', 'two'),
+ {
+ id: 'a2',
+ role: 'assistant' as const,
+ parts: [
+ {
+ type: 'tool-call' as const,
+ toolCallId: 'tc-legacy',
+ toolName: 'terminal',
+ args: { command: 'large-output' },
+ result: 'x'.repeat(1024 * 1024)
+ },
+ { type: 'text' as const, text: 'partial two' }
+ ],
+ pending: true
+ }
+ ],
+ streamId: 'a2',
+ turnStartedAt: 2,
+ updatedAt: Date.now()
+ }
+
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ entries: { one: first, two: second }, version: 1 }))
+
+ expect(readInFlightTurnJournal('one')).toEqual(first)
+ const migratedSecondRaw = window.localStorage.getItem(sessionStorageKey('two'))!
+ const migratedSecond = JSON.parse(migratedSecondRaw)
+
+ expect(migratedSecondRaw.length).toBeLessThan(256 * 1024)
+ expect(migratedSecond.messages[1].parts).toEqual([
+ { args: {}, result: {}, toolCallId: 'tc-legacy', toolName: 'terminal', type: 'tool-call' },
+ { text: 'partial two', type: 'text' }
+ ])
+ expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull()
+ expect(window.localStorage.getItem(MIGRATION_KEY)).toBe('1')
+
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ entries: { three: first }, version: 1 }))
+ expect(readInFlightTurnJournal('three')).toBeNull()
+ expect(window.localStorage.getItem(STORAGE_KEY)).not.toBeNull()
+ })
+
+ it('drops an oversized legacy aggregate without parsing it', () => {
+ window.localStorage.setItem(STORAGE_KEY, 'x'.repeat(2 * 1024 * 1024 + 1))
+
+ expect(readInFlightTurnJournal('stored-1')).toBeNull()
+ expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull()
+ expect(window.localStorage.getItem(MIGRATION_KEY)).toBe('1')
+ })
+
+ it('does not overwrite a newer per-session snapshot while migrating another legacy session', () => {
+ persistInFlightTurnState(journalState())
+ vi.advanceTimersByTime(400)
+
+ const legacyCurrent = {
+ messages: [user('legacy-u1', 'old prompt'), assistant('legacy-a1', 'old partial', { pending: true })],
+ streamId: 'legacy-a1',
+ turnStartedAt: 1,
+ updatedAt: Date.now() - 1_000
+ }
+
+ const legacyOther = {
+ messages: [user('u2', 'other prompt'), assistant('a2', 'other partial', { pending: true })],
+ streamId: 'a2',
+ turnStartedAt: 2,
+ updatedAt: Date.now()
+ }
+
+ window.localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({ entries: { 'stored-1': legacyCurrent, other: legacyOther }, version: 1 })
+ )
+
+ expect(readInFlightTurnJournal('other')).toEqual(legacyOther)
+ expect(readInFlightTurnJournal('stored-1')?.messages[0]).toEqual(user('u1', 'do the thing'))
+ })
+
+ it('does not resurrect a legacy entry after that session settles before migration', () => {
+ const legacyCurrent = {
+ messages: [user('legacy-u1', 'old prompt'), assistant('legacy-a1', 'old partial', { pending: true })],
+ streamId: 'legacy-a1',
+ turnStartedAt: 1,
+ updatedAt: Date.now()
+ }
+
+ const legacyOther = {
+ messages: [user('u2', 'other prompt'), assistant('a2', 'other partial', { pending: true })],
+ streamId: 'a2',
+ turnStartedAt: 2,
+ updatedAt: Date.now()
+ }
+
+ window.localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({ entries: { 'stored-1': legacyCurrent, other: legacyOther }, version: 1 })
+ )
+
+ persistInFlightTurnState(journalState({ busy: false, awaitingResponse: false, streamId: null }))
+
+ expect(readInFlightTurnJournal('stored-1')).toBeNull()
+ expect(readInFlightTurnJournal('other')).toEqual(legacyOther)
})
})
@@ -183,7 +559,7 @@ describe('recoverInFlightTurnJournal', () => {
])
const base = [user('u0', 'earlier turn'), assistant('a0', 'earlier reply')]
- const result = recoverInFlightTurnJournal('stored-1', base)
+ const result = recoverInFlightTurnJournal('stored-1', base, { keepPending: true })
expect(result.applied).toBe(true)
expect(result.messages.map(m => m.id)).toEqual(['u0', 'a0', 'u1', 'assistant-stream-1'])
@@ -201,6 +577,9 @@ describe('recoverInFlightTurnJournal', () => {
expect(result.applied).toBe(true)
expect(result.messages.map(m => m.id)).toEqual(['db-u1', 'assistant-stream-1'])
+ // Idle resume: the assistant-tail append path must not resurrect the
+ // stream target either, or the journal entry re-folds on every open.
+ expect(result.streamId).toBeNull()
const tail = result.messages.at(-1)!
expect(tail.pending).toBe(false)
expect(tail.parts[0]).toMatchObject({ type: 'tool-call' })
@@ -283,6 +662,76 @@ describe('recoverInFlightTurnJournal', () => {
expect(merged.id).toBe('assistant-stream-rt9')
expect(merged.parts[1]).toMatchObject({ type: 'text', text: 'a much longer locally journaled partial answer' })
})
+
+ // ── Scrambled-transcript regression (duplicate trailing answers) ───────────
+ // The journal can outlive the turn it recorded (reclaim/reconnect/restart
+ // races skip the settle that would clear it). On resume the fold then
+ // re-appends content that the committed transcript ALREADY holds, rendering
+ // the same answers twice at the end of the conversation. Reported on the
+ // desktop as "the answer was already there, but it was inputted again".
+
+ it('does not re-append committed answers when the journaled user row never persisted', () => {
+ // A resume projection can journal a `user-inflight-*` row that was never
+ // written to the DB (and may even belong to a different conversation).
+ // Because no base user matches it, the fold used to treat the whole tail
+ // as unknown and append it — duplicating the assistant answers below.
+ journalEntry([
+ user('user-inflight-a3c2beb1', 'a stray user bubble that never persisted'),
+ assistant('assistant-stream-1', 'the committed answer')
+ ])
+
+ const base = [user('db-u1', 'the real prompt'), assistant('db-a1', 'the committed answer')]
+ const result = recoverInFlightTurnJournal('stored-1', base, { keepPending: false })
+
+ expect(result.caughtUp).toBe(true)
+ expect(result.applied).toBe(false)
+ expect(result.messages).toBe(base)
+ expect(result.messages.map(m => m.id)).toEqual(['db-u1', 'db-a1'])
+ // The stale entry is cleared so the next resume stays clean.
+ expect(readInFlightTurnJournal('stored-1')).toBeNull()
+ })
+
+ it('does not re-append committed answers when the journal tail has no user row', () => {
+ // A tail captured after a partial hydrate can end on assistant rows with
+ // no user prompt before them. The old code appended them verbatim, so the
+ // transcript ended with a duplicate of an answer that was already settled.
+ journalEntry([assistant('assistant-stream-1', 'the committed answer')])
+
+ const base = [user('db-u1', 'the real prompt'), assistant('db-a1', 'the committed answer')]
+ const result = recoverInFlightTurnJournal('stored-1', base, { keepPending: false })
+
+ expect(result.caughtUp).toBe(true)
+ expect(result.messages).toBe(base)
+ expect(readInFlightTurnJournal('stored-1')).toBeNull()
+ })
+
+ it('keeps appending a genuinely unknown turn (crash recovery still works)', () => {
+ // The staleness check must not swallow a tail the base never saw: that is
+ // the crash-recovery path the journal exists for.
+ journalEntry([user('u1', 'the live prompt'), assistant('assistant-stream-1', 'partial answer', { pending: true })])
+
+ const base = [user('db-u0', 'an earlier turn'), assistant('db-a0', 'earlier reply')]
+ const result = recoverInFlightTurnJournal('stored-1', base, { keepPending: true })
+
+ expect(result.applied).toBe(true)
+ expect(result.caughtUp).toBe(false)
+ expect(result.messages.map(m => m.id)).toEqual(['db-u0', 'db-a0', 'u1', 'assistant-stream-1'])
+ expect(readInFlightTurnJournal('stored-1')).not.toBeNull()
+ })
+
+ it('does not resurrect the journal streamId on a not-running resume (journal self-clear)', () => {
+ // The fold used to carry the stale entry's streamId onto the resumed state
+ // even when the backend reported the session idle. persistInFlightTurnState
+ // then re-wrote the journal instead of clearing it, so the same stale tail
+ // was folded again on every open — the scramble never healed.
+ journalEntry([user('u1', 'do the thing'), assistant('assistant-stream-1', 'partial answer', { pending: true })])
+
+ const base = [user('db-u0', 'an earlier turn'), assistant('db-a0', 'earlier reply')]
+ const result = recoverInFlightTurnJournal('stored-1', base, { keepPending: false })
+
+ expect(result.applied).toBe(true)
+ expect(result.streamId).toBeNull()
+ })
})
describe('mergeInFlightMessages', () => {
diff --git a/apps/desktop/src/lib/inflight-turn-journal.ts b/apps/desktop/src/lib/inflight-turn-journal.ts
index 27912970ff..31a314bb05 100644
--- a/apps/desktop/src/lib/inflight-turn-journal.ts
+++ b/apps/desktop/src/lib/inflight-turn-journal.ts
@@ -16,20 +16,34 @@ import { type ChatMessage, type ChatMessagePart, chatMessageText } from '@/lib/c
* Best-effort by design: storage failures must never break chat streaming.
*/
-/** One localStorage key PER SESSION. The v1 single-key store meant every
- * throttled write re-parsed and re-stringified EVERY busy session's tail —
- * with a grid of concurrent streams that was a whole-store JSON round-trip
- * dozens of times a second, all on the main thread. Per-session keys make a
- * write O(own tail) regardless of how many other sessions are streaming. */
-const STORAGE_PREFIX = 'hermes.desktop.inflightTurnJournal.v2:'
const LEGACY_STORAGE_KEY = 'hermes.desktop.inflightTurnJournal.v1'
-const MAX_ENTRIES = 24
+const STORAGE_PREFIX = 'hermes.desktop.inflightTurnJournal.v2:'
+const LEGACY_MIGRATION_KEY = 'hermes.desktop.inflightTurnJournal.v2.migrated'
+const DISCARDED_SNAPSHOT_RAW = '0'
+const STORE_VERSION = 1
+const MAX_SESSION_STORE_CHARS = 4 * 1024 * 1024
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
+// Keep the worst-case v2 namespace below a conservative localStorage budget
+// while retaining the 24 newest session slots for ordinary small snapshots.
+const MAX_ENTRY_CHARS = 160 * 1024
+const MAX_ENTRIES = Math.min(24, Math.floor(MAX_SESSION_STORE_CHARS / MAX_ENTRY_CHARS))
+const MAX_LEGACY_STORE_CHARS = 2 * 1024 * 1024
+const MAX_SESSION_KEY_CHARS = 512
+const MAX_JOURNALED_MESSAGES = 24
+const MAX_TEXT_PART_CHARS = 64 * 1024
+const MAX_METADATA_CHARS = 2 * 1024
+const MAX_USER_ATTACHMENT_REFS = 256
+const MAX_USER_ATTACHMENT_REF_CHARS = 64 * 1024
/** Streaming repaints arrive every ~33ms; localStorage writes are synchronous.
* Trailing-edge throttle keeps the journal off the hot path — a crash costs at
* most this much of the newest tail. */
const PERSIST_THROTTLE_MS = 400
+// A renderer can accumulate one entry per session over its lifetime. Sweep the
+// bounded v2 namespace once on first journal access; never scan it on the
+// 400ms streaming write path.
+let sessionStoreSwept = false
+
export interface InFlightTurnSnapshot {
messages: ChatMessage[]
streamId: null | string
@@ -46,6 +60,11 @@ export interface JournalableSessionState {
turnStartedAt: null | number
}
+interface JournalStore {
+ entries: Record
+ version: typeof STORE_VERSION
+}
+
export interface InFlightRecoveryResult {
applied: boolean
/** The base transcript already contains the journaled turn's completed
@@ -64,133 +83,431 @@ function storage(): Storage | null {
}
}
-const entryKey = (storedSessionId: string) => `${STORAGE_PREFIX}${storedSessionId}`
-
-function isExpired(entry: InFlightTurnSnapshot, now = Date.now()): boolean {
- return now - entry.updatedAt > MAX_AGE_MS
-}
-
-function loadEntry(storedSessionId: string): InFlightTurnSnapshot | null {
- const store = storage()
-
- if (!store) {
- return null
- }
-
+function sessionStorageKey(storedSessionId: string): null | string {
try {
- const raw = store.getItem(entryKey(storedSessionId))
- const parsed = raw ? (JSON.parse(raw) as InFlightTurnSnapshot) : null
+ const encoded = encodeURIComponent(storedSessionId)
- return parsed && typeof parsed.updatedAt === 'number' && Array.isArray(parsed.messages) ? parsed : null
+ return encoded.length > 0 && encoded.length <= MAX_SESSION_KEY_CHARS ? `${STORAGE_PREFIX}${encoded}` : null
} catch {
return null
}
}
-function saveEntry(storedSessionId: string, entry: InFlightTurnSnapshot): void {
+function readRaw(store: Storage, key: string): null | string {
try {
- storage()?.setItem(entryKey(storedSessionId), JSON.stringify(entry))
+ return store.getItem(key)
} catch {
- // Quota/private-mode failures: the journal is a recovery aid, not truth.
+ return null
}
}
-function removeEntry(storedSessionId: string): void {
+function removeRaw(store: Storage, key: string): void {
try {
- storage()?.removeItem(entryKey(storedSessionId))
+ store.removeItem(key)
} catch {
- // Same best-effort stance as saveEntry.
+ // Best-effort recovery state must not interrupt chat streaming.
}
}
-// Split a v1 single-key store into per-session entries. Checked on every
-// journal touch (a null getItem is free); a populated v1 store exists at most
-// once, right after the upgrade.
-function migrateLegacyStore(store: Storage): void {
+function writeRaw(store: Storage, key: string, value: string): boolean {
try {
- const legacy = store.getItem(LEGACY_STORAGE_KEY)
+ store.setItem(key, value)
- if (!legacy) {
- return
- }
+ return true
+ } catch {
+ return false
+ }
+}
- const parsed = JSON.parse(legacy)
+function isSnapshot(value: unknown): value is InFlightTurnSnapshot {
+ if (!value || typeof value !== 'object') {
+ return false
+ }
- if (parsed && typeof parsed.entries === 'object' && !Array.isArray(parsed.entries)) {
- for (const [id, entry] of Object.entries(parsed.entries as Record)) {
- saveEntry(id, entry)
+ const snapshot = value as Partial
+
+ return (
+ Array.isArray(snapshot.messages) &&
+ snapshot.messages.every(
+ message =>
+ Boolean(message) &&
+ typeof message === 'object' &&
+ typeof message.id === 'string' &&
+ ['assistant', 'system', 'tool', 'user'].includes(message.role) &&
+ Array.isArray(message.parts) &&
+ message.parts.every(
+ part =>
+ Boolean(part) &&
+ typeof part === 'object' &&
+ typeof part.type === 'string' &&
+ (part.type !== 'text' && part.type !== 'reasoning'
+ ? part.type !== 'tool-call' ||
+ (typeof part.toolName === 'string' &&
+ (part.toolCallId === undefined || typeof part.toolCallId === 'string') &&
+ (part.isError === undefined || typeof part.isError === 'boolean'))
+ : typeof part.text === 'string' && (part.parentId === undefined || typeof part.parentId === 'string'))
+ ) &&
+ (message.timestamp === undefined ||
+ (typeof message.timestamp === 'number' && Number.isFinite(message.timestamp))) &&
+ (message.pending === undefined || typeof message.pending === 'boolean') &&
+ (message.error === undefined || typeof message.error === 'string') &&
+ (message.branchGroupId === undefined || typeof message.branchGroupId === 'string') &&
+ (message.hidden === undefined || typeof message.hidden === 'boolean') &&
+ (message.interim === undefined || typeof message.interim === 'boolean') &&
+ (message.attachmentRefs === undefined ||
+ (Array.isArray(message.attachmentRefs) && message.attachmentRefs.every(ref => typeof ref === 'string'))) &&
+ (message.rowId === undefined || (typeof message.rowId === 'number' && Number.isFinite(message.rowId)))
+ ) &&
+ (snapshot.streamId === null || typeof snapshot.streamId === 'string') &&
+ (snapshot.turnStartedAt === null || typeof snapshot.turnStartedAt === 'number') &&
+ typeof snapshot.updatedAt === 'number' &&
+ Number.isFinite(snapshot.updatedAt)
+ )
+}
+
+function parseSnapshot(raw: string): InFlightTurnSnapshot | null {
+ if (raw.length > MAX_ENTRY_CHARS) {
+ return null
+ }
+
+ try {
+ const parsed = JSON.parse(raw)
+
+ return isSnapshot(parsed) ? parsed : null
+ } catch {
+ return null
+ }
+}
+
+function serializeSnapshot(snapshot: InFlightTurnSnapshot): string | null {
+ let messages = snapshot.messages
+
+ while (messages.length > 0) {
+ try {
+ const raw = JSON.stringify({ ...snapshot, messages })
+
+ if (raw.length <= MAX_ENTRY_CHARS) {
+ return raw
}
+ } catch {
+ return null
}
- } catch {
- // A corrupt v1 store has nothing worth carrying over.
+
+ // Keep the join-key row and newest assistant progress while dropping the
+ // oldest sealed rows. If those two rows alone do not fit, the caller must
+ // avoid replacing an older recoverable snapshot with a tombstone.
+ if (messages.length <= 2) {
+ return null
+ }
+
+ messages = [messages[0], ...messages.slice(2)]
}
- try {
- store.removeItem(LEGACY_STORAGE_KEY)
- } catch {
- // Best-effort, like every other journal write.
- }
+ return null
}
-// One-time prune per renderer: drop expired/overflow entries. Startup-only on
-// purpose — entries clear on settle, so anything left over is crash residue,
-// and enumerating localStorage on the write path would defeat the point.
-let housekeepingDone = false
-
-function ensureHousekeeping(): void {
- const store = storage()
-
- if (!store) {
+function sweepSessionStore(store: Storage, reserveSlot = false): void {
+ if (sessionStoreSwept) {
return
}
- migrateLegacyStore(store)
-
- if (housekeepingDone) {
- return
- }
-
- housekeepingDone = true
+ sessionStoreSwept = true
try {
- const keys: string[] = []
+ const sessionKeys: string[] = []
for (let index = 0; index < store.length; index += 1) {
const key = store.key(index)
if (key?.startsWith(STORAGE_PREFIX)) {
- keys.push(key)
+ sessionKeys.push(key)
}
}
- const live: { key: string; updatedAt: number }[] = []
+ const liveEntries: Array<{ key: string; snapshot: InFlightTurnSnapshot }> = []
+ const migrated = readRaw(store, LEGACY_MIGRATION_KEY) !== null
- for (const key of keys) {
- let entry: InFlightTurnSnapshot | null = null
+ for (const key of sessionKeys) {
+ const raw = readRaw(store, key)
- try {
- entry = JSON.parse(store.getItem(key) ?? '') as InFlightTurnSnapshot
- } catch {
- // Unparseable — prune below.
+ // A tombstone is intentional state. It suppresses the stale v1
+ // predecessor until the one-shot migration removes the aggregate.
+ if (raw === DISCARDED_SNAPSHOT_RAW) {
+ if (migrated) {
+ removeRaw(store, key)
+ }
+
+ continue
}
- if (!entry || typeof entry.updatedAt !== 'number' || isExpired(entry)) {
- store.removeItem(key)
- } else {
- live.push({ key, updatedAt: entry.updatedAt })
+ const snapshot = raw ? parseSnapshot(raw) : null
+
+ if (!snapshot || isExpired(snapshot)) {
+ removeRaw(store, key)
+
+ continue
+ }
+
+ liveEntries.push({ key, snapshot })
+ }
+
+ liveEntries
+ .sort((left, right) => right.snapshot.updatedAt - left.snapshot.updatedAt)
+ .slice(reserveSlot ? MAX_ENTRIES - 1 : MAX_ENTRIES)
+ .forEach(entry => removeRaw(store, entry.key))
+ } catch {
+ // The journal is best effort; a storage enumeration failure must not
+ // interrupt renderer work or turn persistence.
+ }
+}
+
+function boundedString(value: string, maxChars: number): string {
+ return value.length <= maxChars ? value : value.slice(0, maxChars)
+}
+
+function boundedPart(part: ChatMessagePart): ChatMessagePart | null {
+ if (part.type === 'text') {
+ return {
+ type: 'text',
+ text: boundedString(part.text, MAX_TEXT_PART_CHARS),
+ ...(part.parentId === undefined ? {} : { parentId: boundedString(part.parentId, MAX_METADATA_CHARS) })
+ }
+ }
+
+ if (part.type === 'reasoning') {
+ return {
+ type: 'reasoning',
+ text: boundedString(part.text, MAX_TEXT_PART_CHARS),
+ ...(part.parentId === undefined ? {} : { parentId: boundedString(part.parentId, MAX_METADATA_CHARS) })
+ }
+ }
+
+ if (part.type === 'tool-call') {
+ // Tool payloads can contain multi-megabyte command output. Recovery only
+ // needs invocation identity and failure state; args/results are available
+ // from the backend transcript when it survives.
+ return {
+ type: 'tool-call',
+ toolName: boundedString(part.toolName, MAX_METADATA_CHARS),
+ args: {},
+ ...(part.toolCallId === undefined ? {} : { toolCallId: boundedString(part.toolCallId, MAX_METADATA_CHARS) }),
+ ...(part.result === undefined ? {} : { result: {} }),
+ ...(part.isError === undefined ? {} : { isError: part.isError })
+ }
+ }
+
+ // Rich file/image/data/source parts can embed large payloads. They are not
+ // required for in-flight text/tool recovery and remain backend-owned.
+ return null
+}
+
+function boundedMessages(messages: ChatMessage[]): ChatMessage[] | null {
+ const bounded =
+ messages.length <= MAX_JOURNALED_MESSAGES
+ ? messages
+ : [messages[0], ...messages.slice(-(MAX_JOURNALED_MESSAGES - 1))]
+
+ // User text and attachment refs are the recovery join key. Truncating either
+ // could attach a journal tail to the wrong transcript row, so pathological
+ // prompts skip journaling instead of weakening the match.
+ if (
+ bounded.some(message => {
+ if (message.role !== 'user') {
+ return false
+ }
+
+ if (
+ message.parts.some(
+ part => (part.type === 'text' || part.type === 'reasoning') && part.text.length > MAX_TEXT_PART_CHARS
+ )
+ ) {
+ return true
+ }
+
+ const refs = message.attachmentRefs
+
+ if (!refs) {
+ return false
+ }
+
+ if (refs.length > MAX_USER_ATTACHMENT_REFS) {
+ return true
+ }
+
+ let chars = 0
+
+ for (const ref of refs) {
+ chars += ref.length
+
+ if (chars > MAX_USER_ATTACHMENT_REF_CHARS) {
+ return true
+ }
+ }
+
+ return false
+ })
+ ) {
+ return null
+ }
+
+ return bounded.map(message => ({
+ id: boundedString(message.id, MAX_METADATA_CHARS),
+ role: message.role,
+ parts: message.parts.map(boundedPart).filter((part): part is ChatMessagePart => part !== null),
+ ...(message.timestamp === undefined ? {} : { timestamp: message.timestamp }),
+ ...(message.pending === undefined ? {} : { pending: message.pending }),
+ ...(message.error === undefined ? {} : { error: boundedString(message.error, MAX_METADATA_CHARS) }),
+ ...(message.branchGroupId === undefined
+ ? {}
+ : { branchGroupId: boundedString(message.branchGroupId, MAX_METADATA_CHARS) }),
+ ...(message.hidden === undefined ? {} : { hidden: message.hidden }),
+ ...(message.interim === undefined ? {} : { interim: message.interim }),
+ ...(message.attachmentRefs === undefined
+ ? {}
+ : {
+ attachmentRefs:
+ message.role === 'user'
+ ? [...message.attachmentRefs]
+ : message.attachmentRefs
+ .slice(0, MAX_USER_ATTACHMENT_REFS)
+ .map(ref => boundedString(ref, MAX_METADATA_CHARS))
+ }),
+ ...(message.rowId === undefined ? {} : { rowId: message.rowId })
+ }))
+}
+
+function migrateLegacyStore(store: Storage): void {
+ if (readRaw(store, LEGACY_MIGRATION_KEY) !== null) {
+ return
+ }
+
+ const raw = readRaw(store, LEGACY_STORAGE_KEY)
+
+ if (raw === null) {
+ return
+ }
+
+ // Claim the migration before touching the aggregate. If storage is failing,
+ // skip legacy recovery rather than retrying an expensive parse on every read.
+ if (!writeRaw(store, LEGACY_MIGRATION_KEY, '1')) {
+ return
+ }
+
+ // Release the multi-megabyte aggregate before allocating per-session v2
+ // entries. The captured string remains available for this one migration.
+ removeRaw(store, LEGACY_STORAGE_KEY)
+
+ if (!raw) {
+ return
+ }
+
+ if (raw.length > MAX_LEGACY_STORE_CHARS) {
+ return
+ }
+
+ try {
+ const parsed = JSON.parse(raw) as Partial
+
+ if (
+ parsed.version !== STORE_VERSION ||
+ !parsed.entries ||
+ typeof parsed.entries !== 'object' ||
+ Array.isArray(parsed.entries)
+ ) {
+ return
+ }
+
+ const existingV2Keys = new Set()
+
+ for (let index = 0; index < store.length; index += 1) {
+ const key = store.key(index)
+
+ if (key?.startsWith(STORAGE_PREFIX)) {
+ existingV2Keys.add(key)
}
}
- live.sort((a, b) => b.updatedAt - a.updatedAt)
+ const entries = Object.entries(parsed.entries)
+ .filter((entry): entry is [string, InFlightTurnSnapshot] => isSnapshot(entry[1]) && !isExpired(entry[1]))
+ .sort((a, b) => b[1].updatedAt - a[1].updatedAt)
+ .slice(0, Math.max(0, MAX_ENTRIES - existingV2Keys.size))
- for (const { key } of live.slice(MAX_ENTRIES)) {
- store.removeItem(key)
+ for (const [storedSessionId, snapshot] of entries) {
+ const key = sessionStorageKey(storedSessionId)
+ const messages = boundedMessages(snapshot.messages)
+ const value = messages ? serializeSnapshot({ ...snapshot, messages }) : null
+
+ // A v2 snapshot may have been written before the one-shot migration ran.
+ // Never replace newer per-session state with its stale v1 predecessor.
+ if (key && value && readRaw(store, key) === null) {
+ if (writeRaw(store, key, value)) {
+ existingV2Keys.add(key)
+ }
+ }
}
} catch {
- // Best-effort, like every other journal write.
+ // Malformed legacy data is discarded below.
}
}
+function discardSnapshot(store: Storage, key: string): void {
+ // Migrate first so an existing v2 key suppresses its stale v1 predecessor,
+ // then remove the current session. This keeps every discard path from
+ // resurrecting legacy state on a later read.
+ migrateLegacyStore(store)
+ removeRaw(store, key)
+}
+
+function readSnapshot(storedSessionId: string): InFlightTurnSnapshot | null {
+ const store = storage()
+ const key = sessionStorageKey(storedSessionId)
+
+ if (!store || !key) {
+ return null
+ }
+
+ sweepSessionStore(store)
+
+ let raw = readRaw(store, key)
+
+ if (!raw) {
+ migrateLegacyStore(store)
+ raw = readRaw(store, key)
+ }
+
+ if (!raw) {
+ return null
+ }
+
+ const snapshot = parseSnapshot(raw)
+
+ if (!snapshot || isExpired(snapshot)) {
+ discardSnapshot(store, key)
+
+ return null
+ }
+
+ return snapshot
+}
+
+function removeSnapshot(storedSessionId: string): void {
+ const store = storage()
+ const key = sessionStorageKey(storedSessionId)
+
+ if (store && key) {
+ sweepSessionStore(store)
+
+ // Settling a session before the one-shot migration must clear its legacy
+ // entry too; otherwise a later read can migrate and resurrect stale state.
+ // This aggregate parse is terminal-transition work, never a stream write.
+ discardSnapshot(store, key)
+ }
+}
+
+function isExpired(entry: InFlightTurnSnapshot, now = Date.now()): boolean {
+ return now - entry.updatedAt > MAX_AGE_MS
+}
+
function cloneMessages(messages: ChatMessage[]): ChatMessage[] {
try {
return JSON.parse(JSON.stringify(messages)) as ChatMessage[]
@@ -286,7 +603,7 @@ function recoverableTail(messages: ChatMessage[], streamId: null | string): Chat
}
}
- return cloneMessages(visible.slice(start))
+ return visible.slice(start)
}
function normalizeRecoveredTail(tail: ChatMessage[], keepPending: boolean): ChatMessage[] {
@@ -376,6 +693,32 @@ function withoutBaseIds(rows: ChatMessage[], baseMessages: ChatMessage[]): ChatM
return rows.filter(row => !baseIds.has(row.id))
}
+/** Whether every recoverable assistant row in the journal tail already exists
+ * as committed text in the base transcript. When true, the journal outlived
+ * the turn it recorded and appending it would re-render the same answers at
+ * the end of the transcript (the "scrambled conversation" regression). */
+function journalTailAlreadyCommitted(tailAssistants: ChatMessage[], baseMessages: ChatMessage[]): boolean {
+ const recoverable = tailAssistants.filter(assistantHasRecoverableContent)
+
+ if (recoverable.length === 0) {
+ return false
+ }
+
+ const baseTexts = new Set(
+ baseMessages
+ .filter(message => message.role === 'assistant' && !message.hidden)
+ .map(message => normalizedText(chatMessageText(message)))
+ )
+
+ return recoverable.every(message => {
+ const text = normalizedText(chatMessageText(message))
+
+ // Error-only rows carry no text to verify against — keep the conservative
+ // append path rather than risk dropping a recoverable failure.
+ return text.length > 0 && baseTexts.has(text)
+ })
+}
+
export function mergeInFlightMessages(
baseMessages: ChatMessage[],
tailMessages: ChatMessage[],
@@ -402,15 +745,27 @@ export function mergeInFlightMessages(
const matchingUserIndex = tailUser ? baseMessages.findLastIndex(message => userMessagesMatch(message, tailUser)) : -1
if (matchingUserIndex < 0) {
- // Base doesn't know this turn at all (user row was never persisted):
- // append the whole tail.
+ // No base user matches the tail's user row (a projected user-inflight row
+ // that never persisted, or a tail captured without its user prompt). If the
+ // tail's answers are already committed in the transcript, the journal is
+ // stale — appending it would re-render the same replies at the end of the
+ // conversation. Otherwise, the base never saw this turn at all: append the
+ // whole tail (the crash-recovery path the journal exists for).
+ if (journalTailAlreadyCommitted(tailAssistants, baseMessages)) {
+ return { ...noop, caughtUp: true }
+ }
+
const streamId = lastJournalRow?.id ?? null
return {
applied: true,
caughtUp: false,
messages: [...baseMessages, ...withoutBaseIds(tail, baseMessages)],
- streamId,
+ // Only a genuinely running turn keeps a live stream target. On an idle
+ // resume, carrying the stale streamId would keep the journal entry alive
+ // (persistInFlightTurnState only clears when streamId is null) and the
+ // same tail would be folded again on every open.
+ streamId: options.keepPending ? streamId : null,
turnStartedAt: null
}
}
@@ -442,7 +797,11 @@ export function mergeInFlightMessages(
applied: true,
caughtUp: false,
messages: [...baseMessages, ...withoutBaseIds(tailAssistants, baseMessages)],
- streamId,
+ // Same idle-resume rule as the other exit paths: only a running turn
+ // keeps the stream target alive. Carrying the stale streamId here kept
+ // the journal entry alive (persistInFlightTurnState only clears when
+ // streamId is null), so the same tail was folded again on every open.
+ streamId: options.keepPending ? streamId : null,
turnStartedAt: null
}
}
@@ -464,12 +823,32 @@ export function mergeInFlightMessages(
...baseMessages.slice(projectionIndex + 1)
]
- return { applied: true, caughtUp: false, messages, streamId: merged.id, turnStartedAt: null }
+ return {
+ applied: true,
+ caughtUp: false,
+ messages,
+ // Same idle-resume rule as the append path: only a running turn keeps the
+ // stream target alive, so an idle resume clears the journal instead of
+ // re-folding the same tail on every open.
+ streamId: options.keepPending ? merged.id : null,
+ turnStartedAt: null
+ }
}
const persistTimers = new Map>()
const persistLatest = new Map()
+/** @internal Test-only reset for module-scoped throttles and sweep state. */
+export function resetInFlightTurnJournalStateForTests(): void {
+ for (const timer of persistTimers.values()) {
+ clearTimeout(timer)
+ }
+
+ persistTimers.clear()
+ persistLatest.clear()
+ sessionStoreSwept = false
+}
+
function writeSnapshot(storedSessionId: string, state: JournalableSessionState): void {
const tail = recoverableTail(state.messages, state.streamId)
@@ -477,13 +856,63 @@ function writeSnapshot(storedSessionId: string, state: JournalableSessionState):
return
}
- ensureHousekeeping()
- saveEntry(storedSessionId, {
- messages: tail,
+ const store = storage()
+ const key = sessionStorageKey(storedSessionId)
+
+ if (!store || !key) {
+ return
+ }
+
+ sweepSessionStore(store, true)
+
+ const messages = boundedMessages(tail)
+
+ if (!messages) {
+ // Keep the timer write path free of aggregate migration. This tiny invalid
+ // v2 value suppresses the stale v1 predecessor until read/settle performs
+ // the one-shot migration and removes it.
+ tombstoneUnlessRecoverable(store, key)
+
+ return
+ }
+
+ const raw = serializeSnapshot({
+ messages,
streamId: state.streamId,
turnStartedAt: state.turnStartedAt,
updatedAt: Date.now()
})
+
+ if (!raw) {
+ // Preserve an older bounded snapshot if the newest assistant row alone is
+ // too large. A tombstone is only needed when there is no recoverable v2
+ // value, so stale v1 state cannot be resurrected on a later read.
+ tombstoneUnlessRecoverable(store, key)
+
+ return
+ }
+
+ if (!writeRaw(store, key, raw)) {
+ // A quota failure must not leave an older, misleading snapshot behind, or
+ // let the stale v1 predecessor be resurrected on a later read.
+ tombstoneUnlessRecoverable(store, key)
+ }
+}
+
+function tombstoneUnlessRecoverable(store: Storage, key: string): void {
+ const previous = readRaw(store, key)
+
+ if (previous) {
+ const snapshot = parseSnapshot(previous)
+
+ if (snapshot && !isExpired(snapshot)) {
+ return
+ }
+ }
+
+ if (!writeRaw(store, key, DISCARDED_SNAPSHOT_RAW)) {
+ removeRaw(store, key)
+ }
}
/** Persist the running turn's visible tail (throttled), or clear the entry the
@@ -527,20 +956,7 @@ export function readInFlightTurnJournal(storedSessionId: null | string): InFligh
return null
}
- ensureHousekeeping()
- const entry = loadEntry(storedSessionId)
-
- if (!entry) {
- return null
- }
-
- if (isExpired(entry)) {
- removeEntry(storedSessionId)
-
- return null
- }
-
- return entry
+ return readSnapshot(storedSessionId)
}
/** Fold a journaled in-flight tail back onto a restored transcript. A no-op
@@ -570,7 +986,11 @@ export function recoverInFlightTurnJournal(
return {
...recovered,
- streamId: recovered.applied ? (recovered.streamId ?? snapshot.streamId) : null,
+ // Never resurrect a stale stream target on an idle resume: with
+ // keepPending=false the session is not running, so the recovered rows are
+ // settled history and the journal must clear on the next state update —
+ // otherwise the same stale tail is folded again on every open.
+ streamId: recovered.applied ? (recovered.streamId ?? (options.keepPending ? snapshot.streamId : null)) : null,
turnStartedAt: recovered.applied ? snapshot.turnStartedAt : null
}
}
@@ -588,6 +1008,6 @@ export function clearInFlightTurnJournal(storedSessionId: null | string): void {
}
persistLatest.delete(storedSessionId)
- ensureHousekeeping()
- removeEntry(storedSessionId)
+
+ removeSnapshot(storedSessionId)
}
diff --git a/apps/desktop/src/lib/keybinds/actions.test.ts b/apps/desktop/src/lib/keybinds/actions.test.ts
new file mode 100644
index 0000000000..dafc92a5ce
--- /dev/null
+++ b/apps/desktop/src/lib/keybinds/actions.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest'
+
+import { en } from '@/i18n/en'
+
+import { defaultBindings, KEYBIND_ACTIONS, keybindAction } from './actions'
+
+describe('session.archive keybind action', () => {
+ it('is registered under the session category', () => {
+ const action = keybindAction('session.archive')
+
+ expect(action).toBeDefined()
+ expect(action?.category).toBe('session')
+ })
+
+ it('ships unbound so it does not claim a chord for every user', () => {
+ const action = keybindAction('session.archive')
+
+ expect(action?.defaults).toEqual([])
+ // A missing entry would silently drop from the panel; an accidental
+ // default binding would change behaviour for everyone. Guard both.
+ expect(defaultBindings()['session.archive']).toEqual([])
+ })
+
+ it('has an English label so it renders in the shortcuts panel', () => {
+ expect(en.keybinds.actions['session.archive']).toBe('Archive current session')
+ })
+
+ it('appears exactly once in KEYBIND_ACTIONS', () => {
+ const matches = KEYBIND_ACTIONS.filter(action => action.id === 'session.archive')
+
+ expect(matches).toHaveLength(1)
+ })
+})
diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts
index b042cd4f6e..768192b5e9 100644
--- a/apps/desktop/src/lib/keybinds/actions.ts
+++ b/apps/desktop/src/lib/keybinds/actions.ts
@@ -76,16 +76,25 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
{ id: 'profile.create', category: 'profiles', defaults: [] },
// ── Session ──────────────────────────────────────────────────────────────
- { id: 'session.new', category: 'session', defaults: ['mod+n', 'shift+n'] },
+ // `shift+n` was dropped from the defaults (#76185): a bare shifted letter
+ // hijacked normal typing — pressing uppercase N outside an input (or via an
+ // IME) created a new session unexpectedly. The deliberate ⌘/Ctrl+N chord
+ // stays; users who liked ⇧N can rebind it in the panel.
+ { id: 'session.new', category: 'session', defaults: ['mod+n'] },
{ id: 'session.newTab', category: 'session', defaults: ['mod+t'] },
{ id: 'session.newWindow', category: 'session', defaults: ['mod+shift+n'] },
// ⌃Tab / ⌃⇧Tab — the universal tab-cycle chord. Literally Control, not Cmd
// (macOS reserves Cmd+Tab for app switching); see `ctrl` in combo.ts.
- { id: 'session.next', category: 'session', defaults: ['ctrl+tab'] },
- { id: 'session.prev', category: 'session', defaults: ['ctrl+shift+tab'] },
+ { id: 'session.next', category: 'session', defaults: ['ctrl+tab', 'ctrl+pagedown'] },
+ { id: 'session.prev', category: 'session', defaults: ['ctrl+shift+tab', 'ctrl+pageup'] },
...SESSION_SLOT_ACTIONS,
{ id: 'session.focusSearch', category: 'session', defaults: ['mod+shift+f'] },
{ id: 'session.togglePin', category: 'session', defaults: [] },
+ // Archive the active session. Ships unbound (like `session.togglePin`) so an
+ // irreversible-feeling, mouse-only action doesn't silently claim a chord for
+ // every user — surfaced in the panel for opt-in binding (the issue suggests
+ // ⌘⇧⌫ / Ctrl+Shift+⌫).
+ { id: 'session.archive', category: 'session', defaults: [] },
// ⌘⇧B — "b" for branch: spin up a new git worktree from the active repo.
{ id: 'workspace.newWorktree', category: 'session', defaults: ['mod+shift+b'] },
// ⌘O — the editor-standard "open folder" chord (VS Code ⌘O, Zed's
@@ -140,7 +149,7 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
// is a no-op. ⌘⇧T reopens the last closed tab where it was.
{ id: 'view.closeTab', category: 'view', defaults: ['mod+w'] },
{ id: 'view.reopenTab', category: 'view', defaults: ['mod+shift+t'] },
- // ⌘F — open the find-in-page bar. `comboAllowedInInput` lets the combo
+ // ⌘F — open the find-in-page bar. `actionAllowedInInput` lets this action
// fire from inside a textarea / contenteditable (matches browser behavior
// so typing in the composer and pressing ⌘F focuses find, not 'f').
{ id: 'view.findInPage', category: 'view', defaults: ['mod+f'] },
diff --git a/apps/desktop/src/lib/keybinds/combo.test.ts b/apps/desktop/src/lib/keybinds/combo.test.ts
index 3147538ac3..0e30d92534 100644
--- a/apps/desktop/src/lib/keybinds/combo.test.ts
+++ b/apps/desktop/src/lib/keybinds/combo.test.ts
@@ -82,6 +82,13 @@ describe('comboFromEvent — ctrl as a distinct modifier on macOS', () => {
expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true }))).toBe('mod+tab')
expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true, shiftKey: true }))).toBe('mod+shift+tab')
})
+
+ it('recognizes PageUp and PageDown chords', async () => {
+ const { comboFromEvent } = await loadCombo('MacIntel')
+
+ expect(comboFromEvent(keydown({ code: 'PageUp', ctrlKey: true }))).toBe('ctrl+pageup')
+ expect(comboFromEvent(keydown({ code: 'PageDown', ctrlKey: true }))).toBe('ctrl+pagedown')
+ })
})
describe('canonicalizeCombo', () => {
@@ -104,27 +111,84 @@ describe('canonicalizeCombo', () => {
describe('formatCombo — honest Control labels', () => {
it('renders the Control glyph on macOS', async () => {
- const { formatCombo } = await loadCombo('MacIntel')
+ const { formatCombo, formatModifierToken } = await loadCombo('MacIntel')
expect(formatCombo('ctrl+tab')).toBe('⌃⇥')
expect(formatCombo('ctrl+shift+tab')).toBe('⌃⇧⇥')
+ expect(formatCombo('mod+enter')).toBe('⌘↵')
+ expect(formatModifierToken('mod')).toBe('⌘')
})
- it('renders "Ctrl+…" off macOS (base key keeps its glyph)', async () => {
- const { formatCombo } = await loadCombo('Win32')
+ it.each(['Linux x86_64', 'Win32'])('renders "Ctrl+…" off macOS on %s (base key keeps its glyph)', async platform => {
+ const { formatCombo, formatModifierToken } = await loadCombo(platform)
expect(formatCombo('ctrl+tab')).toBe('Ctrl+⇥')
expect(formatCombo('ctrl+shift+tab')).toBe('Ctrl+Shift+⇥')
+ expect(formatCombo('mod+enter')).toBe('Ctrl+↵')
+ expect(formatModifierToken('mod')).toBe('Ctrl')
+ })
+
+ it('renders PageUp and PageDown with compact labels', async () => {
+ const { formatCombo } = await loadCombo('Win32')
+
+ expect(formatCombo('ctrl+pageup')).toBe('Ctrl+PgUp')
+ expect(formatCombo('ctrl+pagedown')).toBe('Ctrl+PgDn')
})
})
-describe('comboAllowedInInput', () => {
- it('lets ctrl combos fire while typing (e.g. ⌃Tab from the composer)', async () => {
- const { comboAllowedInInput } = await loadCombo('MacIntel')
+describe('actionAllowedInInput', () => {
+ it('keeps only explicit text-entry-safe global actions active while typing', async () => {
+ const { actionAllowedInInput } = await loadCombo('MacIntel')
- expect(comboAllowedInInput('ctrl+tab')).toBe(true)
- expect(comboAllowedInInput('ctrl+shift+tab')).toBe(true)
- expect(comboAllowedInInput('mod+k')).toBe(true)
- expect(comboAllowedInInput('shift+x')).toBe(false)
+ expect(actionAllowedInInput('session.next', 'ctrl+tab')).toBe(true)
+ expect(actionAllowedInInput('session.prev', 'ctrl+shift+tab')).toBe(true)
+ expect(actionAllowedInInput('nav.commandPalette', 'mod+k')).toBe(true)
+ expect(actionAllowedInInput('view.findInPage', 'mod+f')).toBe(true)
+ expect(actionAllowedInInput('nav.skills', 'mod+k')).toBe(false)
+ expect(actionAllowedInInput('view.showTerminal', 'ctrl+`')).toBe(false)
+ expect(actionAllowedInInput('profile.next', 'mod+shift+]')).toBe(false)
+ })
+
+ it('leaves text navigation chords with the focused input even when rebound to an allowed action', async () => {
+ const { actionAllowedInInput } = await loadCombo('Win32')
+
+ expect(actionAllowedInInput('session.next', 'mod+right')).toBe(false)
+ expect(actionAllowedInInput('session.prev', 'mod+left')).toBe(false)
+ expect(actionAllowedInInput('nav.commandPalette', 'mod+pageup')).toBe(false)
+ expect(actionAllowedInInput('view.findInPage', 'mod+end')).toBe(false)
+ })
+})
+
+describe('comboFromEvent — IME composition keydowns never resolve to combos (#84957)', () => {
+ it('returns null while a composition is in progress (isComposing)', async () => {
+ const { comboFromEvent } = await loadCombo('MacIntel')
+
+ // Typing 你 with a Chinese IME: the preedit keydowns carry isComposing.
+ // Before the guard, these canonicalized to combos and fired keybinds
+ // (e.g. dispatched `session.new` mid-composition).
+ expect(comboFromEvent(keydown({ code: 'KeyN', isComposing: true, key: 'n' }))).toBeNull()
+ expect(comboFromEvent(keydown({ code: 'Enter', isComposing: true, key: 'Enter' }))).toBeNull()
+ expect(comboFromEvent(keydown({ code: 'Space', isComposing: true, key: ' ' }))).toBeNull()
+ })
+
+ it('returns null for the legacy key="Process" (VK_PROCESSKEY) keydown', async () => {
+ const { comboFromEvent } = await loadCombo('Win32')
+
+ expect(comboFromEvent(keydown({ code: 'KeyW', key: 'Process' }))).toBeNull()
+ })
+
+ it('ignores IME-synthesized modifier-name keys on non-modifier codes', async () => {
+ const { comboFromEvent } = await loadCombo('Win32')
+
+ // Q9 2002-style legacy IMEs synthesize key="Control" with code="KeyW",
+ // which would otherwise canonicalize to a phantom ctrl+w (close tab).
+ expect(comboFromEvent(keydown({ code: 'KeyW', key: 'Control' }))).toBeNull()
+ expect(comboFromEvent(keydown({ code: 'KeyA', key: 'Shift' }))).toBeNull()
+ })
+
+ it('still resolves real combos after composition ends', async () => {
+ const { comboFromEvent } = await loadCombo('MacIntel')
+
+ expect(comboFromEvent(keydown({ code: 'KeyN', isComposing: false, key: 'n', metaKey: true }))).toBe('mod+n')
})
})
diff --git a/apps/desktop/src/lib/keybinds/combo.ts b/apps/desktop/src/lib/keybinds/combo.ts
index 78f91e96e2..4409142cb4 100644
--- a/apps/desktop/src/lib/keybinds/combo.ts
+++ b/apps/desktop/src/lib/keybinds/combo.ts
@@ -33,6 +33,8 @@ const CODE_TO_KEY: Record = {
Escape: 'escape',
Backspace: 'backspace',
Tab: 'tab',
+ PageUp: 'pageup',
+ PageDown: 'pagedown',
ArrowUp: 'up',
ArrowDown: 'down',
ArrowLeft: 'left',
@@ -50,6 +52,9 @@ const MODIFIER_CODES = new Set([
'ShiftRight'
])
+// Modifier names as reported by `event.key` on a bare modifier keydown.
+const MODIFIER_KEYS = new Set(['Alt', 'Control', 'Meta', 'Shift'])
+
function baseKeyFromCode(code: string): string | null {
if (code.startsWith('Key')) {
return code.slice(3).toLowerCase()
@@ -101,10 +106,28 @@ function baseKeyFromEventKey(key: string, shiftKey: boolean): string | null {
// Returns the canonical combo for a keydown, or null while only modifiers are
// held (so capture mode keeps waiting for a real key).
export function comboFromEvent(event: KeyboardEvent): string | null {
+ // IME composition (Chinese/Japanese/Korean input): the keydown events
+ // during composition carry preedit keystrokes and the commit keypress
+ // (Enter/Space/Shift for candidate selection). Treating them as combos
+ // fires unrelated keybinds — e.g. typing 你 with a Chinese IME sent a
+ // keydown that dispatched `session.new` and silently opened a new session.
+ // Bail out entirely while composing.
+ if (event.isComposing || event.key === 'Process') {
+ return null
+ }
+
if (MODIFIER_CODES.has(event.code)) {
return null
}
+ // A keydown whose `key` is a modifier name but whose `code` is a regular
+ // key is not a real modifier chord — legacy IMEs that synthesize keystrokes
+ // (Q9 2002 sends key="Control" with code="KeyW") produce these, and they
+ // would canonicalize to phantom combos (Ctrl+W → close active tab). Ignore.
+ if (MODIFIER_KEYS.has(event.key)) {
+ return null
+ }
+
const base = baseKeyFromEventKey(event.key, event.shiftKey) ?? baseKeyFromCode(event.code)
if (!base) {
@@ -148,6 +171,8 @@ const TOKEN_LABELS: Record = {
escape: 'Esc',
backspace: '⌫',
tab: '⇥',
+ pageup: 'PgUp',
+ pagedown: 'PgDn',
space: 'Space',
up: '↑',
down: '↓',
@@ -167,7 +192,7 @@ function labelForBase(base: string): string {
return base.length === 1 ? base.toUpperCase() : base
}
-function labelForMod(mod: string): string {
+export function formatModifierToken(mod: string): string {
if (mod === 'mod') {
return IS_MAC ? '⌘' : 'Ctrl'
}
@@ -193,7 +218,7 @@ export function comboTokens(combo: string): string[] {
const parts = combo.split('+')
const base = parts.pop() ?? ''
- return [...parts.map(labelForMod), labelForBase(base)]
+ return [...parts.map(formatModifierToken), labelForBase(base)]
}
// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
@@ -223,8 +248,27 @@ export function isEditableTarget(target: EventTarget | null): boolean {
)
}
-// A primary modifier (Cmd/Ctrl/Control) fires even while typing (e.g. ⌘K or
-// ⌃Tab from the composer); bare/Shift-only combos are suppressed in inputs.
-export function comboAllowedInInput(combo: string): boolean {
- return /^(?:mod|ctrl)(?:\+|$)/.test(combo)
+const INPUT_SAFE_ACTIONS = new Set([
+ 'composer.modelPicker',
+ 'composer.voice',
+ 'keybinds.openPanel',
+ 'nav.commandPalette',
+ 'session.next',
+ 'session.prev',
+ 'view.findInPage'
+])
+
+const TEXT_NAVIGATION_KEYS = new Set(['up', 'down', 'left', 'right', 'home', 'end', 'pageup', 'pagedown'])
+
+// Only explicit text-entry-safe actions fire while typing. Editing/navigation
+// chords such as Ctrl+Arrow/PageUp must stay with the input even if a user
+// rebinds them to a global navigation action.
+export function actionAllowedInInput(actionId: string, combo: string): boolean {
+ const base = combo.split('+').pop()
+
+ if (base && TEXT_NAVIGATION_KEYS.has(base)) {
+ return false
+ }
+
+ return INPUT_SAFE_ACTIONS.has(actionId)
}
diff --git a/apps/desktop/src/lib/keybinds/contributed-actions.test.ts b/apps/desktop/src/lib/keybinds/contributed-actions.test.ts
index 0b82242789..74e20cb061 100644
--- a/apps/desktop/src/lib/keybinds/contributed-actions.test.ts
+++ b/apps/desktop/src/lib/keybinds/contributed-actions.test.ts
@@ -54,7 +54,9 @@ describe('contributed keybind actions', () => {
// The built-in keeps its own combo and its own (i18n) label — the
// contribution is filtered out rather than overriding core.
- expect(bindingsFor('session.new')).toEqual(['mod+n', 'shift+n'])
+ // (bare shift+n was removed from session.new defaults — it hijacked
+ // typing a capital N into focused inputs, #76185)
+ expect(bindingsFor('session.new')).toEqual(['mod+n'])
expect(allKeybindActions().filter(a => a.id === 'session.new')).toHaveLength(1)
dispose()
diff --git a/apps/desktop/src/lib/mcp-brands.tsx b/apps/desktop/src/lib/mcp-brands.tsx
index c587bb550f..10a8e6136b 100644
--- a/apps/desktop/src/lib/mcp-brands.tsx
+++ b/apps/desktop/src/lib/mcp-brands.tsx
@@ -7,12 +7,17 @@
* a favicon service for it would leak that hostname off-box.
*/
import {
+ SiAirtable,
+ SiAsana,
SiAtlassian,
SiDatadog,
SiFigma,
SiGithub,
SiGitlab,
+ SiHuggingface,
+ SiIntercom,
SiLinear,
+ SiNetlify,
SiNotion,
SiPaypal,
SiPostgresql,
@@ -21,6 +26,7 @@ import {
SiStripe,
SiSupabase,
SiVercel,
+ SiWebflow,
SiZapier
} from '@icons-pack/react-simple-icons'
import type { ComponentType, SVGProps } from 'react'
@@ -35,12 +41,18 @@ export interface McpBrand {
}
export const MCP_BRAND_ICONS: Record = {
+ airtable: { Icon: SiAirtable, color: '#18BFFF' },
+ asana: { Icon: SiAsana, color: '#F06A6A' },
atlassian: { Icon: SiAtlassian, color: '#0052CC' },
datadog: { Icon: SiDatadog, color: '#632CA6' },
figma: { Icon: SiFigma, color: '#F24E1E' },
github: { Icon: SiGithub, color: '#181717', monochrome: true },
gitlab: { Icon: SiGitlab, color: '#FC6D26' },
+ hugging_face: { Icon: SiHuggingface, color: '#FFD21E' },
+ huggingface: { Icon: SiHuggingface, color: '#FFD21E' },
+ intercom: { Icon: SiIntercom, color: '#6AFDEF' },
linear: { Icon: SiLinear, color: '#5E6AD2' },
+ netlify: { Icon: SiNetlify, color: '#00C7B7' },
notion: { Icon: SiNotion, color: '#000000', monochrome: true },
paypal: { Icon: SiPaypal, color: '#003087' },
postgres: { Icon: SiPostgresql, color: '#4169E1' },
@@ -50,6 +62,7 @@ export const MCP_BRAND_ICONS: Record = {
stripe: { Icon: SiStripe, color: '#635BFF' },
supabase: { Icon: SiSupabase, color: '#3FCF8E' },
vercel: { Icon: SiVercel, color: '#000000', monochrome: true },
+ webflow: { Icon: SiWebflow, color: '#146EF5' },
zapier: { Icon: SiZapier, color: '#FF4A00' }
}
diff --git a/apps/desktop/src/lib/mcp-directory.ts b/apps/desktop/src/lib/mcp-directory.ts
index 107fb14479..0426979abb 100644
--- a/apps/desktop/src/lib/mcp-directory.ts
+++ b/apps/desktop/src/lib/mcp-directory.ts
@@ -1,22 +1,23 @@
/**
- * The desktop's own MCP suggestion directory — deliberately NOT the
- * Nous-approved install catalog (`optional-mcps/`).
+ * COMPATIBILITY RUNG — superseded by the MCP catalog's `suggest` metadata.
*
- * The catalog is a trust boundary: presence there means a reviewed, pinned
- * manifest, and it only grows via PR. This directory is a different thing —
- * a renderer-local map of well-known OFFICIAL remote MCP endpoints (vendor
- * docs linked per entry) used for two purposes:
+ * The Nous-approved install catalog (`optional-mcps//manifest.yaml`)
+ * is now the single source of truth for suggestible servers: each hosted
+ * remote entry declares its own `suggest.keywords` / `suggest.hosts`, served
+ * through `GET /api/mcp/catalog`. The suggestion provider and the inline
+ * setup card read the catalog first.
*
- * 1. keyword → suggestion pills over the composer ("you typed jira…"),
- * 2. giving the inline setup card a config to write via the ordinary
- * `POST /api/mcp/servers` endpoint — the exact same path as pasting the
- * vendor's snippet into the Capabilities editor by hand.
+ * This static list remains ONLY for older backends whose catalog responses
+ * carry no `suggest` field (the provider falls back to it when the catalog
+ * yields zero suggestible entries). Do not add new vendors here — add a
+ * manifest under `optional-mcps/` instead. Remove this file at the next
+ * backend contract bump.
*
- * Nothing here changes base Hermes behavior: no backend code reads this file,
- * entries are URL-only remotes (no local process is ever spawned from a
- * suggestion), and every install still lands in config.yaml through the
- * existing validated endpoint. If an entry ALSO exists in the install catalog
- * (e.g. linear, figma), the setup card prefers the catalog path.
+ * GitHub is intentionally absent (here AND in the catalog): its hosted MCP
+ * requires each MCP host to provide its own OAuth app (generic Dynamic
+ * Client Registration 404s at /register), and the bundled github/* skills
+ * via the gh CLI are the more capable integration. The composer's github
+ * suggestion provider offers the `github-auth` skill instead.
*/
export interface McpDirectoryEntry {
/** Server name as it will appear in mcp_servers config. */
@@ -75,15 +76,10 @@ export const MCP_DIRECTORY: McpDirectoryEntry[] = [
name: 'datadog',
url: 'https://mcp.datadoghq.com/api/unstable/mcp-server/mcp'
},
- {
- description: 'Repos, issues, and pull requests via GitHub’s hosted MCP.',
- docs: 'https://docs.github.com/en/copilot/customizing-copilot/using-model-context-protocol/using-the-github-mcp-server',
- // No hosts on purpose: github.com links are everywhere in a coding chat
- // (commits, PRs under review, pasted diffs) and would fire constantly.
- keywords: ['github'],
- name: 'github',
- url: 'https://api.githubcopilot.com/mcp/'
- },
+ // GitHub's hosted MCP is intentionally absent. Directory entries are wired
+ // through generic Dynamic Client Registration, but GitHub requires each MCP
+ // host to provide its own OAuth app (or use a PAT). Advertising it here makes
+ // both the composer pill and setup_mcp fail at /register with HTTP 404.
{
description: 'Pages and databases from your Notion workspace.',
docs: 'https://developers.notion.com/docs/mcp',
@@ -99,6 +95,93 @@ export const MCP_DIRECTORY: McpDirectoryEntry[] = [
keywords: ['stripe'],
name: 'stripe',
url: 'https://mcp.stripe.com'
+ },
+ {
+ description: 'Deployments, logs, and projects via Vercel’s hosted MCP.',
+ docs: 'https://vercel.com/docs/mcp',
+ // No `vercel.app` on purpose (same rule as GitHub): pasted deploy-preview
+ // links are about the site being previewed, not about managing Vercel.
+ hosts: ['vercel.com'],
+ keywords: ['vercel'],
+ name: 'vercel',
+ url: 'https://mcp.vercel.com'
+ },
+ {
+ description: 'Database, auth, and storage from your Supabase projects.',
+ docs: 'https://supabase.com/docs/guides/ai-tools/mcp',
+ hosts: ['supabase.com', 'supabase.co'],
+ keywords: ['supabase'],
+ name: 'supabase',
+ url: 'https://mcp.supabase.com/mcp'
+ },
+ {
+ description: 'Sites, deploys, and env vars via Netlify’s hosted MCP.',
+ docs: 'https://docs.netlify.com/build/build-with-ai/agent-setup-guides/agent-setup-overview/',
+ // No `netlify.app` for the same deploy-preview reason as vercel.app.
+ hosts: ['netlify.com'],
+ keywords: ['netlify'],
+ name: 'netlify',
+ url: 'https://netlify-mcp.netlify.app/mcp'
+ },
+ {
+ description: 'Models, datasets, Spaces, and papers from the Hugging Face Hub.',
+ docs: 'https://huggingface.co/docs/hub/agents-mcp',
+ hosts: ['huggingface.co', 'hf.co'],
+ keywords: ['hugging face', 'huggingface'],
+ // Underscored so prettyName renders "Hugging Face", not "Huggingface".
+ name: 'hugging_face',
+ url: 'https://huggingface.co/mcp'
+ },
+ {
+ description: 'Tasks, projects, and goals from your Asana workspace.',
+ docs: 'https://developers.asana.com/docs/using-asanas-mcp-server',
+ hosts: ['asana.com'],
+ keywords: ['asana'],
+ name: 'asana',
+ url: 'https://mcp.asana.com/sse'
+ },
+ {
+ description: 'Conversations, tickets, and customer data from Intercom.',
+ docs: 'https://developers.intercom.com/docs/guides/mcp',
+ hosts: ['intercom.com', 'intercom.io'],
+ keywords: ['intercom'],
+ name: 'intercom',
+ url: 'https://mcp.intercom.com/mcp'
+ },
+ {
+ description: 'Bases, tables, and records from your Airtable workspace.',
+ docs: 'https://support.airtable.com/articles/9897799762-using-the-airtable-mcp-server',
+ hosts: ['airtable.com'],
+ keywords: ['airtable'],
+ name: 'airtable',
+ url: 'https://mcp.airtable.com/mcp'
+ },
+ {
+ description: 'Sites, CMS collections, and pages via Webflow’s hosted MCP.',
+ docs: 'https://developers.webflow.com/mcp/reference/getting-started',
+ // No `webflow.io` — that's published staging sites, not Webflow intent.
+ hosts: ['webflow.com'],
+ keywords: ['webflow'],
+ name: 'webflow',
+ url: 'https://mcp.webflow.com/mcp'
+ },
+ {
+ description: 'Payments, invoices, and subscriptions via PayPal’s hosted MCP.',
+ docs: 'https://developer.paypal.com/tools/mcp-server/',
+ hosts: ['developer.paypal.com'],
+ keywords: ['paypal'],
+ name: 'paypal',
+ url: 'https://mcp.paypal.com/sse'
+ },
+ {
+ description: 'Catalog, orders, and payments via Square’s hosted MCP.',
+ docs: 'https://developer.squareup.com/docs/mcp',
+ hosts: ['squareup.com'],
+ // "square" the English word is everywhere ("square brackets", "square
+ // corners") — only the unambiguous brand form triggers.
+ keywords: ['squareup'],
+ name: 'square',
+ url: 'https://mcp.squareup.com/sse'
}
]
diff --git a/apps/desktop/src/lib/media.remote.test.ts b/apps/desktop/src/lib/media.remote.test.ts
index c16835cd67..72b8eabb14 100644
--- a/apps/desktop/src/lib/media.remote.test.ts
+++ b/apps/desktop/src/lib/media.remote.test.ts
@@ -1,5 +1,3 @@
-// @vitest-environment jsdom
-// downloadGatewayMediaFile drives an click, so these need a DOM.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
@@ -11,6 +9,7 @@ import {
isInlineMediaSrc,
isRemoteGateway,
mediaExternalUrl,
+ mediaGatewayStreamUrl,
resolveMediaDisplaySrc,
resolveMediaPlaybackSrc
} from './media'
@@ -78,6 +77,24 @@ describe('mediaExternalUrl', () => {
})
})
+describe('mediaGatewayStreamUrl', () => {
+ afterEach(() => {
+ $connection.set(null)
+ })
+
+ it('rewrites gateway-local media to the main-process remote stream proxy', () => {
+ $connection.set({ mode: 'remote', baseUrl: 'https://gw', token: 's e/cret' } as never)
+ expect(mediaGatewayStreamUrl('file:///tmp/a b.mp4')).toBe('hermes-media://remote/%2Ftmp%2Fa%20b.mp4')
+ })
+
+ it('supports OAuth remotes with no renderer-visible token and scopes pool profiles', () => {
+ $connection.set({ authMode: 'oauth', mode: 'remote', profile: 'voice reviewer', token: null } as never)
+ expect(mediaGatewayStreamUrl('/tmp/a.mp4')).toBe(
+ 'hermes-media://remote/%2Ftmp%2Fa.mp4?profile=voice%20reviewer'
+ )
+ })
+})
+
describe('resolveMediaDisplaySrc', () => {
const api = vi.fn(async ({ path }: { path: string }) => {
if (path.startsWith('/api/fs/read-data-url?')) {
@@ -155,12 +172,12 @@ describe('resolveMediaPlaybackSrc', () => {
)
})
- it('routes gateway-local video through the authenticated download endpoint', async () => {
+ it('routes OAuth gateway-local video through the authenticated main-process proxy', async () => {
vi.stubGlobal('window', { hermesDesktop: { api: vi.fn() } })
- $connection.set({ mode: 'remote', baseUrl: 'https://gateway.test', token: 's e/cret' } as never)
+ $connection.set({ authMode: 'oauth', mode: 'remote', profile: 'default', token: null } as never)
await expect(resolveMediaPlaybackSrc('/root/outputs/render.mp4')).resolves.toBe(
- 'https://gateway.test/api/files/download?path=%2Froot%2Foutputs%2Frender.mp4&token=s%20e%2Fcret'
+ 'hermes-media://remote/%2Froot%2Foutputs%2Frender.mp4?profile=default'
)
})
@@ -205,49 +222,37 @@ describe('gatewayMediaDataUrl', () => {
})
describe('downloadGatewayMediaFile', () => {
- const api = vi.fn(async ({ path }: { path: string }) => {
- if (path.startsWith('/api/fs/read-data-url?')) {
- return { dataUrl: 'data:text/markdown;base64,IyByZXBvcnQ=' }
- }
-
- throw new Error(`unexpected path ${path}`)
- })
-
- let clickSpy: ReturnType
+ const saveGatewayFile = vi.fn(async () => ({ path: '/Users/me/Downloads/report.md', saved: true }))
beforeEach(() => {
- api.mockClear()
- vi.stubGlobal('window', { hermesDesktop: { api }, setTimeout: vi.fn() })
- vi.stubGlobal(
- 'fetch',
- vi.fn(async () => ({ blob: async () => new Blob(['# report'], { type: 'text/markdown' }) }))
- )
- URL.createObjectURL = vi.fn(() => 'blob:remote-artifact')
- URL.revokeObjectURL = vi.fn()
- clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
- $connection.set({ mode: 'remote' } as never)
+ saveGatewayFile.mockClear()
+ vi.stubGlobal('window', { hermesDesktop: { saveGatewayFile } })
+ $connection.set({ mode: 'remote', profile: 'docker-gw' } as never)
})
afterEach(() => {
vi.unstubAllGlobals()
- vi.clearAllMocks()
- clickSpy.mockRestore()
$connection.set(null)
})
- it('downloads gateway files through the desktop fs bridge', async () => {
- await downloadGatewayMediaFile('file:///Users/me/project/report.md')
-
- expect(api).toHaveBeenCalledWith({
- path: '/api/fs/read-data-url?path=%2FUsers%2Fme%2Fproject%2Freport.md'
+ it('downloads gateway files through the native desktop save bridge', async () => {
+ await expect(downloadGatewayMediaFile('file:///Users/me/project/a%20b.md')).resolves.toEqual({
+ path: '/Users/me/Downloads/report.md',
+ saved: true
+ })
+
+ expect(saveGatewayFile).toHaveBeenCalledWith({
+ path: '/Users/me/project/a b.md',
+ profile: 'docker-gw',
+ suggestedName: 'a b.md'
})
- expect(clickSpy).toHaveBeenCalledOnce()
})
- it('rejects when the gateway refuses the file read', async () => {
- api.mockRejectedValueOnce(new Error('403 File is not readable'))
+ it('rejects when the desktop bridge is unavailable', async () => {
+ vi.stubGlobal('window', { hermesDesktop: {} })
- await expect(downloadGatewayMediaFile('/Users/me/project/report.md')).rejects.toThrow('403')
- expect(clickSpy).not.toHaveBeenCalled()
+ await expect(downloadGatewayMediaFile('/Users/me/project/report.md')).rejects.toThrow(
+ 'Desktop file download bridge'
+ )
})
})
diff --git a/apps/desktop/src/lib/media.ts b/apps/desktop/src/lib/media.ts
index 33bcfbd963..97404eb189 100644
--- a/apps/desktop/src/lib/media.ts
+++ b/apps/desktop/src/lib/media.ts
@@ -83,16 +83,16 @@ export async function resolveMediaDisplaySrc(path: string): Promise {
}
// Audio/video need a seekable source instead of a whole-file data URL. Keep
-// remote URLs untouched, route gateway-local files through the authenticated
-// download endpoint, and reserve the Electron protocol for files on this
-// desktop machine.
+// remote URLs untouched and route filesystem paths through the Electron media
+// protocol. Its main-process handler reads local files directly or proxies a
+// remote gateway with the connection's bearer/cookie/token authentication.
export async function resolveMediaPlaybackSrc(path: string): Promise {
if (isInlineMediaSrc(path)) {
return path
}
if (window.hermesDesktop && ['audio', 'video'].includes(mediaKind(path))) {
- return isRemoteGateway() ? mediaExternalUrl(path) : mediaStreamUrl(path)
+ return isRemoteGateway() ? mediaGatewayStreamUrl(path) : mediaStreamUrl(path)
}
return resolveMediaDisplaySrc(path)
@@ -119,6 +119,23 @@ export function mediaExternalUrl(path: string): string {
return /^file:/i.test(path) ? path : `file://${path}`
}
+// Remote gateway audio/video is proxied by the Electron main process. OAuth
+// connections intentionally expose no static token to the renderer, so a bare
+// HTTPS source cannot authenticate reliably. The custom protocol keeps secrets
+// out of renderer URLs while forwarding Range requests to /api/files/stream.
+export function mediaGatewayStreamUrl(path: string): string {
+ const conn = $connection.get()
+
+ if (isRemoteGateway()) {
+ const file = encodeURIComponent(filePathFromMediaPath(path))
+ const profile = conn?.profile ? `?profile=${encodeURIComponent(conn.profile)}` : ''
+
+ return `hermes-media://remote/${file}${profile}`
+ }
+
+ return mediaExternalUrl(path)
+}
+
// Custom Electron scheme (registered in electron/main.ts) that streams a local
// file with Range support. Used for audio/video so playback bypasses the data
// URL size cap and supports seeking. `path` may be a plain path or `file://…`.
@@ -165,25 +182,25 @@ export async function gatewayMediaDataUrl(path: string): Promise {
}
// Remote-mode replacement for opening gateway-local file paths with file://.
-// The file lives on the gateway, so fetch it over the authenticated fs bridge
-// and hand the bytes to the local browser shell as a download.
-export async function downloadGatewayMediaFile(path: string): Promise {
- const dataUrl = await readDesktopFileDataUrl(filePathFromMediaPath(path))
+// The file lives on the gateway, so ask the Electron main process to fetch the
+// bytes through the authenticated backend connection and save them locally. This
+// avoids browser/OS downloads losing OAuth cookies and avoids the data-URL cap
+// used by preview endpoints.
+export async function downloadGatewayMediaFile(
+ path: string
+): Promise<{ canceled?: boolean; path?: string; saved: boolean }> {
+ const file = filePathFromMediaPath(path)
+ const conn = $connection.get()
- if (!dataUrl) {
- throw new Error('Gateway returned no file data')
+ if (!window.hermesDesktop?.saveGatewayFile) {
+ throw new Error('Desktop file download bridge is unavailable')
}
- const response = await fetch(dataUrl)
- const blobUrl = URL.createObjectURL(await response.blob())
- const anchor = document.createElement('a')
- anchor.href = blobUrl
- anchor.download = mediaName(path)
- anchor.rel = 'noopener noreferrer'
- document.body.appendChild(anchor)
- anchor.click()
- anchor.remove()
- window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
+ return window.hermesDesktop.saveGatewayFile({
+ path: file,
+ profile: conn?.profile,
+ suggestedName: mediaName(file)
+ })
}
export function mediaDisplayLabel(path: string): string {
diff --git a/apps/desktop/src/lib/model-options.test.ts b/apps/desktop/src/lib/model-options.test.ts
index c855b6d234..4220d7494c 100644
--- a/apps/desktop/src/lib/model-options.test.ts
+++ b/apps/desktop/src/lib/model-options.test.ts
@@ -16,7 +16,11 @@ describe('requestModelOptions', () => {
})
it('uses the connected gateway even before a session exists', async () => {
- const gatewayPayload = { model: 'BeastMode', provider: 'moa', providers: [] }
+ const gatewayPayload = {
+ model: 'BeastMode',
+ provider: 'moa',
+ providers: [{ models: ['BeastMode'], name: 'Mixture of Agents', slug: 'moa' }]
+ }
const gateway = {
request: vi.fn(() => Promise.resolve(gatewayPayload))
@@ -28,6 +32,71 @@ describe('requestModelOptions', () => {
expect(getGlobalModelOptions).not.toHaveBeenCalled()
})
+ it('recovers an empty gateway catalog through profile-scoped REST without replacing the session selection', async () => {
+ const gatewayPayload = { model: 'hermes-local', provider: 'hermes-local' }
+
+ const restPayload = {
+ model: 'profile-default',
+ provider: 'openai-codex',
+ providers: [{ models: ['hermes-local'], name: 'Hermes Local vLLM', slug: 'hermes-local' }]
+ }
+
+ const gateway = {
+ request: vi.fn(() => Promise.resolve(gatewayPayload))
+ }
+
+ vi.mocked(getGlobalModelOptions).mockResolvedValueOnce(restPayload)
+
+ await expect(requestModelOptions({ gateway: gateway as never, sessionId: 'session-1' })).resolves.toEqual({
+ ...restPayload,
+ model: 'hermes-local',
+ provider: 'hermes-local'
+ })
+
+ expect(getGlobalModelOptions).toHaveBeenCalledWith({ explicitOnly: true })
+ })
+
+ it('recovers through profile-scoped REST when the gateway catalog request fails', async () => {
+ const restPayload = {
+ model: 'hermes-local',
+ provider: 'hermes-local',
+ providers: [{ models: ['hermes-local'], name: 'Hermes Local vLLM', slug: 'hermes-local' }]
+ }
+
+ const gateway = {
+ request: vi.fn(() => Promise.reject(new Error('gateway request unavailable')))
+ }
+
+ vi.mocked(getGlobalModelOptions).mockResolvedValueOnce(restPayload)
+
+ await expect(requestModelOptions({ gateway: gateway as never, sessionId: 'session-1' })).resolves.toEqual(
+ restPayload
+ )
+ expect(getGlobalModelOptions).toHaveBeenCalledWith({ explicitOnly: true })
+ })
+
+ it('preserves the gateway error when its REST recovery path also fails', async () => {
+ const gatewayError = new Error('gateway request unavailable')
+
+ const gateway = {
+ request: vi.fn(() => Promise.reject(gatewayError))
+ }
+
+ vi.mocked(getGlobalModelOptions).mockRejectedValueOnce(new Error('REST request unavailable'))
+
+ await expect(requestModelOptions({ gateway: gateway as never })).rejects.toBe(gatewayError)
+ })
+
+ it('keeps the gateway result when both catalog paths have no selectable models', async () => {
+ const gatewayPayload = { model: 'hermes-local', provider: 'hermes-local', providers: [] }
+
+ const gateway = {
+ request: vi.fn(() => Promise.resolve(gatewayPayload))
+ }
+
+ await expect(requestModelOptions({ gateway: gateway as never })).resolves.toBe(gatewayPayload)
+ })
+
it('passes the active session id and refresh flag through the gateway', async () => {
const gateway = {
request: vi.fn(() => Promise.resolve(globalOptions))
@@ -40,6 +109,7 @@ describe('requestModelOptions', () => {
refresh: true,
session_id: 'session-1'
})
+ expect(getGlobalModelOptions).toHaveBeenCalledWith({ explicitOnly: true, refresh: true })
})
it('falls back to REST when no gateway is connected', async () => {
diff --git a/apps/desktop/src/lib/model-options.ts b/apps/desktop/src/lib/model-options.ts
index 0173b6f2b4..6add1bc684 100644
--- a/apps/desktop/src/lib/model-options.ts
+++ b/apps/desktop/src/lib/model-options.ts
@@ -50,7 +50,11 @@ export function modelOptionsQueryKey(profile: null | string | undefined, session
return ['model-options', profileKey, sessionId || 'global'] as const
}
-export function requestModelOptions({
+function hasSelectableModels(options: ModelOptionsResponse | null | undefined): boolean {
+ return options?.providers?.some(provider => (provider.models?.length ?? 0) > 0) ?? false
+}
+
+export async function requestModelOptions({
explicitOnly = true,
gateway,
refresh = false,
@@ -71,7 +75,43 @@ export function requestModelOptions({
params.explicit_only = true
}
- return gateway.request('model.options', params)
+ let gatewayError: unknown
+ let gatewayOptions: ModelOptionsResponse | undefined
+
+ try {
+ gatewayOptions = await gateway.request('model.options', params)
+ } catch (error) {
+ gatewayError = error
+ }
+
+ if (gatewayOptions && hasSelectableModels(gatewayOptions)) {
+ return gatewayOptions
+ }
+
+ // A connected Desktop gateway can occasionally return only the current
+ // provider/model (or an empty provider list) while its authenticated REST
+ // catalog is already populated. Recover through the same profile-scoped
+ // endpoint Settings uses, but keep the live session selection authoritative.
+ try {
+ const restOptions = await getGlobalModelOptions({ explicitOnly, ...(refresh ? { refresh: true } : {}) })
+
+ if (hasSelectableModels(restOptions)) {
+ return {
+ ...restOptions,
+ ...(gatewayOptions?.provider ? { provider: gatewayOptions.provider } : {}),
+ ...(gatewayOptions?.model ? { model: gatewayOptions.model } : {})
+ }
+ }
+ } catch {
+ // Preserve the gateway result (or its original error) when the recovery
+ // path is unavailable.
+ }
+
+ if (gatewayOptions) {
+ return gatewayOptions
+ }
+
+ throw gatewayError
}
return getGlobalModelOptions({ explicitOnly, ...(refresh ? { refresh: true } : {}) })
diff --git a/apps/desktop/src/lib/persisted.test.ts b/apps/desktop/src/lib/persisted.test.ts
new file mode 100644
index 0000000000..95eaa3ff36
--- /dev/null
+++ b/apps/desktop/src/lib/persisted.test.ts
@@ -0,0 +1,66 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import { Codecs, persistentAtom } from './persisted'
+
+// Unique key per test run — persistentAtom instances are singletons per call,
+// so tests mint fresh keys instead of sharing one.
+let counter = 0
+const freshKey = () => `test.persisted.${++counter}`
+
+describe('persistentAtom', () => {
+ beforeEach(() => {
+ window.localStorage.clear()
+ })
+
+ it('seeds from a stored value', () => {
+ const key = freshKey()
+ window.localStorage.setItem(key, JSON.stringify({ a: 1 }))
+
+ const $value = persistentAtom>(key, {})
+
+ expect($value.get()).toEqual({ a: 1 })
+ })
+
+ it('does NOT write at creation — an empty read must never clobber storage', () => {
+ // A cold boot can evaluate the bundle against a storage snapshot that has
+ // not caught up yet (early hidden/boot load). If creation echoed the
+ // fallback back out, that load would overwrite the real record other
+ // loads are about to read. Creation must be read-only.
+ const key = freshKey()
+ persistentAtom>(key, {})
+
+ expect(window.localStorage.getItem(key)).toBeNull()
+ })
+
+ it('leaves an existing stored value untouched at creation even when it fails to decode', () => {
+ const key = freshKey()
+ window.localStorage.setItem(key, 'not json')
+
+ const $value = persistentAtom>(key, { fallback: 1 })
+
+ expect($value.get()).toEqual({ fallback: 1 })
+ // The corrupt value survives until a real write replaces it — creation
+ // never writes.
+ expect(window.localStorage.getItem(key)).toBe('not json')
+ })
+
+ it('persists real changes', () => {
+ const key = freshKey()
+ const $value = persistentAtom>(key, {})
+
+ $value.set({ b: 2 })
+
+ expect(window.localStorage.getItem(key)).toBe(JSON.stringify({ b: 2 }))
+ })
+
+ it('removes the key when the codec encodes null', () => {
+ const key = freshKey()
+ window.localStorage.setItem(key, JSON.stringify(['x']))
+
+ const $value = persistentAtom(key, [], Codecs.stringArray)
+
+ $value.set([])
+
+ expect(window.localStorage.getItem(key)).toBeNull()
+ })
+})
diff --git a/apps/desktop/src/lib/persisted.ts b/apps/desktop/src/lib/persisted.ts
index 95cd0335ae..f9d3c7c400 100644
--- a/apps/desktop/src/lib/persisted.ts
+++ b/apps/desktop/src/lib/persisted.ts
@@ -72,7 +72,26 @@ export function persistentAtom(key: string, fallback: T, codec: Codec = Co
const $value = atom(initial)
- $value.subscribe(value => writeKey(key, codec.encode(value)))
+ // Persist CHANGES only — never the creation-time value. nanostores'
+ // subscribe fires immediately, and writing what was just read back is a
+ // no-op at best; at worst it is a data-loss clobber: on a cold boot the
+ // renderer bundle can run against a storage snapshot that has not caught
+ // up yet (an early hidden/boot load of the same bundle sees an empty
+ // area), and echoing the fallback back out overwrites the real record
+ // other loads are about to read. Observed with the unread-dot records:
+ // the early load wrote `{}` over a populated store between the disk read
+ // and the main window's module init.
+ let creationEmission = true
+
+ $value.subscribe(value => {
+ if (creationEmission) {
+ creationEmission = false
+
+ return
+ }
+
+ writeKey(key, codec.encode(value))
+ })
return $value
}
diff --git a/apps/desktop/src/lib/renderer-loop-pause.test.ts b/apps/desktop/src/lib/renderer-loop-pause.test.ts
new file mode 100644
index 0000000000..46563c627a
--- /dev/null
+++ b/apps/desktop/src/lib/renderer-loop-pause.test.ts
@@ -0,0 +1,31 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { installRendererAnimationPauseState, RENDERER_ANIMATIONS_PAUSED_ATTRIBUTE } from './renderer-loop-pause'
+
+describe('installRendererAnimationPauseState', () => {
+ afterEach(() => {
+ document.documentElement.removeAttribute(RENDERER_ANIMATIONS_PAUSED_ATTRIBUTE)
+ vi.restoreAllMocks()
+ })
+
+ it('pauses on blur, resumes on focus, and cleans up its root state', () => {
+ let focused = true
+ vi.spyOn(document, 'hasFocus').mockImplementation(() => focused)
+
+ const dispose = installRendererAnimationPauseState()
+ expect(document.documentElement.hasAttribute(RENDERER_ANIMATIONS_PAUSED_ATTRIBUTE)).toBe(false)
+
+ focused = false
+ window.dispatchEvent(new Event('blur'))
+ expect(document.documentElement.hasAttribute(RENDERER_ANIMATIONS_PAUSED_ATTRIBUTE)).toBe(true)
+
+ focused = true
+ window.dispatchEvent(new Event('focus'))
+ expect(document.documentElement.hasAttribute(RENDERER_ANIMATIONS_PAUSED_ATTRIBUTE)).toBe(false)
+
+ focused = false
+ window.dispatchEvent(new Event('blur'))
+ dispose()
+ expect(document.documentElement.hasAttribute(RENDERER_ANIMATIONS_PAUSED_ATTRIBUTE)).toBe(false)
+ })
+})
diff --git a/apps/desktop/src/lib/renderer-loop-pause.ts b/apps/desktop/src/lib/renderer-loop-pause.ts
index 88b9e3559b..55fb19b1c7 100644
--- a/apps/desktop/src/lib/renderer-loop-pause.ts
+++ b/apps/desktop/src/lib/renderer-loop-pause.ts
@@ -3,6 +3,8 @@ interface WindowStatePayload {
isVisible?: boolean
}
+export const RENDERER_ANIMATIONS_PAUSED_ATTRIBUTE = 'data-renderer-animations-paused'
+
export function createRendererLoopPauseController(onChange: () => void, { pauseWhenUnfocused = true } = {}) {
let windowPaused = false
let windowFocused = document.hasFocus()
@@ -48,3 +50,23 @@ export function createRendererLoopPauseController(onChange: () => void, { pauseW
isPaused: () => document.visibilityState === 'hidden' || (pauseWhenUnfocused && !windowFocused) || windowPaused
}
}
+
+/**
+ * Mirrors the main window's observability onto :root so continuous decorative
+ * CSS animations can sleep with the JS renderer loops. The caller owns the
+ * returned cleanup; overlay windows intentionally do not install this state.
+ */
+export function installRendererAnimationPauseState(): () => void {
+ const root = document.documentElement
+ let controller: ReturnType
+
+ const sync = () => root.toggleAttribute(RENDERER_ANIMATIONS_PAUSED_ATTRIBUTE, controller.isPaused())
+
+ controller = createRendererLoopPauseController(sync)
+ sync()
+
+ return () => {
+ controller.dispose()
+ root.removeAttribute(RENDERER_ANIMATIONS_PAUSED_ATTRIBUTE)
+ }
+}
diff --git a/apps/desktop/src/lib/session-signatures.test.ts b/apps/desktop/src/lib/session-signatures.test.ts
index aeecd19b61..0c4c280c8e 100644
--- a/apps/desktop/src/lib/session-signatures.test.ts
+++ b/apps/desktop/src/lib/session-signatures.test.ts
@@ -4,7 +4,8 @@ import type { SessionInfo } from '@/hermes'
import { sameCronSignature, sessionMessagesSignature } from './session-signatures'
-const session = (id: string, title: string | null): SessionInfo => ({ id, title }) as SessionInfo
+const session = (id: string, title: string | null, extra: Partial = {}): SessionInfo =>
+ ({ id, title, ...extra }) as SessionInfo
describe('sameCronSignature', () => {
it('is false when the lengths differ', () => {
@@ -28,6 +29,28 @@ describe('sameCronSignature', () => {
const b = [session('b', 't'), session('a', 't')]
expect(sameCronSignature(a, b)).toBe(false)
})
+
+ // A pin-only page must reach $sessions: session-pin-sync treats the row as
+ // authoritative and releases its write guard when a page confirms the value
+ // it wrote. Gating that page out froze the row and re-pinned what the user
+ // had just unpinned (#76919).
+ it('is false when only the pinned flag changed', () => {
+ const a = [session('a', 't', { pinned: true })]
+ const b = [session('a', 't', { pinned: false })]
+ expect(sameCronSignature(a, b)).toBe(false)
+ })
+
+ it('is false when only the archived flag changed', () => {
+ const a = [session('a', 't', { archived: false })]
+ const b = [session('a', 't', { archived: true })]
+ expect(sameCronSignature(a, b)).toBe(false)
+ })
+
+ it('is true when both flags match', () => {
+ const a = [session('a', 't', { archived: false, pinned: true })]
+ const b = [session('a', 't', { archived: false, pinned: true })]
+ expect(sameCronSignature(a, b)).toBe(true)
+ })
})
describe('sessionMessagesSignature', () => {
diff --git a/apps/desktop/src/lib/session-signatures.ts b/apps/desktop/src/lib/session-signatures.ts
index 4ef20e1fab..fac4725422 100644
--- a/apps/desktop/src/lib/session-signatures.ts
+++ b/apps/desktop/src/lib/session-signatures.ts
@@ -23,7 +23,14 @@ export function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
session.preview === other.preview &&
session.message_count === other.message_count &&
session.last_active === other.last_active &&
- session.ended_at === other.ended_at
+ session.ended_at === other.ended_at &&
+ // Row STATE, not just row content: session-pin-sync reconciles the
+ // sidebar's pins against `pinned` on the rows in this atom, so a page
+ // whose only delta is a flag has to swap in or the reconciler reads a
+ // frozen copy forever. An idle conversation never moves any of the
+ // fields above again, which is exactly when a pin gets toggled (#76919).
+ session.pinned === other.pinned &&
+ session.archived === other.archived
)
})
}
diff --git a/apps/desktop/src/lib/statusbar.tsx b/apps/desktop/src/lib/statusbar.tsx
index 0c0d22599c..01ca3b645a 100644
--- a/apps/desktop/src/lib/statusbar.tsx
+++ b/apps/desktop/src/lib/statusbar.tsx
@@ -1,6 +1,7 @@
-import { useEffect, useState } from 'react'
+import { useState } from 'react'
import { StableText } from '@/components/chat/stable-text'
+import { useViewedInterval } from '@/hooks/use-viewed-interval'
import { compactNumber } from '@/lib/format'
import type { UsageStats } from '@/types/hermes'
@@ -61,17 +62,7 @@ export function contextBarLabel(usage: UsageStats): string {
export function LiveDuration({ since }: { since: number | null | undefined }) {
const [now, setNow] = useState(() => Date.now())
- useEffect(() => {
- if (!since) {
- return
- }
-
- const tick = () => setNow(Date.now())
- tick()
- const timer = window.setInterval(tick, 1000)
-
- return () => window.clearInterval(timer)
- }, [since])
+ useViewedInterval(() => setNow(Date.now()), 1000, Boolean(since))
if (!since) {
return null
diff --git a/apps/desktop/src/lib/tool-run-continuity.test.ts b/apps/desktop/src/lib/tool-run-continuity.test.ts
index a009053db3..1955565410 100644
--- a/apps/desktop/src/lib/tool-run-continuity.test.ts
+++ b/apps/desktop/src/lib/tool-run-continuity.test.ts
@@ -248,3 +248,37 @@ describe('run identity', () => {
expect(runs.map(run => run.map(t => t.toolCallId))).toEqual([['a'], ['b']])
})
})
+
+describe('coalesced tool lifecycle', () => {
+ it('keeps the latest completion boundary from a folded tool-only message', () => {
+ const messages: ChatMessage[] = [
+ {
+ completedAt: 2,
+ id: 'assistant-text',
+ parts: [assistantTextPart('Checking.')],
+ role: 'assistant',
+ timestamp: 1
+ },
+ {
+ id: 'assistant-tool',
+ parts: [
+ {
+ args: {} as never,
+ argsText: '{}',
+ completedAt: 5,
+ timestamp: 3,
+ toolCallId: 'call-1',
+ toolName: 'terminal',
+ type: 'tool-call'
+ }
+ ],
+ role: 'assistant',
+ timestamp: 3
+ }
+ ]
+
+ const [merged] = coalesceToolOnlyAssistants(messages, createToolMergeCache())
+
+ expect(merged.completedAt).toBe(5)
+ })
+})
diff --git a/apps/desktop/src/lib/version-status.test.ts b/apps/desktop/src/lib/version-status.test.ts
index 54a4bb2d57..0ea9408039 100644
--- a/apps/desktop/src/lib/version-status.test.ts
+++ b/apps/desktop/src/lib/version-status.test.ts
@@ -30,6 +30,18 @@ describe('resolveVersionStatus', () => {
expect(status.tooltip).toContain('12 commits behind main')
})
+ // FAIL-BEFORE (#84591 class): a shallow install reports behind:null +
+ // updateAvailable. The client target ignored updateAvailable entirely, so
+ // the statusbar showed no update at all — and further back, the fabricated
+ // behind:1 rendered a frozen "(+1)" while the real distance grew to 61.
+ it('shows a count-free update hint when the client count is unknown', () => {
+ const status = client({ behind: 0, updateAvailable: true, version: '0.4.2' })
+
+ expect(status.label).toBe(`v0.4.2 (${copy.update})`)
+ expect(status.label).not.toContain('+1')
+ expect(status.hasUpdate).toBe(true)
+ })
+
it('names the client as one of two versions in remote mode', () => {
expect(client({ remote: true, version: '0.4.2' }).label).toBe('client v0.4.2')
})
diff --git a/apps/desktop/src/lib/version-status.ts b/apps/desktop/src/lib/version-status.ts
index 030464e8ec..0b292c9f31 100644
--- a/apps/desktop/src/lib/version-status.ts
+++ b/apps/desktop/src/lib/version-status.ts
@@ -39,7 +39,7 @@ export interface VersionStatusInput {
/** Client only: short commit sha of the running build. */
sha?: null | string
target: UpdateTarget
- /** Backend only: an update the commit count can't express (pip installs). */
+ /** An update the commit count can't express (shallow clones, pip installs). */
updateAvailable?: boolean
version?: null | string
}
@@ -70,7 +70,12 @@ export function resolveVersionStatus({
}: VersionStatusInput): VersionStatusResult {
const client = target === 'client'
const busy = applying || restarting
- const available = behind > 0 || (!client && !!updateAvailable)
+ // updateAvailable covers every "behind but uncountable" shape: shallow
+ // installer clones (behind === null upstream, coalesced to 0 by callers),
+ // SSH-official presence-only checks, and pip installs. It applies to BOTH
+ // targets — the client statusbar item is how a shallow desktop install
+ // learns it's stale at all.
+ const available = behind > 0 || !!updateAvailable
// A client with no version still identifies itself by sha; a backend can't.
const named = version ?? (client ? sha : null) ?? copy.unknown
diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx
index b2ef156a43..39e21ce743 100644
--- a/apps/desktop/src/main.tsx
+++ b/apps/desktop/src/main.tsx
@@ -25,6 +25,7 @@ import { RootTooltipProvider } from './components/ui/tooltip'
import { I18nProvider } from './i18n'
import { installClipboardShim } from './lib/clipboard'
import { queryClient } from './lib/query-client'
+import { installRendererAnimationPauseState } from './lib/renderer-loop-pause'
import { ThemeProvider } from './themes/context'
installClipboardShim()
@@ -50,6 +51,11 @@ if (winParam === 'overlay') {
} else if (winParam === 'wake') {
void import('./app/wake-indicator/wake-indicator-root').then(({ mountWakeIndicator }) => mountWakeIndicator())
} else {
+ // CSS animations do not inherit Chromium's JS-loop pause policy. Mirror the
+ // main window's focus/visibility state to :root so decorative infinite
+ // animations stop producing frames when nobody can see them.
+ installRendererAnimationPauseState()
+
createRoot(document.getElementById('root')!).render(
diff --git a/apps/desktop/src/plugins/kanban/board.tsx b/apps/desktop/src/plugins/kanban/board.tsx
index 3c4cdd9f97..2124e8dcd0 100644
--- a/apps/desktop/src/plugins/kanban/board.tsx
+++ b/apps/desktop/src/plugins/kanban/board.tsx
@@ -4,8 +4,8 @@
* header row (count, filter kebab, search, settings, new task — the board
* SWITCHER lives in the titlebar, see board-switcher.tsx), columns in
* BOARD_COLUMNS order, drag-to-move (optimistic, workflow-checked),
- * ⌘-click multi-select with a floating bulk bar, right-click actions, and
- * the detail drawer. Dispatch nudges ride every write (see api.ts).
+ * primary-modifier-click multi-select with a floating bulk bar, right-click
+ * actions, and the detail drawer. Dispatch nudges ride every write (see api.ts).
*/
import {
@@ -30,6 +30,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
ErrorState,
+ formatModifierToken,
host,
Input,
Loader,
@@ -309,7 +310,7 @@ function Card({
onToggleSelect(task.id)}>
- {selected ? k.deselect : k.select}
+ {selected ? k.deselect : k.select(formatModifierToken('mod'))}
{columns
diff --git a/apps/desktop/src/plugins/kanban/i18n.ts b/apps/desktop/src/plugins/kanban/i18n.ts
index e751f698c8..34a64fccbb 100644
--- a/apps/desktop/src/plugins/kanban/i18n.ts
+++ b/apps/desktop/src/plugins/kanban/i18n.ts
@@ -29,7 +29,7 @@ type KanbanMessages = {
noMatch: string
noTasks: string
open: string
- select: string
+ select: (modifier: string) => string
deselect: string
moveTo: (label: string) => string
delete: string
@@ -217,7 +217,7 @@ const en: KanbanMessages = {
noMatch: 'No tasks match the filters',
noTasks: 'No tasks on this board',
open: 'Open',
- select: 'Select (⌘-click)',
+ select: modifier => `Select (${modifier}-click)`,
deselect: 'Deselect',
moveTo: label => `Move to ${label}`,
delete: 'Delete',
@@ -409,7 +409,7 @@ const ja: KanbanMessages = {
noMatch: 'フィルタに一致するタスクはありません',
noTasks: 'このボードにタスクはありません',
open: '開く',
- select: '選択(⌘クリック)',
+ select: modifier => `選択(${modifier}クリック)`,
deselect: '選択解除',
moveTo: label => `${label} へ移動`,
delete: '削除',
@@ -600,7 +600,7 @@ const zh: KanbanMessages = {
noMatch: '没有符合筛选条件的任务',
noTasks: '此面板暂无任务',
open: '打开',
- select: '选择(⌘点击)',
+ select: modifier => `选择(${modifier}点击)`,
deselect: '取消选择',
moveTo: label => `移动到 ${label}`,
delete: '删除',
@@ -788,7 +788,7 @@ const zhHant: KanbanMessages = {
noMatch: '沒有符合篩選條件的任務',
noTasks: '此面板尚無任務',
open: '開啟',
- select: '選取(⌘點擊)',
+ select: modifier => `選取(${modifier}點擊)`,
deselect: '取消選取',
moveTo: label => `移至 ${label}`,
delete: '刪除',
diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts
index d3d9ae923e..adf70aa325 100644
--- a/apps/desktop/src/sdk/index.ts
+++ b/apps/desktop/src/sdk/index.ts
@@ -24,7 +24,7 @@ import { openSession, type OpenSessionIntent } from '@/app/open-session'
import { $narrowViewport } from '@/components/pane-shell/tree/store'
import { onGatewayEvent } from '@/contrib/events'
import { getLogs, getStatus } from '@/hermes'
-import { $gateway } from '@/store/gateway'
+import { $gateway, openGatewayForProfile } from '@/store/gateway'
import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, ensureGatewayProfile, newSessionInProfile, setShowAllProfiles } from '@/store/profile'
import { $activeSessionId, $currentCwd, $currentModel, $gatewayState } from '@/store/session'
@@ -96,6 +96,23 @@ export const host = {
* unified all-profiles view instead of narrowing it to the target
* profile's sessions — a cross-profile open from a plugin surface is a
* navigation, not a scope choice; pass false to also scope the sidebar. */
+ /** Pre-dial a profile's gateway socket in the background — pool-only, no
+ * activation, no navigation, no scope change (openGatewayForProfile; it
+ * already no-ops for shared-remote routes and the primary). Roster UIs
+ * call this after mount so the FIRST click on an agent doesn't pay the
+ * whole backend spawn + socket dial latency. Fire-and-forget: failures
+ * are swallowed — the click path re-runs its own ensure and surfaces
+ * errors properly. */
+ warmProfile: (profile: string): void => {
+ const name = (profile ?? '').trim()
+
+ if (!name || name === $activeGatewayProfile.get()) {
+ return
+ }
+
+ void openGatewayForProfile(name).catch(() => undefined)
+ },
+
openSession: async (
storedSessionId: string,
options: { intent?: OpenSessionIntent; keepAllProfilesScope?: boolean; profile?: null | string } = {}
@@ -284,6 +301,7 @@ export { triggerHaptic as haptic } from '@/lib/haptics'
/** The app's lucide icon set (RefreshCw, LayoutDashboard, Activity, …). */
export * as icons from '@/lib/icons'
export { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
+export { formatModifierToken } from '@/lib/keybinds/combo'
/** The app's deterministic identity color for a name (profiles, assignees,
* authors) + its translucent tag fill — so plugin-rendered identities read
* the same hue as everywhere else. */
diff --git a/apps/desktop/src/store/clarify.test.ts b/apps/desktop/src/store/clarify.test.ts
index aec413b5b3..072fd0c3f9 100644
--- a/apps/desktop/src/store/clarify.test.ts
+++ b/apps/desktop/src/store/clarify.test.ts
@@ -18,6 +18,7 @@ function clarify(sessionId: string | null, requestId: string): ClarifyRequest {
requestId,
question: `question-${requestId}`,
choices: null,
+ multiSelect: false,
sessionId
}
}
diff --git a/apps/desktop/src/store/clarify.ts b/apps/desktop/src/store/clarify.ts
index 6dfc275e75..2bf13d516d 100644
--- a/apps/desktop/src/store/clarify.ts
+++ b/apps/desktop/src/store/clarify.ts
@@ -7,6 +7,7 @@ export interface ClarifyRequest {
requestId: string
question: string
choices: string[] | null
+ multiSelect: boolean
sessionId: string | null
}
diff --git a/apps/desktop/src/store/composer-popout-preference.test.ts b/apps/desktop/src/store/composer-popout-preference.test.ts
new file mode 100644
index 0000000000..ab5865bd84
--- /dev/null
+++ b/apps/desktop/src/store/composer-popout-preference.test.ts
@@ -0,0 +1,65 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const GESTURES_KEY = 'hermes.desktop.composerPopout.gesturesEnabled'
+const LEGACY_ENABLED_KEY = 'hermes.desktop.composerPopout.enabled'
+const ZONES_KEY = 'hermes.desktop.composerPopout.zones.v1'
+
+const loadStore = () => import('./composer-popout')
+
+describe('composer pop-out preference', () => {
+ beforeEach(() => {
+ window.localStorage.clear()
+ vi.resetModules()
+ })
+
+ it('docks every floating zone, preserves positions, and persists the lock', async () => {
+ const first = await loadStore()
+
+ first.setComposerPopoutPosition('left', { bottom: 100, right: 100 })
+ first.setComposerPoppedOut('left', true)
+ first.setComposerPopoutPosition('right', { bottom: 200, right: 200 })
+ first.setComposerPoppedOut('right', true)
+
+ first.setComposerPopoutGesturesEnabled(false)
+
+ expect(first.$composerPopoutGesturesEnabled.get()).toBe(false)
+ expect(first.getComposerPopoutZone('left')).toEqual({
+ poppedOut: false,
+ position: { bottom: 100, right: 100 }
+ })
+ expect(first.getComposerPopoutZone('right')).toEqual({
+ poppedOut: false,
+ position: { bottom: 200, right: 200 }
+ })
+ expect(window.localStorage.getItem(GESTURES_KEY)).toBe('false')
+ expect(window.localStorage.getItem(LEGACY_ENABLED_KEY)).toBe('false')
+
+ vi.resetModules()
+ const reloaded = await loadStore()
+
+ expect(reloaded.$composerPopoutGesturesEnabled.get()).toBe(false)
+ expect(reloaded.getComposerPopoutZone('left').poppedOut).toBe(false)
+ expect(reloaded.getComposerPopoutZone('right').poppedOut).toBe(false)
+ })
+
+ it('normalizes stale floating zones when the persisted preference is disabled', async () => {
+ window.localStorage.setItem(GESTURES_KEY, 'false')
+ window.localStorage.setItem(
+ ZONES_KEY,
+ JSON.stringify({ stale: { poppedOut: true, position: { bottom: 48, right: 64 } } })
+ )
+
+ const store = await loadStore()
+
+ expect(store.getComposerPopoutZone('stale')).toEqual({
+ poppedOut: false,
+ position: { bottom: 48, right: 64 }
+ })
+ })
+
+ it('keeps pop-out gestures enabled by default', async () => {
+ const store = await loadStore()
+
+ expect(store.$composerPopoutGesturesEnabled.get()).toBe(true)
+ })
+})
diff --git a/apps/desktop/src/store/composer-popout.ts b/apps/desktop/src/store/composer-popout.ts
index a5d3b5ab6d..ea6ec7e464 100644
--- a/apps/desktop/src/store/composer-popout.ts
+++ b/apps/desktop/src/store/composer-popout.ts
@@ -1,14 +1,17 @@
import { atom, computed, type ReadableAtom } from 'nanostores'
-import { persistString, storedString } from '@/lib/storage'
+import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage'
const POPOUT_STORAGE_KEY = 'hermes.desktop.composerPopout.zones.v1'
+const POPOUT_GESTURES_ENABLED_STORAGE_KEY = 'hermes.desktop.composerPopout.gesturesEnabled'
// Pre-zone keys: one flag + one position for the whole window. Read at load to
// seed the first zone the user touches (see `legacySeed`), never written again.
const LEGACY_ENABLED_KEY = 'hermes.desktop.composerPopout.enabled'
const LEGACY_POSITION_KEY = 'hermes.desktop.composerPopout.position'
+const gesturesEnabledAtLoad = storedBoolean(POPOUT_GESTURES_ENABLED_STORAGE_KEY, true)
+
/** Where the floating composer's bottom-right corner sits, measured as an inset
* from the viewport's bottom/right edges. Anchoring to the bottom-right keeps
* the box visually pinned to its default corner as the window resizes and as
@@ -63,7 +66,7 @@ function load(): Record {
const zone = value as null | Partial
if (typeof zone?.poppedOut === 'boolean' && isPosition(zone.position)) {
- out[id] = { poppedOut: zone.poppedOut, position: { ...zone.position } }
+ out[id] = { poppedOut: gesturesEnabledAtLoad && zone.poppedOut, position: { ...zone.position } }
}
}
} catch {
@@ -80,6 +83,7 @@ function load(): Record {
* put it), while a split zone beside them keeps its own — popping out on the
* left doesn't fling a composer out of the right. */
export const $composerPopoutZones = atom>(load())
+export const $composerPopoutGesturesEnabled = atom(gesturesEnabledAtLoad)
/** Write-through to storage. Called explicitly — NOT on every store change: a
* drag updates the position once per frame, and serializing every zone to
@@ -91,7 +95,9 @@ const persistZones = () => persistString(POPOUT_STORAGE_KEY, JSON.stringify($com
* — but ONLY until they touch any zone. Once real per-zone state exists, that
* is the truth, and a zone split later starts docked like any other. */
let legacySeed: PopoutZoneState | null =
- storedString(LEGACY_ENABLED_KEY) === 'true' ? { poppedOut: true, position: legacyPosition() } : null
+ gesturesEnabledAtLoad && storedString(LEGACY_ENABLED_KEY) === 'true'
+ ? { poppedOut: true, position: legacyPosition() }
+ : null
const zoneState = (zones: Record, groupId: string): PopoutZoneState =>
zones[groupId] ?? legacySeed ?? DEFAULT_ZONE
@@ -223,6 +229,32 @@ export function setComposerPoppedOut(groupId: string, value: boolean) {
persistZones()
}
+export function setComposerPopoutGesturesEnabled(value: boolean) {
+ $composerPopoutGesturesEnabled.set(value)
+ persistBoolean(POPOUT_GESTURES_ENABLED_STORAGE_KEY, value)
+
+ if (value) {
+ return
+ }
+
+ // Turning the feature off is an immediate lock-to-dock action for every
+ // layout zone, not merely a promise to ignore the next gesture. Keep each
+ // zone's resting position so opting back in restores where the user put it.
+ const zones = $composerPopoutZones.get()
+
+ const docked = Object.fromEntries(
+ Object.entries(zones).map(([groupId, zone]) => [groupId, { ...zone, poppedOut: false }])
+ )
+
+ legacySeed = null
+ $composerPopoutZones.set(docked)
+ persistZones()
+
+ // Neutralize the pre-zone singleton migration seed too, otherwise a reload
+ // with no materialized zones could revive an old floating composer.
+ persistBoolean(LEGACY_ENABLED_KEY, false)
+}
+
/** Move this zone's box. Used per-frame during a drag, so it only writes the
* in-memory store by default; pass `persist` for the resting position on
* release. Returns the clamped position so callers can sync their live ref. */
diff --git a/apps/desktop/src/store/composer-suggestions.test.ts b/apps/desktop/src/store/composer-suggestions.test.ts
index 2dc3d6337b..e76c56a2f4 100644
--- a/apps/desktop/src/store/composer-suggestions.test.ts
+++ b/apps/desktop/src/store/composer-suggestions.test.ts
@@ -80,4 +80,47 @@ describe('composer suggestion bus', () => {
offerSuggestions('s6', 'test', [])
})
+
+ it('replaces an offer whose rendered copy changed under the same key', () => {
+ offerSuggestions('s7', 'test', [{ ...suggestion('linear'), tip: 'because you mentioned “linear”' }])
+ offerSuggestions('s7', 'test', [{ ...suggestion('linear'), tip: 'because you pasted linear.app' }])
+
+ // Same key, new trigger — the strip must paint the new reason, not the
+ // first one it ever saw.
+ expect(($composerSuggestionsBySession.get().s7 ?? []).map(s => s.tip)).toEqual(['because you pasted linear.app'])
+
+ offerSuggestions('s7', 'test', [])
+ })
+
+ it('re-offering the same key swaps in the fresh invoke closure', async () => {
+ const calls: string[] = []
+
+ const offer = (tag: string) =>
+ offerSuggestions('s8', 'test', [
+ { ...suggestion('linear'), invoke: async () => void calls.push(tag), label: `Add linear ${tag}` }
+ ])
+
+ offer('first')
+ offer('second')
+
+ await ($composerSuggestionsBySession.get().s8 ?? [])[0]!.invoke({ cancelled: () => false, sessionId: 's8' })
+
+ // A pinned first object means the pill runs work built for a draft the
+ // user has since changed.
+ expect(calls).toEqual(['second'])
+
+ offerSuggestions('s8', 'test', [])
+ })
+
+ it('keeps the array reference when nothing the pill paints changed', () => {
+ offerSuggestions('s9', 'test', [suggestion('linear')])
+
+ const first = $composerSuggestionsBySession.get().s9
+
+ offerSuggestions('s9', 'test', [suggestion('linear')])
+
+ expect($composerSuggestionsBySession.get().s9).toBe(first)
+
+ offerSuggestions('s9', 'test', [])
+ })
})
diff --git a/apps/desktop/src/store/composer-suggestions.ts b/apps/desktop/src/store/composer-suggestions.ts
index d0724ddd88..a241c3395a 100644
--- a/apps/desktop/src/store/composer-suggestions.ts
+++ b/apps/desktop/src/store/composer-suggestions.ts
@@ -67,8 +67,31 @@ export const $composerSuggestionsBySession = atom sessionId ?? ''
+// Everything the pill actually paints. Compared field-by-field rather than by
+// key alone: a provider rebuilds its suggestion objects on every sample, so
+// key equality is true constantly, and treating that as "no change" pins the
+// FIRST object forever — the strip then paints a stale tip and, worse, calls a
+// stale `invoke` closure. Comparing the rendered copy keeps the cheap bail-out
+// for the common case (same draft, same match) while letting a genuinely
+// changed offer through.
+const RENDERED: readonly (keyof ComposerSuggestion)[] = [
+ 'brand',
+ 'doneLabel',
+ 'doneTip',
+ 'icon',
+ 'label',
+ 'tip',
+ 'workingLabel',
+ 'workingTip'
+]
+
const sameSuggestions = (a: readonly ComposerSuggestion[], b: readonly ComposerSuggestion[]) =>
- a.length === b.length && a.every((x, i) => suggestionKey(x) === suggestionKey(b[i]!))
+ a.length === b.length &&
+ a.every((x, i) => {
+ const y = b[i]!
+
+ return suggestionKey(x) === suggestionKey(y) && RENDERED.every(field => x[field] === y[field])
+ })
function write(sessionId: string | null | undefined, suggestions: ComposerSuggestion[]): void {
const key = keyFor(sessionId)
diff --git a/apps/desktop/src/store/composer.test.ts b/apps/desktop/src/store/composer.test.ts
index d5445ea64c..0fc034b1d9 100644
--- a/apps/desktop/src/store/composer.test.ts
+++ b/apps/desktop/src/store/composer.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import {
$composerAttachments,
@@ -6,6 +6,8 @@ import {
addComposerAttachment,
clearSessionDraft,
type ComposerAttachment,
+ createComposerAttachmentOccurrenceId,
+ createComposerAttachmentScope,
migrateSessionDraft,
removeComposerAttachment,
requestVoiceConversationStart,
@@ -61,6 +63,147 @@ describe('updateComposerAttachment', () => {
expect(updated).toBe(false)
expect($composerAttachments.get()).toHaveLength(0)
})
+
+ it('updates only the exact attachment occurrence captured before an async operation', () => {
+ const scope = createComposerAttachmentScope()
+
+ const first = attachment({
+ id: 'image:a',
+ kind: 'image',
+ occurrenceId: createComposerAttachmentOccurrenceId(),
+ path: '/tmp/a.png'
+ })
+
+ const replacement = attachment({
+ id: 'image:a',
+ kind: 'image',
+ occurrenceId: createComposerAttachmentOccurrenceId(),
+ path: '/tmp/a.png'
+ })
+
+ scope.add(first)
+ scope.remove(first.id)
+ scope.add(replacement)
+
+ expect(scope.updateIfCurrent(first, { thumbnailUrl: 'data:image/png;base64,stale' })).toBe(false)
+ expect(scope.$attachments.get()).toEqual([replacement])
+ expect(scope.updateIfCurrent(replacement, { thumbnailUrl: 'data:image/png;base64,current' })).toBe(true)
+ expect(scope.$attachments.get()[0]?.thumbnailUrl).toBe('data:image/png;base64,current')
+ })
+
+ it('recognizes the same attachment occurrence after a session-draft clone', () => {
+ const scope = createComposerAttachmentScope()
+
+ const original = attachment({
+ id: 'image:draft',
+ kind: 'image',
+ occurrenceId: createComposerAttachmentOccurrenceId(),
+ path: '/tmp/draft.png'
+ })
+
+ stashSessionDraft('session-a', '', [original])
+ const restored = takeSessionDraft('session-a').attachments[0]!
+ scope.add(restored)
+
+ expect(restored).not.toBe(original)
+ expect(scope.updateIfCurrent(original, { thumbnailUrl: 'data:image/png;base64,current' })).toBe(true)
+ expect(scope.$attachments.get()[0]?.thumbnailUrl).toBe('data:image/png;base64,current')
+ clearSessionDraft('session-a')
+ })
+
+ it('merges concurrent staging fields without discarding an existing thumbnail', () => {
+ const scope = createComposerAttachmentScope()
+
+ const original = attachment({
+ id: 'image:staging',
+ kind: 'image',
+ occurrenceId: createComposerAttachmentOccurrenceId(),
+ path: 'C:\\Users\\alice\\Pictures\\photo.png'
+ })
+
+ scope.add(original)
+ expect(scope.updateIfCurrent(original, { thumbnailUrl: 'data:image/png;base64,current' })).toBe(true)
+ expect(
+ scope.updateIfCurrent(original, {
+ attachedSessionId: 'session-1',
+ path: '/root/.hermes/attachments/photo.png',
+ uploadState: undefined
+ })
+ ).toBe(true)
+
+ expect(scope.$attachments.get()[0]).toMatchObject({
+ attachedSessionId: 'session-1',
+ path: '/root/.hermes/attachments/photo.png',
+ thumbnailUrl: 'data:image/png;base64,current'
+ })
+ })
+
+ it('removes submitted occurrences while preserving unrelated attachments', () => {
+ const scope = createComposerAttachmentScope()
+
+ const submitted = attachment({
+ id: 'image:submitted',
+ kind: 'image',
+ occurrenceId: 'occurrence-submitted'
+ })
+
+ const other = attachment({ id: 'file:other', occurrenceId: 'occurrence-other' })
+
+ scope.add(submitted)
+ scope.add(other)
+ scope.removeOccurrences([submitted])
+
+ expect(scope.$attachments.get()).toEqual([other])
+ })
+
+ it('preserves a same-id replacement of a submitted occurrence', () => {
+ const scope = createComposerAttachmentScope()
+
+ const submitted = attachment({
+ id: 'image:submitted',
+ kind: 'image',
+ occurrenceId: 'occurrence-submitted'
+ })
+
+ const replacement = attachment({
+ ...submitted,
+ occurrenceId: 'occurrence-replacement'
+ })
+
+ scope.add(replacement)
+ scope.removeOccurrences([submitted])
+
+ expect(scope.$attachments.get()).toEqual([replacement])
+ })
+
+ it('preserves a newer same-id legacy attachment and still emits on successful cleanup', () => {
+ const scope = createComposerAttachmentScope()
+ const submitted = attachment({ id: 'url:https://example.com', kind: 'url', label: 'old' })
+ const replacement = attachment({ id: submitted.id, kind: 'url', label: 'new' })
+ const listener = vi.fn()
+ const unlisten = scope.$attachments.listen(listener)
+
+ scope.add(submitted)
+ scope.remove(submitted.id)
+ scope.add(replacement)
+ listener.mockClear()
+
+ scope.removeOccurrences([submitted])
+
+ expect(scope.$attachments.get()).toEqual([replacement])
+ expect(listener).toHaveBeenCalledTimes(1)
+ unlisten()
+ })
+
+ it('removes the exact submitted legacy attachment', () => {
+ const scope = createComposerAttachmentScope()
+ const submitted = attachment({ id: 'url:https://example.com', kind: 'url' })
+
+ scope.add(submitted)
+ scope.removeOccurrences([submitted])
+
+ expect(scope.$attachments.get()).toEqual([])
+ })
})
describe('session drafts', () => {
diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts
index 1b6847a537..81cb6e5129 100644
--- a/apps/desktop/src/store/composer.ts
+++ b/apps/desktop/src/store/composer.ts
@@ -5,11 +5,19 @@ import { triggerHaptic } from '@/lib/haptics'
export interface ComposerAttachment {
id: string
+ /** Renderer-lifetime identity for one attachment occurrence. Unlike `id`,
+ * which is content/path-derived, this survives draft cloning but changes
+ * when the user removes and re-adds the same attachment. */
+ occurrenceId?: string
kind: 'file' | 'folder' | 'image' | 'review' | 'terminal' | 'url'
label: string
detail?: string
refText?: string
+ /** Legacy/on-demand full source. New local image chips omit this and read
+ * `path` only when the lightbox opens, avoiding retained multi-MB base64. */
previewUrl?: string
+ /** Downscaled data URL for the attachment card and optimistic bubble only. */
+ thumbnailUrl?: string
path?: string
attachedSessionId?: string
/** Set while the file/image bytes are being staged into the session
@@ -18,6 +26,8 @@ export interface ComposerAttachment {
uploadState?: 'uploading' | 'error'
}
+export type ComposerAttachmentPatch = Partial>
+
export const $composerDraft = atom('')
export const $composerAttachments = atom([])
export const $composerTerminalSelections = atom>({})
@@ -27,6 +37,7 @@ export const $composerTerminalSelections = atom>({})
export const $voiceConversationStartRequest = atom(0)
let nextVoiceStartRequest = 0
let handledVoiceStartRequest = 0
+export const createComposerAttachmentOccurrenceId = (): string => crypto.randomUUID()
export const requestVoiceConversationStart = (): void => $voiceConversationStartRequest.set(++nextVoiceStartRequest)
@@ -53,8 +64,18 @@ export interface ComposerAttachmentScope {
add(attachment: ComposerAttachment): void
clear(): void
remove(id: string): ComposerAttachment | null
+ removeOccurrences(attachments: readonly ComposerAttachment[]): void
setUploadState(id: string, uploadState?: ComposerAttachment['uploadState']): void
update(attachment: ComposerAttachment): boolean
+ updateIfCurrent(expected: ComposerAttachment, patch: ComposerAttachmentPatch): boolean
+}
+
+function attachmentOccurrenceIndex(attachments: ComposerAttachment[], expected: ComposerAttachment): number {
+ return attachments.findIndex(item =>
+ expected.occurrenceId === undefined
+ ? item === expected
+ : item.id === expected.id && item.occurrenceId === expected.occurrenceId
+ )
}
export function createComposerAttachmentScope($attachments = atom([])): ComposerAttachmentScope {
@@ -79,6 +100,28 @@ export function createComposerAttachmentScope($attachments = atom attachment.occurrenceId !== undefined)
+ .map(attachment => `${attachment.id}\u0000${attachment.occurrenceId}`)
+ )
+
+ const submittedLegacy = new Set(attachments.filter(attachment => attachment.occurrenceId === undefined))
+
+ const next = current.filter(attachment =>
+ attachment.occurrenceId === undefined
+ ? !submittedLegacy.has(attachment)
+ : !submittedOccurrences.has(`${attachment.id}\u0000${attachment.occurrenceId}`)
+ )
+
+ // Preserve clear()'s notification semantics even when no captured
+ // occurrence remains. Some composer consumers settle local state on the
+ // successful-submit store emission.
+ $attachments.set(next)
+ },
setUploadState(id, uploadState) {
const current = $attachments.get()
const index = current.findIndex(attachment => attachment.id === id)
@@ -103,6 +146,20 @@ export function createComposerAttachmentScope($attachments = atom(loadPersistedDraftTexts())
+/**
+ * Patch one asynchronous attachment occurrence wherever the main composer owns
+ * it. During a session switch the occurrence moves from the live atom into the
+ * per-session in-memory draft stash; a preview may finish on either side of
+ * that handoff. Updating both stores is safe because occurrence ids are unique,
+ * and merging into the latest object preserves concurrent staging metadata.
+ */
+export function patchMainComposerAttachmentOccurrence(
+ expected: ComposerAttachment,
+ patch: ComposerAttachmentPatch
+): boolean {
+ let updated = mainComposerScope.updateIfCurrent(expected, patch)
+
+ for (const [key, draft] of draftsBySession) {
+ const index = attachmentOccurrenceIndex(draft.attachments, expected)
+
+ if (index < 0) {
+ continue
+ }
+
+ const attachments = [...draft.attachments]
+ attachments[index] = { ...attachments[index]!, ...patch }
+ draftsBySession.set(key, { ...draft, attachments })
+ updated = true
+ }
+
+ return updated
+}
+
/**
* What each unsent draft would be called, keyed the same way its text is.
*
diff --git a/apps/desktop/src/store/cron.test.ts b/apps/desktop/src/store/cron.test.ts
new file mode 100644
index 0000000000..2e69343548
--- /dev/null
+++ b/apps/desktop/src/store/cron.test.ts
@@ -0,0 +1,39 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import { $cronJobs, beginCronJobsRequest, commitCronJobsRequest, setCronJobs, updateCronJobs } from './cron'
+
+const oldJob = { id: 'old' } as never
+const newJob = { id: 'new' } as never
+
+describe('cron jobs request fencing', () => {
+ beforeEach(() => {
+ setCronJobs([])
+ })
+
+ it('rejects an older refresh after a newer refresh commits', () => {
+ const older = beginCronJobsRequest('all')
+ const newer = beginCronJobsRequest('all')
+
+ expect(commitCronJobsRequest(newer, [newJob])).toBe(true)
+ expect(commitCronJobsRequest(older, [oldJob])).toBe(false)
+ expect($cronJobs.get()).toEqual([newJob])
+ })
+
+ it('rejects a refresh from the previous profile scope', () => {
+ const work = beginCronJobsRequest('work')
+
+ beginCronJobsRequest('personal')
+
+ expect(commitCronJobsRequest(work, [oldJob])).toBe(false)
+ expect($cronJobs.get()).toEqual([])
+ })
+
+ it('rejects an in-flight poll after a local mutation', () => {
+ const poll = beginCronJobsRequest('all')
+
+ updateCronJobs(() => [newJob])
+
+ expect(commitCronJobsRequest(poll, [oldJob])).toBe(false)
+ expect($cronJobs.get()).toEqual([newJob])
+ })
+})
diff --git a/apps/desktop/src/store/cron.ts b/apps/desktop/src/store/cron.ts
index f017f60dc1..b6df8cb0fc 100644
--- a/apps/desktop/src/store/cron.ts
+++ b/apps/desktop/src/store/cron.ts
@@ -6,11 +6,81 @@ import type { CronJob } from '@/types/hermes'
// the job — schedule, state, live next-run countdown — makes the job the
// first-class entity; its runs (sessions) resolve under it in the cron detail.
export const $cronJobs = atom([])
-export const setCronJobs = (jobs: CronJob[]) => $cronJobs.set(jobs)
+
+export interface CronJobsRequest {
+ generation: number
+ scope: string
+}
+
+export interface CronJobsScopeToken {
+ generation: number
+ scope: string
+}
+
+let cronJobsRequestGeneration = 0
+let cronJobsRequestScope = ''
+let cronJobsScopeGeneration = 0
+
+function activateCronJobsScope(scope: string): void {
+ if (scope === cronJobsRequestScope) {
+ return
+ }
+
+ cronJobsRequestScope = scope
+ cronJobsRequestGeneration += 1
+ cronJobsScopeGeneration += 1
+}
+
+export function beginCronJobsRequest(scope: string): CronJobsRequest {
+ activateCronJobsScope(scope)
+ cronJobsRequestGeneration += 1
+
+ return { generation: cronJobsRequestGeneration, scope }
+}
+
+export function beginCronJobsAction(scope: string): CronJobsScopeToken {
+ activateCronJobsScope(scope)
+
+ return { generation: cronJobsScopeGeneration, scope }
+}
+
+export function isCronJobsScopeCurrent(token: CronJobsScopeToken): boolean {
+ return token.scope === cronJobsRequestScope && token.generation === cronJobsScopeGeneration
+}
+
+export function isCronJobsRequestCurrent(request: CronJobsRequest): boolean {
+ return request.scope === cronJobsRequestScope && request.generation === cronJobsRequestGeneration
+}
+
+export function invalidateCronJobsRequests(): void {
+ cronJobsRequestGeneration += 1
+ cronJobsScopeGeneration += 1
+}
+
+export function commitCronJobsRequest(request: CronJobsRequest, jobs: CronJob[]): boolean {
+ if (!isCronJobsRequestCurrent(request)) {
+ return false
+ }
+
+ // Consume the token so neither a duplicate completion nor any older request
+ // can publish after this authoritative snapshot.
+ cronJobsRequestGeneration += 1
+ $cronJobs.set(jobs)
+
+ return true
+}
+
+export const setCronJobs = (jobs: CronJob[]) => {
+ cronJobsRequestGeneration += 1
+ $cronJobs.set(jobs)
+}
// In-place edit so the cron overlay's mutations (create/edit/delete/pause/…)
// land in the same atom the sidebar renders — no stale list until the next poll.
-export const updateCronJobs = (fn: (jobs: CronJob[]) => CronJob[]) => $cronJobs.set(fn($cronJobs.get()))
+export const updateCronJobs = (fn: (jobs: CronJob[]) => CronJob[]) => {
+ cronJobsRequestGeneration += 1
+ $cronJobs.set(fn($cronJobs.get()))
+}
// One-shot focus target: clicking "Manage" on a job sets this, then opens the
// cron overlay, which reads it once to select + scroll to that job. Cleared
diff --git a/apps/desktop/src/store/disable-f12.ts b/apps/desktop/src/store/disable-f12.ts
new file mode 100644
index 0000000000..5686cfc395
--- /dev/null
+++ b/apps/desktop/src/store/disable-f12.ts
@@ -0,0 +1,26 @@
+/**
+ * Disable F12 DevTools shortcut — a device-local preference.
+ *
+ * When enabled, F12 is blocked in the main process (before-input-event).
+ * Ctrl+Shift+I (or Cmd+Opt+I on Mac) still works regardless.
+ * Mirrors the toggle to the main process via IPC.
+ */
+
+import { atom } from 'nanostores'
+
+import { persistBoolean, storedBoolean } from '@/lib/storage'
+
+const KEY = 'hermes.desktop.disableF12.v1'
+
+export const $disableF12 = atom(typeof window === 'undefined' ? false : storedBoolean(KEY, false))
+
+export function setDisableF12(on: boolean): void {
+ $disableF12.set(on)
+}
+
+if (typeof window !== 'undefined') {
+ $disableF12.subscribe(on => {
+ persistBoolean(KEY, on)
+ window.hermesDesktop?.setDisableF12?.(on)
+ })
+}
diff --git a/apps/desktop/src/store/display-timestamps.ts b/apps/desktop/src/store/display-timestamps.ts
new file mode 100644
index 0000000000..75e30673f9
--- /dev/null
+++ b/apps/desktop/src/store/display-timestamps.ts
@@ -0,0 +1,20 @@
+/**
+ * `display.timestamps` — one config key for message timestamps everywhere.
+ *
+ * The same config.yaml key that puts [HH:MM] stamps on classic-CLI labels
+ * gates the desktop transcript's per-message / per-tool-run timeline
+ * timestamps (#41531, #65272, #68052). Off by default, matching the CLI
+ * default in hermes_cli/config_defaults.py.
+ *
+ * Display-only: reading or toggling it never mutates model context, so it is
+ * prompt-cache safe. Hover tooltips with the exact time (#70450) are NOT
+ * gated — they add no visual noise until the user asks for them.
+ */
+
+import { atom } from 'nanostores'
+
+export const $displayTimestamps = atom(false)
+
+export function setDisplayTimestampsFromConfig(value: unknown): void {
+ $displayTimestamps.set(value === true || value === 'true' || value === 1)
+}
diff --git a/apps/desktop/src/store/find-in-page.ts b/apps/desktop/src/store/find-in-page.ts
index 463209cf08..037d4daa30 100644
--- a/apps/desktop/src/store/find-in-page.ts
+++ b/apps/desktop/src/store/find-in-page.ts
@@ -1,5 +1,12 @@
import { atom } from 'nanostores'
+import {
+ captureFindScope,
+ currentFindScope,
+ performScopedFind,
+ releaseFindScope
+} from '@/lib/find-in-page-scope'
+
export interface FindInPageState {
active: boolean
query: string
@@ -11,61 +18,104 @@ const EMPTY: FindInPageState = { active: false, query: '', matchOrdinal: 0, matc
export const $findInPage = atom({ ...EMPTY })
+/**
+ * Open the find bar and capture the CURRENT VIEW as the search scope.
+ *
+ * Capturing once at open time (rather than re-resolving on every keystroke)
+ * means a mid-search route change can't silently re-home the highlights onto
+ * a different session — the FindBar's `useLocation` cleanup closes the bar
+ * before the route flips, so the scope the user actually sees in the input
+ * is the only one ever searched. See apps/desktop/src/components/find-bar.tsx
+ * for the route-change close logic; see lib/find-in-page-scope.ts for the
+ * "current view" predicate (#81726).
+ */
export function openFindBar(): void {
$findInPage.set({ ...EMPTY, active: true })
+ captureFindScope()
}
export function closeFindBar(): void {
- // Already closed: don't re-issue stopFindInPage. Escape is a shared gesture
- // (the switcher and dialogs claim it too), so a stray second close must not
- // reach into Electron again.
+ // Already closed: don't re-issue clear. Escape is a shared gesture (the
+ // switcher and dialogs claim it too), so a stray second close must not
+ // re-strip highlights from a bar that has already been torn down.
if (!$findInPage.get().active) {
return
}
$findInPage.set({ ...EMPTY })
- // Clears both the search and the native highlight/selection in the page.
- void window.hermesDesktop?.stopFindInPage()
+ // Strip highlights and the scope marker from the DOM we previously wrapped.
+ releaseFindScope()
}
-export function setFindQuery(query: string): void {
+export async function setFindQuery(query: string): Promise {
const prev = $findInPage.get()
// Never search for a closed bar. The component clears its debounce on
// close, but a timer that already fired (or any late caller) must not
- // re-issue a find and re-highlight the page after the user pressed Escape.
+ // re-wrap matches after the user pressed Escape.
if (!prev.active) {
return
}
if (!query) {
$findInPage.set({ ...prev, query: '', matchOrdinal: 0, matchCount: 0 })
- void window.hermesDesktop?.stopFindInPage()
+ const scope = currentFindScope()
+
+ if (scope) {
+ // Re-run the scoped walker with an empty query — same code path,
+ // strips highlights + zeroes the counter without special-casing.
+ performScopedFind(scope, '', { forward: true, findNext: false })
+ }
return
}
- $findInPage.set({ ...prev, query })
- void window.hermesDesktop?.findInPage(query, { forward: true, findNext: false })
+ const scope = currentFindScope()
+
+ if (!scope) {
+ // No chat surface to search (e.g. settings page, command center). The
+ // bar still accepts a query for parity with the bridge-driven path, but
+ // matches will be zero — there's nothing on screen that IS a "view".
+ $findInPage.set({ ...prev, query, matchOrdinal: 0, matchCount: 0 })
+
+ return
+ }
+
+ const result = performScopedFind(scope, query, { forward: true, findNext: false })
+
+ $findInPage.set({ ...prev, query, matchOrdinal: result.activeOrdinal, matchCount: result.count })
}
export function findNext(): void {
- const { query } = $findInPage.get()
-
- if (query) {
- void window.hermesDesktop?.findInPage(query, { forward: true, findNext: true })
- }
+ step(true)
}
export function findPrevious(): void {
- const { query } = $findInPage.get()
-
- if (query) {
- void window.hermesDesktop?.findInPage(query, { forward: false, findNext: true })
- }
+ step(false)
}
-/** Called by the preload bridge when `found-in-page` fires on webContents. */
+function step(forward: boolean): void {
+ const { query } = $findInPage.get()
+
+ if (!query) {
+ return
+ }
+
+ const scope = currentFindScope()
+
+ if (!scope) {
+ return
+ }
+
+ const result = performScopedFind(scope, query, { forward, findNext: true })
+
+ $findInPage.set({ ...$findInPage.get(), matchOrdinal: result.activeOrdinal, matchCount: result.count })
+}
+
+/** Called by the preload bridge when `found-in-page` fires on webContents.
+ * Retained for the multi-window case (a secondary session window still uses
+ * the Electron bridge — see electron/find-in-page.ts); the renderer-side
+ * walker for the primary window never fires this. */
export function updateFindResults(activeMatch: number, count: number): void {
const prev = $findInPage.get()
$findInPage.set({ ...prev, matchOrdinal: activeMatch, matchCount: count })
@@ -84,6 +134,10 @@ let detachListener: (() => void) | undefined
* Subscribe to `found-in-page` results. Returns a release fn; the underlying
* bridge listener is installed on the first subscriber and removed when the
* last one releases. Safe to call from an effect with a `[]` dep list.
+ *
+ * Kept for secondary-window renderers that still drive search via the
+ * Electron bridge. The primary window's renderer-side walker calls
+ * `updateFindResults` synchronously and never wires this listener.
*/
export function initFindInPageListener(): () => void {
listenerRefs += 1
@@ -128,3 +182,50 @@ export function resetFindInPageListenerForTest(): void {
detachListener = undefined
listenerRefs = 0
}
+
+// Same refcount pattern as `initFindInPageListener`, but for the
+// "main-process Ctrl/Cmd+F forwarded to renderer" channel. On Pop!_OS /
+// GNOME-based Linux distros the GTK compositor grabs Ctrl+F before the
+// renderer's keydown listener can fire — the main process intercepts the
+// chord via `before-input-event` and emits this IPC, so the renderer can
+// still open the FindBar (#81727).
+let openFindBarRefs = 0
+let detachOpenFindBar: (() => void) | undefined
+
+export function initOpenFindBarListener(): () => void {
+ openFindBarRefs += 1
+
+ if (openFindBarRefs === 1) {
+ detachOpenFindBar = window.hermesDesktop?.onOpenFindBarRequested?.(() => {
+ openFindBar()
+ })
+ }
+
+ let released = false
+
+ return () => {
+ if (released) {
+ return
+ }
+
+ released = true
+ openFindBarRefs -= 1
+
+ if (openFindBarRefs === 0) {
+ detachOpenFindBar?.()
+ detachOpenFindBar = undefined
+ }
+ }
+}
+
+/** Test seam: number of live "open find bar" subscriptions. */
+export function openFindBarListenerCount(): number {
+ return openFindBarRefs
+}
+
+/** Test seam: detach the open-find-bar bridge listener and zero the refcount. */
+export function resetOpenFindBarListenerForTest(): void {
+ detachOpenFindBar?.()
+ detachOpenFindBar = undefined
+ openFindBarRefs = 0
+}
diff --git a/apps/desktop/src/store/gateway-shared-remote.test.ts b/apps/desktop/src/store/gateway-shared-remote.test.ts
new file mode 100644
index 0000000000..7d3d33c501
--- /dev/null
+++ b/apps/desktop/src/store/gateway-shared-remote.test.ts
@@ -0,0 +1,141 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+// The global-remote share (backend routing case 3): every profile is served
+// by the PRIMARY backend over one host, and getConnection() explicitly tags
+// the shared descriptor with `sharedPrimary`. Dialing a second WebSocket at it
+// used to fail over SSH (per-backend tunnel/ticket) and poison the active
+// gateway with a closed socket — "Hermes gateway is not connected" for every
+// profile except the primary. Pooled backends (own-remote override, local
+// named profile) also carry `profile` for WS URL minting, so `profile` alone
+// cannot identify the shared-primary route. These tests pin the fix: only a
+// `sharedPrimary` descriptor activates the primary socket; a pooled descriptor
+// that also carries `profile` must still dial its own socket.
+
+const gatewayMocks = vi.hoisted(() => ({
+ connect: vi.fn(async (_wsUrl: string): Promise => {
+ throw new Error('dialed a socket for a shared-primary profile')
+ }),
+ setConnection: vi.fn()
+}))
+
+vi.mock('@/hermes', () => ({
+ HermesGateway: class {
+ connectionState = 'closed'
+ connect = async (wsUrl: string): Promise => {
+ await gatewayMocks.connect(wsUrl)
+ this.connectionState = 'open'
+ }
+ close = vi.fn()
+ onEvent = vi.fn(() => () => {})
+ onState = vi.fn(() => () => {})
+ }
+}))
+vi.mock('@/store/session', () => ({
+ setConnection: gatewayMocks.setConnection,
+ setGatewayState: vi.fn()
+}))
+vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() }))
+
+const {
+ $gateway,
+ closeSecondaryGateways,
+ configureGatewayRegistry,
+ ensureActiveGatewayOpen,
+ ensureGatewayForProfile,
+ setPrimaryGateway
+} = await import('./gateway')
+
+type DesktopStub = { getConnection: ReturnType }
+
+function installDesktop(stub: DesktopStub): void {
+ ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = stub
+}
+
+function makePrimary(): { connectionState: string } {
+ // Only connectionState is consulted by setActive/isOpen for these paths.
+ return { connectionState: 'open' }
+}
+
+beforeEach(() => {
+ configureGatewayRegistry({
+ onEvent: vi.fn(),
+ primaryProfile: 'default'
+ } as never)
+})
+
+afterEach(() => {
+ closeSecondaryGateways()
+ vi.clearAllMocks()
+ delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
+})
+
+describe('ensureGatewayForProfile under a shared global remote', () => {
+ it('activates the primary socket for an explicitly shared-primary descriptor', async () => {
+ const primary = makePrimary()
+ setPrimaryGateway(primary as never, 'default')
+ installDesktop({
+ // Shared descriptor: primary connection tagged with the profile scope
+ // AND the explicit sharedPrimary marker.
+ getConnection: vi.fn(async () => ({ port: 4242, profile: 'venture', sharedPrimary: true, token: 't' }))
+ })
+
+ await ensureGatewayForProfile('venture')
+
+ expect(gatewayMocks.connect).not.toHaveBeenCalled()
+ expect($gateway.get()).toBe(primary)
+ })
+
+ it('dials the exact WebSocket URL for a pooled profile descriptor that carries profile', async () => {
+ const primary = makePrimary()
+ const remoteWsUrl = 'wss://remote.invalid/api/ws?token=fake-test-token'
+
+ setPrimaryGateway(primary as never, 'default')
+ installDesktop({
+ // Pooled descriptor: carries `profile` for WS URL minting but is NOT
+ // shared-primary (no marker) — it must dial its own socket, not reuse
+ // the primary. This is the local named / own-remote profile case.
+ getConnection: vi.fn(async () => ({
+ authMode: 'token',
+ baseUrl: 'https://remote.invalid',
+ mode: 'remote',
+ profile: 'worker',
+ token: 'fake-test-token',
+ wsUrl: remoteWsUrl
+ }))
+ })
+ gatewayMocks.connect.mockResolvedValueOnce(undefined)
+
+ await ensureGatewayForProfile('worker')
+
+ expect(gatewayMocks.connect).toHaveBeenCalledOnce()
+ expect(gatewayMocks.connect).toHaveBeenCalledWith(remoteWsUrl)
+ expect($gateway.get()).not.toBe(primary)
+ })
+
+ it('refreshes the active connection after a pooled profile reconnect succeeds', async () => {
+ const connection = {
+ authMode: 'token',
+ baseUrl: 'https://worker.invalid',
+ mode: 'remote',
+ profile: 'worker',
+ token: 'fake-test-token',
+ wsUrl: 'wss://worker.invalid/api/ws?token=fake-test-token'
+ }
+
+ const getConnection = vi.fn(async () => connection)
+
+ setPrimaryGateway(makePrimary() as never, 'default')
+ installDesktop({ getConnection })
+
+ gatewayMocks.connect.mockRejectedValueOnce(new Error('temporarily offline')).mockResolvedValueOnce(undefined)
+
+ await ensureGatewayForProfile('worker')
+
+ expect(gatewayMocks.setConnection).not.toHaveBeenCalled()
+
+ await ensureActiveGatewayOpen()
+
+ expect(gatewayMocks.setConnection).toHaveBeenCalledOnce()
+ expect(gatewayMocks.setConnection).toHaveBeenCalledWith(connection)
+ })
+})
diff --git a/apps/desktop/src/store/gateway-switch.ts b/apps/desktop/src/store/gateway-switch.ts
index 0338f06bc4..3003be4b32 100644
--- a/apps/desktop/src/store/gateway-switch.ts
+++ b/apps/desktop/src/store/gateway-switch.ts
@@ -58,7 +58,11 @@ export function wipeSessionListsForGatewaySwitch(): void {
setMessagingTruncated(false)
// Clearing $sessionStates automatically clears $workingSessionIds and
// $attentionSessionIds (computed) and $stalledSessionIds (owned beside it).
- // $unreadFinishedSessionIds is separate, so wipe it explicitly.
+ // $unreadFinishedSessionIds is separate, so wipe it explicitly. Only the
+ // transient paint layer is wiped: the persisted markers/watermarks in
+ // session-unread.ts are keyed by durable session id and repaint the rows
+ // that are still unread once the next gateway's lists load — so a profile
+ // round-trip doesn't swallow green dots.
clearAllSessionStates()
resetLiveRuntimeTracking()
resetLiveSync()
diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts
index 149235e42f..8e73a1bfbf 100644
--- a/apps/desktop/src/store/gateway.ts
+++ b/apps/desktop/src/store/gateway.ts
@@ -4,7 +4,7 @@ import { atom } from 'nanostores'
import { HermesGateway } from '@/hermes'
import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff'
import { markNativeNotifyBaseline } from '@/store/notify-baseline'
-import { setGatewayState } from '@/store/session'
+import { setConnection, setGatewayState } from '@/store/session'
// ── Multi-profile gateway routing ──────────────────────────────────────────
// Concurrent sessions across profiles need concurrent sockets: the renderer's
@@ -177,6 +177,11 @@ async function openSecondary(entry: Secondary): Promise {
const conn = await desktop.getConnection(entry.profile)
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await entry.gateway.connect(wsUrl)
+
+ if (g.activeKey === entry.profile) {
+ setConnection(conn)
+ }
+
void desktop.touchBackend?.(entry.profile).catch(() => undefined)
}
@@ -247,6 +252,31 @@ function createSecondary(profile: string): Secondary {
return entry
}
+// True when `profile`'s backend route resolves to the SHARED primary backend
+// (global-remote case 3 in resolveProfileBackendRoute). Both shared-primary and
+// pooled descriptors carry `profile` so WebSocket URL minting targets the right
+// profile. `sharedPrimary` is the explicit discriminator; treating every tagged
+// descriptor as shared strands local/own-remote pooled profiles on the default
+// socket. Dialing a second socket at the shared descriptor is wrong — over SSH
+// the second dial fails (tunnel/token are per-backend) and the closed socket
+// poisons the active gateway with "not connected" even though the primary is
+// open right next to it.
+async function sharedPrimaryRoute(profile: string): Promise {
+ const desktop = window.hermesDesktop
+
+ if (!desktop) {
+ return false
+ }
+
+ try {
+ const conn = await desktop.getConnection(profile)
+
+ return Boolean(conn && typeof conn === 'object' && (conn as { sharedPrimary?: boolean }).sharedPrimary === true)
+ } catch {
+ return false
+ }
+}
+
// Open `profile`'s socket WITHOUT making it active — the hover-intent pre-warm
// (store/profile). Runs the same spawn + connect chain as a real switch, so by
// click time ensureGatewayForProfile finds an open socket and just activates
@@ -260,6 +290,11 @@ export async function openGatewayForProfile(profile: string): Promise {
return
}
+ if (await sharedPrimaryRoute(key)) {
+ // Served by the primary backend — there is no per-profile socket to warm.
+ return
+ }
+
const entry = g.secondaries.get(key) ?? createSecondary(key)
entry.wantOpen = true
@@ -279,6 +314,17 @@ export async function ensureGatewayForProfile(profile: string): Promise {
return
}
+ // Global-remote share (routing case 3): one remote host serves every
+ // profile through the PRIMARY socket, scoped per request. Activate the
+ // primary instead of dialing a doomed duplicate socket at the same
+ // descriptor — $activeGatewayProfile still moves to `key`, so request
+ // scoping and profile-aware surfaces behave identically.
+ if (await sharedPrimaryRoute(key)) {
+ setActive(g.primaryProfile)
+
+ return
+ }
+
let entry = g.secondaries.get(key)
if (!entry) {
diff --git a/apps/desktop/src/store/prompts.test.ts b/apps/desktop/src/store/prompts.test.ts
index a761adf6f2..bbe2d2f2ca 100644
--- a/apps/desktop/src/store/prompts.test.ts
+++ b/apps/desktop/src/store/prompts.test.ts
@@ -10,6 +10,8 @@ import {
clearApprovalRequest,
clearSecretRequest,
clearSudoRequest,
+ receiveApprovalRequest,
+ replayPendingApproval,
setApprovalRequest,
setSecretRequest,
setSudoRequest
@@ -67,6 +69,66 @@ describe('approval prompt store', () => {
expect($approvalRequest.get()?.allowPermanent).toBe(false)
})
+
+ it('correlates clearing to the exact approval request id', () => {
+ setApprovalRequest({ command: 'x', description: 'd', requestId: 'r1', sessionId: 's1' })
+
+ clearApprovalRequest('s1', 'stale')
+ expect($approvalRequest.get()?.requestId).toBe('r1')
+ clearApprovalRequest('s1', 'r1')
+ expect($approvalRequest.get()).toBeNull()
+ })
+
+ it('acknowledges an approval only after parking it', async () => {
+ const calls: Array<[string, Record]> = []
+
+ const gateway = {
+ request: async (method: string, params: Record) => {
+ calls.push([method, params])
+
+ return { acknowledged: true }
+ }
+ }
+
+ await receiveApprovalRequest(gateway, {
+ command: 'x',
+ description: 'd',
+ requestId: 'r1',
+ sessionId: 's1'
+ })
+
+ expect($approvalRequest.get()?.requestId).toBe('r1')
+ expect(calls).toEqual([['approval.received', { request_id: 'r1', session_id: 's1' }]])
+ })
+
+ it('replays and acknowledges the oldest unresolved approval after reconnect', async () => {
+ const calls: Array<[string, Record]> = []
+
+ const gateway = {
+ request: async (method: string, params: Record) => {
+ calls.push([method, params])
+
+ if (method === 'approval.pending') {
+ return {
+ approvals: [
+ { command: 'first', description: 'd1', request_id: 'r1' },
+ { command: 'second', description: 'd2', request_id: 'r2' }
+ ]
+ }
+ }
+
+ return { acknowledged: true }
+ }
+ }
+
+ await replayPendingApproval(gateway, 's1')
+
+ expect($approvalRequest.get()?.requestId).toBe('r1')
+ expect(calls).toEqual([
+ ['approval.pending', { session_id: 's1' }],
+ ['approval.received', { request_id: 'r1', session_id: 's1' }]
+ ])
+ })
})
describe('sudo prompt store', () => {
@@ -144,7 +206,7 @@ describe('$activeSessionAwaitingInput', () => {
clearApprovalRequest('s1')
expect($activeSessionAwaitingInput.get()).toBe(false)
- setClarifyRequest({ choices: null, question: 'q', requestId: 'c1', sessionId: 's1' })
+ setClarifyRequest({ choices: null, multiSelect: false, question: 'q', requestId: 'c1', sessionId: 's1' })
expect($activeSessionAwaitingInput.get()).toBe(true)
})
diff --git a/apps/desktop/src/store/prompts.ts b/apps/desktop/src/store/prompts.ts
index 0efe97515a..da9c7dedb5 100644
--- a/apps/desktop/src/store/prompts.ts
+++ b/apps/desktop/src/store/prompts.ts
@@ -67,18 +67,32 @@ function keyedPromptStore(): PromptStore {
}
}
-// Approval is session-keyed on the backend (one in-flight approval per session,
-// resolved via approval.respond {choice, session_id}). It carries no request_id,
-// unlike sudo/secret which are _block()-style request/response.
+// Approval is session-keyed on the backend and correlated by `request_id` when
+// available (legacy ID-free responses remain FIFO-compatible). Resolved via
+// approval.respond {choice, request_id, session_id}.
export interface ApprovalRequest extends KeyedPrompt {
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
allowPermanent?: boolean
choices?: string[]
command: string
description: string
+ requestId?: string
smartDenied?: boolean
}
+interface ApprovalGateway {
+ request: (method: string, params: Record) => Promise
+}
+
+interface PendingApprovalPayload {
+ allow_permanent?: boolean
+ choices?: unknown
+ command?: unknown
+ description?: unknown
+ request_id?: unknown
+ smart_denied?: boolean
+}
+
export interface SudoRequest extends KeyedPrompt {
requestId: string
}
@@ -101,6 +115,46 @@ export const $approvalRequest = approval.$active
export const setApprovalRequest = approval.set
export const clearApprovalRequest = approval.clear
+export async function receiveApprovalRequest(gateway: ApprovalGateway | null, request: ApprovalRequest): Promise {
+ setApprovalRequest(request)
+
+ if (gateway && request.requestId && request.sessionId) {
+ await gateway.request('approval.received', {
+ request_id: request.requestId,
+ session_id: request.sessionId
+ })
+ }
+}
+
+export async function replayPendingApproval(gateway: ApprovalGateway | null, sessionId: string | null): Promise {
+ if (!gateway || !sessionId) {
+ return
+ }
+
+ const rawResult = await gateway.request('approval.pending', {
+ session_id: sessionId
+ })
+
+ const result =
+ rawResult && typeof rawResult === 'object' ? (rawResult as { approvals?: PendingApprovalPayload[] }) : {}
+
+ const pending = Array.isArray(result?.approvals) ? result.approvals[0] : undefined
+
+ if (!pending || typeof pending.request_id !== 'string') {
+ return
+ }
+
+ await receiveApprovalRequest(gateway, {
+ allowPermanent: pending.allow_permanent !== false,
+ choices: Array.isArray(pending.choices) ? pending.choices.filter(choice => typeof choice === 'string') : undefined,
+ command: typeof pending.command === 'string' ? pending.command : '',
+ description: typeof pending.description === 'string' ? pending.description : 'dangerous command',
+ requestId: pending.request_id,
+ sessionId,
+ smartDenied: pending.smart_denied === true
+ })
+}
+
/** The prompt request for one specific session — the tile counterpart of the
* active-session `$*Request` views (same map, fixed key). */
export const sessionApprovalRequest = (sessionId: string | null) =>
diff --git a/apps/desktop/src/store/reasoning-disclosure.ts b/apps/desktop/src/store/reasoning-disclosure.ts
new file mode 100644
index 0000000000..584c00a57a
--- /dev/null
+++ b/apps/desktop/src/store/reasoning-disclosure.ts
@@ -0,0 +1,14 @@
+import { atom } from 'nanostores'
+
+import { persistBoolean, storedBoolean } from '@/lib/storage'
+
+const REASONING_COLLAPSED_BY_DEFAULT_STORAGE_KEY = 'hermes.desktop.reasoning.collapsedByDefault'
+
+/** Desktop-local presentation preference; shared backend config must not be changed by a single window. */
+export const $reasoningCollapsedByDefault = atom(storedBoolean(REASONING_COLLAPSED_BY_DEFAULT_STORAGE_KEY, false))
+
+$reasoningCollapsedByDefault.subscribe(value => persistBoolean(REASONING_COLLAPSED_BY_DEFAULT_STORAGE_KEY, value))
+
+export function setReasoningCollapsedByDefault(value: boolean) {
+ $reasoningCollapsedByDefault.set(value)
+}
diff --git a/apps/desktop/src/store/session-dot-state.test.ts b/apps/desktop/src/store/session-dot-state.test.ts
index 5e81c3201b..1661c3ba09 100644
--- a/apps/desktop/src/store/session-dot-state.test.ts
+++ b/apps/desktop/src/store/session-dot-state.test.ts
@@ -1,6 +1,13 @@
-import { describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
-import { hasLiveTurn, showsRunningArc } from './session-dot-state'
+import { createClientSessionState } from '@/lib/chat-runtime'
+import type { SessionInfo } from '@/types/hermes'
+
+import { $sessions, $unreadFinishedSessionIds, setSessions } from './session'
+import { $delegatingSessionIds, $sessionDotStateById, hasLiveTurn, showsRunningArc } from './session-dot-state'
+import { clearAllSessionStates, publishSessionState } from './session-states'
+import { $unreadWriteGuard } from './session-unread-remote'
+import { $subagentsBySession, type SubagentProgress } from './subagents'
describe('showsRunningArc', () => {
it('keeps the arc when an authoritative turn goes quiet', () => {
@@ -35,3 +42,109 @@ describe('hasLiveTurn', () => {
expect(hasLiveTurn('unread')).toBe(false)
})
})
+
+describe('$delegatingSessionIds', () => {
+ const subagent = (status: SubagentProgress['status']): SubagentProgress => ({
+ id: 'sub-1',
+ parentId: null,
+ goal: 'do a thing',
+ status,
+ taskCount: 1,
+ taskIndex: 0,
+ startedAt: 0,
+ updatedAt: 0,
+ filesRead: [],
+ filesWritten: [],
+ stream: []
+ })
+
+ afterEach(() => {
+ clearAllSessionStates()
+ $subagentsBySession.set({})
+ $sessions.set([])
+ })
+
+ it('claims the stored id while a subagent is running after the parent turn ended', () => {
+ publishSessionState('runtime-1', { ...createClientSessionState('stored-1'), busy: false })
+ $subagentsBySession.set({ 'runtime-1': [subagent('running')] })
+
+ expect($delegatingSessionIds.get()).toContain('stored-1')
+ })
+
+ it('drops the session once every subagent reaches a terminal status', () => {
+ publishSessionState('runtime-1', { ...createClientSessionState('stored-1'), busy: false })
+ $subagentsBySession.set({ 'runtime-1': [subagent('running')] })
+ $subagentsBySession.set({ 'runtime-1': [subagent('completed')] })
+
+ expect($delegatingSessionIds.get()).not.toContain('stored-1')
+ })
+
+ it('falls back to the runtime id for a not-yet-persisted conversation', () => {
+ $subagentsBySession.set({ 'runtime-fresh': [subagent('queued')] })
+
+ expect($delegatingSessionIds.get()).toContain('runtime-fresh')
+ })
+})
+
+const storedRow = (id: string, extra: Partial = {}): SessionInfo =>
+ ({ id, message_count: 1, source: 'cli', started_at: 0, title: id, ...extra }) as SessionInfo
+
+describe('persisted unread (backend watermark)', () => {
+ beforeEach(() => {
+ clearAllSessionStates()
+ $sessions.set([])
+ $unreadFinishedSessionIds.set([])
+ $unreadWriteGuard.set(new Map())
+ })
+
+ afterEach(() => {
+ clearAllSessionStates()
+ $sessions.set([])
+ $unreadFinishedSessionIds.set([])
+ $unreadWriteGuard.set(new Map())
+ })
+
+ it('claims unread for a session whose row carries unread: true', () => {
+ setSessions([storedRow('s1', { unread: true })])
+
+ expect($sessionDotStateById.get()['s1']).toBe('unread')
+ })
+
+ it('keeps draft weaker than persisted unread', () => {
+ // A blank tile (no busy, no messages, message_count 0) is a draft; the
+ // persisted unread claim must speak over it.
+ setSessions([storedRow('s1', { message_count: 0, unread: true })])
+ publishSessionState('rt1', createClientSessionState('s1'))
+
+ expect($sessionDotStateById.get()['s1']).toBe('unread')
+ })
+
+ it('lets working outrank persisted unread', () => {
+ setSessions([storedRow('s1', { unread: true })])
+ publishSessionState('rt1', { ...createClientSessionState('s1'), busy: true })
+
+ expect($sessionDotStateById.get()['s1']).toBe('working')
+ })
+
+ it('fences a stale page with the write guard', () => {
+ const guard = new Map()
+ guard.set('s1', { at: Date.now(), value: true })
+ $unreadWriteGuard.set(guard)
+
+ // A page issued before our PATCH still says read — keep OUR value.
+ setSessions([storedRow('s1', { unread: false })])
+ expect($sessionDotStateById.get()['s1']).toBe('unread')
+
+ // The guard expires: the page wins and the dot drops.
+ const expired = new Map()
+ expired.set('s1', { at: Date.now() - 60_000, value: true })
+ $unreadWriteGuard.set(expired)
+ expect($sessionDotStateById.get()['s1']).not.toBe('unread')
+ })
+
+ it('leaves a row alone when the backend omits the flag (older runtime)', () => {
+ setSessions([storedRow('s1')])
+
+ expect($sessionDotStateById.get()['s1'] ?? 'idle').not.toBe('unread')
+ })
+})
diff --git a/apps/desktop/src/store/session-dot-state.ts b/apps/desktop/src/store/session-dot-state.ts
index bf94a1658e..f6f30169ff 100644
--- a/apps/desktop/src/store/session-dot-state.ts
+++ b/apps/desktop/src/store/session-dot-state.ts
@@ -15,15 +15,57 @@
*
* The inputs are all reference-stable across stream deltas, so this recomputes
* on status edges rather than per token.
+ *
+ * Unread has TWO sources, both claiming the same state: the runtime marker
+ * (a turn finished in the background while this window wasn't looking at it,
+ * $unreadFinishedSessionIds — transient) and the backend's derived read-state
+ * watermark (row.unread — persists across restarts and is visible to every
+ * surface). The write side of the persisted flag lives in session-unread.ts.
*/
import { computed } from 'nanostores'
-import { stableRecord } from '@/lib/stable-array'
+import { stableArray, stableRecord } from '@/lib/stable-array'
import { $backgroundRunningSessionIds } from './composer-status'
import { $sessions, $unreadFinishedSessionIds, lineageAliases } from './session'
-import { $attentionSessionIds, $draftSessionIds, $stalledSessionIds, $workingSessionIds } from './session-states'
+import {
+ $attentionSessionIds,
+ $draftSessionIds,
+ $sessionStates,
+ $stalledSessionIds,
+ $workingSessionIds
+} from './session-states'
+import { $unreadWriteGuard, UNREAD_WRITE_GUARD_MS } from './session-unread-remote'
+import { $subagentsBySession, activeSubagentCount } from './subagents'
+
+// Sessions parked in async delegation: the parent turn has ended (busy=false —
+// delegate_task(background=true) returns its handle the moment the children
+// are spawned) while those subagents keep working for minutes. Without this
+// input the sidebar row dropped to a plain idle dot mid-delegation, reading as
+// "done" while work was still running in child sessions. Same runtime→stored
+// bridge and fresh-chat fallback as $backgroundRunningSessionIds:
+// $subagentsBySession is keyed by runtime id, surfaces key on stored ids, and
+// lineageAliases covers whichever tip of the conversation a surface holds.
+let delegatingIds: readonly string[] = []
+export const $delegatingSessionIds = computed(
+ [$subagentsBySession, $sessionStates, $sessions],
+ (bySession, states, sessions) => {
+ const ids = new Set()
+
+ for (const [runtimeId, items] of Object.entries(bySession)) {
+ if (activeSubagentCount(items) === 0) {
+ continue
+ }
+
+ for (const alias of lineageAliases(states[runtimeId]?.storedSessionId ?? runtimeId, sessions)) {
+ ids.add(alias)
+ }
+ }
+
+ return (delegatingIds = stableArray(delegatingIds, [...ids]))
+ }
+)
export type SessionDotState = 'background' | 'draft' | 'idle' | 'needs-input' | 'stalled' | 'unread' | 'working'
@@ -63,11 +105,13 @@ export const $sessionDotStateById = computed(
$workingSessionIds,
$stalledSessionIds,
$backgroundRunningSessionIds,
+ $delegatingSessionIds,
$unreadFinishedSessionIds,
$draftSessionIds,
- $sessions
+ $sessions,
+ $unreadWriteGuard
],
- (attention, working, stalled, background, unread, draft, sessions) => {
+ (attention, working, stalled, background, delegating, unread, draft, sessions, unreadWriteGuard) => {
const next: Record = {}
const claim = (ids: readonly string[], state: SessionDotState) => {
@@ -86,7 +130,39 @@ export const $sessionDotStateById = computed(
// the first thing that does happen speaks over it.
claim(draft, 'draft')
claim(unread, 'unread')
+
+ // Persisted read state (backend watermark): a row marked unread keeps the
+ // same emerald dot a background finish would paint, and survives
+ // restarts. Same tier as the runtime marker — both mean "there is
+ // something here you haven't opened". A list page that predates one of
+ // our own writes is fenced out by the write guard: keep OUR value until a
+ // page confirms it or the guard expires.
+ const persistedUnread: string[] = []
+
+ for (const s of sessions) {
+ const entry = unreadWriteGuard.get(s.id)
+
+ if (entry && Date.now() - entry.at < UNREAD_WRITE_GUARD_MS) {
+ if (entry.value) {
+ persistedUnread.push(s.id)
+ }
+
+ continue
+ }
+
+ if (s.unread === true) {
+ persistedUnread.push(s.id)
+ }
+ }
+
+ claim(persistedUnread, 'unread')
+
claim(background, 'background')
+ // Async delegation: the parent turn has ended but its subagents are still
+ // running, so the session's work continues in child sessions. Same visual
+ // claim as background processes — and it yields to `working` below the
+ // moment the parent turn itself is live (synchronous orchestrator children).
+ claim(delegating, 'background')
claim(working, 'working')
// Stalled REFINES working rather than rivalling it — the turn is still
diff --git a/apps/desktop/src/store/session-list-density.test.ts b/apps/desktop/src/store/session-list-density.test.ts
new file mode 100644
index 0000000000..bde020789a
--- /dev/null
+++ b/apps/desktop/src/store/session-list-density.test.ts
@@ -0,0 +1,30 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const loadStore = async () => {
+ vi.resetModules()
+
+ return import('./session-list-density')
+}
+
+describe('session list density preference', () => {
+ beforeEach(() => {
+ window.localStorage.clear()
+ })
+
+ it('defaults to compact (pre-density behavior) and persists changes', async () => {
+ const first = await loadStore()
+
+ expect(first.$sessionListDensity.get()).toBe('compact')
+
+ first.setSessionListDensity('detailed')
+
+ expect(window.localStorage.getItem('hermes.desktop.sessionListDensity')).toBe('detailed')
+ expect((await loadStore()).$sessionListDensity.get()).toBe('detailed')
+ })
+
+ it('falls back to compact for an unknown stored value', async () => {
+ window.localStorage.setItem('hermes.desktop.sessionListDensity', 'tiny')
+
+ expect((await loadStore()).$sessionListDensity.get()).toBe('compact')
+ })
+})
diff --git a/apps/desktop/src/store/session-list-density.ts b/apps/desktop/src/store/session-list-density.ts
new file mode 100644
index 0000000000..a2d0b355a4
--- /dev/null
+++ b/apps/desktop/src/store/session-list-density.ts
@@ -0,0 +1,18 @@
+import { type Codec, persistentAtom } from '@/lib/persisted'
+
+export type SessionListDensity = 'compact' | 'comfortable' | 'detailed'
+
+const STORAGE_KEY = 'hermes.desktop.sessionListDensity'
+
+// Compact is the pre-density row exactly as it shipped, so existing users see
+// no change until they opt into a denser-information mode themselves (#68119).
+const densityCodec: Codec = {
+ decode: raw => (raw === 'comfortable' || raw === 'detailed' ? raw : 'compact'),
+ encode: value => value
+}
+
+export const $sessionListDensity = persistentAtom(STORAGE_KEY, 'compact', densityCodec)
+
+export function setSessionListDensity(density: SessionListDensity) {
+ $sessionListDensity.set(density)
+}
diff --git a/apps/desktop/src/store/session-states-eviction.test.ts b/apps/desktop/src/store/session-states-eviction.test.ts
index 8958df05ee..b82aa79756 100644
--- a/apps/desktop/src/store/session-states-eviction.test.ts
+++ b/apps/desktop/src/store/session-states-eviction.test.ts
@@ -8,8 +8,8 @@ import { $sessionStates, $sessionTiles, closeSessionTile, publishSessionState }
* The closed-tile leak: gateway events keep publishing for sessions whose
* surface is gone, and every parked transcript taxes every later publish (map
* spread + the status projections run per entry per message delta). A settled
- * state nothing references must leave the map; everything a surface still
- * needs must stay.
+ * state nothing references must release its transcript; lightweight status
+ * stays so sidebar projections remain available.
*/
const state = (storedId: string, patch: Partial> = {}) => ({
@@ -28,13 +28,14 @@ beforeEach(() => {
})
describe('publish-time eviction', () => {
- it('evicts a settling session no surface references, keeping its unread dot', () => {
+ it('releases an unreferenced settled transcript while keeping status and its unread dot', () => {
publishSessionState('rt-1', state('stored-1', { busy: true }))
expect($sessionStates.get()['rt-1']).toBeDefined()
publishSessionState('rt-1', state('stored-1', { busy: false }))
- expect($sessionStates.get()['rt-1']).toBeUndefined()
+ expect($sessionStates.get()['rt-1']?.messages).toEqual([])
+ expect($sessionStates.get()['rt-1']).toMatchObject({ storedSessionId: 'stored-1', busy: false })
// The settle transition still fired: the sidebar's unread marker landed.
expect($unreadFinishedSessionIds.get()).toContain('stored-1')
})
@@ -100,8 +101,9 @@ describe('closeSessionTile eviction', () => {
expect($sessionStates.get()['rt-1']).toBeDefined()
- // ... and its settle publish is what evicts it.
+ // ... and its settle publish releases only the heavy transcript.
publishSessionState('rt-1', state('stored-1', { busy: false }))
- expect($sessionStates.get()['rt-1']).toBeUndefined()
+ expect($sessionStates.get()['rt-1']?.messages).toEqual([])
+ expect($sessionStates.get()['rt-1']).toMatchObject({ storedSessionId: 'stored-1', busy: false })
})
})
diff --git a/apps/desktop/src/store/session-states.test.ts b/apps/desktop/src/store/session-states.test.ts
index 3bff4ba520..0abc89bfd5 100644
--- a/apps/desktop/src/store/session-states.test.ts
+++ b/apps/desktop/src/store/session-states.test.ts
@@ -6,16 +6,39 @@ import { $layoutTree } from '@/components/pane-shell/tree/store'
import { $selectedStoredSessionId } from '@/store/session'
import type { SessionTile } from '@/store/session-states'
import {
+ $sessionStates,
blankDraftTile,
focusedSessionNeedsRoute,
markSelectionRestore,
orderTilesByTree,
+ releaseSessionTranscript,
selectionHomesToWorkspace
} from '@/store/session-states'
const tile = (storedSessionId: string): SessionTile => ({ storedSessionId })
const tilePane = (id: string) => `session-tile:${id}`
+describe('releaseSessionTranscript', () => {
+ afterEach(() => {
+ $sessionStates.set({})
+ })
+
+ it('normalizes legacy state whose messages field is undefined', () => {
+ const legacy = { busy: false, storedSessionId: 'stored' } as ClientSessionState
+ $sessionStates.set({ runtime: legacy })
+
+ expect(() => releaseSessionTranscript('runtime')).not.toThrow()
+ expect($sessionStates.get().runtime).toEqual({ ...legacy, messages: [] })
+ })
+
+ it('ignores a legacy undefined state without throwing', () => {
+ $sessionStates.set({ runtime: undefined } as unknown as Record)
+
+ expect(() => releaseSessionTranscript('runtime')).not.toThrow()
+ expect($sessionStates.get()).toHaveProperty('runtime', undefined)
+ })
+})
+
describe('orderTilesByTree', () => {
it('no-ops (null) without a tree or below two tiles', () => {
expect(orderTilesByTree(null, [tile('a'), tile('b')])).toBeNull()
diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts
index 1064b90d7a..108b8d761e 100644
--- a/apps/desktop/src/store/session-states.ts
+++ b/apps/desktop/src/store/session-states.ts
@@ -35,14 +35,17 @@ import type { SessionInfo } from '@/types/hermes'
import { $activeGatewayProfile, normalizeProfileKey } from './profile'
import {
$activeSessionId,
+ $lastReadAtBySessionId,
$selectedStoredSessionId,
$sessions,
- $unreadFinishedSessionIds,
+ clearReadBaseline,
lineageAliases,
+ markSessionRead,
sessionMatchesStoredId,
setActiveSessionStoredIdRotation,
setSessions
} from './session'
+import { ackStoredSessionId, markSessionUnreadFinished } from './session-unread'
import { isSecondaryWindow } from './windows'
// ---------------------------------------------------------------------------
@@ -179,14 +182,27 @@ function handleTransition(previous: ClientSessionState | null, next: ClientSessi
if (next.busy && !wasWorking) {
clearSettled(storedId)
+ // A NEW turn is starting: the read baseline guarded the PREVIOUS
+ // completion's re-asserts. Dropping it here means this turn's finish
+ // re-lights even if it lands within the same millisecond as the last
+ // read (same-tick submit → finish in tests and fast local models).
+ clearReadBaseline(storedId)
} else if (!next.busy && wasWorking) {
markSettled(storedId)
- if (storedId !== $selectedStoredSessionId.get()) {
- const cur = $unreadFinishedSessionIds.get()
+ // FOCUSED, not selected: a session finishing in the tile the user is
+ // watching is already seen, and a tile is never the primary selection.
+ if (storedId !== $focusedStoredSessionId.get()) {
+ // Re-light only genuinely new completions: if the user already viewed
+ // this session (or its family) at or after this settle moment, a
+ // re-assert of the same completion must not re-arm the dot. `-1` for
+ // "never read" (not `0`) so fake-timer tests pinned to t=0 still light.
+ const lastReadAt = $lastReadAtBySessionId.get()[storedId] ?? -1
- if (!cur.includes(storedId)) {
- $unreadFinishedSessionIds.set([...cur, storedId])
+ if (Date.now() > lastReadAt) {
+ // Flags the transient atom AND persists a marker, so the green dot
+ // survives an app restart (see session-unread.ts).
+ markSessionUnreadFinished(storedId)
}
}
}
@@ -228,15 +244,13 @@ function evictable(runtimeId: string, state: ClientSessionState): boolean {
* is updated independently by the caller, so the visual path stays live
* without the store churn.
*
- * A settled state nothing references is EVICTED instead of republished:
- * gateway events keep flowing for sessions whose tile was closed mid-turn,
- * and parking each one's full transcript here forever is the leak that made
- * the app crawl after a day of tile use — every entry taxes every later
- * publish (map spread + the status-set projections). Transition side effects
- * still fire, so the closed session's settle keeps its unread dot. Only an
- * entry already in the map is evicted — a FIRST publish always lands, because
- * a resume can publish its idle state a beat before `$activeSessionId` /
- * the tile's runtime binding points at it. */
+ * A settled state nothing references releases its transcript instead of
+ * republishing it. Gateway events keep flowing for sessions whose tile was
+ * closed mid-turn, and parking each one's full transcript here forever is the
+ * leak that made the app crawl after a day of tile use. Transition side
+ * effects still fire, so lightweight status and the unread dot survive. A
+ * FIRST publish always lands in full because a resume can publish its idle
+ * state a beat before `$activeSessionId` / the tile binding points at it. */
export function publishSessionState(runtimeId: string, state: ClientSessionState) {
const current = $sessionStates.get()
const prev = current[runtimeId] ?? null
@@ -247,8 +261,7 @@ export function publishSessionState(runtimeId: string, state: ClientSessionState
if (prev && evictable(runtimeId, state)) {
handleTransition(prev, state, runtimeId)
- const { [runtimeId]: _dropped, ...rest } = current
- $sessionStates.set(rest)
+ releaseSessionTranscript(runtimeId, state)
return
}
@@ -257,6 +270,30 @@ export function publishSessionState(runtimeId: string, state: ClientSessionState
handleTransition(prev, state, runtimeId)
}
+/** Keep the cheap status projection for a cold session while releasing its
+ * transcript. Unread completion is stored separately, so it survives too. */
+export function releaseSessionTranscript(runtimeId: string, state?: ClientSessionState) {
+ const current = $sessionStates.get()
+
+ if (!(runtimeId in current)) {
+ return
+ }
+
+ const retained = state ?? current[runtimeId]
+
+ // Older persisted snapshots can contain an undefined state or omit the
+ // messages field. Treat either shape as already cold instead of throwing
+ // while memory pressure is being relieved.
+ if (!retained) {
+ return
+ }
+
+ const lightweight =
+ Array.isArray(retained.messages) && retained.messages.length === 0 ? retained : { ...retained, messages: [] }
+
+ $sessionStates.set({ ...current, [runtimeId]: lightweight })
+}
+
export function dropSessionState(runtimeId: string) {
// Disarm the watchdog — a dropped runtime must not fire a stale clear later.
// Settle-grace entries are keyed by stored id and self-expire; leave them so
@@ -644,6 +681,14 @@ export function openSessionTile(
) {
const tiles = $sessionTiles.get()
+ // Opening a session in a tab/tile is "reading" it — clear its unread dot
+ // exactly like main-thread resume does. Previously only
+ // setSelectedStoredSessionId cleared unread, so tile-opened sessions kept
+ // their green dot even while the user was reading them. Acks the persisted
+ // watermark/marker too so a later list refresh doesn't repaint it.
+ markSessionRead(storedSessionId)
+ ackStoredSessionId(storedSessionId)
+
if (storedSessionId === $selectedStoredSessionId.get()) {
return
}
@@ -891,6 +936,20 @@ export const $focusedSessionState = computed([$focusedRuntimeId, $sessionStates]
export const selectionHomesToWorkspace = (selected: null | string, tiles: readonly SessionTile[]): boolean =>
!(selected && tiles.some(t => t.storedSessionId === selected))
+// Bringing a finished session to the front clears its green dot. Keyed on the
+// FOCUSED session, not the selected one: a tile is never $selectedStoredSessionId,
+// and a tile tab click goes through activateTreePane rather than focusOpenSession,
+// so this is the one hook that catches every way a tile reaches the front.
+// Clears the whole conversation family (markSessionRead) AND acks the
+// persisted watermark/marker (ackStoredSessionId) so the next list refresh
+// doesn't repaint the dot the user just cleared by looking at it.
+$focusedStoredSessionId.listen(focused => {
+ if (focused) {
+ markSessionRead(focused)
+ ackStoredSessionId(focused)
+ }
+})
+
// Cold-start restore is the one selection change that is NOT a navigation: the
// route already pointed at the primary session before the window loaded, and
// homing on it would front the workspace tab over the PERSISTED active tab —
diff --git a/apps/desktop/src/store/session-unread-remote.test.ts b/apps/desktop/src/store/session-unread-remote.test.ts
new file mode 100644
index 0000000000..1cbc967baa
--- /dev/null
+++ b/apps/desktop/src/store/session-unread-remote.test.ts
@@ -0,0 +1,113 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { SessionInfo } from '@/types/hermes'
+
+const patch = vi.fn<(id: string, unread: boolean, profile?: null | string) => Promise<{ ok: boolean }>>(() =>
+ Promise.resolve({ ok: true })
+)
+
+vi.mock('@/hermes', () => ({
+ // The store only needs the REST mutation; keep the mock minimal.
+ setApiRequestProfile: () => {},
+ setSessionUnreadRemote: (id: string, unread: boolean, profile?: null | string) => patch(id, unread, profile)
+}))
+
+import { $sessions } from '@/store/session'
+
+import { $unreadWriteGuard, clearUnreadOnOpen, markSessionUnread, watchUnreadWriteGuard } from './session-unread-remote'
+
+const row = (id: string, extra: Partial = {}): SessionInfo =>
+ ({ id, message_count: 1, source: 'cli', started_at: 0, title: id, ...extra }) as SessionInfo
+
+beforeEach(() => {
+ $sessions.set([])
+ $unreadWriteGuard.set(new Map())
+ patch.mockClear()
+})
+
+afterEach(() => {
+ $sessions.set([])
+ $unreadWriteGuard.set(new Map())
+})
+
+describe('markSessionUnread', () => {
+ it('optimistically paints the row, then PATCHes with the owning profile', async () => {
+ $sessions.set([row('a', { profile: 'work', unread: false })])
+
+ await markSessionUnread('a', true)
+
+ expect(patch).toHaveBeenCalledWith('a', true, 'work')
+ expect($sessions.get().find(s => s.id === 'a')?.unread).toBe(true)
+ })
+
+ it('no-ops for a runtime-only session with no persisted row', async () => {
+ await markSessionUnread('ghost', true)
+
+ expect(patch).not.toHaveBeenCalled()
+ })
+
+ it('rolls back the row and rethrows when the PATCH fails', async () => {
+ $sessions.set([row('a', { unread: false })])
+ patch.mockImplementationOnce(() => Promise.reject(new Error('offline')))
+
+ await expect(markSessionUnread('a', true)).rejects.toThrow('offline')
+
+ // The backend kept the old value, so the optimistic flip is undone and
+ // the guard is released (nothing to fence a page about).
+ expect($sessions.get().find(s => s.id === 'a')?.unread).toBe(false)
+ expect($unreadWriteGuard.get().has('a')).toBe(false)
+ })
+})
+
+describe('clearUnreadOnOpen', () => {
+ it('no-ops for a session that is already read', async () => {
+ $sessions.set([row('a', { unread: false })])
+
+ await clearUnreadOnOpen('a')
+
+ expect(patch).not.toHaveBeenCalled()
+ })
+
+ it('PATCHes read for an unread session, using its owning profile', async () => {
+ $sessions.set([row('a', { profile: 'p2', unread: true })])
+
+ await clearUnreadOnOpen('a')
+
+ expect(patch).toHaveBeenCalledWith('a', false, 'p2')
+ expect($sessions.get().find(s => s.id === 'a')?.unread).toBe(false)
+ })
+
+ it('swallows a failed PATCH (the next honest refresh heals the dot)', async () => {
+ $sessions.set([row('a', { unread: true })])
+ patch.mockImplementationOnce(() => Promise.reject(new Error('offline')))
+
+ await expect(clearUnreadOnOpen('a')).resolves.toBeUndefined()
+ })
+})
+
+describe('watchUnreadWriteGuard', () => {
+ it('drops a guard entry once a list page confirms the value we wrote', () => {
+ watchUnreadWriteGuard()
+ const guard = new Map()
+ guard.set('a', { at: Date.now(), value: true })
+ $unreadWriteGuard.set(guard)
+
+ // The server caught up and echoes our value back.
+ $sessions.set([row('a', { unread: true })])
+
+ expect($unreadWriteGuard.get().has('a')).toBe(false)
+ })
+
+ it('keeps the guard while a page contradicts a write still in flight', () => {
+ watchUnreadWriteGuard()
+ const guard = new Map()
+ guard.set('a', { at: Date.now(), value: true })
+ $unreadWriteGuard.set(guard)
+
+ // A list request issued before the PATCH still says read. Honouring it
+ // would silently undo the mark the user just made.
+ $sessions.set([row('a', { unread: false })])
+
+ expect($unreadWriteGuard.get().has('a')).toBe(true)
+ })
+})
diff --git a/apps/desktop/src/store/session-unread-remote.ts b/apps/desktop/src/store/session-unread-remote.ts
new file mode 100644
index 0000000000..6200b8b6c9
--- /dev/null
+++ b/apps/desktop/src/store/session-unread-remote.ts
@@ -0,0 +1,101 @@
+/**
+ * Persisted unread flag sync (backend read-state watermark via
+ * PATCH /api/sessions/{id} → SessionDB.set_session_read).
+ *
+ * The sidebar's dot is fed by TWO sources (see session-dot-state.ts): the
+ * runtime "turn finished in background" marker ($unreadFinishedSessionIds,
+ * transient) and the backend's derived `unread` key (last_read_at watermark
+ * vs last_active — survives restarts and is visible to every surface). This
+ * module owns the WRITE side of the persisted flag: the row-level
+ * "Mark as unread"/"Mark as read" toggle and the automatic clear when a
+ * session is opened. The read side lives in session-dot-state.ts.
+ *
+ * Optimistic, then honest (AGENTS.md): paint the row immediately, PATCH the
+ * backend, roll back visibly on failure. A list page already in flight when
+ * we PATCH can land after the ack carrying the OLD value — the write guard
+ * lets our value outrank that stale page briefly (#74570 pattern, same as
+ * session-pin-sync.ts).
+ *
+ * NOTE: import cycle with ./session is inert — both modules only touch each
+ * other's exports inside function bodies, never at module evaluation time.
+ */
+import { atom } from 'nanostores'
+
+import { setSessionUnreadRemote } from '@/hermes'
+
+import { $sessions, setSessions } from './session'
+
+export const UNREAD_WRITE_GUARD_MS = 10_000
+
+/** id -> the value we wrote and when. Guarded rows outrank list pages. */
+export const $unreadWriteGuard = atom