mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-17 09:41:58 +00:00
Merge pull request #37379 from NousResearch/bb/desktop-session-list
feat(desktop): session-list overhaul + cancellable install
This commit is contained in:
commit
f58db77cd0
18 changed files with 472 additions and 74 deletions
|
|
@ -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
|
||||
|
|
|
|||
27
apps/desktop/electron/bootstrap-runner.test.cjs
Normal file
27
apps/desktop/electron/bootstrap-runner.test.cjs
Normal file
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 => <Codicon name="robot" {...props} />,
|
||||
action: 'new-session'
|
||||
},
|
||||
{ id: 'skills', label: 'Skills & Tools', icon: props => <Codicon name="symbol-misc" {...props} />, route: SKILLS_ROUTE },
|
||||
{
|
||||
id: 'skills',
|
||||
label: 'Skills & Tools',
|
||||
icon: props => <Codicon name="symbol-misc" {...props} />,
|
||||
route: SKILLS_ROUTE
|
||||
},
|
||||
{ id: 'messaging', label: 'Messaging', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
|
||||
{ id: 'artifacts', label: 'Artifacts', icon: props => <Codicon name="files" {...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<string, SidebarSessionGroup>()
|
||||
|
||||
|
|
@ -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<string[]>([])
|
||||
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
|
||||
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<string, SessionInfo>()
|
||||
|
||||
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<string>()
|
||||
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<string, SessionInfo>()
|
||||
|
||||
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({
|
|||
</SidebarGroup>
|
||||
|
||||
{sidebarOpen && showSessionSections && (
|
||||
<div className="shrink-0 pb-1 pt-1">
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-transparent bg-transparent px-2 transition-colors focus-within:border-(--ui-stroke-tertiary)">
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="search" size="0.75rem" />
|
||||
<input
|
||||
aria-label="Search sessions"
|
||||
className="h-6 min-w-0 flex-1 bg-transparent text-[0.8125rem] text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
|
||||
onChange={event => setSearchQuery(event.target.value)}
|
||||
placeholder="Search sessions…"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
aria-label="Clear search"
|
||||
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-active-background) hover:text-foreground"
|
||||
onClick={() => setSearchQuery('')}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sidebarOpen && showSessionSections && trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
|
||||
emptyState={
|
||||
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
|
||||
No sessions match “{trimmedQuery}”.
|
||||
</div>
|
||||
}
|
||||
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 && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
|
||||
|
|
@ -352,7 +522,7 @@ export function ChatSidebar({
|
|||
/>
|
||||
)}
|
||||
|
||||
{sidebarOpen && showSessionSections && (
|
||||
{sidebarOpen && showSessionSections && !trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
|
||||
|
|
@ -536,7 +706,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<VirtualSessionListProps> = ({
|
|||
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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)}
|
||||
>
|
||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center">{icon}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'truncate text-sm font-medium',
|
||||
state === 'pending' && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<span className={cn('truncate text-sm font-medium', state === 'pending' && 'text-muted-foreground')}>
|
||||
{formatStageName(descriptor.name)}
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
|
|
@ -135,9 +126,7 @@ function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
|
|||
{state === 'failed' ? STATE_LABEL[state] : null}
|
||||
</span>
|
||||
</div>
|
||||
{reason && state !== 'pending' && (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>
|
||||
)}
|
||||
{reason && state !== 'pending' && <p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
|
|
@ -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<DesktopBootstrapState>(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<HTMLDivElement | null>(null)
|
||||
|
||||
|
|
@ -293,8 +283,8 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||
<div className="w-full max-w-xl rounded-xl border bg-card p-8 shadow-xl">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Hermes needs a one-time install</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
|
|
@ -328,11 +318,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||
<span className="text-xs text-muted-foreground">
|
||||
Will install to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">{ups.activeRoot}</code>
|
||||
</span>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
<Button variant="default" size="sm" onClick={() => window.location.reload()}>
|
||||
I{'\u2019'}ve run it -- retry
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -382,10 +368,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full transition-all duration-300',
|
||||
failed ? 'bg-destructive' : 'bg-primary'
|
||||
)}
|
||||
className={cn('h-full transition-all duration-300', failed ? 'bg-destructive' : 'bg-primary')}
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -431,14 +414,18 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||
>
|
||||
{logOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
<span>{logOpen ? 'Hide installer output' : 'Show installer output'}</span>
|
||||
<span className="ml-1 tabular-nums">({state.log.length} line{state.log.length === 1 ? '' : 's'})</span>
|
||||
<span className="ml-1 tabular-nums">
|
||||
({state.log.length} line{state.log.length === 1 ? '' : 's'})
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{logOpen && (
|
||||
<div className={cn(
|
||||
'mt-2 overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed',
|
||||
failed ? 'max-h-96' : 'max-h-64'
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-2 overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed',
|
||||
failed ? 'max-h-96' : 'max-h-64'
|
||||
)}
|
||||
>
|
||||
{state.log.length === 0 ? (
|
||||
<div className="text-muted-foreground">No output yet.</div>
|
||||
) : (
|
||||
|
|
@ -457,12 +444,38 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active footer: let the user actually cancel a running install. */}
|
||||
{state.active && !failed && (
|
||||
<div className="flex-shrink-0 border-t bg-card p-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
disabled={cancelling}
|
||||
onClick={async () => {
|
||||
setCancelling(true)
|
||||
|
||||
try {
|
||||
await window.hermesDesktop?.cancelBootstrap?.()
|
||||
} catch {
|
||||
// ignore -- the failed/cancelled event will surface the result
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{cancelling ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{cancelling ? 'Cancelling...' : 'Cancel install'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer -- always visible, never scrolls; only renders on failure */}
|
||||
{failed && (
|
||||
<div className="flex-shrink-0 border-t bg-card p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Full transcript saved to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">%LOCALAPPDATA%\hermes\logs\</code>
|
||||
Full transcript saved to{' '}
|
||||
<code className="rounded bg-muted/50 px-1 py-0.5 font-mono">%LOCALAPPDATA%\hermes\logs\</code>
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
|
|
|
|||
9
apps/desktop/src/global.d.ts
vendored
9
apps/desktop/src/global.d.ts
vendored
|
|
@ -48,6 +48,7 @@ declare global {
|
|||
getBootstrapState: () => Promise<DesktopBootstrapState>
|
||||
resetBootstrap: () => Promise<{ ok: boolean }>
|
||||
repairBootstrap: () => Promise<{ ok: boolean }>
|
||||
cancelBootstrap: () => Promise<{ ok: boolean; cancelled: boolean }>
|
||||
onBootstrapEvent: (callback: (payload: DesktopBootstrapEvent) => void) => () => void
|
||||
getVersion: () => Promise<DesktopVersionInfo>
|
||||
updates: {
|
||||
|
|
@ -194,12 +195,7 @@ export interface DesktopBootstrapStageDescriptor {
|
|||
needs_user_input?: boolean
|
||||
}
|
||||
|
||||
export type DesktopBootstrapStageState =
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'succeeded'
|
||||
| 'skipped'
|
||||
| 'failed'
|
||||
export type DesktopBootstrapStageState = 'pending' | 'running' | 'succeeded' | 'skipped' | 'failed'
|
||||
|
||||
export interface DesktopBootstrapStageResult {
|
||||
state: DesktopBootstrapStageState
|
||||
|
|
@ -248,7 +244,6 @@ export type DesktopBootstrapEvent =
|
|||
docsUrl: string
|
||||
}
|
||||
|
||||
|
||||
export interface HermesApiRequest {
|
||||
path: string
|
||||
method?: string
|
||||
|
|
|
|||
|
|
@ -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<PaginatedSessions> {
|
||||
const result = await window.hermesDesktop.api<PaginatedSessions>({
|
||||
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 {
|
||||
|
|
|
|||
36
apps/desktop/src/store/session.test.ts
Normal file
36
apps/desktop/src/store/session.test.ts
Normal file
|
|
@ -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>): 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -16,6 +16,12 @@ function updateAtom<T>(store: AppAtom<T>, next: Updater<T>) {
|
|||
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<SessionInfo, '_lineage_root_id' | 'id'>): string =>
|
||||
session._lineage_root_id ?? session.id
|
||||
|
||||
export const $connection = atom<HermesConnection | null>(null)
|
||||
export const $gatewayState = atom('idle')
|
||||
export const $sessions = atom<SessionInfo[]>([])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,15 @@ 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.
|
||||
raw_cwd = str(params.get("cwd") or "").strip()
|
||||
try:
|
||||
explicit_cwd = bool(raw_cwd) and os.path.isdir(os.path.abspath(os.path.expanduser(raw_cwd)))
|
||||
except Exception:
|
||||
explicit_cwd = False
|
||||
_enable_gateway_prompts()
|
||||
|
||||
ready = threading.Event()
|
||||
|
|
@ -2759,6 +2777,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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue