From de8bdf529d6478ae54d1677b5e59bffe639ca8e2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 2 Jun 2026 07:12:05 -0500 Subject: [PATCH 1/6] fix(desktop): keep pinned + recent sessions visible across compression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long-running sessions auto-compress: the gateway ends the original session and surfaces the live continuation under a new id (list_sessions_rich projects the root forward to its tip). Two symptoms fell out of the id rotation: - A pinned session "vanished" — the pin is stored as the pre-compression root id, but the sidebar only matched on the live id, so it was filtered out. Pins now resolve on the durable lineage-root id (`_lineage_root_id`, already surfaced by the projection): the sidebar indexes sessions by both ids, pin/ unpin and reorder operate on the durable id, and `sessionPinId()` is shared with the Cmd+P toggle. Existing pins keep working with no migration. - A freshly-continued session was missing from the list until you ungrouped + "load 50 more" — the list paginated by original start time, so an old-but- active conversation sat past the first page. The desktop now requests `order=recent` (GET /api/sessions gains an `order` param backed by the existing recency CTE), surfacing live continuations on the first page. --- apps/desktop/src/app/chat/sidebar/index.tsx | 62 ++++++++++++++----- .../app/chat/sidebar/virtual-session-list.tsx | 3 +- apps/desktop/src/app/desktop-controller.tsx | 12 +++- apps/desktop/src/hermes.ts | 5 +- apps/desktop/src/store/session.test.ts | 36 +++++++++++ apps/desktop/src/store/session.ts | 6 ++ apps/desktop/src/types/hermes.ts | 4 ++ hermes_cli/web_server.py | 12 ++++ tests/hermes_cli/test_web_server.py | 43 +++++++++++++ 9 files changed, 161 insertions(+), 22 deletions(-) create mode 100644 apps/desktop/src/store/session.test.ts diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 53fb9688e3e..4d73857473e 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -54,7 +54,8 @@ import { $sessions, $sessionsLoading, $sessionsTotal, - $workingSessionIds + $workingSessionIds, + sessionPinId } from '@/store/session' import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes' @@ -73,7 +74,12 @@ const SIDEBAR_NAV: SidebarNavItem[] = [ icon: props => , action: 'new-session' }, - { id: 'skills', label: 'Skills & Tools', icon: props => , route: SKILLS_ROUTE }, + { + id: 'skills', + label: 'Skills & Tools', + icon: props => , + route: SKILLS_ROUTE + }, { id: 'messaging', label: 'Messaging', icon: props => , route: MESSAGING_ROUTE }, { id: 'artifacts', label: 'Artifacts', icon: props => , route: ARTIFACTS_ROUTE } ] @@ -189,24 +195,45 @@ export function ChatSidebar({ const sortedSessions = useMemo(() => [...sessions].sort((a, b) => sessionTime(b) - sessionTime(a)), [sessions]) - const sessionsById = useMemo(() => new Map(sessions.map(s => [s.id, s])), [sessions]) const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds]) - const visiblePinnedIds = useMemo( - () => pinnedSessionIds.filter(id => sessionsById.has(id)), - [pinnedSessionIds, sessionsById] - ) + // Index sessions by both their live id and their lineage-root id so a pin + // stored as the pre-compression root resolves to the live continuation tip. + const sessionByAnyId = useMemo(() => { + const map = new Map() - const visiblePinnedIdSet = useMemo(() => new Set(visiblePinnedIds), [visiblePinnedIds]) + for (const s of sessions) { + map.set(s.id, s) - const pinnedSessions = useMemo( - () => visiblePinnedIds.map(id => sessionsById.get(id)!).filter(Boolean), - [visiblePinnedIds, sessionsById] - ) + if (s._lineage_root_id && !map.has(s._lineage_root_id)) { + map.set(s._lineage_root_id, s) + } + } + + return map + }, [sessions]) + + const pinnedSessions = useMemo(() => { + const seen = new Set() + const out: SessionInfo[] = [] + + for (const pinId of pinnedSessionIds) { + const session = sessionByAnyId.get(pinId) + + if (session && !seen.has(session.id)) { + seen.add(session.id) + out.push(session) + } + } + + return out + }, [pinnedSessionIds, sessionByAnyId]) + + const pinnedRealIdSet = useMemo(() => new Set(pinnedSessions.map(s => s.id)), [pinnedSessions]) const unpinnedAgentSessions = useMemo( - () => sortedSessions.filter(s => !visiblePinnedIdSet.has(s.id)), - [sortedSessions, visiblePinnedIdSet] + () => sortedSessions.filter(s => !pinnedRealIdSet.has(s.id)), + [sortedSessions, pinnedRealIdSet] ) const agentSessions = useMemo( @@ -236,7 +263,10 @@ export function ChatSidebar({ return } - reorderPinnedSession(String(active.id), newIndex) + // Sortable ids are live session ids; the pinned store is keyed by durable + // (lineage-root) ids, so translate before reordering. + const dragged = sessionByAnyId.get(String(active.id)) + reorderPinnedSession(dragged ? sessionPinId(dragged) : String(active.id), newIndex) } const handleAgentDragEnd = ({ active, over }: DragEndEvent) => { @@ -536,7 +566,7 @@ function SidebarSessionsSection({ isWorking: workingSessionIdSet.has(session.id), onArchive: () => onArchiveSession(session.id), onDelete: () => onDeleteSession(session.id), - onPin: () => onTogglePin(session.id), + onPin: () => onTogglePin(sessionPinId(session)), onResume: () => onResumeSession(session.id), session } diff --git a/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx b/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx index 2f6d8deb8cd..debcdd8cd82 100644 --- a/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx +++ b/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx @@ -5,6 +5,7 @@ import { type FC, useCallback, useMemo, useRef } from 'react' import type { SessionInfo } from '@/hermes' import { cn } from '@/lib/utils' +import { sessionPinId } from '@/store/session' import { SidebarSessionRow } from './session-row' @@ -77,7 +78,7 @@ export const VirtualSessionList: FC = ({ isWorking: workingSessionIdSet.has(session.id), onArchive: () => onArchiveSession(session.id), onDelete: () => onDeleteSession(session.id), - onPin: () => onTogglePin(session.id), + onPin: () => onTogglePin(sessionPinId(session)), onResume: () => onResumeSession(session.id) } diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index ba513a2cfee..58a7dd0e59a 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -32,6 +32,8 @@ import { $freshDraftReady, $gatewayState, $selectedStoredSessionId, + $sessions, + sessionPinId, setAwaitingResponse, setBusy, setCurrentBranch, @@ -224,10 +226,14 @@ export function DesktopController() { return } - if ($pinnedSessionIds.get().includes(sessionId)) { - unpinSession(sessionId) + // Pin on the durable lineage-root id so the pin survives auto-compression. + const session = $sessions.get().find(s => s.id === sessionId || s._lineage_root_id === sessionId) + const pinId = session ? sessionPinId(session) : sessionId + + if ($pinnedSessionIds.get().includes(pinId)) { + unpinSession(pinId) } else { - pinSession(sessionId) + pinSession(pinId) } }, []) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 3e06027fc07..6e41f382804 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -114,10 +114,11 @@ export class HermesGateway extends JsonRpcGatewayClient { export async function listSessions( limit = 40, minMessages = 0, - archived: 'exclude' | 'include' | 'only' = 'exclude' + archived: 'exclude' | 'include' | 'only' = 'exclude', + order: 'created' | 'recent' = 'recent' ): Promise { const result = await window.hermesDesktop.api({ - path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}` + path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}&order=${order}` }) return { diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts new file mode 100644 index 00000000000..d9d2befb7e4 --- /dev/null +++ b/apps/desktop/src/store/session.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionInfo } from '@/types/hermes' + +import { sessionPinId } from './session' + +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 +}) + +describe('sessionPinId', () => { + it('uses the live id when there is no compression lineage', () => { + expect(sessionPinId(session({ id: 'abc' }))).toBe('abc') + }) + + it('uses the lineage root so a pin survives compression', () => { + // After auto-compression the entry surfaces under a fresh tip id but keeps + // the original root — pinning on the root keeps the pin stable. + expect(sessionPinId(session({ id: 'tip', _lineage_root_id: 'root' }))).toBe('root') + }) +}) diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 6cd26f6b95c..cf2372f3d04 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -16,6 +16,12 @@ function updateAtom(store: AppAtom, next: Updater) { store.set(typeof next === 'function' ? (next as (current: T) => T)(store.get()) : next) } +/** Durable id for pinning. Auto-compression rotates a conversation's session + * id (root -> continuation tip), so pins keyed on the live id evaporate. The + * lineage root is stable across every compression, so we pin on that. */ +export const sessionPinId = (session: Pick): string => + session._lineage_root_id ?? session.id + export const $connection = atom(null) export const $gatewayState = atom('idle') export const $sessions = atom([]) diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 550b66deb77..0fbad5f25a2 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -244,6 +244,10 @@ export interface SessionInfo { cwd?: null | string ended_at: null | number id: string + /** Original root id of a compression chain, when this entry is a projected + * continuation tip. Stable across compressions — used as the durable id for + * pins so a pinned conversation survives auto-compression. */ + _lineage_root_id?: null | string input_tokens: number is_active: boolean last_active: number diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 92d4119cf7d..4a3e20a6eaf 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1363,6 +1363,7 @@ async def get_sessions( offset: int = 0, min_messages: int = 0, archived: str = "exclude", + order: str = "created", ): """List sessions. @@ -1370,12 +1371,22 @@ async def get_sessions( ``exclude`` (default) hides them, ``only`` returns just the archived ones (used by the desktop "Archived sessions" settings panel), and ``include`` returns both. + + ``order`` controls pagination order: ``created`` (default, by original + start time) or ``recent`` (by latest activity across the compression + chain). ``recent`` keeps a long-running conversation on the first page + after it auto-compresses into a fresh continuation id. """ if archived not in ("exclude", "only", "include"): raise HTTPException( status_code=400, detail="archived must be one of: exclude, only, include", ) + if order not in ("created", "recent"): + raise HTTPException( + status_code=400, + detail="order must be one of: created, recent", + ) try: from hermes_state import SessionDB db = SessionDB() @@ -1389,6 +1400,7 @@ async def get_sessions( min_message_count=min_message_count, include_archived=include_archived, archived_only=archived_only, + order_by_last_active=order == "recent", ) total = db.session_count( min_message_count=min_message_count, diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 8dd39fa1f36..d994797e4e6 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -293,6 +293,49 @@ class TestWebServerEndpoints: resp = self.client.get("/api/sessions?archived=bogus") assert resp.status_code == 400 + def test_get_sessions_rejects_unknown_order_value(self): + resp = self.client.get("/api/sessions?order=sideways") + assert resp.status_code == 400 + + def test_get_sessions_order_recent_surfaces_compression_tip(self): + """A long-running conversation that auto-compresses must stay on the + first page by recency, listed under its live continuation id.""" + import time as _time + + from hermes_state import SessionDB + + db = SessionDB() + try: + old = _time.time() - 86_400 + # Old conversation that later compresses into a fresh continuation. + # The continuation must start at/after the parent's ended_at to be + # recognised as a compression tip (not a sub-agent/branch). + db.create_session(session_id="root-old", source="cli") + db.append_message(session_id="root-old", role="user", content="kickoff") + db.end_session("root-old", "compression") + db._conn.execute( + "UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = ?", + (old, old + 10, "root-old"), + ) + db.create_session(session_id="tip-new", source="cli", parent_session_id="root-old") + db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = ?", (old + 10, "tip-new")) + db.append_message(session_id="tip-new", role="user", content="continued just now") + # A brand-new unrelated session started after the root but before now. + db.create_session(session_id="mid", source="cli") + db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = ?", (_time.time() - 3600, "mid")) + db.append_message(session_id="mid", role="user", content="hello") + db._conn.commit() + finally: + db.close() + + rows = self.client.get("/api/sessions?order=recent&limit=5").json()["sessions"] + ids = [r["id"] for r in rows] + # The compressed conversation surfaces under its live tip id... + assert "tip-new" in ids + # ...carrying the durable lineage root so the desktop can match pins. + tip = next(r for r in rows if r["id"] == "tip-new") + assert tip.get("_lineage_root_id") == "root-old" + def test_get_sessions_archived_is_boolean(self): from hermes_state import SessionDB From 135c65093a0a523058229049d6a52e3feb246055 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 2 Jun 2026 07:18:47 -0500 Subject: [PATCH 2/6] feat(desktop): stable in-workspace ordering + No-workspace default - Sidebar: rows within a workspace group now sort by creation time instead of last activity, so they stop reshuffling every time a message lands (muscle memory). Groups still float up by recency. - Sessions only persist a workspace cwd when one was explicitly chosen; an auto-detected launch directory is no longer stamped on the row, so untargeted sessions group under "No workspace" instead of "desktop". The agent still runs in the detected directory. --- apps/desktop/src/app/chat/sidebar/index.tsx | 8 ++++++ tests/test_tui_gateway_server.py | 27 +++++++++++++++++---- tui_gateway/server.py | 21 +++++++++++++--- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 4d73857473e..8bd235b6600 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -139,6 +139,14 @@ function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] { groups.set(id, group) } + // Groups keep recency order (Map insertion = first-seen in the recency-sorted + // input, so an active project floats up), but rows *within* a group sort by + // creation time so they don't reshuffle every time a message lands — keeps + // muscle memory intact. + for (const group of groups.values()) { + group.sessions.sort((a, b) => b.started_at - a.started_at) + } + return [...groups.values()] } diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 4fc01772965..6c93dd629d3 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -933,8 +933,27 @@ def test_session_create_does_not_persist_empty_row(monkeypatch): server._sessions.pop(sid, None) -def test_ensure_session_db_row_persists_with_cwd(monkeypatch, tmp_path): - """First prompt persists the row (INSERT OR IGNORE) capturing cwd up front.""" +def test_ensure_session_db_row_persists_explicit_cwd(monkeypatch, tmp_path): + """An explicitly chosen workspace is persisted as the session cwd.""" + created = [] + + class _FakeDB: + def create_session(self, key, source=None, model=None, cwd=None): + created.append({"key": key, "source": source, "model": model, "cwd": cwd}) + + monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) + monkeypatch.setattr(server, "_resolve_model", lambda: "test-model") + + server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path), "explicit_cwd": True}) + + assert created == [ + {"key": "k1", "source": "tui", "model": "test-model", "cwd": str(tmp_path)} + ] + + +def test_ensure_session_db_row_defaults_to_no_workspace(monkeypatch, tmp_path): + """Without an explicit workspace, cwd is left null so the session groups + under "No workspace" rather than the gateway's launch directory.""" created = [] class _FakeDB: @@ -946,9 +965,7 @@ def test_ensure_session_db_row_persists_with_cwd(monkeypatch, tmp_path): server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path)}) - assert created == [ - {"key": "k1", "source": "tui", "model": "test-model", "cwd": str(tmp_path)} - ] + assert created == [{"key": "k1", "source": "tui", "model": "test-model", "cwd": None}] def test_session_title_clears_pending_after_persist(monkeypatch): diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 83963f80139..e1bb5a387b4 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -708,8 +708,14 @@ def _ensure_session_db_row(session: dict) -> None: Called from prompt.submit so a row only exists once the user actually sends a message — abandoned drafts never leave an empty "Untitled" session behind. Uses INSERT OR IGNORE under the hood, so re-calls (and the AIAgent's own - lazy create) are no-ops. Captures cwd up front so workspace grouping works - without waiting for a separate cwd update. + lazy create) are no-ops. + + Only an *explicitly chosen* workspace is persisted as the session's cwd. + The agent still runs in the auto-detected directory (session["cwd"]), but + we don't stamp that onto the row — otherwise every session the user never + picked a folder for gets grouped under whatever directory the desktop + happened to launch in (e.g. "desktop"). Leaving it null groups them under + "No workspace", which is the desired default. """ key = session.get("session_key") if not key: @@ -722,7 +728,7 @@ def _ensure_session_db_row(session: dict) -> None: key, source="tui", model=_resolve_model(), - cwd=_session_cwd(session), + cwd=_session_cwd(session) if session.get("explicit_cwd") else None, ) except Exception: logger.debug("failed to persist desktop session row", exc_info=True) @@ -733,6 +739,9 @@ def _set_session_cwd(session: dict, cwd: str) -> str: if not os.path.isdir(resolved): raise ValueError(f"working directory does not exist: {cwd}") session["cwd"] = resolved + # An explicit user choice — persist it as the workspace (and let a later + # lazy row creation persist it too, not the launch-dir fallback). + session["explicit_cwd"] = True _register_session_cwd(session) db = _get_db() if db is not None: @@ -2746,6 +2755,11 @@ def _(rid, params: dict) -> dict: cols = int(params.get("cols", 80)) history = _coerce_seed_history(params.get("messages")) title = str(params.get("title") or "").strip() + # Did the client pick a workspace, or are we falling back to the gateway's + # launch directory? Only an explicit choice is persisted as the session's + # workspace (see _ensure_session_db_row); otherwise it lands in "No + # workspace" instead of whatever folder the desktop launched in. + explicit_cwd = bool(str(params.get("cwd") or "").strip()) _enable_gateway_prompts() ready = threading.Event() @@ -2759,6 +2773,7 @@ def _(rid, params: dict) -> dict: "cols": cols, "created_at": now, "edit_snapshots": {}, + "explicit_cwd": explicit_cwd, "history": history, "history_lock": threading.Lock(), "history_version": 0, From 5b71f7dd724904cf0542fa258a07840e0bb5c028 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 2 Jun 2026 07:21:03 -0500 Subject: [PATCH 3/6] feat(desktop): session search in the sidebar Adds a search box above the session list. Loaded sessions match instantly client-side; a debounced full-text search (existing /api/sessions/search FTS) covers the rest so all sessions stay findable at 699+. Results replace the pinned/agents sections while a query is active and resume on click. --- apps/desktop/src/app/chat/sidebar/index.tsx | 138 +++++++++++++++++++- 1 file changed, 135 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 8bd235b6600..3a64842c3f4 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -17,7 +17,7 @@ import { import { CSS } from '@dnd-kit/utilities' import { useStore } from '@nanostores/react' import type * as React from 'react' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' @@ -33,7 +33,7 @@ import { SidebarMenuItem } from '@/components/ui/sidebar' import { Skeleton } from '@/components/ui/skeleton' -import type { SessionInfo } from '@/hermes' +import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes' import { cn } from '@/lib/utils' import { $pinnedSessionIds, @@ -126,6 +126,31 @@ const baseName = (path: string) => .filter(Boolean) .pop() +// FTS results cover sessions that aren't in the loaded page; synthesize a +// minimal SessionInfo so they render in the same row component (resume works +// by id; the snippet stands in for the preview). +function searchResultToSession(result: SessionSearchResult): SessionInfo { + const ts = result.session_started ?? Date.now() / 1000 + + return { + archived: false, + cwd: null, + ended_at: null, + id: result.session_id, + input_tokens: 0, + is_active: false, + last_active: ts, + message_count: 0, + model: result.model ?? null, + output_tokens: 0, + preview: result.snippet?.trim() || null, + source: result.source ?? null, + started_at: ts, + title: null, + tool_call_count: 0 + } +} + function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] { const groups = new Map() @@ -193,6 +218,9 @@ export function ChatSidebar({ const workingSessionIds = useStore($workingSessionIds) const [agentOrderIds, setAgentOrderIds] = useState([]) const [workspaceOrderIds, setWorkspaceOrderIds] = useState([]) + const [searchQuery, setSearchQuery] = useState('') + const [serverMatches, setServerMatches] = useState([]) + const trimmedQuery = searchQuery.trim() const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null @@ -239,6 +267,60 @@ export function ChatSidebar({ const pinnedRealIdSet = useMemo(() => new Set(pinnedSessions.map(s => s.id)), [pinnedSessions]) + // Full-text search across *all* sessions (not just the loaded page) so 699 + // sessions stay findable. Debounced; loaded sessions are matched instantly + // client-side and merged ahead of the server hits. + useEffect(() => { + if (!trimmedQuery) { + setServerMatches([]) + + return + } + + let cancelled = false + + const id = window.setTimeout(() => { + void searchSessions(trimmedQuery) + .then(res => { + if (!cancelled) { + setServerMatches(res.results) + } + }) + .catch(() => undefined) + }, 200) + + return () => { + cancelled = true + window.clearTimeout(id) + } + }, [trimmedQuery]) + + const searchResults = useMemo(() => { + if (!trimmedQuery) { + return [] + } + + const needle = trimmedQuery.toLowerCase() + const out = new Map() + + for (const s of sortedSessions) { + if (`${s.title ?? ''} ${s.preview ?? ''} ${s.cwd ?? ''}`.toLowerCase().includes(needle)) { + out.set(s.id, s) + } + } + + for (const match of serverMatches) { + if (out.has(match.session_id)) { + continue + } + + const loaded = sessionByAnyId.get(match.session_id) + out.set(match.session_id, loaded ?? searchResultToSession(match)) + } + + return [...out.values()] + }, [trimmedQuery, sortedSessions, serverMatches, sessionByAnyId]) + const unpinnedAgentSessions = useMemo( () => sortedSessions.filter(s => !pinnedRealIdSet.has(s.id)), [sortedSessions, pinnedRealIdSet] @@ -369,6 +451,56 @@ export function ChatSidebar({ {sidebarOpen && showSessionSections && ( +
+
+ + setSearchQuery(event.target.value)} + placeholder="Search sessions…" + type="text" + value={searchQuery} + /> + {searchQuery && ( + + )} +
+
+ )} + + {sidebarOpen && showSessionSections && trimmedQuery && ( + + No sessions match “{trimmedQuery}”. + + } + label="Results" + labelMeta={String(searchResults.length)} + onArchiveSession={onArchiveSession} + onDeleteSession={onDeleteSession} + onResumeSession={onResumeSession} + onToggle={() => undefined} + onTogglePin={pinSession} + open + pinned={false} + rootClassName="min-h-0 flex-1 p-0" + sessions={searchResults} + workingSessionIdSet={workingSessionIdSet} + /> + )} + + {sidebarOpen && showSessionSections && !trimmedQuery && ( )} - {sidebarOpen && showSessionSections && ( + {sidebarOpen && showSessionSections && !trimmedQuery && ( Date: Tue, 2 Jun 2026 08:50:45 -0500 Subject: [PATCH 4/6] feat(desktop): cancellable first-launch install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install overlay had no way to stop a running install — the runner already supported an abortSignal, but nothing drove it. Wire it end to end: - main.cjs holds an AbortController for the active runBootstrap and aborts it on a new hermes:bootstrap:cancel IPC and on app quit, so quitting/cancelling mid-install actually kills install.sh/ps1 instead of orphaning it. - runBootstrap bails before spawning anything if the signal is already aborted. - Install overlay gains a "Cancel install" button while a bootstrap is active; a cancel surfaces the recovery overlay (retry/repair). Test: electron/bootstrap-runner.test.cjs asserts the already-aborted early return (no spawn) via `node --test`. --- apps/desktop/electron/bootstrap-runner.cjs | 12 +++ .../electron/bootstrap-runner.test.cjs | 27 +++++++ apps/desktop/electron/main.cjs | 35 ++++++++ apps/desktop/electron/preload.cjs | 1 + apps/desktop/package.json | 2 +- .../components/desktop-install-overlay.tsx | 79 +++++++++++-------- apps/desktop/src/global.d.ts | 9 +-- 7 files changed, 124 insertions(+), 41 deletions(-) create mode 100644 apps/desktop/electron/bootstrap-runner.test.cjs diff --git a/apps/desktop/electron/bootstrap-runner.cjs b/apps/desktop/electron/bootstrap-runner.cjs index 9e427d147d5..51f24090b02 100644 --- a/apps/desktop/electron/bootstrap-runner.cjs +++ b/apps/desktop/electron/bootstrap-runner.cjs @@ -482,6 +482,18 @@ async function runBootstrap(opts) { writeMarker // callback to write the bootstrap-complete marker; main.cjs provides } = opts + // Bail before spawning anything if the user already cancelled — otherwise an + // already-aborted signal would still fetch the manifest (a spawn) before the + // in-loop abort check fires. + if (abortSignal && abortSignal.aborted) { + if (typeof onEvent === 'function') { + try { + onEvent({ type: 'failed', error: 'bootstrap cancelled by user' }) + } catch {} + } + return { ok: false, cancelled: true } + } + const runLog = openRunLog(logRoot || path.join(hermesHome, 'logs')) // Tee every event to the runLog AND the caller's onEvent. This gives us a diff --git a/apps/desktop/electron/bootstrap-runner.test.cjs b/apps/desktop/electron/bootstrap-runner.test.cjs new file mode 100644 index 00000000000..f105c735564 --- /dev/null +++ b/apps/desktop/electron/bootstrap-runner.test.cjs @@ -0,0 +1,27 @@ +const assert = require('node:assert/strict') +const test = require('node:test') + +const { runBootstrap } = require('./bootstrap-runner.cjs') + +test('runBootstrap bails immediately when the signal is already aborted', async () => { + const controller = new AbortController() + controller.abort() + + const events = [] + const result = await runBootstrap({ + installStamp: null, + activeRoot: '/tmp/hermes-runner-test', + sourceRepoRoot: null, + hermesHome: '/tmp/hermes-runner-test', + logRoot: '/tmp/hermes-runner-test', + onEvent: ev => events.push(ev), + abortSignal: controller.signal + }) + + // Cancelled before any install script is spawned. + assert.deepEqual(result, { ok: false, cancelled: true }) + assert.ok( + events.some(ev => ev.type === 'failed' && /cancelled/i.test(ev.error)), + 'should emit a cancelled failure event' + ) +}) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index b07e53ee030..bd94e69a5f1 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -435,6 +435,9 @@ let connectionPromise = null // instead of re-running install.ps1 in a hot loop. Cleared explicitly by // the renderer's "Reload and retry" path or by quitting the app. let bootstrapFailure = null +// Active first-launch install, so the renderer's Cancel button (and app quit) +// can abort the in-flight install.sh/ps1 instead of leaving it running. +let bootstrapAbortController = null let connectionConfigCache = null const hermesLog = [] const previewWatchers = new Map() @@ -1740,12 +1743,15 @@ async function ensureRuntime(backend) { }) } catch {} + bootstrapAbortController = new AbortController() + const bootstrapResult = await runBootstrap({ installStamp: backend.installStamp, activeRoot: backend.activeRoot, sourceRepoRoot: SOURCE_REPO_ROOT, hermesHome: HERMES_HOME, logRoot: path.join(HERMES_HOME, 'logs'), + abortSignal: bootstrapAbortController.signal, onEvent: ev => { // Tee every bootstrap event to (a) the desktop log for forensics // and (b) the renderer for live progress UI. Either may be absent; @@ -1761,6 +1767,16 @@ async function ensureRuntime(backend) { writeMarker: writeBootstrapMarker }) + bootstrapAbortController = null + + if (bootstrapResult.cancelled) { + const cancelledError = new Error('Hermes install was cancelled.') + cancelledError.isBootstrapFailure = true + cancelledError.bootstrapCancelled = true + bootstrapFailure = cancelledError + throw cancelledError + } + if (!bootstrapResult.ok) { const bootstrapError = new Error( `Hermes bootstrap failed${bootstrapResult.failedStage ? ` at stage '${bootstrapResult.failedStage}'` : ''}: ` + @@ -3256,6 +3272,18 @@ ipcMain.handle('hermes:bootstrap:repair', async () => { resetHermesConnection() return { ok: true } }) +ipcMain.handle('hermes:bootstrap:cancel', async () => { + // Renderer's Cancel button during first-launch install. Abort the running + // install script (SIGTERM via the runner's abortSignal). runBootstrap + // resolves with { cancelled: true }, which surfaces the recovery overlay. + if (bootstrapAbortController) { + try { + bootstrapAbortController.abort() + } catch {} + return { ok: true, cancelled: true } + } + return { ok: false, cancelled: false } +}) ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState) ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState()) ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig()) @@ -3726,6 +3754,13 @@ app.whenReady().then(() => { }) app.on('before-quit', () => { + // Quitting mid-install should stop the installer, not orphan it. + if (bootstrapAbortController) { + try { + bootstrapAbortController.abort() + } catch {} + } + if (desktopLogFlushTimer) { clearTimeout(desktopLogFlushTimer) desktopLogFlushTimer = null diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index eb0fc1fdf2e..fcdf789ae3e 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -91,6 +91,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { getBootstrapState: () => ipcRenderer.invoke('hermes:bootstrap:get'), resetBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:reset'), repairBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:repair'), + cancelBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:cancel'), onBootstrapEvent: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:bootstrap:event', listener) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7056ec73601..a9a0db7e21b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -32,7 +32,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs", "type-check": "tsc -b", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx index 1864c6840ca..2ddac1b41b1 100644 --- a/apps/desktop/src/components/desktop-install-overlay.tsx +++ b/apps/desktop/src/components/desktop-install-overlay.tsx @@ -62,9 +62,7 @@ function formatStageName(name: string): string { if (name.length <= 3) return name return name .split('-') - .map((word, i) => - i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word - ) + .map((word, i) => (i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word)) .join(' ') } @@ -116,17 +114,10 @@ function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) { state === 'failed' && 'bg-destructive/10' )} > -
- {icon} -
+
{icon}
- + {formatStageName(descriptor.name)} @@ -135,9 +126,7 @@ function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) { {state === 'failed' ? STATE_LABEL[state] : null}
- {reason && state !== 'pending' && ( -

{reason}

- )} + {reason && state !== 'pending' &&

{reason}

}
) @@ -180,7 +169,7 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De durationMs: ev.durationMs ?? null, // Stamp the start time on the running transition so the UI can show // a live elapsed timer; preserve it across repeated running events. - startedAt: ev.state === 'running' ? prev?.startedAt ?? Date.now() : prev?.startedAt ?? null, + startedAt: ev.state === 'running' ? (prev?.startedAt ?? Date.now()) : (prev?.startedAt ?? null), json: ev.json ?? null, error: ev.error ?? null } @@ -217,6 +206,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP const [state, setState] = useState(EMPTY_STATE) const [logOpen, setLogOpen] = useState(false) const [copied, setCopied] = useState(false) + const [cancelling, setCancelling] = useState(false) const [now, setNow] = useState(() => Date.now()) const logEndRef = useRef(null) @@ -293,8 +283,8 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP

Hermes needs a one-time install

- Automated first-launch install isn{'\u2019'}t available on {platformLabel} yet. Open Terminal and - run the command below, then relaunch this app. Subsequent launches will skip this step. + Automated first-launch install isn{'\u2019'}t available on {platformLabel} yet. Open Terminal and run the + command below, then relaunch this app. Subsequent launches will skip this step.

@@ -328,11 +318,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP Will install to {ups.activeRoot} -
@@ -382,10 +368,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
@@ -431,14 +414,18 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP > {logOpen ? : } {logOpen ? 'Hide installer output' : 'Show installer output'} - ({state.log.length} line{state.log.length === 1 ? '' : 's'}) + + ({state.log.length} line{state.log.length === 1 ? '' : 's'}) + {logOpen && ( -
+
{state.log.length === 0 ? (
No output yet.
) : ( @@ -457,12 +444,38 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
+ {/* Active footer: let the user actually cancel a running install. */} + {state.active && !failed && ( +
+
+ +
+
+ )} + {/* Footer -- always visible, never scrolls; only renders on failure */} {failed && (
- Full transcript saved to %LOCALAPPDATA%\hermes\logs\ + Full transcript saved to{' '} + %LOCALAPPDATA%\hermes\logs\