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/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 53fb9688e3e..351a57c0ded 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, @@ -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 } ] @@ -120,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() @@ -133,6 +164,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()] } @@ -179,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 @@ -189,24 +231,99 @@ 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]) + + // 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 => !visiblePinnedIdSet.has(s.id)), - [sortedSessions, visiblePinnedIdSet] + () => sortedSessions.filter(s => !pinnedRealIdSet.has(s.id)), + [sortedSessions, pinnedRealIdSet] ) const agentSessions = useMemo( @@ -236,7 +353,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) => { @@ -331,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 && ( 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 6ede12a197a..22bd7eedbe9 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/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\