From 0292aa9ca409ed3822c8e33e065c59a4f9b15bc5 Mon Sep 17 00:00:00 2001 From: Aman Merchant <274313970+aman-merchant@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:01:38 +0400 Subject: [PATCH] fix(desktop): persist pinned sessions across updates --- apps/desktop/src/app/contrib/wiring.tsx | 12 +- apps/desktop/src/hermes-profile-scope.test.ts | 12 ++ apps/desktop/src/hermes.ts | 19 +++ .../src/lib/pinned-session-state.test.ts | 86 ++++++++++ apps/desktop/src/lib/pinned-session-state.ts | 114 +++++++++++++ .../store/layout-pinned-session-sync.test.ts | 150 ++++++++++++++++++ apps/desktop/src/store/layout.ts | 92 ++++++++++- hermes_cli/desktop_ui_state.py | 97 +++++++++++ hermes_cli/web_server.py | 30 ++++ tests/hermes_cli/test_desktop_ui_state.py | 46 ++++++ .../test_web_server_profile_unification.py | 26 +++ 11 files changed, 682 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/lib/pinned-session-state.test.ts create mode 100644 apps/desktop/src/lib/pinned-session-state.ts create mode 100644 apps/desktop/src/store/layout-pinned-session-sync.test.ts create mode 100644 hermes_cli/desktop_ui_state.py create mode 100644 tests/hermes_cli/test_desktop_ui_state.py diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index ca7a043a9164..18061699b319 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -28,7 +28,7 @@ import { sessionMessagesSignature } from '@/lib/session-signatures' import { isMessagingSource } from '@/lib/session-source' import { latestSessionTodos } from '@/lib/todos' import { setCronFocusJobId } from '@/store/cron' -import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout' +import { $pinnedSessionIds, hydratePinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout' import { $filePreviewTarget, $previewTarget } from '@/store/preview' import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '@/store/profile' import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '@/store/projects' @@ -404,6 +404,16 @@ export function ContribWiring({ children }: { children: ReactNode }) { // global model + active-profile pill (both are nanostores — the blanket // invalidateQueries on swap doesn't touch them). const activeGatewayProfile = useStore($activeGatewayProfile) + + // Pins span profiles and used to exist only in this renderer's localStorage. + // Only the primary window owns reconciliation: secondary windows share + // localStorage but have independent Nanostore instances. + useEffect(() => { + if (!isSecondaryWindow()) { + void hydratePinnedSessionIds() + } + }, []) + const lastGatewayProfileRef = useRef(activeGatewayProfile) useEffect(() => { diff --git a/apps/desktop/src/hermes-profile-scope.test.ts b/apps/desktop/src/hermes-profile-scope.test.ts index 88c920da3de6..c7773f8ed729 100644 --- a/apps/desktop/src/hermes-profile-scope.test.ts +++ b/apps/desktop/src/hermes-profile-scope.test.ts @@ -3,8 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { checkHermesUpdate, getActionStatus, + getDesktopPinnedSessions, getStatus, restartGateway, + saveDesktopPinnedSessions, setApiRequestProfile, updateHermes } from './hermes' @@ -46,4 +48,14 @@ describe('backend action helpers are profile-scoped', () => { expect(call[0].profile).toBe('coder') } }) + + it('keeps the machine-global Desktop pin recovery record on the primary backend', () => { + setApiRequestProfile('coder') + + void getDesktopPinnedSessions() + expect(lastProfile()).toBeUndefined() + + void saveDesktopPinnedSessions(['root-a']) + expect(lastProfile()).toBeUndefined() + }) }) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 01c9eb6ec857..9a05d793c8f2 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -477,6 +477,25 @@ export function getLogs(params: { }) } +export interface DesktopPinnedSessionsState { + exists: boolean + pinned_session_ids: string[] +} + +export function getDesktopPinnedSessions(): Promise { + return window.hermesDesktop.api({ + path: '/api/desktop/pinned-sessions' + }) +} + +export function saveDesktopPinnedSessions(pinnedSessionIds: string[]): Promise<{ ok: boolean; pinned_session_ids: string[] }> { + return window.hermesDesktop.api<{ ok: boolean; pinned_session_ids: string[] }>({ + path: '/api/desktop/pinned-sessions', + method: 'PUT', + body: { pinned_session_ids: pinnedSessionIds } + }) +} + export function getHermesConfig(): Promise { return window.hermesDesktop.api({ ...profileScoped(), diff --git a/apps/desktop/src/lib/pinned-session-state.test.ts b/apps/desktop/src/lib/pinned-session-state.test.ts new file mode 100644 index 000000000000..aef3a71e3a07 --- /dev/null +++ b/apps/desktop/src/lib/pinned-session-state.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createPinnedSessionWriter, reconcilePinnedSessions } from './pinned-session-state' + +describe('reconcilePinnedSessions', () => { + it('bootstraps an absent backend record from legacy local pins', () => { + expect( + reconcilePinnedSessions( + [' root-a ', 'root-a', 'root-b'], + [' root-a ', 'root-a', 'root-b'], + { exists: false, pinned_session_ids: [] } + ) + ).toEqual({ pinnedSessionIds: ['root-a', 'root-b'], shouldPersist: true }) + }) + + it('restores a durable backend record when browser storage was wiped', () => { + expect(reconcilePinnedSessions([], [], { exists: true, pinned_session_ids: ['root-a'] })).toEqual({ + pinnedSessionIds: ['root-a'], + shouldPersist: false + }) + }) + + it('treats an existing backend record as canonical, including an intentional empty list', () => { + expect(reconcilePinnedSessions(['stale-local'], ['stale-local'], { exists: true, pinned_session_ids: [] })).toEqual({ + pinnedSessionIds: [], + shouldPersist: false + }) + }) + + it('applies a pin made during recovery without discarding recovered pins', () => { + expect(reconcilePinnedSessions([], ['new-pin'], { exists: true, pinned_session_ids: ['saved-a', 'saved-b'] })).toEqual({ + pinnedSessionIds: ['saved-a', 'saved-b', 'new-pin'], + shouldPersist: true + }) + }) + + it('applies removals, additions, and reordering made during recovery', () => { + expect( + reconcilePinnedSessions(['saved-a', 'saved-b'], ['saved-b', 'new-pin'], { + exists: true, + pinned_session_ids: ['saved-a', 'saved-b'] + }) + ).toEqual({ pinnedSessionIds: ['saved-b', 'new-pin'], shouldPersist: true }) + }) +}) + +describe('createPinnedSessionWriter', () => { + it('serializes writes so a slower old value cannot overwrite the latest value', async () => { + let releaseFirst!: () => void + + const firstWrite = new Promise(resolve => { + releaseFirst = resolve + }) + + const saved: string[][] = [] + + const write = createPinnedSessionWriter(async ids => { + saved.push(ids) + + if (saved.length === 1) { + await firstWrite + } + }) + + const oldWrite = write(['old']) + const latestWrite = write(['latest']) + + await new Promise(resolve => setTimeout(resolve, 0)) + expect(saved).toEqual([['old']]) + releaseFirst() + await Promise.all([oldWrite, latestWrite]) + expect(saved).toEqual([['old'], ['latest']]) + }) + + it('retries a transient failure even when it affects the final value', async () => { + const save = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue(undefined) + const waitForRetry = vi.fn().mockResolvedValue(undefined) + const write = createPinnedSessionWriter(save, waitForRetry) + + await expect(write(['latest'])).resolves.toBeUndefined() + expect(save).toHaveBeenCalledTimes(2) + expect(save).toHaveBeenNthCalledWith(1, ['latest']) + expect(save).toHaveBeenNthCalledWith(2, ['latest']) + expect(waitForRetry).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/desktop/src/lib/pinned-session-state.ts b/apps/desktop/src/lib/pinned-session-state.ts new file mode 100644 index 000000000000..1bd8340f554d --- /dev/null +++ b/apps/desktop/src/lib/pinned-session-state.ts @@ -0,0 +1,114 @@ +export interface DurablePinnedSessions { + exists: boolean + pinned_session_ids: string[] +} + +export interface PinnedSessionReconciliation { + pinnedSessionIds: string[] + shouldPersist: boolean +} + +type SavePinnedSessions = (pinnedSessionIds: string[]) => Promise +type WaitForRetry = (attempt: number) => Promise + +function normalize(ids: unknown): string[] { + if (!Array.isArray(ids)) { + return [] + } + + const seen = new Set() + const normalized: string[] = [] + + for (const id of ids) { + if (typeof id !== 'string') { + continue + } + + const value = id.trim() + + if (!value || seen.has(value)) { + continue + } + + seen.add(value) + normalized.push(value) + } + + return normalized +} + +/** + * Resolve the one-time startup handoff from legacy localStorage to the + * machine-owned backend record. Once the backend record exists it is canonical, + * including an intentionally empty list after a user unpinned every chat. + */ +export function reconcilePinnedSessions( + localPinsAtRequest: unknown, + localPinsNow: unknown, + durable: DurablePinnedSessions +): PinnedSessionReconciliation { + const baseline = normalize(localPinsAtRequest) + const current = normalize(localPinsNow) + const remote = normalize(durable.pinned_session_ids) + + if (!durable.exists) { + return { pinnedSessionIds: current, shouldPersist: true } + } + + const changedDuringRequest = + baseline.length !== current.length || baseline.some((id, index) => id !== current[index]) + + if (!changedDuringRequest) { + return { pinnedSessionIds: remote, shouldPersist: false } + } + + // Apply the local user's in-flight delta to the recovered durable list. This + // preserves remote-only pins after a localStorage wipe while still honoring + // removals, additions, and reorderings made before the GET completed. + const baselineSet = new Set(baseline) + const currentSet = new Set(current) + const removed = new Set(baseline.filter(id => !currentSet.has(id))) + const remoteSurvivors = remote.filter(id => !removed.has(id)) + const remoteSurvivorSet = new Set(remoteSurvivors) + const reorderedKnown = current.filter(id => baselineSet.has(id) && remoteSurvivorSet.has(id)) + const remoteOnly = remoteSurvivors.filter(id => !baselineSet.has(id)) + const localOnly = current.filter(id => !baselineSet.has(id) && !remoteSurvivorSet.has(id)) + + return { pinnedSessionIds: normalize([...reorderedKnown, ...remoteOnly, ...localOnly]), shouldPersist: true } +} + +const defaultWaitForRetry: WaitForRetry = attempt => + new Promise(resolve => window.setTimeout(resolve, 250 * 2 ** (attempt - 1))) + +/** Serialize writes and retry transient failures without reordering snapshots. */ +export function createPinnedSessionWriter( + save: SavePinnedSessions, + waitForRetry: WaitForRetry = defaultWaitForRetry, + maxAttempts = 3 +): (pinnedSessionIds: readonly string[]) => Promise { + let queue = Promise.resolve() + + return pinnedSessionIds => { + const snapshot = normalize(pinnedSessionIds) + + queue = queue + .catch(() => undefined) + .then(async () => { + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await save(snapshot) + + return + } catch (error) { + if (attempt === maxAttempts) { + throw error + } + + await waitForRetry(attempt) + } + } + }) + + return queue + } +} diff --git a/apps/desktop/src/store/layout-pinned-session-sync.test.ts b/apps/desktop/src/store/layout-pinned-session-sync.test.ts new file mode 100644 index 000000000000..510789682cec --- /dev/null +++ b/apps/desktop/src/store/layout-pinned-session-sync.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const api = vi.hoisted(() => ({ + getPins: vi.fn(), + savePins: vi.fn() +})) + +vi.mock('@/hermes', () => ({ + getDesktopPinnedSessions: (...args: unknown[]) => api.getPins(...args), + saveDesktopPinnedSessions: (...args: unknown[]) => api.savePins(...args) +})) + +const PIN_STORAGE_KEY = 'hermes.desktop.pinnedSessions' +const PIN_DIRTY_STORAGE_KEY = 'hermes.desktop.pinnedSessionsDirty' + +interface Deferred { + promise: Promise + reject: (reason?: unknown) => void + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + + const promise = new Promise((accept, fail) => { + resolve = accept + reject = fail + }) + + return { promise, reject, resolve } +} + +async function loadLayout(localPins: string[] = []) { + if (localPins.length > 0) { + window.localStorage.setItem(PIN_STORAGE_KEY, JSON.stringify(localPins)) + } + + return import('./layout') +} + +describe('durable pinned-session synchronization', () => { + beforeEach(() => { + window.history.replaceState({}, '', '/') + window.localStorage.clear() + api.getPins.mockReset() + api.savePins.mockReset() + api.savePins.mockResolvedValue({ ok: true, pinned_session_ids: [] }) + vi.resetModules() + }) + + afterEach(() => { + window.history.replaceState({}, '', '/') + window.localStorage.clear() + vi.useRealTimers() + }) + + it('merges a pin made during recovery with the recovered list', async () => { + const recovery = deferred<{ exists: boolean; pinned_session_ids: string[] }>() + api.getPins.mockReturnValue(recovery.promise) + const layout = await loadLayout() + + const hydration = layout.hydratePinnedSessionIds() + layout.pinSession('new-pin') + recovery.resolve({ exists: true, pinned_session_ids: ['saved-a', 'saved-b'] }) + await hydration + + await vi.waitFor(() => { + expect(layout.$pinnedSessionIds.get()).toEqual(['saved-a', 'saved-b', 'new-pin']) + expect(api.savePins).toHaveBeenLastCalledWith(['saved-a', 'saved-b', 'new-pin']) + }) + }) + + it('persists a mutation made while the legacy migration write is pending', async () => { + api.getPins.mockResolvedValue({ exists: false, pinned_session_ids: [] }) + const migrationWrite = deferred<{ ok: boolean; pinned_session_ids: string[] }>() + api.savePins.mockImplementationOnce(() => migrationWrite.promise).mockResolvedValue({ + ok: true, + pinned_session_ids: ['legacy-pin', 'new-pin'] + }) + const layout = await loadLayout(['legacy-pin']) + + await layout.hydratePinnedSessionIds() + await vi.waitFor(() => expect(api.savePins).toHaveBeenCalledWith(['legacy-pin'])) + layout.pinSession('new-pin') + migrationWrite.resolve({ ok: true, pinned_session_ids: ['legacy-pin'] }) + + await vi.waitFor(() => { + expect(api.savePins).toHaveBeenLastCalledWith(['legacy-pin', 'new-pin']) + expect(window.localStorage.getItem(PIN_DIRTY_STORAGE_KEY)).toBe('false') + }) + }) + + it('keeps a locally dirty list authoritative after a failed final write and restart', async () => { + window.localStorage.setItem(PIN_DIRTY_STORAGE_KEY, 'true') + api.getPins.mockResolvedValue({ exists: true, pinned_session_ids: ['stale-remote'] }) + const layout = await loadLayout(['newer-local']) + + await layout.hydratePinnedSessionIds() + + await vi.waitFor(() => { + expect(layout.$pinnedSessionIds.get()).toEqual(['newer-local']) + expect(api.savePins).toHaveBeenLastCalledWith(['newer-local']) + expect(window.localStorage.getItem(PIN_DIRTY_STORAGE_KEY)).toBe('false') + }) + }) + + it('leaves the local dirty marker set when the final write exhausts its retries', async () => { + vi.useFakeTimers() + api.getPins.mockResolvedValue({ exists: true, pinned_session_ids: [] }) + api.savePins.mockRejectedValue(new Error('offline')) + const layout = await loadLayout() + + await layout.hydratePinnedSessionIds() + layout.pinSession('newer-local') + expect(window.localStorage.getItem(PIN_DIRTY_STORAGE_KEY)).toBe('true') + + await vi.runAllTimersAsync() + expect(api.savePins).toHaveBeenCalledTimes(3) + expect(window.localStorage.getItem(PIN_DIRTY_STORAGE_KEY)).toBe('true') + }) + + it('coalesces duplicate StrictMode hydration calls into one backend read', async () => { + const recovery = deferred<{ exists: boolean; pinned_session_ids: string[] }>() + api.getPins.mockReturnValue(recovery.promise) + const layout = await loadLayout() + + const first = layout.hydratePinnedSessionIds() + const second = layout.hydratePinnedSessionIds() + recovery.resolve({ exists: true, pinned_session_ids: ['saved-a'] }) + await Promise.all([first, second]) + + expect(api.getPins).toHaveBeenCalledTimes(1) + expect(layout.$pinnedSessionIds.get()).toEqual(['saved-a']) + }) + + it('never hydrates or persists pins from a secondary session window', async () => { + window.history.replaceState({}, '', '/?win=secondary#/session-a') + api.getPins.mockResolvedValue({ exists: true, pinned_session_ids: ['saved-a'] }) + const layout = await loadLayout(['stale-secondary']) + + await layout.hydratePinnedSessionIds() + layout.pinSession('secondary-only') + await Promise.resolve() + + expect(api.getPins).not.toHaveBeenCalled() + expect(api.savePins).not.toHaveBeenCalled() + expect(window.localStorage.getItem(PIN_DIRTY_STORAGE_KEY)).toBeNull() + }) +}) diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts index 25cbab5c7b60..d21f13d159cd 100644 --- a/apps/desktop/src/store/layout.ts +++ b/apps/desktop/src/store/layout.ts @@ -2,11 +2,14 @@ import { atom, computed, type ReadableAtom, type WritableAtom } from 'nanostores import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '@/app/layout-constants' import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell' +import { getDesktopPinnedSessions, saveDesktopPinnedSessions } from '@/hermes' import { matchesQuery } from '@/hooks/use-media-query' import { Codecs, persistentAtom } from '@/lib/persisted' -import { arraysEqual, insertUniqueId } from '@/lib/storage' +import { createPinnedSessionWriter, reconcilePinnedSessions } from '@/lib/pinned-session-state' +import { arraysEqual, insertUniqueId, persistBoolean, storedBoolean } from '@/lib/storage' import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride, togglePane } from './panes' +import { isSecondaryWindow } from './windows' export const SIDEBAR_DEFAULT_WIDTH = 237 export const SIDEBAR_MAX_WIDTH = 360 @@ -20,6 +23,7 @@ export const FILE_BROWSER_MAX_WIDTH = '20rem' export const SIDEBAR_SESSIONS_PAGE_SIZE = 50 const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions' +const SIDEBAR_PINNED_DIRTY_STORAGE_KEY = 'hermes.desktop.pinnedSessionsDirty' const SIDEBAR_AGENTS_GROUPED_STORAGE_KEY = 'hermes.desktop.agentsGroupedByWorkspace' const SIDEBAR_CRON_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarCronOpen' const SIDEBAR_MESSAGING_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarMessagingOpen' @@ -70,6 +74,92 @@ export const $sidebarWidth: ReadableAtom = computed($paneStates, states }) export const $pinnedSessionIds = persistentAtom(SIDEBAR_PINNED_STORAGE_KEY, [] as string[], Codecs.stringArray) + +// The renderer keeps localStorage for instant paint, but the machine-owned +// backend record is the recovery source after an Electron/localStorage reset. +// A local dirty marker prevents a failed final PUT from being overwritten by a +// stale durable record on the next launch. +let pinSyncReady = false +let pinSyncApplyingRemote = false +let pinSyncObservedInitialValue = false +let pinSyncPersistGeneration = 0 +let pinSyncHydration: null | Promise = null +const persistPinnedSessionIds = createPinnedSessionWriter(saveDesktopPinnedSessions) + +function schedulePinnedSessionPersist(ids: readonly string[]): void { + const generation = ++pinSyncPersistGeneration + persistBoolean(SIDEBAR_PINNED_DIRTY_STORAGE_KEY, true) + + void persistPinnedSessionIds(ids) + .then(() => { + if (generation === pinSyncPersistGeneration) { + persistBoolean(SIDEBAR_PINNED_DIRTY_STORAGE_KEY, false) + } + }) + .catch(() => undefined) +} + +if (!isSecondaryWindow()) { + $pinnedSessionIds.subscribe(ids => { + const isLocalMutation = pinSyncObservedInitialValue && !pinSyncApplyingRemote + + pinSyncObservedInitialValue = true + + if (isLocalMutation) { + persistBoolean(SIDEBAR_PINNED_DIRTY_STORAGE_KEY, true) + + if (pinSyncReady) { + schedulePinnedSessionPersist(ids) + } + } + }) +} + +/** Hydrate machine-global sidebar pins, preserving legacy local pins on first migration. */ +export function hydratePinnedSessionIds(): Promise { + if (isSecondaryWindow()) { + return Promise.resolve() + } + + if (pinSyncHydration) { + return pinSyncHydration + } + + const localPinsAtRequest = $pinnedSessionIds.get() + const localWasDirty = storedBoolean(SIDEBAR_PINNED_DIRTY_STORAGE_KEY, false) + + pinSyncHydration = (async () => { + try { + const durable = await getDesktopPinnedSessions() + const localPinsNow = $pinnedSessionIds.get() + + const reconciliation = localWasDirty + ? reconcilePinnedSessions(localPinsNow, localPinsNow, { exists: false, pinned_session_ids: [] }) + : reconcilePinnedSessions(localPinsAtRequest, localPinsNow, durable) + + pinSyncApplyingRemote = true + + try { + setOrderIds($pinnedSessionIds, reconciliation.pinnedSessionIds) + } finally { + pinSyncApplyingRemote = false + } + + pinSyncReady = true + + if (reconciliation.shouldPersist) { + schedulePinnedSessionPersist(reconciliation.pinnedSessionIds) + } + } catch { + // Older/offline backends retain the local cache. The dirty marker keeps + // a failed local write authoritative on the next startup. + pinSyncReady = true + } + })() + + return pinSyncHydration +} + export const $sidebarSessionOrderIds = persistentAtom( SIDEBAR_SESSION_ORDER_STORAGE_KEY, [] as string[], diff --git a/hermes_cli/desktop_ui_state.py b/hermes_cli/desktop_ui_state.py new file mode 100644 index 000000000000..d6ae04b35023 --- /dev/null +++ b/hermes_cli/desktop_ui_state.py @@ -0,0 +1,97 @@ +"""Durable, machine-scoped UI state owned by Hermes Desktop. + +The renderer keeps a localStorage cache for immediate sidebar responsiveness, but +that cache is disposable across Electron updates and browser-profile resets. This +module owns the backend copy used to restore pinned sessions after such a reset. +Pins intentionally span Hermes profiles: the Desktop sidebar can show all +profiles at once and its legacy localStorage key is machine-global. +""" + +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path +from typing import Any, Iterable + + +PINNED_SESSIONS_RELATIVE_PATH = Path("state") / "desktop-pinned-sessions.json" +_SCHEMA_VERSION = 1 + + +def _normalize_session_ids(values: Any) -> list[str]: + """Return stable, ordered, non-empty string ids without duplicates.""" + if not isinstance(values, list): + return [] + + seen: set[str] = set() + normalized: list[str] = [] + for value in values: + if not isinstance(value, str): + continue + session_id = value.strip() + if not session_id or session_id in seen: + continue + seen.add(session_id) + normalized.append(session_id) + return normalized + + +def pinned_sessions_path(hermes_home: Path) -> Path: + return Path(hermes_home) / PINNED_SESSIONS_RELATIVE_PATH + + +def read_pinned_sessions(hermes_home: Path) -> tuple[bool, list[str]]: + """Read the persisted list, returning ``(exists, pinned_session_ids)``. + + A malformed or unreadable recovery file behaves as absent so a valid legacy + renderer cache can repair it. An intentional unpin-all is represented by a + valid existing file whose list is empty, so it remains authoritative. + """ + path = pinned_sessions_path(hermes_home) + if not path.is_file(): + return False, [] + + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False, [] + + if not isinstance(payload, dict): + return False, [] + values = payload.get("pinned_session_ids") + if not isinstance(values, list): + return False, [] + return True, _normalize_session_ids(values) + + +def write_pinned_sessions(hermes_home: Path, pinned_session_ids: Iterable[Any]) -> list[str]: + """Atomically persist normalized pins with owner-only permissions.""" + home = Path(hermes_home) + path = pinned_sessions_path(home) + path.parent.mkdir(parents=True, exist_ok=True) + normalized = _normalize_session_ids(list(pinned_session_ids)) + payload = { + "schema_version": _SCHEMA_VERSION, + "pinned_session_ids": normalized, + } + + fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) + temporary_path = Path(temporary_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + fchmod = getattr(os, "fchmod", None) + if fchmod is not None: + fchmod(handle.fileno(), 0o600) + json.dump(payload, handle, ensure_ascii=False, separators=(",", ":")) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary_path, path) + os.chmod(path, 0o600) + except Exception: + temporary_path.unlink(missing_ok=True) + raise + + return normalized diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index ac40ff8eba4e..d70c52fe6df6 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -5400,6 +5400,36 @@ async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpd raise HTTPException(status_code=500, detail="Internal server error") +class DesktopPinnedSessionsUpdate(BaseModel): + pinned_session_ids: List[str] + + +@app.get("/api/desktop/pinned-sessions") +async def get_desktop_pinned_sessions(): + """Return the machine-owned recovery copy of Desktop sidebar pins.""" + from hermes_constants import get_default_hermes_root + from hermes_cli.desktop_ui_state import read_pinned_sessions + + exists, pinned_session_ids = read_pinned_sessions(get_default_hermes_root()) + return {"exists": exists, "pinned_session_ids": pinned_session_ids} + + +@app.put("/api/desktop/pinned-sessions") +async def put_desktop_pinned_sessions(body: DesktopPinnedSessionsUpdate): + """Persist ordered Desktop sidebar pins outside disposable renderer state.""" + from hermes_constants import get_default_hermes_root + from hermes_cli.desktop_ui_state import write_pinned_sessions + + try: + pinned_session_ids = write_pinned_sessions(get_default_hermes_root(), body.pinned_session_ids) + return {"ok": True, "pinned_session_ids": pinned_session_ids} + except HTTPException: + raise + except Exception: + _log.exception("PUT /api/desktop/pinned-sessions failed") + raise HTTPException(status_code=500, detail="Internal server error") + + @app.get("/api/config") async def get_config(profile: Optional[str] = None): with _profile_scope(profile): diff --git a/tests/hermes_cli/test_desktop_ui_state.py b/tests/hermes_cli/test_desktop_ui_state.py new file mode 100644 index 000000000000..43d571d8b1f8 --- /dev/null +++ b/tests/hermes_cli/test_desktop_ui_state.py @@ -0,0 +1,46 @@ +"""Durable profile-backed Desktop sidebar state.""" + +import json +import stat +import sys + + +def test_missing_state_returns_absent_empty_list(tmp_path): + from hermes_cli.desktop_ui_state import read_pinned_sessions + + assert read_pinned_sessions(tmp_path) == (False, []) + + +def test_write_normalizes_deduplicates_and_reads_back(tmp_path): + from hermes_cli.desktop_ui_state import PINNED_SESSIONS_RELATIVE_PATH, read_pinned_sessions, write_pinned_sessions + + saved = write_pinned_sessions(tmp_path, ["session-a", "", "session-a", 9, "session-b"]) + + assert saved == ["session-a", "session-b"] + assert read_pinned_sessions(tmp_path) == (True, ["session-a", "session-b"]) + + path = tmp_path / PINNED_SESSIONS_RELATIVE_PATH + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["schema_version"] == 1 + assert payload["pinned_session_ids"] == ["session-a", "session-b"] + if sys.platform != "win32": + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + + +def test_corrupt_or_wrong_shaped_state_falls_back_to_legacy_local_cache(tmp_path): + from hermes_cli.desktop_ui_state import PINNED_SESSIONS_RELATIVE_PATH, read_pinned_sessions + + path = tmp_path / PINNED_SESSIONS_RELATIVE_PATH + path.parent.mkdir() + path.write_text('{"pinned_session_ids":"not-an-array"}', encoding="utf-8") + + assert read_pinned_sessions(tmp_path) == (False, []) + + +def test_write_works_on_platforms_without_fchmod(tmp_path, monkeypatch): + import hermes_cli.desktop_ui_state as desktop_ui_state + + monkeypatch.delattr(desktop_ui_state.os, "fchmod") + + assert desktop_ui_state.write_pinned_sessions(tmp_path, ["session-a"]) == ["session-a"] + assert desktop_ui_state.read_pinned_sessions(tmp_path) == (True, ["session-a"]) diff --git a/tests/hermes_cli/test_web_server_profile_unification.py b/tests/hermes_cli/test_web_server_profile_unification.py index 14141a815362..cc54e663e61b 100644 --- a/tests/hermes_cli/test_web_server_profile_unification.py +++ b/tests/hermes_cli/test_web_server_profile_unification.py @@ -107,6 +107,32 @@ def test_unknown_profile_404(self, client, isolated_profiles): assert resp.status_code == 404 +class TestDesktopPinnedSessions: + def test_pinned_sessions_are_machine_global_not_active_profile_state(self, client, isolated_profiles): + """Desktop pins span profile scopes, including the All profiles view. + + The renderer's legacy localStorage key is global to the Desktop app. + Persisting its recovery copy under the currently active profile would + replace the pin set every time the user switched profiles. + """ + response = client.put( + "/api/desktop/pinned-sessions?profile=worker_beta", + json={"pinned_session_ids": ["lineage-root-a", "lineage-root-b"]}, + ) + + assert response.status_code == 200 + assert response.json()["pinned_session_ids"] == ["lineage-root-a", "lineage-root-b"] + + worker = client.get("/api/desktop/pinned-sessions", params={"profile": "worker_beta"}) + default = client.get("/api/desktop/pinned-sessions") + + assert worker.status_code == 200 + assert worker.json() == {"exists": True, "pinned_session_ids": ["lineage-root-a", "lineage-root-b"]} + assert default.status_code == 200 + assert default.json() == worker.json() + assert not (isolated_profiles["worker_beta"] / "state" / "desktop-pinned-sessions.json").exists() + + class TestProfileScopedEnv: def test_env_set_lands_in_target_profile_only(self, client, isolated_profiles): resp = client.put(