From 1abc7ce8f059aadb60466fe9400913c55bd90b92 Mon Sep 17 00:00:00 2001 From: rroverin Date: Wed, 12 Aug 2026 18:32:57 +0200 Subject: [PATCH 01/48] feat(models): add Nemotron 3.5 Lightning 30B-A3B to NVIDIA NIM picker --- hermes_cli/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 3a809c5f1a..e2dc50c3cb 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -341,6 +341,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = { # NVIDIA flagship reasoning models "nvidia/nemotron-3-ultra-550b-a55b", "nvidia/nemotron-3-super-120b-a12b", + "nvidia/nemotron-3.5-lightning-30b-a3b", "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", # Third-party agentic models hosted on build.nvidia.com # (map to OpenRouter defaults — users get familiar picks on NIM) From 48c1eefa2677419a5b80967f708eb1098e415709 Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:43:52 -0600 Subject: [PATCH 02/48] fix(desktop): repair missing Windows runtime --- apps/desktop/electron/main.ts | 24 ++++++----- .../electron/windows-hermes-path.test.ts | 43 ++++++++++++++----- apps/desktop/electron/windows-hermes-path.ts | 40 +++++++++-------- 3 files changed, 68 insertions(+), 39 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 06a1eb9181..51607c1397 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -3707,16 +3707,20 @@ async function handOffWindowsBootstrapRecovery(reason) { const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes') const venvPython = path.join(venvBin, IS_WINDOWS ? 'python.exe' : 'python') - // Choose the gentle in-place --update when ANY real-install signal is present, - // not just the `hermes.exe` console-script shim. That shim is generated at the - // END of venv setup and is absent in exactly the interrupted/quarantined states - // this recovery exists to heal — gating on it alone forced the destructive - // --repair (full venv recreate) and drove reinstall loops. The venv interpreter - // and the bootstrap-complete marker are present earlier and are better signals. - const haveRealInstall = - fileExists(venvPython) || fileExists(venvHermes) || fileExists(path.join(updateRoot, '.hermes-bootstrap-complete')) - - const updaterArgs = chooseUpdaterArgs(haveRealInstall, branch) + // The updater invokes the venv's Hermes launcher, which in turn requires the + // venv interpreter. A bootstrap-complete marker proves only that setup once + // finished; it can outlive a manually removed or quarantined venv. Sending a + // marker-only install through --update dead-ends at "Could not find the hermes + // CLI" instead of rebuilding the runtime, so only a runnable pair gets the + // gentle update path. Partial or missing runtimes go through full repair. + const updaterArgs = chooseUpdaterArgs( + { + hasBootstrapMarker: fileExists(path.join(updateRoot, '.hermes-bootstrap-complete')), + hasVenvHermes: fileExists(venvHermes), + hasVenvPython: fileExists(venvPython) + }, + branch + ) await releaseBackendLockForUpdate(updateRoot) diff --git a/apps/desktop/electron/windows-hermes-path.test.ts b/apps/desktop/electron/windows-hermes-path.test.ts index 778042b278..76e4d0ac50 100644 --- a/apps/desktop/electron/windows-hermes-path.test.ts +++ b/apps/desktop/electron/windows-hermes-path.test.ts @@ -5,9 +5,9 @@ // 1. buildPathExtCandidates() — PATHEXT extensions must be tried BEFORE the // empty extension, or an extensionless Git-Bash `hermes` shim shadows // the real hermes.cmd/hermes.exe. -// 2. chooseUpdaterArgs() — must gate on haveRealInstall (any real-install -// signal), not just the hermes.exe console-script shim, or healthy -// installs get forced into a destructive --repair. +// 2. chooseUpdaterArgs() — must distinguish a runnable updater from stale +// install provenance. The bootstrap marker can outlive the venv, and a +// partial venv cannot run the updater; those states require --repair. // 3. resolveVenvHermesCommand() — must probe the venv python via // canImportHermesCli() before trusting it, or a broken venv gets // re-selected forever instead of falling through to bootstrap. @@ -45,17 +45,40 @@ test('buildPathExtCandidates: non-Windows only tries the bare name', () => { assert.deepEqual(buildPathExtCandidates(undefined, false), ['']) }) -test('chooseUpdaterArgs: gentle --update when a real-install signal is present', () => { - assert.deepEqual(chooseUpdaterArgs(true, 'main'), ['--update', '--branch', 'main']) +test('chooseUpdaterArgs: gentle --update when both updater runtime files exist', () => { + assert.deepEqual( + chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: true, hasVenvPython: true }, 'main'), + ['--update', '--branch', 'main'] + ) }) -test('chooseUpdaterArgs: destructive --repair only when NO real-install signal is present', () => { - assert.deepEqual(chooseUpdaterArgs(false, 'main'), ['--repair', '--branch', 'main']) +test('chooseUpdaterArgs: marker-only install uses --repair when the venv is gone', () => { + assert.deepEqual( + chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: false, hasVenvPython: false }, 'main'), + ['--repair', '--branch', 'main'] + ) }) -test('chooseUpdaterArgs: passes the branch through unchanged in both cases', () => { - assert.deepEqual(chooseUpdaterArgs(true, 'release/1.2'), ['--update', '--branch', 'release/1.2']) - assert.deepEqual(chooseUpdaterArgs(false, 'release/1.2'), ['--repair', '--branch', 'release/1.2']) +test('chooseUpdaterArgs: partial updater runtimes use --repair', () => { + assert.deepEqual( + chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: false, hasVenvPython: true }, 'main'), + ['--repair', '--branch', 'main'] + ) + assert.deepEqual( + chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: true, hasVenvPython: false }, 'main'), + ['--repair', '--branch', 'main'] + ) +}) + +test('chooseUpdaterArgs: passes the branch through unchanged in both modes', () => { + assert.deepEqual( + chooseUpdaterArgs({ hasBootstrapMarker: false, hasVenvHermes: true, hasVenvPython: true }, 'release/1.2'), + ['--update', '--branch', 'release/1.2'] + ) + assert.deepEqual( + chooseUpdaterArgs({ hasBootstrapMarker: false, hasVenvHermes: false, hasVenvPython: false }, 'release/1.2'), + ['--repair', '--branch', 'release/1.2'] + ) }) function makeDeps(overrides: Partial[2]> = {}) { diff --git a/apps/desktop/electron/windows-hermes-path.ts b/apps/desktop/electron/windows-hermes-path.ts index 6f3542853e..f94d4b2c39 100644 --- a/apps/desktop/electron/windows-hermes-path.ts +++ b/apps/desktop/electron/windows-hermes-path.ts @@ -11,12 +11,11 @@ * hermes.cmd/hermes.exe; the shim then failed the --version probe and * the desktop fell through to a spurious bootstrap/repair. The fix: * PATHEXT extensions first, empty extension LAST. - * 2. chooseUpdaterArgs() — handOffWindowsBootstrapRecovery() chose - * --update vs the destructive --repair by checking ONLY - * venv\Scripts\hermes.exe (the console-script shim, written at the END - * of venv setup and absent in interrupted states), so it escalated to a - * full venv recreate even on healthy installs. The fix: gate on ANY - * real-install signal, not just the shim. + * 2. chooseUpdaterArgs() — handOffWindowsBootstrapRecovery() must separate + * install provenance from updater viability. A bootstrap-complete marker + * can outlive a deleted venv, while the updater needs BOTH the venv Python + * and Hermes launcher. Marker-only or partial runtimes must use --repair; + * only a runnable pair can use --update. * 3. resolveVenvHermesCommand() — unwrapWindowsVenvHermesCommand() returned * the venv python with NO runtime probe (bypassing the caller's * --version check too), so a venv broken mid-update (e.g. missing @@ -61,23 +60,26 @@ export function buildPathExtCandidates(pathext: string | undefined, isWindows: b } /** - * Choose the Windows bootstrap-recovery updater invocation: the gentle - * in-place --update when ANY real-install signal is present, the - * destructive --repair (full venv recreate) otherwise. + * Choose the Windows bootstrap-recovery invocation. The gentle in-place + * updater can only start when both pieces of its runtime contract exist: the + * venv Python interpreter and the Hermes launcher that drives `hermes update`. + * A bootstrap-complete marker proves install provenance, not current runtime + * usability, and may remain after the venv is removed or quarantined. * - * haveRealInstall must be computed by the caller from ALL real-install - * signals (venv python interpreter, venv hermes shim, bootstrap-complete - * marker) — gating on just the hermes.exe console-script shim alone is the - * regression this function's callers must avoid: that shim is written at - * the END of venv setup and is absent in exactly the interrupted/quarantined - * states this recovery exists to heal. - * - * @param {boolean} haveRealInstall + * @param {BootstrapRecoverySignals} signals * @param {string} branch * @returns {string[]} updater argv, e.g. ['--update', '--branch', 'main']. */ -export function chooseUpdaterArgs(haveRealInstall: boolean, branch: string): string[] { - return haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] +export interface BootstrapRecoverySignals { + hasBootstrapMarker: boolean + hasVenvHermes: boolean + hasVenvPython: boolean +} + +export function chooseUpdaterArgs(signals: BootstrapRecoverySignals, branch: string): string[] { + const canRunUpdater = signals.hasVenvHermes && signals.hasVenvPython + + return canRunUpdater ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] } /** From ca20ee90bba57115421e54e121073d1169b72b77 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Wed, 19 Aug 2026 20:10:18 +0700 Subject: [PATCH 03/48] fix(desktop): wrap toast titles so long errors stay readable AlertTitle clamps to one line, so Desktop error toasts hide the rest of the message behind an ellipsis. Override that clamp, let the title wrap, and cap height so a huge error scrolls instead of covering the chat. --- apps/desktop/src/components/notifications.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/components/notifications.tsx b/apps/desktop/src/components/notifications.tsx index 6406032364..788873c531 100644 --- a/apps/desktop/src/components/notifications.tsx +++ b/apps/desktop/src/components/notifications.tsx @@ -119,7 +119,7 @@ function TopCenterStack({ aria-label={copy.region} className={cn( REGION_BASE, - 'left-1/2 top-[calc(var(--titlebar-height,34px)+0.75rem)] w-[min(32rem,calc(100%-2rem))] -translate-x-1/2 flex-col' + 'left-1/2 top-[calc(var(--titlebar-height,34px)+0.75rem)] w-[min(40rem,calc(100%-2rem))] -translate-x-1/2 flex-col' )} role="region" > @@ -187,6 +187,13 @@ function renderMessage(message: string, accent?: string): ReactNode { ) } +// AlertTitle defaults to a single-line clamp. Toast errors are often a full +// sentence, so the toast wraps — then caps height and scrolls instead of +// growing down the chat or clipping with an ellipsis. +export function toastTitleClassName() { + return 'col-start-auto line-clamp-none max-h-[4.5em] overflow-y-auto overscroll-contain whitespace-normal wrap-break-word' +} + function NotificationItem({ notification }: { notification: AppNotification }) { const styles = tone[notification.kind] const Icon = styles.icon @@ -213,9 +220,13 @@ function NotificationItem({ notification }: { notification: AppNotification }) { )}
- {notification.title && {notification.title}} + {notification.title && ( + + {notification.title} + + )} -

{renderMessage(notification.message, accent)}

+

{renderMessage(notification.message, accent)}

{notification.meta &&

{notification.meta}

} {hasDetail && } {notification.action && ( From ab6e7b93da55f216d4816c4002672585c607f148 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Wed, 19 Aug 2026 20:10:18 +0700 Subject: [PATCH 04/48] test(desktop): keep long toast titles readable Cover the wrap override and height cap so a one-line clamp cannot hide the rest of an error toast again. --- .../src/components/notifications.test.tsx | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 apps/desktop/src/components/notifications.test.tsx diff --git a/apps/desktop/src/components/notifications.test.tsx b/apps/desktop/src/components/notifications.test.tsx new file mode 100644 index 0000000000..5c345ce5ea --- /dev/null +++ b/apps/desktop/src/components/notifications.test.tsx @@ -0,0 +1,50 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { I18nProvider } from '@/i18n' +import { clearNotifications, notify } from '@/store/notifications' + +import { NotificationStack, toastTitleClassName } from './notifications' + +const LONG_TITLE = 'This turn is no longer in server history (it may have been compressed away).' +const DETAIL = 'target user message is no longer in session history' + +describe('toast titles', () => { + beforeEach(() => { + clearNotifications() + }) + + afterEach(() => { + cleanup() + clearNotifications() + }) + + it('drops the one-line clamp so a long error title can wrap', () => { + const className = toastTitleClassName() + + expect(className).toMatch(/\bline-clamp-none\b/) + expect(className).not.toMatch(/\bline-clamp-1\b/) + expect(className).toMatch(/\bwhitespace-normal\b/) + expect(className).toContain('max-h-[4.5em]') + expect(className).toMatch(/\boverflow-y-auto\b/) + }) + + it('renders the full title and body instead of truncating them', () => { + notify({ kind: 'error', title: LONG_TITLE, message: DETAIL }) + + render( + + + + ) + + const title = screen.getByText(LONG_TITLE) + + expect(title.textContent).toBe(LONG_TITLE) + expect(title.getAttribute('title')).toBe(LONG_TITLE) + expect(title.className).toMatch(/\bline-clamp-none\b/) + expect(title.className).not.toMatch(/\bline-clamp-1\b/) + expect(title.className).toMatch(/\boverflow-y-auto\b/) + expect(screen.getByText(DETAIL)).toBeTruthy() + }) +}) From 0599b66de77e59daaad0d928010b23016feb6172 Mon Sep 17 00:00:00 2001 From: dcdexhome Date: Wed, 19 Aug 2026 10:45:27 -0400 Subject: [PATCH 05/48] fix(desktop): stop re-entrant pin-sync reconcile from overflowing nanostores The Desktop renderer crashes with `RangeError: Invalid array length` thrown from `Array.push` inside nanostores' `notify()`. The shared `listenerQueue` grows without bound because `reconcile()` is subscribed to BOTH `$sessions` and `$pinnedSessionIds`, and `pullRemotePins()` mutates `$pinnedSessionIds` (via `pinSession`/`unpinSession`), which fires `reconcile()` again synchronously. The existing `mirrored`/`pending`/`unconfirmed` fences only cover a *bounded* single-toggle echo. They do not cover the *unbounded* oscillation that occurs when two profiles share a session id with conflicting `pinned` flags (copied or imported profile databases). A profile-blind pull then pins and unpins the same durable id in one pass, re-firing `reconcile` forever until the queue overflows and the renderer dies. Two changes: 1. `rowsByPinId()` collapses the cross-profile session list to one authoritative row per durable pin id, preferring the active gateway's profile (the same tie-break `resolveLoadedRow` uses). `pullRemotePins()` iterates the deduped rows, so a conflicting duplicate can no longer pin then unpin the same id in a single pass. 2. A re-entrancy guard on `reconcile()` so a synchronous re-entry (from the `$pinnedSessionIds` listener firing during `pullRemotePins`) returns immediately instead of recursing. Also fixes a latent TDZ `ReferenceError` in `session-unread.ts`: `isPlainRecord` was declared after its first use through `persistentAtom`, so decoding a persisted value could throw `Cannot access 'isPlainRecord' before initialization`. Regression tests cover the duplicate-id oscillation and the active-profile tie-break. --- .../src/store/session-pin-sync.test.ts | 30 +++++++++ apps/desktop/src/store/session-pin-sync.ts | 61 ++++++++++++++++++- apps/desktop/src/store/session-unread.ts | 6 +- 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/store/session-pin-sync.test.ts b/apps/desktop/src/store/session-pin-sync.test.ts index 634b2b09be..d77d9d68d5 100644 --- a/apps/desktop/src/store/session-pin-sync.test.ts +++ b/apps/desktop/src/store/session-pin-sync.test.ts @@ -14,6 +14,7 @@ vi.mock('@/hermes', () => ({ })) import { $pinnedSessionIds } from '@/store/layout' +import { $activeGatewayProfile } from '@/store/profile' import { $sessions } from '@/store/session' import { resetSessionPinMirror, watchSessionPins } from './session-pin-sync' @@ -290,4 +291,33 @@ describe('watchSessionPins remote pull', () => { expect($pinnedSessionIds.get()).toContain('failed') expect(patch).toHaveBeenCalledWith('failed', true, undefined) }) + + it('does not oscillate when two profiles share a session id with conflicting pins', async () => { + // The cross-profile list can hold the same durable id twice with opposite + // `pinned` flags (copied/imported profile DBs). A profile-blind pull would + // pin then unpin the id in one pass and re-fire reconcile forever, + // overflowing nanostores' listenerQueue (RangeError: Invalid array length). + $sessions.set([ + row('shared', { profile: 'default', pinned: true }), + row('shared', { profile: 'hcoder', pinned: false }) + ]) + await flush() + + // Deterministic: exactly one row wins, so the local set settles and no + // runaway re-entrant reconcile occurs. + expect($pinnedSessionIds.get()).toEqual(['shared']) + }) + + it('prefers the active gateway profile when duplicate ids disagree', async () => { + $activeGatewayProfile.set('hcoder') + $sessions.set([ + row('shared', { profile: 'default', pinned: true }), + row('shared', { profile: 'hcoder', pinned: false }) + ]) + await flush() + + // The active profile's row is authoritative, so the pin is dropped. + expect($pinnedSessionIds.get()).toEqual([]) + $activeGatewayProfile.set('default') + }) }) diff --git a/apps/desktop/src/store/session-pin-sync.ts b/apps/desktop/src/store/session-pin-sync.ts index e22bf9433d..e299b72cb2 100644 --- a/apps/desktop/src/store/session-pin-sync.ts +++ b/apps/desktop/src/store/session-pin-sync.ts @@ -24,7 +24,9 @@ import { setSessionPinnedRemote } from '@/hermes' import { onConnectionScopeChange } from '@/lib/connection-scoped' import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout' +import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' import { $sessions, sessionMatchesStoredId, sessionPinId } from '@/store/session' +import type { SessionInfo } from '@/types/hermes' // pin ids we've successfully PATCHed pinned=true this session. const mirrored = new Set() @@ -46,6 +48,39 @@ function profileFor(pinId: string): null | string | undefined { return $sessions.get().find(row => sessionMatchesStoredId(row, pinId))?.profile } +/** + * One authoritative row per durable pin id. Session ids are only unique inside + * a profile, so the cross-profile list can legitimately hold two rows with the + * same `sessionPinId` but different `pinned` flags (copied/imported profile + * databases). Iterating both would pin then unpin the same id in one pass and + * re-fire `reconcile` forever — the runaway that overflows nanostores' + * listenerQueue. Collapse to a single row per id, preferring the active + * gateway's profile (the same tie-break `resolveLoadedRow` uses), so the pull + * is deterministic and never oscillates. + */ +function rowsByPinId(rows: readonly SessionInfo[]): Map { + const byId = new Map() + const gateway = normalizeProfileKey($activeGatewayProfile.get()) + + for (const row of rows) { + const pinId = sessionPinId(row) + const existing = byId.get(pinId) + + if (!existing) { + byId.set(pinId, row) + + continue + } + + // Prefer the active gateway's profile; otherwise keep the first seen. + if (normalizeProfileKey(row.profile) === gateway && normalizeProfileKey(existing.profile) !== gateway) { + byId.set(pinId, row) + } + } + + return byId +} + /** PATCH the flag, guarding reads against pages that predate the write. */ function writePin(id: string, pinned: boolean, profile?: null | string): Promise { unconfirmed.set(id, { at: Date.now(), value: pinned }) @@ -78,7 +113,7 @@ function writePin(id: string, pinned: boolean, profile?: null | string): Promise function pullRemotePins(): void { const local = new Set($pinnedSessionIds.get()) - for (const row of $sessions.get()) { + for (const row of rowsByPinId($sessions.get()).values()) { // A backend without the flag has no opinion; never act on `undefined`. if (typeof row.pinned !== 'boolean') { continue @@ -128,7 +163,31 @@ function pullRemotePins(): void { } } +// Re-entrancy guard: reconcile() is subscribed to BOTH $sessions and +// $pinnedSessionIds, and pullRemotePins() mutates $pinnedSessionIds (via +// pinSession/unpinSession), which fires reconcile() again synchronously. +// Without this guard, a session whose pin state oscillates — two rows with the +// same durable id but conflicting `pinned` flags, possible when profile +// databases share session ids — drives an unbounded re-entrant loop that +// overflows nanostores' shared listenerQueue and crashes the renderer with +// `RangeError: Invalid array length`. +let reconciling = false + function reconcile(): void { + if (reconciling) { + return + } + + reconciling = true + + try { + reconcileInner() + } finally { + reconciling = false + } +} + +function reconcileInner(): void { // Config/session REST is only reachable through the Electron bridge. if (!window.hermesDesktop) { return diff --git a/apps/desktop/src/store/session-unread.ts b/apps/desktop/src/store/session-unread.ts index b6afadace0..64f3cdf115 100644 --- a/apps/desktop/src/store/session-unread.ts +++ b/apps/desktop/src/store/session-unread.ts @@ -64,6 +64,9 @@ type SeenCounts = Record> /** profile key → durable ids flagged by the live busy→idle edge. */ type Markers = Record +const isPlainRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value) + export const $sessionSeenCounts = persistentAtom( 'hermes.desktop.sessionSeenCounts', {}, @@ -76,9 +79,6 @@ export const $unreadFinishedMarkers = persistentAtom( Codecs.json(sanitizeMarkers) ) -const isPlainRecord = (value: unknown): value is Record => - Boolean(value) && typeof value === 'object' && !Array.isArray(value) - // Also the migration: a pre-profile-scoping flat record (durable id → number) // has non-object values, so it drops wholesale rather than mis-attributing // every id to a profile named after it. Dropped watermarks reseed on next From d959ba5601f342ba673ce1c245a2535f7662a06f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 19 Aug 2026 02:00:47 -0500 Subject: [PATCH 06/48] fix(desktop): scope session lookup to the active profile Unscoped getSession hits the primary backend. A 404 then skipped the active profile in the remaining probes, so chats on a non-default profile never loaded. Co-authored-by: Michael McAllister --- .../resolve-stored-session.test.ts | 36 ++++++------- .../hooks/use-session-actions/utils.ts | 54 ++++++++----------- 2 files changed, 39 insertions(+), 51 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/resolve-stored-session.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/resolve-stored-session.test.ts index 68f9bac298..468936f5b6 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/resolve-stored-session.test.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/resolve-stored-session.test.ts @@ -62,31 +62,33 @@ describe('resolveStoredSession profile ownership', () => { it('treats a profile-less cache hit as unresolved when multiple profiles exist', async () => { $sessions.set([session({ id: 's1' })]) mockGetSession.mockRejectedValueOnce(new Error('404: Session not found')) - mockGetSession.mockRejectedValueOnce(new Error('404: Session not found')) mockGetSession.mockResolvedValueOnce(session({ id: 's1', profile: 'default' })) const resolved = await resolveStoredSession('s1') expect(resolved?.profile).toBe('default') - // rung 2 (bare) then rung 3 (stamped probes: active profile first) - expect(mockGetSession).toHaveBeenNthCalledWith(1, 's1') - expect(mockGetSession).toHaveBeenNthCalledWith(2, 's1', 'meta') - expect(mockGetSession).toHaveBeenNthCalledWith(3, 's1', 'default') + // rung 2 (active profile) then rung 3 (stamped cross-profile probe) + expect(mockGetSession).toHaveBeenNthCalledWith(1, 's1', 'meta') + expect(mockGetSession).toHaveBeenNthCalledWith(2, 's1', 'default') }) - it('probes the ACTIVE profile when the unscoped lookup 404s (hidden Bot Mode chat)', async () => { - // A hidden Bot Mode canonical chat is never in the sidebar cache, and the - // unscoped GET routes to the PRIMARY backend (404). The owner is the - // active profile itself — it must be probed, not skipped. - mockGetSession.mockRejectedValueOnce(new Error('404: Session not found')) - mockGetSession.mockResolvedValueOnce(session({ id: 's1', message_count: 2 })) + it('scopes the first by-id lookup so a miss does not skip the active profile', async () => { + $activeGatewayProfile.set('brain') + $profiles.set(profiles('default', 'brain')) + mockGetSession.mockImplementation(async (id, profile) => { + if (profile === 'brain') { + return session({ id, profile: 'brain' }) + } + + throw new Error('404: Session not found') + }) const resolved = await resolveStoredSession('s1') - expect(resolved?.profile).toBe('meta') - expect(mockGetSession).toHaveBeenNthCalledWith(1, 's1') - expect(mockGetSession).toHaveBeenNthCalledWith(2, 's1', 'meta') - expect($sessions.get().find(s => s.id === 's1')?.profile).toBe('meta') + expect(resolved?.profile).toBe('brain') + expect(mockGetSession).toHaveBeenCalledWith('s1', 'brain') + expect(mockGetSession).not.toHaveBeenCalledWith('s1') + expect(mockGetSession).not.toHaveBeenCalledWith('s1', 'default') }) it('accepts a profile-less cache hit for single-profile users', async () => { @@ -105,6 +107,7 @@ describe('resolveStoredSession profile ownership', () => { const resolved = await resolveStoredSession('s1') expect(resolved?.profile).toBe('meta') + expect(mockGetSession).toHaveBeenCalledWith('s1', 'meta') // the upserted cache row is owned too, so the next hit short-circuits expect($sessions.get().find(s => s.id === 's1')?.profile).toBe('meta') }) @@ -113,7 +116,6 @@ describe('resolveStoredSession profile ownership', () => { // Per-profile remote override: Electron strips the desktop alias before // forwarding, so the standalone backend stamps its backend-local root. mockGetSession.mockRejectedValueOnce(new Error('404: Session not found')) - mockGetSession.mockRejectedValueOnce(new Error('404: Session not found')) mockGetSession.mockResolvedValueOnce(session({ id: 's1', profile: 'default' })) $activeGatewayProfile.set('default') $profiles.set(profiles('default', 'meta')) @@ -125,7 +127,6 @@ describe('resolveStoredSession profile ownership', () => { }) it('stamps the probed profile on a scoped hit from an older backend that omits it', async () => { - mockGetSession.mockRejectedValueOnce(new Error('404: Session not found')) mockGetSession.mockRejectedValueOnce(new Error('404: Session not found')) mockGetSession.mockResolvedValueOnce(session({ id: 's1' })) @@ -137,7 +138,6 @@ describe('resolveStoredSession profile ownership', () => { }) it('resolveSessionProfile routes a default-profile session from a non-default gateway', async () => { - mockGetSession.mockRejectedValueOnce(new Error('404: Session not found')) mockGetSession.mockRejectedValueOnce(new Error('404: Session not found')) mockGetSession.mockResolvedValueOnce(session({ id: 's1', profile: 'default' })) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index e622a52463..6474b93431 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -1307,17 +1307,19 @@ export async function resolveStoredSession(storedSessionId: string): Promise
- } - below={ - translucency.mode === 'glass' && GLASS_SUPPORTED ? ( -
-
- - {a.translucencyFrostTitle} - + {/* Linux has neither half of this setting (see TRANSLUCENCY_SUPPORTED), + so the row is absent there rather than offering a dead lever. */} + {TRANSLUCENCY_SUPPORTED && ( + + {GLASS_SUPPORTED && ( ({ - id: material, - label: a.translucencyFrost[material] - }))} - value={translucency.material} + onChange={pickTranslucency(setTranslucencyMode)} + options={[ + { id: 'clear' as const, label: a.translucencyModeClear }, + { id: 'glass' as const, label: a.translucencyModeGlass } + ]} + value={translucency.mode} /> -
-
- - {a.translucencyScopeTitle} - - ({ - id: scope, - label: a.translucencyScope[scope] - }))} - value={translucency.scope} + )} + {/* Clear has one lever and it belongs beside the mode. Glass + has four controls, so they move into the labelled panel + below rather than crowding this line with an unlabelled + slider that means something different. */} + {!glassMode && ( + -
+ )}
- ) : undefined - } - description={translucency.mode === 'glass' ? a.translucencyGlassDesc : a.translucencyDesc} - id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.translucency)} - title={a.translucencyTitle} - /> + } + below={ + glassMode ? ( +
+ + + + + + + + ({ + id: material, + label: a.translucencyFrost[material] + }))} + value={glassMaterialForPicker(translucency.material, GLASS_IS_WINDOWS)} + /> + + + ({ + id: scope, + label: a.translucencyScope[scope] + }))} + value={translucency.scope} + /> + +
+ ) : undefined + } + description={glassMode ? a.translucencyGlassDesc : a.translucencyDesc} + id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.translucency)} + title={a.translucencyTitle} + /> + )} Date: Wed, 19 Aug 2026 03:34:50 -0500 Subject: [PATCH 14/48] refactor(desktop): give duplicated helpers one owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splitting the god files made a pile of copy-paste helpers visible and, for the first time, fixable — sharing them previously meant importing a god file. Hashing function bodies through the TypeScript AST found twelve groups desktop-wide; production code is now at zero duplicates. Each helper went to the module that already owns its concern: firstStringField to lib/text, the two REST 404 predicates to lib/gateway-rpc beside isMissingRpcMethod, useDebounced and prefersReducedMotion to their hooks, the superseded-bootstrap guard to electron/ssh-connection, the composer keyup handler to the trigger hook that owns the rest of that state machine, and clampDataUrlReadMaxMb to apps/shared, replacing a "keep these in sync" comment between two copies. Only helpers with no existing owner got a new file: lib/mcp-servers, lib/audio-context, lib/keyed-timeouts, lib/pointer-drag, and the command palette's status row. Error-shape predicates are the worst thing to copy — when the backend changes how it reports a missing route, every copy has to be found. --- .../composer/hooks/use-composer-trigger.ts | 20 ++++++++++ apps/desktop/src/app/chat/composer/index.tsx | 18 +-------- .../marketplace-theme-page.tsx | 36 +++--------------- .../app/command-palette/pet-palette-page.tsx | 23 +++--------- .../src/app/command-palette/status-row.tsx | 18 +++++++++ .../contrib/mcp-install-deeplink-dialog.tsx | 9 +---- .../src/app/settings/appearance-settings.tsx | 13 +------ apps/desktop/src/app/skills/mcp-tab.tsx | 25 +------------ .../thread/user-edit-composer.tsx | 18 +-------- .../assistant-ui/tool/delegate-model.ts | 4 +- .../assistant-ui/tool/fallback-model/index.ts | 14 +------ .../assistant-ui/tool/run-summary.ts | 3 +- .../components/gateway-connecting-overlay.tsx | 5 +-- .../pane-shell/tree/renderer/edit-bar.tsx | 9 +---- .../pane-shell/tree/zone-editor.tsx | 18 ++------- .../desktop/src/components/ui/decode-text.tsx | 5 +-- apps/desktop/src/hooks/use-media-query.ts | 4 ++ apps/desktop/src/lib/audio-context.ts | 37 +++++++++++++++++++ apps/desktop/src/lib/completion-sound.ts | 34 +---------------- apps/desktop/src/lib/desktop-git.ts | 17 --------- apps/desktop/src/lib/gateway-rpc.ts | 18 +++++++++ apps/desktop/src/lib/keyed-timeouts.ts | 29 +++++++++++++++ apps/desktop/src/lib/mcp-import.ts | 17 +-------- apps/desktop/src/lib/mcp-servers.ts | 29 +++++++++++++++ apps/desktop/src/lib/pointer-drag.ts | 26 +++++++++++++ apps/desktop/src/lib/text.ts | 14 +++++++ apps/desktop/src/lib/thinking-sound.ts | 31 +--------------- apps/desktop/src/lib/wake-sound.ts | 34 +---------------- apps/desktop/src/store/data-url-read-max.ts | 25 +++++-------- apps/desktop/src/store/goals.ts | 25 +++---------- apps/desktop/src/store/pet-gallery.ts | 13 ++----- apps/desktop/src/store/pet-generate.ts | 9 +---- apps/desktop/src/store/projects.ts | 6 +-- apps/desktop/src/store/todos.ts | 24 +++--------- apps/shared/src/data-url-read-max.ts | 27 ++++++++++++++ apps/shared/src/index.ts | 6 +++ 36 files changed, 296 insertions(+), 367 deletions(-) create mode 100644 apps/desktop/src/app/command-palette/status-row.tsx create mode 100644 apps/desktop/src/lib/audio-context.ts create mode 100644 apps/desktop/src/lib/keyed-timeouts.ts create mode 100644 apps/desktop/src/lib/mcp-servers.ts create mode 100644 apps/desktop/src/lib/pointer-drag.ts create mode 100644 apps/shared/src/data-url-read-max.ts diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts index fae1d03a31..a6c5c898af 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts @@ -36,6 +36,26 @@ import { detectTrigger, textBeforeCaret, type TriggerState } from '../text-utils * prose off and stranded a partial `folder:` in front of the chip, because the * window it removed wasn't the token the user was typing. */ +/** The keyup half of trigger detection, shared by both composers. + * + * If the open popover already consumed this key in keydown (Arrow/Enter/Tab/ + * Escape), skip the refresh: those keys never edit text, and for Escape the + * keydown already closed the menu — refreshing here would re-detect the + * still-present `/` and instantly reopen it. It reads a ref set during keydown + * rather than `trigger`, because by keyup time React has re-rendered and + * `trigger` may already be null. */ +export function triggerKeyUpHandler(consumedRef: MutableRefObject, refreshTrigger: () => void) { + return () => { + if (consumedRef.current) { + consumedRef.current = false + + return + } + + window.setTimeout(refreshTrigger, 0) + } +} + export function rebuildAroundCaret(editor: HTMLDivElement, tokenLength: number, insert: DocumentFragment | string) { const current = composerPlainText(editor) const caret = caretOffsetInEditor(editor) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index c61f81f1fc..519e6422eb 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -50,7 +50,7 @@ import { useComposerPlaceholder } from './hooks/use-composer-placeholder' import { useComposerPopout } from './hooks/use-composer-popout' import { useComposerQueue } from './hooks/use-composer-queue' import { useComposerSubmit } from './hooks/use-composer-submit' -import { useComposerTrigger } from './hooks/use-composer-trigger' +import { triggerKeyUpHandler, useComposerTrigger } from './hooks/use-composer-trigger' import { useComposerUndo } from './hooks/use-composer-undo' import { useComposerUrlDialog } from './hooks/use-composer-url-dialog' import { useComposerVoice } from './hooks/use-composer-voice' @@ -904,21 +904,7 @@ export function ChatBar({ } } - const handleEditorKeyUp = () => { - // If this keyup belongs to a key the open trigger popover already consumed - // in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never - // edit text, and for Escape the keydown already closed the menu — a refresh - // here would re-detect the still-present `/` and instantly reopen it. We - // read a ref set during keydown rather than `trigger`, because by keyup - // time React has re-rendered and `trigger` may already be null. - if (triggerKeyConsumedRef.current) { - triggerKeyConsumedRef.current = false - - return - } - - window.setTimeout(refreshTrigger, 0) - } + const handleEditorKeyUp = triggerKeyUpHandler(triggerKeyConsumedRef, refreshTrigger) const { dragActive, diff --git a/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx b/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx index 6766b2dae3..1adab907f3 100644 --- a/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx +++ b/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx @@ -10,9 +10,11 @@ import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' -import { useEffect, useState } from 'react' +import { useState } from 'react' +import { StatusRow } from '@/app/command-palette/status-row' import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud' +import { useDebounced } from '@/app/hooks/use-debounced' import type { DesktopMarketplaceSearchItem } from '@/global' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' @@ -23,18 +25,6 @@ import { $marketplaceInstalls } from '@/themes/user-themes' const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }) -function useDebounced(value: T, delayMs: number): T { - const [debounced, setDebounced] = useState(value) - - useEffect(() => { - const handle = setTimeout(() => setDebounced(value), delayMs) - - return () => clearTimeout(handle) - }, [value, delayMs]) - - return debounced -} - interface MarketplaceThemePageProps { search: string /** Activate a freshly installed theme by slug. */ @@ -90,17 +80,17 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa } if (query.isLoading) { - return } text={copy.loading} /> + return } text={copy.loading} /> } if (query.isError) { - return + return } const results = query.data ?? [] if (results.length === 0) { - return + return } return ( @@ -156,17 +146,3 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa ) } - -function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) { - return ( -
- {icon} - {text} -
- ) -} diff --git a/apps/desktop/src/app/command-palette/pet-palette-page.tsx b/apps/desktop/src/app/command-palette/pet-palette-page.tsx index 241bb43312..b23157f5d4 100644 --- a/apps/desktop/src/app/command-palette/pet-palette-page.tsx +++ b/apps/desktop/src/app/command-palette/pet-palette-page.tsx @@ -10,6 +10,7 @@ import { useStore } from '@nanostores/react' import { useEffect, useMemo } from 'react' +import { StatusRow } from '@/app/command-palette/status-row' import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud' import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' import { PetThumb } from '@/components/pet/pet-thumb' @@ -59,15 +60,15 @@ export function PetPalettePage({ search, onGenerate }: PetPalettePageProps) { } if (status === 'loading' && !gallery) { - return } text={copy.loading} /> + return } text={copy.loading} /> } if (status === 'stale') { - return + return } if (!gallery?.pets.length && error) { - return + return } const mutating = Boolean(busy) @@ -95,7 +96,7 @@ export function PetPalettePage({ search, onGenerate }: PetPalettePageProps) { {error &&

{error}

} {shown.length === 0 ? ( - + ) : ( shown.map(pet => { const isActive = enabled && pet.slug === active @@ -198,17 +199,3 @@ export function PetInlineToggle() { ) } - -function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) { - return ( -
- {icon} - {text} -
- ) -} diff --git a/apps/desktop/src/app/command-palette/status-row.tsx b/apps/desktop/src/app/command-palette/status-row.tsx new file mode 100644 index 0000000000..2fad2de52f --- /dev/null +++ b/apps/desktop/src/app/command-palette/status-row.tsx @@ -0,0 +1,18 @@ +import type React from 'react' + +import { cn } from '@/lib/utils' + +/** Centered loading / empty / error row for a Cmd-K page's result list. */ +export function StatusRow({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) { + return ( +
+ {icon} + {text} +
+ ) +} diff --git a/apps/desktop/src/app/contrib/mcp-install-deeplink-dialog.tsx b/apps/desktop/src/app/contrib/mcp-install-deeplink-dialog.tsx index aa4b1b3cab..a657c98687 100644 --- a/apps/desktop/src/app/contrib/mcp-install-deeplink-dialog.tsx +++ b/apps/desktop/src/app/contrib/mcp-install-deeplink-dialog.tsx @@ -16,19 +16,12 @@ import { getHermesConfigRecord, saveMcpServers } from '@/hermes' import { useI18n } from '@/i18n' import { AlertTriangle } from '@/lib/icons' import { MCP_DEEPLINK_NAME_RE } from '@/lib/mcp-deeplink' +import { getServers } from '@/lib/mcp-servers' import { $mcpInstallRequest } from '@/store/mcp-deeplink-install' import { notify, readableError } from '@/store/notifications' import { setHermesConfigCache } from '../hooks/use-config-record' -type McpServers = Record> - -const getServers = (config: { mcp_servers?: unknown } | null): McpServers => { - const raw = config?.mcp_servers - - return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as McpServers) : {} -} - /** * Explicit-confirm gate for `hermes://mcp/install` deep links. The payload is * arbitrary attacker-controllable input (any web page can open the link), so diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index b84debd606..c401bea383 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import { useEffect, useState } from 'react' +import { useDebounced } from '@/app/hooks/use-debounced' import { LanguageSwitcher } from '@/components/language-switcher' import { Button } from '@/components/ui/button' import { SegmentedControl } from '@/components/ui/segmented-control' @@ -102,18 +103,6 @@ function matchUiScalePreset(percent: number): UiScalePreset | null { return UI_SCALE_PRESETS.find(preset => Number(preset) === percent) ?? null } -function useDebounced(value: T, delayMs: number): T { - const [debounced, setDebounced] = useState(value) - - useEffect(() => { - const handle = setTimeout(() => setDebounced(value), delayMs) - - return () => clearTimeout(handle) - }, [value, delayMs]) - - return debounced -} - const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }) /** diff --git a/apps/desktop/src/app/skills/mcp-tab.tsx b/apps/desktop/src/app/skills/mcp-tab.tsx index 78b5d74937..93088c6258 100644 --- a/apps/desktop/src/app/skills/mcp-tab.tsx +++ b/apps/desktop/src/app/skills/mcp-tab.tsx @@ -38,12 +38,12 @@ import { estimateServerTokens, serverUsageCount } from '@/lib/mcp-cost' import { completeMcpDesktopOAuth } from '@/lib/mcp-dashboard-oauth' import { type McpImportEntry, parseMcpImport } from '@/lib/mcp-import' import { NEEDS_AUTH_RE, PROBE_TTL_MS, probeCache, probeKey, serverFingerprint } from '@/lib/mcp-probe-cache' +import { getServers, isServerShape, type McpServers, normalizeEntry } from '@/lib/mcp-servers' import { countEnabledTools, isToolEnabled, toggleToolInServer } from '@/lib/mcp-tool-filter' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' import { $activeSessionId } from '@/store/session' -import type { HermesConfigRecord } from '@/types/hermes' import { hermesConfigCacheWriter, useHermesConfigRecord } from '../hooks/use-config-record' import { useOnProfileSwitch } from '../hooks/use-on-profile-switch' @@ -52,8 +52,6 @@ import { PanelAddButton, PanelEmpty } from '../overlays/panel' import { prettyName } from '../settings/helpers' import { useDeepLinkHighlight } from '../settings/use-deep-link-highlight' -type McpServers = Record> - // The editor always speaks the ecosystem's mcp.json document format — names // are the JSON keys, transport is inferred from `command` vs `url` — so any // README's "add this to your mcp.json" snippet pastes verbatim. Storage stays @@ -63,21 +61,6 @@ const STARTER_ENTRY = { command: 'npx', args: ['-y', '@modelcontextprotocol/serv const pretty = (value: unknown) => JSON.stringify(value, null, 2) const wrapDoc = (entries: McpServers) => pretty({ mcpServers: entries }) -const isServerShape = (value: Record) => - typeof value.command === 'string' || typeof value.url === 'string' - -// Cursor/Claude write `type`; Hermes reads `transport`. Normalize on the way -// in so pasted configs behave identically under the CLI/TUI loader. -function normalizeEntry(entry: Record): Record { - if (typeof entry.type === 'string' && entry.transport === undefined) { - const { type, ...rest } = entry - - return { ...rest, transport: type } - } - - return entry -} - /** Accepts `{"mcpServers": {...}}` (ecosystem), a bare name→config map, or throws. */ function parseServersDoc(raw: string): McpServers { const parsed = JSON.parse(raw) as unknown @@ -100,12 +83,6 @@ function parseServersDoc(raw: string): McpServers { return Object.fromEntries(Object.entries(map).map(([name, entry]) => [name, normalizeEntry(entry)])) } -function getServers(config: HermesConfigRecord | null): McpServers { - const raw = config?.mcp_servers - - return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as McpServers) : {} -} - // The runtime gate is `enabled: false` — the same flag `hermes mcp` and the // agent's MCP loader read. const serverEnabled = (server: Record) => server.enabled !== false diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index b29715f98b..d9bf458c80 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -24,7 +24,7 @@ import { releaseActiveComposer } from '@/app/chat/composer/focus' import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions' -import { rebuildAroundCaret } from '@/app/chat/composer/hooks/use-composer-trigger' +import { rebuildAroundCaret, triggerKeyUpHandler } from '@/app/chat/composer/hooks/use-composer-trigger' import { useComposerUndo } from '@/app/chat/composer/hooks/use-composer-undo' import { useEmojiCompletions } from '@/app/chat/composer/hooks/use-emoji-completions' import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions' @@ -760,21 +760,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess } } - const handleKeyUp = () => { - // If this keyup belongs to a key the open trigger popover already consumed - // in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never - // edit text, and for Escape the keydown already closed the menu — a refresh - // here would re-detect the still-present `/` and instantly reopen it. We - // read a ref set during keydown rather than `trigger`, because by keyup - // time React has re-rendered and `trigger` may already be null. - if (triggerKeyConsumedRef.current) { - triggerKeyConsumedRef.current = false - - return - } - - window.setTimeout(refreshTrigger, 0) - } + const handleKeyUp = triggerKeyUpHandler(triggerKeyConsumedRef, refreshTrigger) return ( diff --git a/apps/desktop/src/components/assistant-ui/tool/delegate-model.ts b/apps/desktop/src/components/assistant-ui/tool/delegate-model.ts index eff61ec17d..694c34d752 100644 --- a/apps/desktop/src/components/assistant-ui/tool/delegate-model.ts +++ b/apps/desktop/src/components/assistant-ui/tool/delegate-model.ts @@ -1,7 +1,7 @@ -import { normalize } from '@/lib/text' +import { firstStringField, normalize } from '@/lib/text' import type { SubagentProgress, SubagentStatus } from '@/store/subagents' -import { firstStringField, numberValue, parseMaybeObject } from './fallback-model' +import { numberValue, parseMaybeObject } from './fallback-model' /** * A delegation runs somewhere the transcript can't see: the tool call carries diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts index 2a21657f68..4ea420732e 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts @@ -1,7 +1,7 @@ import { type ToolTitleKey, translateNow } from '@/i18n' import { normalizeExternalUrl } from '@/lib/external-link' import { summarizeShellCommand } from '@/lib/summarize-command' -import { capitalize, normalize } from '@/lib/text' +import { capitalize, firstStringField, normalize } from '@/lib/text' import { isCardTool, isFileEditTool, isSilentTool } from '@/lib/tool-render-class' import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary' @@ -595,18 +595,6 @@ function summarizeBrowserSnapshot(snapshot: string): string { return labels.length ? `${stats}\nTop controls: ${labels.join(', ')}` : stats } -export function firstStringField(record: Record, keys: readonly string[]): string { - for (const key of keys) { - const value = record[key] - - if (typeof value === 'string' && value.trim()) { - return value.trim() - } - } - - return '' -} - function collectResultItems(value: unknown): unknown[] { if (Array.isArray(value)) { return value diff --git a/apps/desktop/src/components/assistant-ui/tool/run-summary.ts b/apps/desktop/src/components/assistant-ui/tool/run-summary.ts index 6abf559a33..7693e67ed6 100644 --- a/apps/desktop/src/components/assistant-ui/tool/run-summary.ts +++ b/apps/desktop/src/components/assistant-ui/tool/run-summary.ts @@ -1,6 +1,7 @@ import { summarizeShellCommand } from '@/lib/summarize-command' +import { firstStringField } from '@/lib/text' -import { fileEditBasename, firstStringField, isFileEditTool, parseMaybeObject } from './fallback-model' +import { fileEditBasename, isFileEditTool, parseMaybeObject } from './fallback-model' /** * The little a summary needs from a tool call, stated structurally so both diff --git a/apps/desktop/src/components/gateway-connecting-overlay.tsx b/apps/desktop/src/components/gateway-connecting-overlay.tsx index 34303bb52c..dcc24c64d8 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.tsx @@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react' import { useEffect, useRef, useState } from 'react' import { DecodeText } from '@/components/ui/decode-text' +import { prefersReducedMotion } from '@/hooks/use-media-query' import { cn } from '@/lib/utils' import { $desktopBoot } from '@/store/boot' import { $gatewaySwitching } from '@/store/gateway-switch' @@ -35,10 +36,6 @@ function forcedPreview(): boolean { } } -function prefersReducedMotion(): boolean { - return typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) -} - export function GatewayConnectingOverlay() { const gatewayState = useStore($gatewayState) const boot = useStore($desktopBoot) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/edit-bar.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/edit-bar.tsx index 56e5c66e22..854229c8f8 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/edit-bar.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/edit-bar.tsx @@ -11,6 +11,7 @@ import { type PointerEvent as ReactPointerEvent, useCallback, useRef, useState } import { Button } from '@/components/ui/button' import { useI18n } from '@/i18n' import { formatCombo } from '@/lib/keybinds/combo' +import { startPointerDrag } from '@/lib/pointer-drag' import { $bindings, bindingsFor } from '@/store/keybinds' import { $layoutEditMode } from '../../edit-mode' @@ -61,13 +62,7 @@ export function TreeEditBar() { setPos(next) } - const onUp = () => { - window.removeEventListener('pointermove', onMove, true) - window.removeEventListener('pointerup', onUp, true) - } - - window.addEventListener('pointermove', onMove, true) - window.addEventListener('pointerup', onUp, true) + startPointerDrag(onMove) }, []) if (!editMode) { diff --git a/apps/desktop/src/components/pane-shell/tree/zone-editor.tsx b/apps/desktop/src/components/pane-shell/tree/zone-editor.tsx index 74a269463e..fe40ca2c8b 100644 --- a/apps/desktop/src/components/pane-shell/tree/zone-editor.tsx +++ b/apps/desktop/src/components/pane-shell/tree/zone-editor.tsx @@ -25,6 +25,7 @@ import { Input } from '@/components/ui/input' import { registry } from '@/contrib/registry' import { useI18n } from '@/i18n' import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers' +import { startPointerDrag } from '@/lib/pointer-drag' import { cn } from '@/lib/utils' import { @@ -214,9 +215,7 @@ export function ZoneEditor() { setSelection(mergeClosureIndices(model, picked)) } - const onUp = (ev: PointerEvent) => { - window.removeEventListener('pointermove', onMove, true) - window.removeEventListener('pointerup', onUp, true) + startPointerDrag(onMove, ev => { setSelectBox(null) if (!dragged) { @@ -233,10 +232,7 @@ export function ZoneEditor() { const rect = canvasRef.current!.getBoundingClientRect() setMergeAt({ x: ev.clientX - rect.x, y: ev.clientY - rect.y }) - } - - window.addEventListener('pointermove', onMove, true) - window.addEventListener('pointerup', onUp, true) + }) } const startResizerDrag = (index: number, e: ReactPointerEvent) => { @@ -267,13 +263,7 @@ export function ZoneEditor() { } } - const onUp = () => { - window.removeEventListener('pointermove', onMove, true) - window.removeEventListener('pointerup', onUp, true) - } - - window.addEventListener('pointermove', onMove, true) - window.addEventListener('pointerup', onUp, true) + startPointerDrag(onMove) } const merge = () => { diff --git a/apps/desktop/src/components/ui/decode-text.tsx b/apps/desktop/src/components/ui/decode-text.tsx index 243de60f62..edd91641a6 100644 --- a/apps/desktop/src/components/ui/decode-text.tsx +++ b/apps/desktop/src/components/ui/decode-text.tsx @@ -1,5 +1,6 @@ import { type ComponentProps, useEffect, useState } from 'react' +import { prefersReducedMotion } from '@/hooks/use-media-query' import { cn } from '@/lib/utils' /** @@ -23,10 +24,6 @@ export const DECODE_SCRAMBLE_CHARS = '/\\|-_=+<>~:*' const TICK_MS = 45 const HOLD_TICKS = 16 -function prefersReducedMotion(): boolean { - return typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) -} - function scrambled(tail: string, resolvedCount: number): string { return Array.from(tail, (ch, i) => ch === ' ' || i < resolvedCount ? ch : DECODE_SCRAMBLE_CHARS[(Math.random() * DECODE_SCRAMBLE_CHARS.length) | 0] diff --git a/apps/desktop/src/hooks/use-media-query.ts b/apps/desktop/src/hooks/use-media-query.ts index aa368dfb93..219715811d 100644 --- a/apps/desktop/src/hooks/use-media-query.ts +++ b/apps/desktop/src/hooks/use-media-query.ts @@ -3,6 +3,10 @@ import { useEffect, useState } from 'react' export const matchesQuery = (query: string) => typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(query).matches +/** Read at call time, not render time: animations check this as they start, and + * the OS setting can flip mid-session. */ +export const prefersReducedMotion = () => matchesQuery('(prefers-reduced-motion: reduce)') + export function useMediaQuery(query: string): boolean { const [matches, setMatches] = useState(() => matchesQuery(query)) diff --git a/apps/desktop/src/lib/audio-context.ts b/apps/desktop/src/lib/audio-context.ts new file mode 100644 index 0000000000..a506399c2d --- /dev/null +++ b/apps/desktop/src/lib/audio-context.ts @@ -0,0 +1,37 @@ +// One lazily-created AudioContext for the app's synthesized UI cues (wake +// chime, turn-completion sound, thinking blips). Browsers cap how many +// contexts a page may open, and these cues are short oscillator bursts that +// need no isolation from each other, so they share one instead of each holding +// its own. Nothing closes it — it lives for the window. + +let ctx: AudioContext | null = null + +/** The shared AudioContext, or null where WebAudio is unavailable. */ +export function getAudioContext(): AudioContext | null { + if (typeof window === 'undefined') { + return null + } + + try { + if (!ctx) { + const Ctor = + window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + + if (!Ctor) { + return null + } + + ctx = new Ctor() + } + + // Autoplay policies can leave the context suspended until a gesture; a + // resume() here recovers it once the user has interacted with the window. + if (ctx.state === 'suspended') { + void ctx.resume().catch(() => undefined) + } + + return ctx + } catch { + return null + } +} diff --git a/apps/desktop/src/lib/completion-sound.ts b/apps/desktop/src/lib/completion-sound.ts index 1557c58e41..6245c10723 100644 --- a/apps/desktop/src/lib/completion-sound.ts +++ b/apps/desktop/src/lib/completion-sound.ts @@ -1,43 +1,13 @@ // Completion sound bank for agent turn-end cues. // Fourteen curated presets for A/B in Settings → Appearance. Default is variant 1. +import { getAudioContext } from '@/lib/audio-context' import { ownsAmbientCue } from '@/store/ambient' import { $completionSoundVariantId, resolveCompletionSoundVariantId } from '@/store/completion-sound' import { $hapticsMuted } from '@/store/haptics' type OscType = OscillatorType -let ctx: AudioContext | null = null - -function getCtx(): AudioContext | null { - if (typeof window === 'undefined') { - return null - } - - try { - if (!ctx) { - const Ctor = - window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext - - if (!Ctor) { - return null - } - - ctx = new Ctor() - } - - // Autoplay policies can leave the context suspended until a gesture; a - // resume() here recovers it once the user has interacted with the window. - if (ctx.state === 'suspended') { - void ctx.resume().catch(() => undefined) - } - - return ctx - } catch { - return null - } -} - // One enveloped oscillator voice → master. Linear attack into an exponential // decay keeps the tail smooth and avoids the click you get ramping to zero. function voice(ac: AudioContext, master: GainNode, t0: number, spec: ToneSpec) { @@ -417,7 +387,7 @@ function playVariant(variantId: number) { return } - const ac = getCtx() + const ac = getAudioContext() if (!ac) { return diff --git a/apps/desktop/src/lib/desktop-git.ts b/apps/desktop/src/lib/desktop-git.ts index 0009900b72..98f41fbf87 100644 --- a/apps/desktop/src/lib/desktop-git.ts +++ b/apps/desktop/src/lib/desktop-git.ts @@ -115,20 +115,3 @@ export function desktopGit(): GitBridge | undefined { return isDesktopFsRemoteMode() ? remoteGit : window.hermesDesktop?.git } - -// True only for "the /api/git route does not exist on this backend" shapes: -// the backend catch-all ('404: {"detail":"No such API endpoint: ...}'), a bare -// FastAPI 404 (directly or through the IPC bridge's "Error invoking remote -// method" wrapper), and the Electron JSON-guard ("endpoint is likely -// missing"). Transient failures (timeouts, 5xx, connection refused) must NOT -// match — they are retryable, not a capability verdict. Mirrors the sidebar -// batch-endpoint detector in hermes.ts. -export function isGitEndpointMissingError(err: unknown): boolean { - const message = err instanceof Error ? err.message : String(err) - - return ( - /no such api endpoint/i.test(message) || - /endpoint is likely missing/i.test(message) || - /(?:^\s*|error:\s*)404\b/i.test(message) - ) -} diff --git a/apps/desktop/src/lib/gateway-rpc.ts b/apps/desktop/src/lib/gateway-rpc.ts index 372cf0bc49..1b52ef1f33 100644 --- a/apps/desktop/src/lib/gateway-rpc.ts +++ b/apps/desktop/src/lib/gateway-rpc.ts @@ -5,6 +5,24 @@ export function isMissingRpcMethod(error: unknown): boolean { return /method not found|-32601|unknown method|no such method/i.test(message) } +/** REST twin of isMissingRpcMethod: the route does not exist on this backend. + * Matches the backend catch-all ('404: {"detail":"No such API endpoint: …}'), + * FastAPI's bare 404 on headless serve — directly, or wrapped as "Error + * invoking remote method 'hermes:api': Error: 404: …" through the IPC bridge + * — and the Electron JSON-guard ("endpoint is likely missing"). Transient + * failures (timeouts, 5xx, connection refused) must NOT match: they are + * retryable, not a capability verdict. Only sound for calls where a 404 can + * mean nothing else — a route with path params can 404 on a bad id. */ +export function isMissingRestEndpoint(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + + return ( + /no such api endpoint/i.test(message) || + /endpoint is likely missing/i.test(message) || + /(?:^\s*|error:\s*)404\b/i.test(message) + ) +} + /** True when a prompt response raced a backend-side timeout / completion. */ export function isMissingPendingPromptRequest(error: unknown, key: string): boolean { const message = error instanceof Error ? error.message : String(error) diff --git a/apps/desktop/src/lib/keyed-timeouts.ts b/apps/desktop/src/lib/keyed-timeouts.ts new file mode 100644 index 0000000000..fc3208cb92 --- /dev/null +++ b/apps/desktop/src/lib/keyed-timeouts.ts @@ -0,0 +1,29 @@ +/** Per-key setTimeout registry for "let this linger, then drop it" UI state. + * Scheduling a key replaces its pending timer, and an entry removes itself + * when it fires, so the map never accumulates dead keys. */ +export function keyedTimeouts() { + const timers = new Map>() + + const cancel = (key: string) => { + const timer = timers.get(key) + + if (timer !== undefined) { + clearTimeout(timer) + timers.delete(key) + } + } + + return { + cancel, + schedule(key: string, delayMs: number, run: () => void) { + cancel(key) + timers.set( + key, + setTimeout(() => { + timers.delete(key) + run() + }, delayMs) + ) + } + } +} diff --git a/apps/desktop/src/lib/mcp-import.ts b/apps/desktop/src/lib/mcp-import.ts index 7b83460b01..7437c375ea 100644 --- a/apps/desktop/src/lib/mcp-import.ts +++ b/apps/desktop/src/lib/mcp-import.ts @@ -4,6 +4,8 @@ // entries. Pure and side-effect free; the MCP tab merges the result into the // editor draft. Returns null when nothing recognizable is in the text. +import { isServerShape, normalizeEntry } from '@/lib/mcp-servers' + export interface McpImportEntry { config: Record name: string @@ -12,21 +14,6 @@ export interface McpImportEntry { const isRecord = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value) -const isServerShape = (value: Record) => - typeof value.command === 'string' || typeof value.url === 'string' - -// Cursor/Claude write `type`; Hermes reads `transport` (same normalization the -// MCP tab applies to pasted JSON). -function normalizeEntry(entry: Record): Record { - if (typeof entry.type === 'string' && entry.transport === undefined) { - const { type, ...rest } = entry - - return { ...rest, transport: type } - } - - return entry -} - const URL_RE = /^https?:\/\/\S+$/i // "mcp.linear.app" → "linear": drop generic front labels, keep the brand one. diff --git a/apps/desktop/src/lib/mcp-servers.ts b/apps/desktop/src/lib/mcp-servers.ts new file mode 100644 index 0000000000..38bf9046bc --- /dev/null +++ b/apps/desktop/src/lib/mcp-servers.ts @@ -0,0 +1,29 @@ +// Shape helpers for the `mcp_servers` config map, shared by everything that +// reads or writes it: the MCP tab editor, the paste-anything importer, and the +// `hermes://mcp/install` deeplink dialog. These agree on what a server entry +// looks like, so they belong in one place — a config written by one path has to +// be readable by the others. + +export type McpServers = Record> + +export const isServerShape = (value: Record) => + typeof value.command === 'string' || typeof value.url === 'string' + +/** Cursor/Claude write `type`; Hermes reads `transport`. Normalizing on the way + * in makes pasted configs behave identically under the CLI/TUI loader. */ +export function normalizeEntry(entry: Record): Record { + if (typeof entry.type === 'string' && entry.transport === undefined) { + const { type, ...rest } = entry + + return { ...rest, transport: type } + } + + return entry +} + +/** The `mcp_servers` map out of a config record, or `{}` when absent/malformed. */ +export function getServers(config: { mcp_servers?: unknown } | null): McpServers { + const raw = config?.mcp_servers + + return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as McpServers) : {} +} diff --git a/apps/desktop/src/lib/pointer-drag.ts b/apps/desktop/src/lib/pointer-drag.ts new file mode 100644 index 0000000000..43ae8aa5a0 --- /dev/null +++ b/apps/desktop/src/lib/pointer-drag.ts @@ -0,0 +1,26 @@ +/** Run a pointer drag until pointerup, then unbind both listeners. + * + * Capture-phase, on `window`, so the drag keeps tracking when the pointer + * leaves the element it started on. Forgetting one half of the teardown leaks + * a live pointermove handler for the rest of the session, which is why the + * unbinding lives here rather than at each call site. */ +export function startPointerDrag( + onMove: (event: PointerEvent) => void, + onEnd?: (event: PointerEvent) => void +): () => void { + const move = (event: PointerEvent) => onMove(event) + + const up = (event: PointerEvent) => { + window.removeEventListener('pointermove', move, true) + window.removeEventListener('pointerup', up, true) + onEnd?.(event) + } + + window.addEventListener('pointermove', move, true) + window.addEventListener('pointerup', up, true) + + return () => { + window.removeEventListener('pointermove', move, true) + window.removeEventListener('pointerup', up, true) + } +} diff --git a/apps/desktop/src/lib/text.ts b/apps/desktop/src/lib/text.ts index 815a5d731d..eecb0cc715 100644 --- a/apps/desktop/src/lib/text.ts +++ b/apps/desktop/src/lib/text.ts @@ -13,3 +13,17 @@ export const normalize = (v: unknown): string => asText(v).trim().toLowerCase() /** Uppercase the first character, leave the rest. Matches the * `s.charAt(0).toUpperCase() + s.slice(1)` idiom (empty-safe). */ export const capitalize = (v: string): string => (v ? v.charAt(0).toUpperCase() + v.slice(1) : v) + +/** First non-empty string among `keys`, trimmed. For reading tool args and + * results, where the key carrying the interesting value varies by tool. */ +export const firstStringField = (record: Record, keys: readonly string[]): string => { + for (const key of keys) { + const value = record[key] + + if (typeof value === 'string' && value.trim()) { + return value.trim() + } + } + + return '' +} diff --git a/apps/desktop/src/lib/thinking-sound.ts b/apps/desktop/src/lib/thinking-sound.ts index 6a4d2bd6da..6d78dc50fe 100644 --- a/apps/desktop/src/lib/thinking-sound.ts +++ b/apps/desktop/src/lib/thinking-sound.ts @@ -11,40 +11,13 @@ // stopThinkingSound() — callers fire it the moment TTS starts, the mic re-arms, // or the conversation ends. +import { getAudioContext } from '@/lib/audio-context' import { $hapticsMuted } from '@/store/haptics' import { $thinkingSoundEnabled } from '@/store/voice-prefs' -let ctx: AudioContext | null = null let timer: number | null = null let blipIndex = 0 -function getCtx(): AudioContext | null { - if (typeof window === 'undefined') { - return null - } - - try { - if (!ctx) { - const Ctor = - window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext - - if (!Ctor) { - return null - } - - ctx = new Ctor() - } - - if (ctx.state === 'suspended') { - void ctx.resume().catch(() => undefined) - } - - return ctx - } catch { - return null - } -} - // One soft "blub": short sine with a gentle downward pitch glide and a smooth // attack into an exponential decay — no clicks, deliberately quiet. function blub(ac: AudioContext, freq: number) { @@ -79,7 +52,7 @@ export function startThinkingSound(): void { const tick = () => { if ($hapticsMuted.get() === false) { - const ac = getCtx() + const ac = getAudioContext() if (ac) { try { diff --git a/apps/desktop/src/lib/wake-sound.ts b/apps/desktop/src/lib/wake-sound.ts index 3eb9f1c0d8..acd61a267c 100644 --- a/apps/desktop/src/lib/wake-sound.ts +++ b/apps/desktop/src/lib/wake-sound.ts @@ -5,39 +5,9 @@ // the completion cue settles (done). Reuses the same lightweight WebAudio // synthesis approach — no asset file to ship. +import { getAudioContext } from '@/lib/audio-context' import { $hapticsMuted } from '@/store/haptics' -let ctx: AudioContext | null = null - -function getCtx(): AudioContext | null { - if (typeof window === 'undefined') { - return null - } - - try { - if (!ctx) { - const Ctor = - window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext - - if (!Ctor) { - return null - } - - ctx = new Ctor() - } - - // Autoplay policies can leave the context suspended until a gesture; a - // resume() here recovers it once the user has interacted with the window. - if (ctx.state === 'suspended') { - void ctx.resume().catch(() => undefined) - } - - return ctx - } catch { - return null - } -} - // One enveloped sine voice → master. Linear-ish attack into an exponential // decay keeps the tail smooth and avoids the click you get ramping to zero. function ding(ac: AudioContext, master: GainNode, t0: number, freq: number, dur: number, gain: number) { @@ -66,7 +36,7 @@ export function playWakeSound(): void { return } - const ac = getCtx() + const ac = getAudioContext() if (!ac) { return diff --git a/apps/desktop/src/store/data-url-read-max.ts b/apps/desktop/src/store/data-url-read-max.ts index b37556d3fd..e95dfb61af 100644 --- a/apps/desktop/src/store/data-url-read-max.ts +++ b/apps/desktop/src/store/data-url-read-max.ts @@ -2,28 +2,21 @@ * Max size for local files Desktop loads as data URLs (composer attach, image * previews, etc.). Main owns the real cap + its JSON under userData — this * atom only mirrors it for Settings → Chat. See electron/main.ts - * (`hermes:data-url-read-max:*`) and the default/clamp in electron/hardening.ts. + * (`hermes:data-url-read-max:*`). The default and the clamp are shared with + * main via apps/shared so the two ends cannot drift. */ +import { + clampDataUrlReadMaxMb, + DATA_URL_READ_DEFAULT_MAX_MB, + DATA_URL_READ_MAX_MAX_MB, + DATA_URL_READ_MIN_MAX_MB +} from '@hermes/shared' import { atom } from 'nanostores' import { notifyError } from '@/store/notifications' -/** Ship default; must match DATA_URL_READ_DEFAULT_MAX_MB in hardening.ts. */ -export const DATA_URL_READ_DEFAULT_MAX_MB = 16 -export const DATA_URL_READ_MIN_MAX_MB = 1 -/** Typo / absurd-value guard only — large values can still OOM the app. */ -export const DATA_URL_READ_MAX_MAX_MB = 4096 - -export function clampDataUrlReadMaxMb(value: unknown): number { - const parsed = Number(value) - - if (!Number.isFinite(parsed)) { - return DATA_URL_READ_DEFAULT_MAX_MB - } - - return Math.min(DATA_URL_READ_MAX_MAX_MB, Math.max(DATA_URL_READ_MIN_MAX_MB, Math.round(parsed))) -} +export { clampDataUrlReadMaxMb, DATA_URL_READ_DEFAULT_MAX_MB, DATA_URL_READ_MAX_MAX_MB, DATA_URL_READ_MIN_MAX_MB } export const $dataUrlReadMaxMb = atom(DATA_URL_READ_DEFAULT_MAX_MB) diff --git a/apps/desktop/src/store/goals.ts b/apps/desktop/src/store/goals.ts index 3b19b7eb63..eab0188d66 100644 --- a/apps/desktop/src/store/goals.ts +++ b/apps/desktop/src/store/goals.ts @@ -1,5 +1,7 @@ import { atom } from 'nanostores' +import { keyedTimeouts } from '@/lib/keyed-timeouts' + import { $gateway } from './gateway' export type GoalStatus = 'active' | 'done' | 'paused' | 'waiting' @@ -14,38 +16,23 @@ export interface SessionGoal { export const $goalsBySession = atom>({}) const DONE_LINGER_MS = 8_000 -const clearTimers = new Map>() - -function cancelScheduledClear(sid: string) { - const timer = clearTimers.get(sid) - - if (timer !== undefined) { - clearTimeout(timer) - clearTimers.delete(sid) - } -} +const clearTimers = keyedTimeouts() export function setSessionGoal(sid: string, goal: SessionGoal) { if (!sid) { return } - cancelScheduledClear(sid) + clearTimers.cancel(sid) $goalsBySession.set({ ...$goalsBySession.get(), [sid]: goal }) if (goal.status === 'done') { - clearTimers.set( - sid, - setTimeout(() => { - clearTimers.delete(sid) - clearSessionGoal(sid) - }, DONE_LINGER_MS) - ) + clearTimers.schedule(sid, DONE_LINGER_MS, () => clearSessionGoal(sid)) } } export function clearSessionGoal(sid: string) { - cancelScheduledClear(sid) + clearTimers.cancel(sid) const map = $goalsBySession.get() diff --git a/apps/desktop/src/store/pet-gallery.ts b/apps/desktop/src/store/pet-gallery.ts index 60322bcc1f..8c7f282c56 100644 --- a/apps/desktop/src/store/pet-gallery.ts +++ b/apps/desktop/src/store/pet-gallery.ts @@ -1,5 +1,6 @@ import { atom } from 'nanostores' +import { isMissingRpcMethod } from '@/lib/gateway-rpc' import { normalize } from '@/lib/text' import { $petInfo, @@ -64,12 +65,6 @@ const petRpc = (request: GatewayRequest, method: string, params: Record(method, { ...params, profile: petProfile() }) /** A JSON-RPC "method not found" — the backend predates the pet RPCs. */ -function isMissingMethod(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - - return /method not found|-32601|unknown method|no such method/i.test(message) -} - export const $petGallery = atom(null) export const $petGalleryStatus = atom('idle') export const $petGalleryError = atom(null) @@ -148,7 +143,7 @@ export function loadPetGallery(request: GatewayRequest, options: { force?: boole localOk = true } } catch (e) { - if (isMissingMethod(e)) { + if (isMissingRpcMethod(e)) { $petGalleryStatus.set('stale') } else if (!$petGallery.get()) { // Only surface a hard error when we have nothing to show; a transient @@ -188,7 +183,7 @@ async function syncInfo(request: GatewayRequest): Promise { try { meta = await petRpc(request, 'pet.info.meta') } catch (e) { - if (!isMissingMethod(e)) { + if (!isMissingRpcMethod(e)) { throw e } @@ -308,7 +303,7 @@ async function mutate( return true } catch (e) { - if (isMissingMethod(e)) { + if (isMissingRpcMethod(e)) { $petGalleryStatus.set('stale') } else { $petGalleryError.set(e instanceof Error ? e.message : fallback) diff --git a/apps/desktop/src/store/pet-generate.ts b/apps/desktop/src/store/pet-generate.ts index e829e68c09..bbb6986edc 100644 --- a/apps/desktop/src/store/pet-generate.ts +++ b/apps/desktop/src/store/pet-generate.ts @@ -1,5 +1,6 @@ import { atom } from 'nanostores' +import { isMissingRpcMethod } from '@/lib/gateway-rpc' import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage' import { capitalize } from '@/lib/text' import { $gateway } from '@/store/gateway' @@ -181,12 +182,6 @@ export const $petGenInput = atom('') export const $petGenRefImage = atom(null) export const $petGenRefName = atom('') -function isMissingMethod(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - - return /method not found|-32601|unknown method|no such method/i.test(message) -} - /** Clear all generation state (before a fresh run). */ export function resetPetGen(): void { $petGenStatus.set('idle') @@ -439,7 +434,7 @@ export async function generateDrafts(request: GatewayRequest, options: GenerateO return false } - if (isMissingMethod(e)) { + if (isMissingRpcMethod(e)) { $petGenStatus.set('stale') } else { $petGenStatus.set('error') diff --git a/apps/desktop/src/store/projects.ts b/apps/desktop/src/store/projects.ts index 9af7255c6f..b904b0f34e 100644 --- a/apps/desktop/src/store/projects.ts +++ b/apps/desktop/src/store/projects.ts @@ -9,8 +9,8 @@ import type { HermesGitBaseBranch, HermesGitBranch } from '@/global' import { getHermesConfig, hermesApi, type HermesGateway } from '@/hermes' import { translateNow } from '@/i18n' import { desktopDefaultCwd, isDesktopFsRemoteMode, selectDesktopPaths, writeDesktopFileText } from '@/lib/desktop-fs' -import { desktopGit, isGitEndpointMissingError } from '@/lib/desktop-git' -import { isMissingRpcMethod } from '@/lib/gateway-rpc' +import { desktopGit } from '@/lib/desktop-git' +import { isMissingRestEndpoint, isMissingRpcMethod } from '@/lib/gateway-rpc' import { isUnderPath } from '@/lib/path-compare' import { persistentAtom } from '@/lib/persisted' import { $gateway, activeGateway, ensureActiveGatewayOpen } from '@/store/gateway' @@ -1097,7 +1097,7 @@ export async function startWorkInRepo( // backend's /api/git mirror, and an older backend may predate it. The raw // failure ("Expected JSON … but got HTML" / a bare 404) reads like a git // error — name the real remedy instead of degrading silently. - if (isDesktopFsRemoteMode() && isGitEndpointMissingError(err)) { + if (isDesktopFsRemoteMode() && isMissingRestEndpoint(err)) { throw new Error(translateNow('sidebar.projects.worktreeStaleBackend')) } diff --git a/apps/desktop/src/store/todos.ts b/apps/desktop/src/store/todos.ts index e63c4f67f5..864ab5c2cb 100644 --- a/apps/desktop/src/store/todos.ts +++ b/apps/desktop/src/store/todos.ts @@ -1,5 +1,6 @@ import { atom, computed } from 'nanostores' +import { keyedTimeouts } from '@/lib/keyed-timeouts' import { stableRecord } from '@/lib/stable-array' import type { TodoItem } from '@/lib/todos' @@ -66,38 +67,23 @@ export function todosForHydration(todos: readonly TodoItem[] | null): TodoItem[] // lingers just long enough to see the last checkmark land, then the group // drops out of the stack on its own. const FINISHED_LINGER_MS = 4_000 -const clearTimers = new Map>() - -function cancelScheduledClear(sid: string) { - const timer = clearTimers.get(sid) - - if (timer !== undefined) { - clearTimeout(timer) - clearTimers.delete(sid) - } -} +const clearTimers = keyedTimeouts() export function setSessionTodos(sid: string, todos: TodoItem[]) { if (!sid) { return } - cancelScheduledClear(sid) + clearTimers.cancel(sid) $todosBySession.set({ ...$todosBySession.get(), [sid]: todos }) if (!todoListActive(todos)) { - clearTimers.set( - sid, - setTimeout(() => { - clearTimers.delete(sid) - clearSessionTodos(sid) - }, FINISHED_LINGER_MS) - ) + clearTimers.schedule(sid, FINISHED_LINGER_MS, () => clearSessionTodos(sid)) } } export function clearSessionTodos(sid: string) { - cancelScheduledClear(sid) + clearTimers.cancel(sid) const map = $todosBySession.get() diff --git a/apps/shared/src/data-url-read-max.ts b/apps/shared/src/data-url-read-max.ts new file mode 100644 index 0000000000..1b8cd157f6 --- /dev/null +++ b/apps/shared/src/data-url-read-max.ts @@ -0,0 +1,27 @@ +/** + * Size cap for local files Desktop loads as data URLs (composer attach, image + * preview, …). + * + * Main owns the persisted value; the renderer mirrors it in Settings → Chat and + * clamps optimistically before sending. Both ends have to agree on the default + * and the bounds, so they live here rather than as two constants with a + * "keep these in sync" comment between them. + * + * The whole file is base64-buffered in main, so this is a memory guard, not a + * model limit. The ceiling is only a typo guard — values well below it can + * still OOM the app. + */ + +export const DATA_URL_READ_DEFAULT_MAX_MB = 16 +export const DATA_URL_READ_MIN_MAX_MB = 1 +export const DATA_URL_READ_MAX_MAX_MB = 4096 + +export function clampDataUrlReadMaxMb(value: unknown): number { + const parsed = Number(value) + + if (!Number.isFinite(parsed)) { + return DATA_URL_READ_DEFAULT_MAX_MB + } + + return Math.min(DATA_URL_READ_MAX_MAX_MB, Math.max(DATA_URL_READ_MIN_MAX_MB, Math.round(parsed))) +} diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index b4f6299ca2..5dfe39e1ff 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -40,6 +40,12 @@ export { type CronTriggerController, type CronTriggerRunResult } from './cron-trigger-controller' +export { + clampDataUrlReadMaxMb, + DATA_URL_READ_DEFAULT_MAX_MB, + DATA_URL_READ_MAX_MAX_MB, + DATA_URL_READ_MIN_MAX_MB +} from './data-url-read-max' export { type ConnectionState, type GatewayClientOptions, From a8d87ac1d9e055b33aaa45dd8b5ca2cae6e84f04 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 19 Aug 2026 02:52:20 -0500 Subject: [PATCH 15/48] fix(desktop): keep the preload bridge alive under the sandbox Deciding whether the OS can back glass needs os.release(), but every Hermes window runs its preload with sandbox: true, where require is a polyfill limited to electron, events, timers and url. The node:os import threw before contextBridge ran, so window.hermesDesktop was never defined and the app booted straight into "Desktop IPC bridge is unavailable". Main already computes both verdicts, so preload asks for them over a synchronous channel instead. No reply degrades to no glass, which is an ordinary opaque window rather than a page thinned over nothing. --- apps/desktop/e2e/boot.spec.ts | 19 +++++++++++++++++++ apps/desktop/electron/main.ts | 11 +++++++++++ apps/desktop/electron/preload.ts | 14 ++++++++++---- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/apps/desktop/e2e/boot.spec.ts b/apps/desktop/e2e/boot.spec.ts index 3a74fa4cc5..19f2d81fa4 100644 --- a/apps/desktop/e2e/boot.spec.ts +++ b/apps/desktop/e2e/boot.spec.ts @@ -50,6 +50,25 @@ test.describe('dev-mode boot with mock backend', () => { }) }) + // A preload that throws never reaches contextBridge, so the renderer boots + // into "Desktop IPC bridge is unavailable" and every test below it dies on a + // 120s never-became-ready timeout instead. Checking the bridge by name makes + // that failure legible. The sandbox lets preload require only electron, + // events, timers and url — adding any other node builtin lands here. + test('the preload bridge reaches the renderer', async () => { + const bridge = await fixture!.page.evaluate(() => { + const desktop = (window as unknown as { hermesDesktop?: Record }).hermesDesktop + + return { + present: typeof desktop, + glassSupported: typeof desktop?.glassSupported, + translucencySupported: typeof desktop?.translucencySupported + } + }) + + expect(bridge).toEqual({ present: 'object', glassSupported: 'boolean', translucencySupported: 'boolean' }) + }) + test('backend boots and app becomes ready', async () => { // This is the big one — wait for the full boot chain to complete: // electron starts → hermes serve is spawned → WS connects → config diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index c4746fc313..d4e3fcf165 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -306,6 +306,7 @@ import { glassActive, glassSupportedOn, normalizeState as normalizeTranslucency, + translucencySupportedOn, vibrancyFor as vibrancyForTranslucency, windowBackingOptions, windowOpacityFor @@ -402,6 +403,9 @@ const DARWIN_MAJOR = IS_MAC ? Number.parseInt(os.release(), 10) || 0 : 0 // Glass: macOS vibrancy, or Windows 11 22H2+ system backdrop. Computed once // so the renderer, the persisted default, and every chat window agree. const GLASS_SUPPORTED = glassSupportedOn(process.platform, os.release()) +// Clear rides setOpacity, a documented no-op on Linux, so neither mode works +// there and Settings drops the row entirely. +const TRANSLUCENCY_SUPPORTED = translucencySupportedOn(process.platform) const APP_ROOT = app.getAppPath() // Device-local preference: block F12 from opening DevTools. @@ -14106,6 +14110,13 @@ app.on('before-quit', () => { } }) +// Answered synchronously so preload can publish the verdict before the +// renderer's first script — see the note there on why it cannot decide this +// itself. Registered at module scope, which runs long before any window. +ipcMain.on('hermes:translucency:support', event => { + event.returnValue = { glass: GLASS_SUPPORTED, translucency: TRANSLUCENCY_SUPPORTED } +}) + ipcMain.on('hermes:translucency', (_event, payload) => { const next = normalizeTranslucency(payload, GLASS_SUPPORTED) const previous = translucencyState diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 1296d08ff9..2d1ba597ce 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -1,11 +1,17 @@ import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' -import os from 'node:os' -import { glassSupportedOn, translucencySupportedOn } from '../../shared/src/translucency' +// Which translucency the OS can back. Asked synchronously because the renderer +// needs it before its first paint, and answered by main because deciding it +// needs `os.release()` — a sandboxed preload may only require electron, events, +// timers and url, so importing node:os here throws before contextBridge runs +// and takes the ENTIRE bridge down with it (window.hermesDesktop undefined => +// "Desktop IPC bridge is unavailable"). No reply means no glass, which degrades +// to an ordinary opaque window rather than a page thinned over nothing. +const translucencySupport = ipcRenderer.sendSync('hermes:translucency:support') contextBridge.exposeInMainWorld('hermesDesktop', { - glassSupported: glassSupportedOn(process.platform, os.release()), - translucencySupported: translucencySupportedOn(process.platform), + glassSupported: translucencySupport?.glass === true, + translucencySupported: translucencySupport?.translucency === true, getConnection: profile => ipcRenderer.invoke('hermes:connection', profile), // Registry-scoped backend resolution: { connectionId, profile } → descriptor. getConnectionFor: payload => ipcRenderer.invoke('hermes:connection:for', payload), From 7ed3a5cbe293692fc33e42b70e025fc8f87ae17e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 19 Aug 2026 03:34:50 -0500 Subject: [PATCH 16/48] docs(desktop): repoint comments at the modules that now own the code Comments naming gateway-event.ts, chat-messages.ts and hermes.ts as the place to look, for files those symbols no longer live in. --- .../desktop/src/app/contrib/dev/credits-notice-demo.ts | 2 +- .../src/app/session/hooks/use-prompt-actions/rewind.ts | 2 +- apps/desktop/src/store/live-sync.ts | 10 +++++----- apps/desktop/src/store/settings-scope.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/app/contrib/dev/credits-notice-demo.ts b/apps/desktop/src/app/contrib/dev/credits-notice-demo.ts index 1766489818..4f934b8e53 100644 --- a/apps/desktop/src/app/contrib/dev/credits-notice-demo.ts +++ b/apps/desktop/src/app/contrib/dev/credits-notice-demo.ts @@ -2,7 +2,7 @@ // usage bands. Each trigger emits ONE synthetic `notification.show` / // `notification.clear` gateway event through the real fan-out // (`emitLocalGatewayEvent`), so it exercises the actual dispatcher branch in -// `use-message-stream/gateway-event.ts` — toast render, key-replacement +// `use-message-stream/gateway-event/status.ts` — toast render, key-replacement // escalation, TTL self-dismiss, native OS notification, and billing re-poll. // // Installed only under `import.meta.env.DEV` (see contrib/wiring.tsx), so none diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts index 8b83ba6c1d..aec6ad6ac6 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts @@ -94,7 +94,7 @@ export function rebindSurvivorRowIds(messages: ChatMessage[], survivorRowIds: Su /** * Renderer-synthetic message ids (`${timestamp}-${index}-${role}` from - * chat-messages.ts, plus older `user-…` / `assistant-…` shapes). Gateway + * chat-messages/hydration.ts, plus older `user-…` / `assistant-…` shapes). Gateway * history never carries them — only durable `row_id` / platform message_id. */ export function isSyntheticRendererId(messageId: string | undefined): boolean { diff --git a/apps/desktop/src/store/live-sync.ts b/apps/desktop/src/store/live-sync.ts index 61b2a55cf9..844c4d39f2 100644 --- a/apps/desktop/src/store/live-sync.ts +++ b/apps/desktop/src/store/live-sync.ts @@ -3,11 +3,11 @@ import { atom } from 'nanostores' // Event-driven "backend data changed" signals — the workspace-events twin for // gateway-side state. The change watcher in tui_gateway broadcasts // `pet.changed` / `cron.changed` / `sessions.changed` when its on-disk -// signatures move (see server._broadcast_watched_changes), gateway-event.ts -// routes them here, and the surfaces that used to poll (cron sidebar/page, -// messaging lists, pet sprite, live session statuses) subscribe to these ticks -// and refresh — so they move exactly when the backend acts and stay idle -// otherwise. +// signatures move (see server._broadcast_watched_changes), the gateway-event +// lifecycle handler routes them here, and the surfaces that used to poll (cron +// sidebar/page, messaging lists, pet sprite, live session statuses) subscribe +// to these ticks and refresh — so they move exactly when the backend acts and +// stay idle otherwise. // // `$changeEventsAvailable` is the compat gate: gateway.ready advertises // `change_events: true` on backends that broadcast. Consumers keep their old diff --git a/apps/desktop/src/store/settings-scope.ts b/apps/desktop/src/store/settings-scope.ts index 0c079bb7ff..c8178f22df 100644 --- a/apps/desktop/src/store/settings-scope.ts +++ b/apps/desktop/src/store/settings-scope.ts @@ -8,7 +8,7 @@ import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' // picking a profile on one page carries to the next instead of resetting per // page. `null` means "follow the app's active profile" — the default, which // keeps single-profile users on the exact pre-existing code path (requests -// fall back to the app-wide active profile in hermes.ts `profileScoped`). +// fall back to the app-wide active profile in api/client.ts `profileScoped`). export const $settingsScopeOverride = atom(null) // The profile the settings pages are currently editing (a concrete key). From 9ab5d92fac2e84021aedb4903c61abcc74daadba Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 19 Aug 2026 02:57:53 -0500 Subject: [PATCH 17/48] fix(desktop): sort translucency named exports for eslint Perfectionist wants values before types in the glass/Windows barrel. --- apps/desktop/electron/translucency.test.ts | 6 +++--- apps/desktop/electron/translucency.ts | 4 ++-- apps/shared/src/index.ts | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/desktop/electron/translucency.test.ts b/apps/desktop/electron/translucency.test.ts index 44f90a2d12..6795fdd5d7 100644 --- a/apps/desktop/electron/translucency.test.ts +++ b/apps/desktop/electron/translucency.test.ts @@ -34,10 +34,10 @@ import { type TranslucencyState, translucencySupportedOn, vibrancyFor, - WINDOWS_BACKGROUND_MATERIALS, - WINDOWS_GLASS_MIN_BUILD, windowBackingOptions, - windowOpacityFor + windowOpacityFor, + WINDOWS_BACKGROUND_MATERIALS, + WINDOWS_GLASS_MIN_BUILD } from './translucency' /** The linear ramp the curve replaced. Endpoints must still agree with it. */ diff --git a/apps/desktop/electron/translucency.ts b/apps/desktop/electron/translucency.ts index aefedc21f0..6e524a8d8f 100644 --- a/apps/desktop/electron/translucency.ts +++ b/apps/desktop/electron/translucency.ts @@ -37,10 +37,10 @@ export { type TranslucencyState, translucencySupportedOn, vibrancyFor, + windowOpacityFor, WINDOWS_BACKGROUND_MATERIALS, WINDOWS_GLASS_MIN_BUILD, - type WindowsBackgroundMaterial, - windowOpacityFor + type WindowsBackgroundMaterial } from '../../shared/src/translucency' /** diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index 7f96081096..1b6ef4558a 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -71,9 +71,9 @@ export { GLASS_SCOPES, glassActive, type GlassMaterial, - type GlassScope, glassMaterialForPicker, glassMaterialsFor, + type GlassScope, glassSupportedOn, glassSurfaceKeep, normalizeMaterial, @@ -89,10 +89,10 @@ export { type TranslucencyState, translucencySupportedOn, vibrancyFor, + windowOpacityFor, WINDOWS_BACKGROUND_MATERIALS, WINDOWS_GLASS_MIN_BUILD, - type WindowsBackgroundMaterial, - windowOpacityFor + type WindowsBackgroundMaterial } from './translucency' export { buildHermesWebSocketUrl, From 808fde106dc7f81aec097a98560e14d28d7a966c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 19 Aug 2026 03:34:50 -0500 Subject: [PATCH 18/48] test(desktop): share the duplicated fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same AST sweep over specs found fixtures maintained in parallel across suites that have no reason to know about each other. Twenty-one specs each mounted useMessageStream themselves and ten of the harnesses were byte-identical; twenty now take renderMessageStream, with overrides for the seams that genuinely vary. The SessionInfo builder was spelled out field-by-field in seven specs, so a new backend field broke seven files instead of one. Twenty specs carried their own inert ResizeObserver and eleven repeated the animation-frame, CSS.escape, scrollTo and WAAPI stubs the transcript needs to mount at all — split by scope into src/test/jsdom for what any component might need and the assistant-ui folder's own kit for the transcript. Plus the window-state bridge, deferred, the external-store thread runtime, the manual createRoot harness, and the per-folder caret, env-var, provider and session fixtures. Left alone on purpose: the store suites' makePrimary, where the vi.mock harness around it is the actual duplication and cannot be hoisted out of a hoisted factory; electron's deferred, where reaching into src/ from the main process would invert the layering for eight lines; and the two suites that compose another hook alongside the stream. --- apps/desktop/e2e/batch-clarify.spec.ts | 4 +- .../composer/hooks/use-composer-undo.test.tsx | 19 +- .../src/app/chat/composer/rich-editor.test.ts | 29 +-- .../src/app/chat/composer/test-utils.ts | 14 ++ .../chat/session-tile-attachments.test.tsx | 12 +- .../sidebar/projects/workspace-groups.test.ts | 178 +++++++++--------- .../highlight-watcher.test.tsx | 14 +- .../hooks/use-desktop-integrations.test.tsx | 21 +-- .../desktop/src/app/cron/cron-actions.test.ts | 12 +- .../terminal/persistent.test.tsx | 97 +++------- .../session/hooks/use-cwd-actions.test.tsx | 12 +- .../session/hooks/use-hermes-config.test.ts | 12 +- .../agent-init-error.test.tsx | 63 ++----- .../approval-mode-event.test.tsx | 63 ++----- .../clarify-hydration.test.tsx | 90 +++------ .../compaction-event.test.tsx | 54 +----- .../composer-model-event.test.tsx | 59 ++---- .../use-message-stream/delta-flush.test.tsx | 62 ++---- .../interim-sealing.test.tsx | 100 ++++------ .../moa-progress-event.test.tsx | 87 ++------- .../moa-reference-event.test.tsx | 79 ++------ .../pet-tool-failure-event.test.tsx | 54 +----- .../provider-wait-event.test.tsx | 44 +---- .../session-info-side-effects.test.tsx | 78 +++----- .../session-reclaimed.test.tsx | 71 ++----- .../stale-pending-settle.test.tsx | 58 +----- .../use-message-stream/stream-flush.test.tsx | 58 +----- .../terminal-error-frame.test.tsx | 66 ++----- .../hooks/use-message-stream/test-harness.tsx | 117 ++++++++++++ .../timeline-events.test.tsx | 59 +----- .../use-message-stream/todo-cleanup.test.tsx | 62 ++---- .../tool-drafting-event.test.tsx | 59 ++---- .../hooks/use-message-stream/usage.test.tsx | 67 ++----- .../session/hooks/use-model-controls.test.tsx | 12 +- .../hooks/use-prompt-actions/rewind.test.ts | 4 +- .../hooks/use-session-actions.test.tsx | 11 +- .../hooks/use-session-list-actions.test.tsx | 11 +- .../session/workspace-session-target.test.ts | 12 +- .../src/app/settings/keys-settings.test.tsx | 26 +-- .../app/settings/searchable-select.test.tsx | 11 +- .../src/app/settings/settings-search.test.ts | 17 +- apps/desktop/src/app/settings/test-utils.ts | 17 ++ .../src/app/shell/approval-mode-menu.test.tsx | 14 +- .../shell/hooks/use-status-snapshot.test.ts | 14 +- .../app/shell/statusbar-visibility.test.tsx | 16 +- .../components/assistant-ui/test-utils.tsx | 86 +++++++++ .../assistant-ui/thread-remount.test.tsx | 50 +---- .../thread/assistant-message.test.tsx | 19 +- .../thread/block-direction.test.tsx | 38 +--- .../thread/double-click-reaction.test.tsx | 30 +-- .../duplicate-activity-indicator.test.tsx | 53 +----- .../assistant-ui/thread/edit-context.test.tsx | 69 +------ .../thread/status-tail-only.test.tsx | 61 ++---- .../assistant-ui/thread/streaming.test.tsx | 58 ++---- .../thread/system-message.test.tsx | 20 +- .../thread/turn-gap-indicator.test.tsx | 65 ++----- .../thread/user-message-edit-gesture.test.tsx | 60 +----- .../thread/user-message-edit.test.tsx | 66 +------ .../assistant-ui/tool/tool-group.test.tsx | 55 +----- .../image-generation-placeholder.test.tsx | 28 +-- .../src/components/language-switcher.test.tsx | 16 +- .../src/components/onboarding/index.test.tsx | 29 ++- .../tree/renderer/floating-panes.test.tsx | 46 ++--- .../tree/renderer/narrow-overlays.test.tsx | 11 +- .../tree/renderer/tool-panel-close.test.tsx | 14 +- .../src/components/pet/pet-sprite.test.tsx | 85 +++------ .../src/components/pet/use-pet-roam.test.tsx | 77 ++------ .../src/components/ui/status-pulse.test.tsx | 34 +--- apps/desktop/src/lib/budgeted-loop.test.ts | 28 +-- .../src/lib/session-branch-tree.test.ts | 19 +- .../src/lib/session-date-groups.test.ts | 19 +- apps/desktop/src/store/approval-mode.test.ts | 14 +- .../src/store/cron-model-impact.test.ts | 12 +- apps/desktop/src/store/onboarding.test.ts | 40 ++-- .../store/profile-agent-activation.test.ts | 12 +- apps/desktop/src/store/session-color.test.ts | 45 ++--- apps/desktop/src/store/session-unread.test.ts | 21 +-- apps/desktop/src/store/session.test.ts | 21 +-- apps/desktop/src/test/deferred.ts | 16 ++ apps/desktop/src/test/jsdom.ts | 26 +++ apps/desktop/src/test/oauth-provider.ts | 14 ++ apps/desktop/src/test/react-root.ts | 44 +++++ apps/desktop/src/test/session-info.ts | 46 +++++ apps/desktop/src/test/window-state.ts | 47 +++++ 84 files changed, 1129 insertions(+), 2404 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/test-utils.ts create mode 100644 apps/desktop/src/app/session/hooks/use-message-stream/test-harness.tsx create mode 100644 apps/desktop/src/app/settings/test-utils.ts create mode 100644 apps/desktop/src/components/assistant-ui/test-utils.tsx create mode 100644 apps/desktop/src/test/deferred.ts create mode 100644 apps/desktop/src/test/jsdom.ts create mode 100644 apps/desktop/src/test/oauth-provider.ts create mode 100644 apps/desktop/src/test/react-root.ts create mode 100644 apps/desktop/src/test/session-info.ts create mode 100644 apps/desktop/src/test/window-state.ts diff --git a/apps/desktop/e2e/batch-clarify.spec.ts b/apps/desktop/e2e/batch-clarify.spec.ts index 8658bd3b1b..acf97ddb2e 100644 --- a/apps/desktop/e2e/batch-clarify.spec.ts +++ b/apps/desktop/e2e/batch-clarify.spec.ts @@ -5,8 +5,8 @@ * model's tool_call_id while `clarify.request` carries a gateway-generated * request_id. A batch payload has no top-level `question`, so the two rows * only merge when the correlation key comes from the question list - * (`batchClarifyMatchValue` in lib/chat-messages.ts). Before that fix this - * exact flow rendered two identical interactive cards. + * (`batchClarifyMatchValue` in lib/chat-messages/tool-parts.ts). Before that + * fix this exact flow rendered two identical interactive cards. * * The flow runs the real chain: composer → gateway → agent → clarify tool → * clarify.request event → renderer, against the mock inference server. diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.test.tsx index bbffc396ec..c687d310e4 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.test.tsx @@ -2,6 +2,8 @@ import { render } from '@testing-library/react' import { createRef, type RefObject } from 'react' import { describe, expect, it, vi } from 'vitest' +import { placeCaretAtEnd } from '../test-utils' + import { useComposerUndo } from './use-composer-undo' /** Mount the hook against a real contentEditable, exposing its API. */ @@ -36,19 +38,10 @@ function makeEditor(text: string) { return { editor, ref } } -const caretAtEnd = (editor: HTMLElement) => { - const range = document.createRange() - const selection = window.getSelection()! - range.selectNodeContents(editor) - range.collapse(false) - selection.removeAllRanges() - selection.addRange(range) -} - describe('useComposerUndo', () => { it('restores the pre-edit text, which is what a paste destroyed', () => { const { editor, ref } = makeEditor('before') - caretAtEnd(editor) + placeCaretAtEnd(editor) const { api, view } = mountUndo(ref, () => editor.textContent || '') @@ -69,7 +62,7 @@ describe('useComposerUndo', () => { it('withUndoPoint banks only when the edit actually ran', () => { const { editor, ref } = makeEditor('text') - caretAtEnd(editor) + placeCaretAtEnd(editor) const { api, view } = mountUndo(ref, () => editor.textContent || '') @@ -95,7 +88,7 @@ describe('useComposerUndo', () => { it('claims a native historyUndo aimed at the focused editor', () => { const { editor, ref } = makeEditor('kept') editor.focus() - caretAtEnd(editor) + placeCaretAtEnd(editor) const { api, view } = mountUndo(ref, () => editor.textContent || '') @@ -162,7 +155,7 @@ describe('useComposerUndo', () => { it('reset drops history so undo cannot cross a draft swap', () => { const { editor, ref } = makeEditor('session A') - caretAtEnd(editor) + placeCaretAtEnd(editor) const { api, view } = mountUndo(ref, () => editor.textContent || '') diff --git a/apps/desktop/src/app/chat/composer/rich-editor.test.ts b/apps/desktop/src/app/chat/composer/rich-editor.test.ts index 6c3d2f8733..d3bfd60eed 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.test.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.test.ts @@ -11,16 +11,7 @@ import { replaceBeforeCaret, RICH_INPUT_SLOT } from './rich-editor' - -const caretIn = (editor: HTMLElement) => { - const range = document.createRange() - const selection = window.getSelection()! - - range.selectNodeContents(editor) - range.collapse(false) - selection.removeAllRanges() - selection.addRange(range) -} +import { placeCaretAtEnd } from './test-utils' describe('renderComposerContents', () => { it('renders refs and raw text without interpreting user text as HTML', () => { @@ -160,7 +151,7 @@ describe('insertInlineRefsIntoEditor', () => { editor.dataset.slot = RICH_INPUT_SLOT editor.append(document.createTextNode('review')) document.body.append(editor) - caretIn(editor) + placeCaretAtEnd(editor) expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ') @@ -172,7 +163,7 @@ describe('insertInlineRefsIntoEditor', () => { editor.dataset.slot = RICH_INPUT_SLOT editor.append(document.createTextNode('review ')) document.body.append(editor) - caretIn(editor) + placeCaretAtEnd(editor) expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ') @@ -185,7 +176,7 @@ describe('insertComposerContentsAtCaret', () => { const editor = document.createElement('div') editor.dataset.slot = RICH_INPUT_SLOT document.body.append(editor) - caretIn(editor) + placeCaretAtEnd(editor) insertComposerContentsAtCaret(editor, 'one\ntwo\nthree') @@ -221,7 +212,7 @@ describe('insertComposerContentsAtCaret', () => { const editor = document.createElement('div') editor.dataset.slot = RICH_INPUT_SLOT document.body.append(editor) - caretIn(editor) + placeCaretAtEnd(editor) insertComposerContentsAtCaret(editor, 'read @url:`https://example.dev/a` now') @@ -237,7 +228,7 @@ describe('insertComposerContentsAtCaret', () => { const editor = document.createElement('div') editor.dataset.slot = RICH_INPUT_SLOT document.body.append(editor) - caretIn(editor) + placeCaretAtEnd(editor) insertComposerContentsAtCaret(editor, '/some-skill') @@ -253,7 +244,7 @@ describe('insertComposerContentsAtCaret', () => { const editor = document.createElement('div') editor.dataset.slot = RICH_INPUT_SLOT document.body.append(editor) - caretIn(editor) + placeCaretAtEnd(editor) insertComposerContentsAtCaret(editor, 'clean @file:`a.ts` with /some-skill then ship') @@ -268,7 +259,7 @@ describe('insertComposerContentsAtCaret', () => { const editor = document.createElement('div') editor.dataset.slot = RICH_INPUT_SLOT document.body.append(editor) - caretIn(editor) + placeCaretAtEnd(editor) insertComposerContentsAtCaret(editor, 'see /usr/local/bin and /goal ship it') @@ -283,7 +274,7 @@ describe('insertComposerContentsAtCaret', () => { editor.dataset.slot = RICH_INPUT_SLOT editor.textContent = 'foo' document.body.append(editor) - caretIn(editor) + placeCaretAtEnd(editor) insertComposerContentsAtCaret(editor, '/some-skill') @@ -298,7 +289,7 @@ describe('insertComposerContentsAtCaret', () => { editor.dataset.slot = RICH_INPUT_SLOT editor.append(refChipElement('file', '`a.ts`')) document.body.append(editor) - caretIn(editor) + placeCaretAtEnd(editor) insertComposerContentsAtCaret(editor, '/some-skill') diff --git a/apps/desktop/src/app/chat/composer/test-utils.ts b/apps/desktop/src/app/chat/composer/test-utils.ts new file mode 100644 index 0000000000..23c71f74d4 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/test-utils.ts @@ -0,0 +1,14 @@ +/** Collapse the selection to the end of the editor's contents. + * + * The composer's undo stack, ref rendering, and trigger detection all read the + * live selection, and jsdom starts with none — without this they see a caret + * at offset 0 and take the wrong branch. */ +export function placeCaretAtEnd(editor: HTMLElement) { + const range = document.createRange() + const selection = window.getSelection()! + + range.selectNodeContents(editor) + range.collapse(false) + selection.removeAllRanges() + selection.addRange(range) +} diff --git a/apps/desktop/src/app/chat/session-tile-attachments.test.tsx b/apps/desktop/src/app/chat/session-tile-attachments.test.tsx index b9a97a8611..b756f821e4 100644 --- a/apps/desktop/src/app/chat/session-tile-attachments.test.tsx +++ b/apps/desktop/src/app/chat/session-tile-attachments.test.tsx @@ -13,6 +13,8 @@ import { import { $connection, $sessions } from '@/store/session' import { $sessionStates, type SessionTileDelegate, setSessionTileDelegate } from '@/store/session-states' +import { deferred } from '../../test/deferred' + const requestGateway = vi.fn() vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({ @@ -44,16 +46,6 @@ const STAGED_PATH = '/root/.hermes/attachments/photo.png' const THUMBNAIL = 'data:image/png;base64,dGh1bWJuYWls' const FULL_SOURCE = 'data:image/png;base64,b3JpZ2luYWw=' -function deferred() { - let resolve!: (value: T) => void - - const promise = new Promise(res => { - resolve = res - }) - - return { promise, resolve } -} - function makeAttachment(occurrenceId = createComposerAttachmentOccurrenceId()): ComposerAttachment { return { detail: HOST_PATH, diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts index bd32d4fd4a..c81f1d72b1 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { HermesGitWorktree } from '@/global' +import { makeCwdSession } from '@/test/session-info' import type { ProjectInfo, SessionInfo } from '@/types/hermes' import { @@ -22,29 +23,6 @@ import { // covered by tests/tui_gateway/test_project_tree.py). This file only covers the // thin render helpers the desktop still owns + the VISUAL worktree enhancer. -let nextId = 0 - -function makeSession(cwd: null | string, overrides: Partial = {}): SessionInfo { - return { - archived: false, - cwd, - ended_at: null, - id: `s${nextId++}`, - input_tokens: 0, - is_active: false, - last_active: 1_000, - message_count: 1, - model: 'claude', - output_tokens: 0, - preview: null, - source: 'cli', - started_at: 1_000, - title: null, - tool_call_count: 0, - ...overrides - } -} - const lane = (over: Partial & Pick): SidebarSessionGroup => ({ path: null, sessions: [], @@ -76,7 +54,7 @@ describe('kanbanWorktreeDir', () => { describe('sortWorktreeGroups', () => { it('pins trunk to the top, sinks kanban to the bottom, and orders the rest by recency', () => { - const at = (t: number) => [makeSession('/x', { last_active: t })] + const at = (t: number) => [makeCwdSession('/x', { last_active: t })] const groups = [ lane({ id: 'k', label: 'kanban', isKanban: true, sessions: at(999) }), @@ -92,7 +70,7 @@ describe('sortWorktreeGroups', () => { it('pins the live home checkout above trunk, even when it has no sessions yet', () => { const groups = [ - lane({ id: 'main', label: 'main', isMain: true, sessions: [makeSession('/x', { last_active: 999 })] }), + lane({ id: 'main', label: 'main', isMain: true, sessions: [makeCwdSession('/x', { last_active: 999 })] }), lane({ id: 'home', label: 'bb/projects-paradigm', isMain: true, isHome: true }) ] @@ -150,7 +128,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'main', isMain: true, path: '/repo', - sessions: [makeSession('/repo')] + sessions: [makeCwdSession('/repo')] }) ] } @@ -176,7 +154,13 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { it('does not add a second "main" for a linked worktree checked out on main', () => { const groups = [ - lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] }) + lane({ + id: '/repo::branch::main', + label: 'main', + isMain: true, + path: '/repo', + sessions: [makeCwdSession('/repo')] + }) ] const discovered: HermesGitWorktree[] = [ @@ -216,14 +200,14 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'main', isMain: true, path: '/repo', - sessions: [makeSession('/repo')] + sessions: [makeCwdSession('/repo')] }), lane({ id: '/repo-ci', label: 'hermes-agent-ci', isMain: false, path: '/repo-ci', - sessions: [makeSession('/repo-ci')] + sessions: [makeCwdSession('/repo-ci')] }) ] } @@ -256,7 +240,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'bb/attempts', isMain: false, path: '/repo/.worktrees/attempts', - sessions: [makeSession('/repo/.worktrees/attempts')] + sessions: [makeCwdSession('/repo/.worktrees/attempts')] }) ] } @@ -291,7 +275,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'bb/feature', isMain: false, path: '/repo-feature', - sessions: [makeSession('/repo-feature'), makeSession('/repo-feature')] + sessions: [makeCwdSession('/repo-feature'), makeCwdSession('/repo-feature')] }) ] } @@ -313,7 +297,13 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { id: '/repo', path: '/repo', groups: [ - lane({ id: '/repo-ci', label: 'repo-ci', isMain: false, path: '/repo-ci', sessions: [makeSession('/repo-ci')] }) + lane({ + id: '/repo-ci', + label: 'repo-ci', + isMain: false, + path: '/repo-ci', + sessions: [makeCwdSession('/repo-ci')] + }) ] } @@ -334,7 +324,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'main', isMain: true, path: '/repo', - sessions: [makeSession('/repo')] + sessions: [makeCwdSession('/repo')] }) ] } @@ -366,7 +356,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'main', isMain: true, path: '/repo', - sessions: [makeSession('/repo')] + sessions: [makeCwdSession('/repo')] }) ] } @@ -391,14 +381,14 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'main', isMain: true, path: '/repo', - sessions: [makeSession('/repo', { id: 'a' })] + sessions: [makeCwdSession('/repo', { id: 'a' })] }), lane({ id: '/repo::branch::old', label: 'old-feature', isMain: true, path: '/repo', - sessions: [makeSession('/repo', { id: 'b' })] + sessions: [makeCwdSession('/repo', { id: 'b' })] }) ] } @@ -425,7 +415,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'main', isMain: true, path: '/repo', - sessions: [makeSession('/repo')] + sessions: [makeCwdSession('/repo')] }) ] } @@ -481,11 +471,11 @@ const homeNode = (sessions: SessionInfo[]): SidebarProjectTree => describe('liveSessionProjectId', () => { it('maps a brand-new (unpersisted) session to its auto project (the repo root)', () => { - expect(liveSessionProjectId(makeSession('/www/app'), [])).toBe('/www/app') + expect(liveSessionProjectId(makeCwdSession('/www/app'), [])).toBe('/www/app') }) it('routes a session under an explicit project folder to that project', () => { - const id = liveSessionProjectId(makeSession('/www/app/src', { git_repo_root: '/www/app', git_branch: 'feat' }), [ + const id = liveSessionProjectId(makeCwdSession('/www/app/src', { git_repo_root: '/www/app', git_branch: 'feat' }), [ makeProject('p_app', ['/www/app']) ]) @@ -495,31 +485,31 @@ describe('liveSessionProjectId', () => { it('anchors a cwd-less session on its git_repo_root (backend groups it there too)', () => { // Older/imported rows carry only a repo root; the sidebar files them under // the repo's project, so membership (and color) must resolve from the root. - expect(liveSessionProjectId(makeSession(null, { git_repo_root: '/www/app' }), [])).toBe('/www/app') + expect(liveSessionProjectId(makeCwdSession(null, { git_repo_root: '/www/app' }), [])).toBe('/www/app') expect( - liveSessionProjectId(makeSession(null, { git_repo_root: '/www/app' }), [makeProject('p_app', ['/www/app'])]) + liveSessionProjectId(makeCwdSession(null, { git_repo_root: '/www/app' }), [makeProject('p_app', ['/www/app'])]) ).toBe('p_app') }) it('skips cwd-less, kanban-task, and out-of-tree (sibling) worktree sessions', () => { - expect(liveSessionProjectId(makeSession(null), [])).toBeNull() + expect(liveSessionProjectId(makeCwdSession(null), [])).toBeNull() // Kanban task worktree → folds into the kanban bucket, not a project preview. - expect(liveSessionProjectId(makeSession('/repo/.worktrees/t_aaaaaaaa'), [])).toBeNull() + expect(liveSessionProjectId(makeCwdSession('/repo/.worktrees/t_aaaaaaaa'), [])).toBeNull() // Sibling worktree OUTSIDE the repo root → project can't be derived from the row. - expect(liveSessionProjectId(makeSession('/elsewhere/wt', { git_repo_root: '/repo' }), [])).toBeNull() + expect(liveSessionProjectId(makeCwdSession('/elsewhere/wt', { git_repo_root: '/repo' }), [])).toBeNull() }) it('places an in-tree worktree session under its repo project (the root is in the path)', () => { // "Convert a branch" / "new worktree" land at `/.worktrees/`, // so they belong to the same auto project as the repo root and must show in // the overview at once, not wait for the next backend refresh. - expect(liveSessionProjectId(makeSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [])).toBe( + expect(liveSessionProjectId(makeCwdSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [])).toBe( '/www/app' ) }) it('routes an in-tree worktree session to the owning explicit project', () => { - const id = liveSessionProjectId(makeSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [ + const id = liveSessionProjectId(makeCwdSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [ makeProject('p_app', ['/www/app']) ]) @@ -532,13 +522,13 @@ describe('liveSessionProjectId', () => { // only the auto-project (repo root) fallback needs cwd-under-root // confidence. Match via the repo root... expect( - liveSessionProjectId(makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }), [ + liveSessionProjectId(makeCwdSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }), [ makeProject('p_proj', ['/home/u/proj']) ]) ).toBe('p_proj') // ...and via the cwd. expect( - liveSessionProjectId(makeSession('/www/elsewhere/sub', { git_repo_root: '/home/u/proj' }), [ + liveSessionProjectId(makeCwdSession('/www/elsewhere/sub', { git_repo_root: '/home/u/proj' }), [ makeProject('p_www', ['/www/elsewhere']) ]) ).toBe('p_www') @@ -547,13 +537,13 @@ describe('liveSessionProjectId', () => { it('matches a mixed-case/separator Windows cwd to its explicit project in the live overlay', () => { // The bug: a fresh Windows session drops into the overlay before the next // backend refresh; case-sensitive matching missed its project until then. - const id = liveSessionProjectId(makeSession('c:/work/notes/SUB'), [makeProject('p_notes', ['C:\\Work\\Notes'])]) + const id = liveSessionProjectId(makeCwdSession('c:/work/notes/SUB'), [makeProject('p_notes', ['C:\\Work\\Notes'])]) expect(id).toBe('p_notes') }) it('matches a root-relative WSL cwd (single backslash) case-insensitively', () => { - const id = liveSessionProjectId(makeSession('//wsl.localhost/Ubuntu/home/alice/PROJ'), [ + const id = liveSessionProjectId(makeCwdSession('//wsl.localhost/Ubuntu/home/alice/PROJ'), [ makeProject('p_proj', ['\\wsl.localhost\\Ubuntu\\home\\alice\\proj']) ]) @@ -562,7 +552,7 @@ describe('liveSessionProjectId', () => { it('keeps POSIX cwd matching case-sensitive (no false project match)', () => { // Distinct case on POSIX is a distinct path → falls back to its own auto id. - expect(liveSessionProjectId(makeSession('/work/notes'), [makeProject('p_notes', ['/Work/Notes'])])).toBe( + expect(liveSessionProjectId(makeCwdSession('/work/notes'), [makeProject('p_notes', ['/Work/Notes'])])).toBe( '/work/notes' ) }) @@ -575,19 +565,19 @@ describe('sessionProjectColor', () => { }) it('inherits the color of the explicit project the session belongs to', () => { - const session = makeSession('/www/app/src', { git_repo_root: '/www/app' }) + const session = makeCwdSession('/www/app/src', { git_repo_root: '/www/app' }) expect(sessionProjectColor(session, [colored('p_app', ['/www/app'], '#4a9eff')])).toBe('#4a9eff') }) it('returns null when the owning project has no color set', () => { - const session = makeSession('/www/app/src', { git_repo_root: '/www/app' }) + const session = makeCwdSession('/www/app/src', { git_repo_root: '/www/app' }) expect(sessionProjectColor(session, [makeProject('p_app', ['/www/app'])])).toBeNull() }) it('colors a cwd-less session by its git_repo_root project (the grouped-but-grey fix)', () => { - const session = makeSession(null, { git_repo_root: '/www/app' }) + const session = makeCwdSession(null, { git_repo_root: '/www/app' }) expect(sessionProjectColor(session, [colored('p_app', ['/www/app'], '#4a9eff')])).toBe('#4a9eff') }) @@ -595,7 +585,7 @@ describe('sessionProjectColor', () => { it('colors a cwd-outside-root session when an explicit project folder matches', () => { // The backend tree groups such a row under the project; the client color // derivation must agree instead of leaving the row (and its tab) grey. - const session = makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }) + const session = makeCwdSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }) expect(sessionProjectColor(session, [colored('p_proj', ['/home/u/proj'], '#4a9eff')])).toBe('#4a9eff') }) @@ -603,15 +593,15 @@ describe('sessionProjectColor', () => { it('returns null for a session that only maps to an auto repo root (no explicit project)', () => { // liveSessionProjectId falls back to the repo root id, which is not a // project row and therefore carries no color. - expect(sessionProjectColor(makeSession('/www/app'), [])).toBeNull() + expect(sessionProjectColor(makeCwdSession('/www/app'), [])).toBeNull() }) it('returns null for an unplaceable (cwd-less) session', () => { - expect(sessionProjectColor(makeSession(null), [colored('p_app', ['/www/app'], '#4a9eff')])).toBeNull() + expect(sessionProjectColor(makeCwdSession(null), [colored('p_app', ['/www/app'], '#4a9eff')])).toBeNull() }) it('uses the longest-prefix project when nested projects both match', () => { - const session = makeSession('/www/app/packages/api/src', { git_repo_root: '/www/app' }) + const session = makeCwdSession('/www/app/packages/api/src', { git_repo_root: '/www/app' }) const projects = [ colored('p_root', ['/www/app'], '#111111'), @@ -630,7 +620,7 @@ describe('overlayLiveLanes', () => { repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }] }) - const live = [makeSession('/www/app', { id: 'fresh', git_branch: 'main' })] + const live = [makeCwdSession('/www/app', { id: 'fresh', git_branch: 'main' })] const overlaid = overlayLiveLanes(project, live) const lane = overlaid.repos[0].groups.find(g => g.label === 'main') @@ -649,7 +639,7 @@ describe('overlayLiveLanes', () => { repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }] }) - const live = [makeSession('/www/app/.worktrees/baby', { id: 'fresh' })] + const live = [makeCwdSession('/www/app/.worktrees/baby', { id: 'fresh' })] const overlaid = overlayLiveLanes(project, live) const lane = overlaid.repos[0].groups.find(g => g.id === '/www/app/.worktrees/baby') @@ -665,7 +655,7 @@ describe('overlayLiveLanes', () => { repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }] }) - const live = [makeSession('/www/app/.worktrees/t_abc12345', { id: 'k' })] + const live = [makeCwdSession('/www/app/.worktrees/t_abc12345', { id: 'k' })] const overlaid = overlayLiveLanes(project, live) const lane = overlaid.repos[0].groups.find(g => g.isKanban) @@ -675,7 +665,7 @@ describe('overlayLiveLanes', () => { }) it('does not duplicate a session already present in a backend lane', () => { - const existing = makeSession('/www/app', { id: 'dup', git_branch: 'main' }) + const existing = makeCwdSession('/www/app', { id: 'dup', git_branch: 'main' }) const project = projectNode({ id: '/www/app', @@ -704,8 +694,8 @@ describe('overlayLiveLanes', () => { // and CREATE a second main lane with the same sessions — dual lanes in the // project drill-in (e.g. main + codex-research-guardian). const root = '/home/hermes/hermes-workspace/codex-research-guardian' - const a = makeSession(root, { id: 's1' }) // empty git_branch / git_repo_root - const b = makeSession(root, { id: 's2' }) + const a = makeCwdSession(root, { id: 's1' }) // empty git_branch / git_repo_root + const b = makeCwdSession(root, { id: 's2' }) const project = projectNode({ id: root, @@ -742,7 +732,7 @@ describe('overlayLiveLanes', () => { it('joins a fresh live session into an existing non-git workspace lane (no branch id)', () => { const root = '/work/notes' - const existing = makeSession(root, { id: 'old' }) + const existing = makeCwdSession(root, { id: 'old' }) const project = projectNode({ id: root, @@ -759,7 +749,7 @@ describe('overlayLiveLanes', () => { ] }) - const fresh = makeSession(root, { id: 'fresh' }) + const fresh = makeCwdSession(root, { id: 'fresh' }) const overlaid = overlayLiveLanes(project, [existing, fresh]) const groups = overlaid.repos[0].groups @@ -769,14 +759,14 @@ describe('overlayLiveLanes', () => { }) it('preserves backend recency order when live sessions overlay a lane', () => { - const recentlyActive = makeSession('/www/app', { + const recentlyActive = makeCwdSession('/www/app', { id: 'recently-active', git_branch: 'main', started_at: 1, last_active: 3 }) - const newlyCreated = makeSession('/www/app', { + const newlyCreated = makeCwdSession('/www/app', { id: 'newly-created', git_branch: 'main', started_at: 2, @@ -816,7 +806,7 @@ describe('overlayLiveLanes', () => { // Backend keyed the worktree lane off a branch-style id (no live git probe), // but the lane PATH is the worktree dir. A new session under that worktree // must join the existing lane, not spawn a twin. - const existing = makeSession('/www/app/.worktrees/baby', { id: 'old' }) + const existing = makeCwdSession('/www/app/.worktrees/baby', { id: 'old' }) const project = projectNode({ id: '/www/app', @@ -838,7 +828,7 @@ describe('overlayLiveLanes', () => { ] }) - const fresh = makeSession('/www/app/.worktrees/baby', { id: 'fresh' }) + const fresh = makeCwdSession('/www/app/.worktrees/baby', { id: 'fresh' }) const overlaid = overlayLiveLanes(project, [existing, fresh]) const lanes = overlaid.repos[0].groups.filter(g => g.path === '/www/app/.worktrees/baby') @@ -850,7 +840,7 @@ describe('overlayLiveLanes', () => { it('places a session into an out-of-tree (sibling) worktree lane by its path', () => { // `hermes-agent-ci` is a linked worktree living BESIDE the repo, not under // it — repo-root nesting fails, but the existing lane carries its real path. - const existing = makeSession('/www/app-ci', { id: 'old' }) + const existing = makeCwdSession('/www/app-ci', { id: 'old' }) const project = projectNode({ id: '/www/app', @@ -868,7 +858,7 @@ describe('overlayLiveLanes', () => { ] }) - const fresh = makeSession('/www/app-ci', { id: 'fresh' }) + const fresh = makeCwdSession('/www/app-ci', { id: 'fresh' }) const overlaid = overlayLiveLanes(project, [existing, fresh]) const ci = overlaid.repos[0].groups.find(g => g.path === '/www/app-ci') @@ -890,7 +880,7 @@ describe('overlayLiveLanes', () => { repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups }] }) - const fresh = makeSession('/www/app-retry', { id: 'fresh' }) + const fresh = makeCwdSession('/www/app-retry', { id: 'fresh' }) const overlaid = overlayLiveLanes(project, [fresh]) const lane = overlaid.repos[0].groups.find(g => g.path === '/www/app-retry') @@ -899,8 +889,8 @@ describe('overlayLiveLanes', () => { }) it('evicts a deleted/archived snapshot row (and drops the lane once empty)', () => { - const a = makeSession('/www/app', { id: 'keep', git_branch: 'main' }) - const b = makeSession('/www/app/.worktrees/baby', { id: 'gone' }) + const a = makeCwdSession('/www/app', { id: 'keep', git_branch: 'main' }) + const b = makeCwdSession('/www/app/.worktrees/baby', { id: 'gone' }) const project = projectNode({ id: '/www/app', @@ -927,11 +917,11 @@ describe('overlayLiveLanes', () => { }) it('adds a brand-new detached chat to Home, and evicts a deleted one', () => { - const existing = makeSession(null, { id: 'old', started_at: 1 }) - const doomed = makeSession(null, { id: 'gone', started_at: 2 }) + const existing = makeCwdSession(null, { id: 'old', started_at: 1 }) + const doomed = makeCwdSession(null, { id: 'gone', started_at: 2 }) const home = homeNode([existing, doomed]) - const overlaid = overlayLiveLanes(home, [makeSession(null, { id: 'fresh', started_at: 9 })], new Set(['gone'])) + const overlaid = overlayLiveLanes(home, [makeCwdSession(null, { id: 'fresh', started_at: 9 })], new Set(['gone'])) expect(overlaid.repos[0].groups[0].sessions.map(s => s.id)).toEqual(['fresh', 'old']) expect(overlaid.sessionCount).toBe(2) @@ -943,14 +933,14 @@ describe('overlayLiveLanes', () => { // and back out on the next snapshot. const home = homeNode([]) - expect(overlayLiveLanes(home, [makeSession('/www/app', { id: 'fresh' })])).toBe(home) + expect(overlayLiveLanes(home, [makeCwdSession('/www/app', { id: 'fresh' })])).toBe(home) }) it('evicts a session from the main lane when the live overlay places it into a worktree lane', () => { // Session was in main when the backend tree was captured, but the live // $sessions cache now has it under a worktree cwd. The overlay must place // it ONLY in the worktree lane — not both. - const session = makeSession('/www/app/.worktrees/feature', { id: 'moved', git_branch: 'feature' }) + const session = makeCwdSession('/www/app/.worktrees/feature', { id: 'moved', git_branch: 'feature' }) const project = projectNode({ id: '/www/app', @@ -989,10 +979,10 @@ describe('overlayLivePreviews', () => { it('merges live sessions into a project preview, live first, capped to the limit', () => { const project = projectNode({ id: '/www/app', - previewSessions: [makeSession('/www/app', { id: 'old', started_at: 1, last_active: 1 })] + previewSessions: [makeCwdSession('/www/app', { id: 'old', started_at: 1, last_active: 1 })] }) - const live = [makeSession('/www/app', { id: 'fresh', started_at: 99, last_active: 99 })] + const live = [makeCwdSession('/www/app', { id: 'fresh', started_at: 99, last_active: 99 })] const previews = overlayLivePreviews([project], live, [], 3) @@ -1003,8 +993,8 @@ describe('overlayLivePreviews', () => { const project = projectNode({ id: '/www/app', previewSessions: [ - makeSession('/www/app', { id: 'gone', started_at: 5, last_active: 5 }), - makeSession('/www/app', { id: 'old', started_at: 1, last_active: 1 }) + makeCwdSession('/www/app', { id: 'gone', started_at: 5, last_active: 5 }), + makeCwdSession('/www/app', { id: 'old', started_at: 1, last_active: 1 }) ] }) @@ -1017,9 +1007,9 @@ describe('overlayLivePreviews', () => { const project = projectNode({ id: '/www/app', previewSessions: [ - makeSession('/www/app', { id: 'newest', last_active: 9, started_at: 9 }), - makeSession('/www/app', { id: 'cheap', last_active: 8, started_at: 8 }), - makeSession('/www/app', { id: 'priciest', last_active: 1, started_at: 1 }) + makeCwdSession('/www/app', { id: 'newest', last_active: 9, started_at: 9 }), + makeCwdSession('/www/app', { id: 'cheap', last_active: 8, started_at: 8 }), + makeCwdSession('/www/app', { id: 'priciest', last_active: 1, started_at: 1 }) ] }) @@ -1029,7 +1019,7 @@ describe('overlayLivePreviews', () => { }) it('previews a detached session under Home, which no cwd could place', () => { - const previews = overlayLivePreviews([homeNode([])], [makeSession(null, { id: 'fresh' })], [], 3) + const previews = overlayLivePreviews([homeNode([])], [makeCwdSession(null, { id: 'fresh' })], [], 3) expect(previews[NO_PROJECT_ID].map(s => s.id)).toEqual(['fresh']) }) @@ -1037,8 +1027,8 @@ describe('overlayLivePreviews', () => { describe('excludeProjectSessions', () => { it('drops matching rows from every lane and recounts the subtree', () => { - const keep = makeSession('/www/app', { id: 'keep' }) - const pinnedRow = makeSession('/www/app', { id: 'pinned' }) + const keep = makeCwdSession('/www/app', { id: 'keep' }) + const pinnedRow = makeCwdSession('/www/app', { id: 'pinned' }) const project = projectNode({ id: '/www/app', @@ -1062,7 +1052,7 @@ describe('excludeProjectSessions', () => { }) it('keeps a lane the filter emptied — a worktree is structure, not a row', () => { - const pinnedRow = makeSession('/www/app/wt', { id: 'pinned' }) + const pinnedRow = makeCwdSession('/www/app/wt', { id: 'pinned' }) const project = projectNode({ id: '/www/app', @@ -1090,7 +1080,7 @@ describe('excludeProjectSessions', () => { it('returns the same node when nothing matches (memo-stable)', () => { const project = projectNode({ id: '/www/app', - previewSessions: [makeSession('/www/app', { id: 'keep' })], + previewSessions: [makeCwdSession('/www/app', { id: 'keep' })], repos: [ { id: '/www/app', @@ -1098,7 +1088,7 @@ describe('excludeProjectSessions', () => { path: '/www/app', sessionCount: 1, groups: [ - lane({ id: 'main', isMain: true, label: 'main', sessions: [makeSession('/www/app', { id: 'keep' })] }) + lane({ id: 'main', isMain: true, label: 'main', sessions: [makeCwdSession('/www/app', { id: 'keep' })] }) ] } ], @@ -1111,7 +1101,7 @@ describe('excludeProjectSessions', () => { it('survives the live overlay: a lane left empty by the filter is not pruned', () => { // The two run in sequence on an entered project (filter, then overlay), and // the overlay drops lanes it empties — it must not take the filter's with it. - const pinnedRow = makeSession('/www/app/wt', { id: 'pinned' }) + const pinnedRow = makeCwdSession('/www/app/wt', { id: 'pinned' }) const project = projectNode({ id: '/www/app', diff --git a/apps/desktop/src/app/command-palette/highlight-watcher.test.tsx b/apps/desktop/src/app/command-palette/highlight-watcher.test.tsx index d2929fcc1e..2e92a24678 100644 --- a/apps/desktop/src/app/command-palette/highlight-watcher.test.tsx +++ b/apps/desktop/src/app/command-palette/highlight-watcher.test.tsx @@ -9,18 +9,12 @@ import { render } from '@testing-library/react' import { Command } from 'cmdk' import { describe, expect, it, vi } from 'vitest' +import { stubMenuDomApis, stubResizeObserver } from '@/test/jsdom' + import { HighlightWatcher } from './highlight-watcher' -// cmdk observes group headings with a ResizeObserver, which jsdom lacks. -class TestResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -} - -vi.stubGlobal('ResizeObserver', TestResizeObserver) - -Element.prototype.scrollIntoView = function scrollIntoView() {} +stubResizeObserver() +stubMenuDomApis() const palette = (onValue: (value: string) => void, onRootValueChange?: (value: string) => void) => ( diff --git a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx index 6a6e47397a..5dd54083cf 100644 --- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx @@ -6,6 +6,8 @@ import { _resetLegacyDiscardForTests } from '@/store/session' import type * as WindowsStore from '@/store/windows' import type { SessionInfo } from '@/types/hermes' +import { makeSessionInfo } from '../../../test/session-info' + import { useDesktopIntegrations } from './use-desktop-integrations' // Mutable HUD-window flag so the restore tests can flip the window kind the @@ -36,24 +38,7 @@ vi.mock('@/store/windows', async importOriginal => { const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } const initialHermesDesktop = desktopWindow.hermesDesktop -const session = (over: Partial = {}): SessionInfo => ({ - archived: false, - cwd: null, - ended_at: null, - id: 'live', - input_tokens: 0, - is_active: false, - last_active: 0, - message_count: 0, - model: null, - output_tokens: 0, - preview: null, - source: null, - started_at: 0, - title: null, - tool_call_count: 0, - ...over -}) +const session = (over: Partial = {}): SessionInfo => makeSessionInfo({ id: 'live', ...over }) describe('useDesktopIntegrations', () => { let navigate: ReturnType void>> diff --git a/apps/desktop/src/app/cron/cron-actions.test.ts b/apps/desktop/src/app/cron/cron-actions.test.ts index d79246a69a..f7cbe89755 100644 --- a/apps/desktop/src/app/cron/cron-actions.test.ts +++ b/apps/desktop/src/app/cron/cron-actions.test.ts @@ -12,18 +12,10 @@ vi.mock('@/hermes', () => ({ import { beginCronJobsRequest } from '@/store/cron' +import { deferred } from '../../test/deferred' + import { mutateAndRefreshCronJobs, refreshCronJobs, triggerAndRefreshCronJobs } from './cron-actions' -function deferred() { - let resolve!: (value: T) => void - - const promise = new Promise(res => { - resolve = res - }) - - return { promise, resolve } -} - describe('triggerAndRefreshCronJobs', () => { beforeEach(() => { getApiRequestConnection.mockReset() diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx index a9c013301b..5cf40b6ee6 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx @@ -1,10 +1,11 @@ -import { act, type ReactNode } from 'react' -import { createRoot, type Root } from 'react-dom/client' +import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { hiddenPaneProps, PANE_HIDDEN_ATTR } from '@/components/pane-shell/pane-visibility' import { $paneStates } from '@/store/panes' +import { reactRoot } from '@/test/react-root' +import { installWindowStateBridge, setDocumentHidden, type WindowStateBridge } from '../../../test/window-state' import { $terminalTakeover } from '../store' import { PersistentTerminal, TerminalSlot } from './persistent' @@ -24,54 +25,8 @@ vi.mock('./workspace', () => ({ let resizeObserverCallback: ResizeObserverCallback | null = null let mutationObserverCallback: MutationCallback | null = null let mutationObserveCalls: Array<{ options?: MutationObserverInit; target: Node }> = [] -let root: Root | null = null -let container: HTMLDivElement | null = null -let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null - -function render(ui: ReactNode) { - container = document.createElement('div') - document.body.append(container) - root = createRoot(container) - - act(() => { - root!.render(ui) - }) -} - -function cleanup() { - if (root) { - act(() => { - root!.unmount() - }) - } - - container?.remove() - root = null - container = null -} - -function setVisibility(hidden: boolean) { - Object.defineProperty(document, 'hidden', { configurable: true, value: hidden }) - Object.defineProperty(document, 'visibilityState', { configurable: true, value: hidden ? 'hidden' : 'visible' }) -} - -function installWindowStateBridge() { - windowStateCallback = null - Object.defineProperty(window, 'hermesDesktop', { - configurable: true, - value: { - onWindowStateChanged: vi.fn((callback: typeof windowStateCallback) => { - windowStateCallback = callback - - return () => { - if (windowStateCallback === callback) { - windowStateCallback = null - } - } - }) - } - }) -} +const mount = reactRoot() +let windowState: WindowStateBridge function rect(top: number, left: number, width: number, height: number): DOMRect { return { @@ -146,9 +101,9 @@ function HiddenPaneHarness({ hidden }: { hidden: boolean }) { describe('PersistentTerminal rect tracking', () => { beforeEach(() => { ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - setVisibility(false) + setDocumentHidden(false) vi.spyOn(document, 'hasFocus').mockReturnValue(true) - installWindowStateBridge() + windowState = installWindowStateBridge() resizeObserverCallback = null mutationObserverCallback = null mutationObserveCalls = [] @@ -181,11 +136,11 @@ describe('PersistentTerminal rect tracking', () => { }) afterEach(() => { - cleanup() + mount.unmount() $terminalTakeover.set(false) vi.unstubAllGlobals() vi.restoreAllMocks() - setVisibility(false) + setDocumentHidden(false) delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop }) @@ -194,7 +149,7 @@ describe('PersistentTerminal rect tracking', () => { let currentRect = rect(10, 20, 200, 100) vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => currentRect) - render() + mount.render() expect(raf.request).toHaveBeenCalledTimes(1) @@ -231,7 +186,7 @@ describe('PersistentTerminal rect tracking', () => { let currentRect = rect(10, 20, 200, 100) vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => currentRect) - render() + mount.render() expect(mutationObserveCalls.length).toBeGreaterThan(0) expect(mutationObserveCalls.every(call => call.options?.subtree === false)).toBe(true) @@ -240,7 +195,7 @@ describe('PersistentTerminal rect tracking', () => { raf.runNext() }) - const overlay = container!.lastElementChild as HTMLElement + const overlay = mount.container!.lastElementChild as HTMLElement expect(overlay.style.top).toBe('10px') expect(overlay.style.left).toBe('20px') expect(raf.pending()).toBe(0) @@ -271,7 +226,7 @@ describe('PersistentTerminal rect tracking', () => { const before = $paneStates.get() vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100)) - render() + mount.render() raf.runNext() expect(raf.pending()).toBe(0) @@ -291,12 +246,12 @@ describe('PersistentTerminal rect tracking', () => { const raf = installRaf() vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100)) - render() + mount.render() expect(raf.request).toHaveBeenCalledTimes(1) act(() => { - windowStateCallback?.({ isMinimized: true, isVisible: false }) + windowState.emit({ isMinimized: true, isVisible: false }) }) expect(raf.cancel).toHaveBeenCalledTimes(1) @@ -309,7 +264,7 @@ describe('PersistentTerminal rect tracking', () => { expect(raf.request).toHaveBeenCalledTimes(1) act(() => { - windowStateCallback?.({ isMinimized: false, isVisible: true }) + windowState.emit({ isMinimized: false, isVisible: true }) }) expect(raf.request).toHaveBeenCalledTimes(2) @@ -319,7 +274,7 @@ describe('PersistentTerminal rect tracking', () => { const raf = installRaf() vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100)) - render() + mount.render() expect(raf.pending()).toBe(1) act(() => window.dispatchEvent(new Event('blur'))) @@ -328,7 +283,7 @@ describe('PersistentTerminal rect tracking', () => { act(() => window.dispatchEvent(new Event('focus'))) expect(raf.pending()).toBe(1) - cleanup() + mount.unmount() expect(raf.pending()).toBe(0) act(() => { @@ -344,7 +299,7 @@ describe('PersistentTerminal rect tracking', () => { vi.mocked(document.hasFocus).mockReturnValue(false) vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100)) - render() + mount.render() expect(raf.request).not.toHaveBeenCalled() @@ -358,10 +313,10 @@ describe('PersistentTerminal rect tracking', () => { vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100)) $terminalTakeover.set(true) - render(