From 85b65e29f09b7c4ff4676880b984fe7ffc9b7f1d Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Mon, 1 Jun 2026 20:41:34 -0500 Subject: [PATCH] feat(desktop): session hygiene, archive, media streaming + connecting overlay (#37099) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(desktop): session hygiene, archive, media streaming + connecting overlay Address a batch of desktop feedback: - Stop leaking empty "Untitled" sessions: the TUI gateway pre-created a DB row on every session.create (i.e. every launch/draft). Persist the row lazily on first prompt instead, and hide message-less rows in the sidebar. - Archive/hide sessions: new `archived` column + set_session_archived, web API (`?archived=` + PATCH archived), Ctrl/⌘-click and a context-menu item in the sidebar, and an "Archived Chats" settings panel to restore/delete. - Videos load via a streaming `hermes-media://` protocol instead of capped, in-memory data URLs (16 MB limit) — bypasses the cap and supports seeking. - Background-process completions route to the session that launched them: the completion event now carries session_key and each poller only consumes its own. - Sidebar: "Group by workspace" toggle is always visible; each workspace group gets a "+" to start a session in that directory; "New agent"/"Agents" relabeled to "New session"/"Sessions". - New gateway connecting overlay (ascii decode → fade out) replacing the bare skeleton/"starting gateway" state. * fix(desktop): bail connecting overlay on boot error The shownRef latch kept the connecting overlay mounted behind BootFailureOverlay after a hard boot failure. Return null on boot.error so the failure recovery surface fully owns the screen. * fix(desktop): address Copilot review - /api/sessions: validate `archived` (400 on unknown) and return `archived` as a JSON boolean instead of SQLite's 0/1. - PATCH /api/sessions/{id}: 400 (not a misleading 404) when the body has no updatable fields; stop conflating a no-op with "not found". - hermes-media protocol: drop `bypassCSP` — streaming only needs secure/standard/stream/supportFetchAPI. - Sidebar workspace header: split the toggle and the "+" into sibling buttons so we no longer nest interactive elements inside a } - label="Agents" + label="Sessions" labelMeta={countLabel(agentSessions.length, knownSessionTotal)} + onArchiveSession={onArchiveSession} onDeleteSession={onDeleteSession} + onNewSessionInWorkspace={onNewSessionInWorkspace} onReorder={handleAgentDragEnd} onResumeSession={onResumeSession} onToggle={() => setSidebarRecentsOpen(!agentsOpen)} @@ -472,7 +484,9 @@ interface SidebarSessionsSectionProps { workingSessionIdSet: Set onResumeSession: (sessionId: string) => void onDeleteSession: (sessionId: string) => void + onArchiveSession: (sessionId: string) => void onTogglePin: (sessionId: string) => void + onNewSessionInWorkspace?: (path: null | string) => void pinned: boolean rootClassName?: string contentClassName?: string @@ -496,7 +510,9 @@ function SidebarSessionsSection({ workingSessionIdSet, onResumeSession, onDeleteSession, + onArchiveSession, onTogglePin, + onNewSessionInWorkspace, pinned, rootClassName, contentClassName, @@ -518,6 +534,7 @@ function SidebarSessionsSection({ isPinned: pinned, isSelected: session.id === activeSessionId, isWorking: workingSessionIdSet.has(session.id), + onArchive: () => onArchiveSession(session.id), onDelete: () => onDeleteSession(session.id), onPin: () => onTogglePin(session.id), onResume: () => onResumeSession(session.id), @@ -551,9 +568,19 @@ function SidebarSessionsSection({ } else if (groups?.length) { const groupNodes = groups.map(group => dndActive ? ( - + ) : ( - + ) ) @@ -568,6 +595,7 @@ function SidebarSessionsSection({ inner = ( { group: SidebarSessionGroup renderRows: (sessions: SessionInfo[]) => React.ReactNode + onNewSession?: (path: null | string) => void reorderable?: boolean dragging?: boolean dragHandleProps?: React.HTMLAttributes @@ -618,6 +647,7 @@ interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> { function SidebarWorkspaceGroup({ group, renderRows, + onNewSession, reorderable = false, dragging = false, dragHandleProps, @@ -634,18 +664,31 @@ function SidebarWorkspaceGroup({ return (
- + {onNewSession && ( + + )} {reorderable && ( )} - +
{open && ( <> {renderRows(visibleSessions)} @@ -687,6 +730,7 @@ function SidebarWorkspaceGroup({ interface SortableWorkspaceProps { group: SidebarSessionGroup renderRows: (sessions: SessionInfo[]) => React.ReactNode + onNewSession?: (path: null | string) => void } function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) { @@ -702,6 +746,7 @@ interface SortableSessionRowProps { isPinned: boolean isSelected: boolean isWorking: boolean + onArchive: () => void onDelete: () => void onPin: () => void onResume: () => void diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx index 65f8d8bf4..13134915d 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -26,6 +26,7 @@ interface SessionActions { title: string pinned?: boolean onPin?: () => void + onArchive?: () => void onDelete?: () => void } @@ -40,7 +41,7 @@ interface ItemSpec { variant?: 'destructive' } -function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete }: SessionActions) { +function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive, onDelete }: SessionActions) { const [renameOpen, setRenameOpen] = useState(false) const items: ItemSpec[] = [ @@ -81,6 +82,15 @@ function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete } setRenameOpen(true) } }, + { + disabled: !onArchive, + icon: 'archive', + label: 'Archive', + onSelect: () => { + triggerHaptic('selection') + onArchive?.() + } + }, { className: 'text-destructive focus:text-destructive', disabled: !onDelete, diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index f4059cb04..81359a6f8 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -14,6 +14,7 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> { isPinned: boolean isSelected: boolean isWorking: boolean + onArchive: () => void onDelete: () => void onPin: () => void onResume: () => void @@ -45,6 +46,7 @@ export function SidebarSessionRow({ isPinned, isSelected, isWorking, + onArchive, onDelete, onPin, onResume, @@ -61,7 +63,14 @@ export function SidebarSessionRow({ const handleLabel = `Reorder ${title}` return ( - +
)} - + + +
+ } + description={session.preview || undefined} + hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`} + key={session.id} + title={sessionTitle(session)} + /> + ) + })} + + )} + + ) +} diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts index ae67c079b..076ed9327 100644 --- a/apps/desktop/src/app/settings/types.ts +++ b/apps/desktop/src/app/settings/types.ts @@ -4,8 +4,8 @@ import type { HermesGateway } from '@/hermes' import type { IconComponent } from '@/lib/icons' import type { EnvVarInfo } from '@/types/hermes' -export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'tools' | `config:${string}` -export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools' +export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools' | `config:${string}` +export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools' export type EnvPatch = Partial> export interface SettingsPageProps { diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index fdd7a95a4..e764b9fff 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -19,9 +19,9 @@ import { filePathFromMediaPath, mediaExternalUrl, mediaKind, - mediaMime, mediaName, - mediaPathFromMarkdownHref + mediaPathFromMarkdownHref, + mediaStreamUrl } from '@/lib/media' import { previewTargetFromMarkdownHref } from '@/lib/preview-targets' import { cn } from '@/lib/utils' @@ -40,24 +40,22 @@ import { cn } from '@/lib/utils' // LLM convention). The default false-setting only accepts `$$...$$`. const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true }) -async function typedBlobUrl(dataUrl: string, mime: string): Promise { - const blob = await fetch(dataUrl).then(response => response.blob()) - - return URL.createObjectURL(new Blob([await blob.arrayBuffer()], { type: mime })) -} - async function mediaSrc(path: string): Promise { if (/^(?:https?|data):/i.test(path)) { return path } + // Stream audio/video through the custom protocol: data URLs are capped and + // load the whole file into memory, which broke playback for larger videos. + if (window.hermesDesktop && ['audio', 'video'].includes(mediaKind(path))) { + return mediaStreamUrl(path) + } + if (!window.hermesDesktop?.readFileDataUrl) { return mediaExternalUrl(path) } - const dataUrl = await window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path)) - - return ['audio', 'video'].includes(mediaKind(path)) ? typedBlobUrl(dataUrl, mediaMime(path)) : dataUrl + return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path)) } function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) { @@ -278,10 +276,7 @@ const MarkdownTextImpl = () => { // render, which churns Streamdown's outer memo + propagates new prop // identities into every Block. The plugin set really only varies on // `isStreaming`, so memoize on that. - const plugins = useMemo( - () => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }), - [isStreaming] - ) + const plugins = useMemo(() => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }), [isStreaming]) const components = useMemo( () => diff --git a/apps/desktop/src/components/gateway-connecting-overlay.tsx b/apps/desktop/src/components/gateway-connecting-overlay.tsx new file mode 100644 index 000000000..2b442b7f7 --- /dev/null +++ b/apps/desktop/src/components/gateway-connecting-overlay.tsx @@ -0,0 +1,183 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useRef, useState } from 'react' + +import { cn } from '@/lib/utils' +import { $desktopBoot } from '@/store/boot' +import { $gatewayState } from '@/store/session' + +// Static, always-legible prefix; only TAIL ever scrambles. Splitting them at +// the render level means no timer logic (even a stale HMR one) can ever +// scramble "CONN". +const PREFIX = 'CONN' +const TAIL = 'ECTING' +// Even-weight mono ascii so cycling glyphs don't jump width (matches the +// nousnet-web download-button decode effect). +const SCRAMBLE_CHARS = '/\\|-_=+<>~:*' +const TICK_MS = 45 + +// Exit choreography (ms): text fades down + out, hold, then the overlay fades. +const TEXT_OUT_MS = 360 +const POST_TEXT_HOLD_MS = 300 +const OVERLAY_OUT_MS = 520 +// Preview-only: how long to "connect" for, and the pause before replaying. +const PREVIEW_CONNECT_MS = 2600 +const PREVIEW_REPLAY_MS = 1100 + +type Phase = 'live' | 'text-out' | 'overlay-out' | 'gone' + +// Dev affordance: a warm Cmd+R reconnects almost instantly, so the overlay +// only flashes. Load with `?connecting=1` to force a looping preview. +function forcedPreview(): boolean { + if (!import.meta.env.DEV || typeof window === 'undefined') { + return false + } + + try { + return new URLSearchParams(window.location.search).get('connecting') === '1' + } catch { + return false + } +} + +function scrambledTail(resolvedCount: number): string { + return Array.from(TAIL, (ch, i) => + i < resolvedCount ? ch : SCRAMBLE_CHARS[(Math.random() * SCRAMBLE_CHARS.length) | 0] + ).join('') +} + +export function GatewayConnectingOverlay() { + const gatewayState = useStore($gatewayState) + const boot = useStore($desktopBoot) + const [previewing] = useState(forcedPreview) + const [tail, setTail] = useState(TAIL) + const [phase, setPhase] = useState('live') + + const connecting = gatewayState !== 'open' && !boot.error + // Latches once we've actually shown the overlay, so the brief frame where + // gatewayState flips to "open" (connecting -> false) before the exit phase + // kicks in doesn't unmount us and cause a flash. + const shownRef = useRef(false) + + if (previewing || connecting) { + shownRef.current = true + } + + // Decode loop — only while live (freeze the resolved word during the exit). + useEffect(() => { + if (phase !== 'live' || (!previewing && !connecting)) { + return + } + + let resolved = 0 + let hold = 0 + + const id = window.setInterval(() => { + if (resolved >= TAIL.length) { + hold += 1 + + if (hold > 16) { + resolved = 0 + hold = 0 + } + + setTail(TAIL) + + return + } + + resolved += 0.5 + setTail(scrambledTail(Math.floor(resolved))) + }, TICK_MS) + + return () => window.clearInterval(id) + }, [phase, previewing, connecting]) + + // Kick off the exit when connected: real connect, or a faked timer in preview. + useEffect(() => { + if (phase !== 'live') { + return + } + + if (previewing) { + const id = window.setTimeout(() => { + setTail(TAIL) + setPhase('text-out') + }, PREVIEW_CONNECT_MS) + + return () => window.clearTimeout(id) + } + + if (gatewayState === 'open' && shownRef.current) { + setTail(TAIL) + setPhase('text-out') + } + }, [phase, previewing, gatewayState]) + + // Advance the exit choreography: text-out -> overlay-out -> gone. + useEffect(() => { + if (phase === 'text-out') { + const id = window.setTimeout(() => setPhase('overlay-out'), TEXT_OUT_MS + POST_TEXT_HOLD_MS) + + return () => window.clearTimeout(id) + } + + if (phase === 'overlay-out') { + const id = window.setTimeout(() => setPhase('gone'), OVERLAY_OUT_MS) + + return () => window.clearTimeout(id) + } + + // Preview replays so we can keep watching the transition. + if (phase === 'gone' && previewing) { + const id = window.setTimeout(() => { + setTail(TAIL) + setPhase('live') + }, PREVIEW_REPLAY_MS) + + return () => window.clearTimeout(id) + } + }, [phase, previewing]) + + // Boot failed — BootFailureOverlay owns the screen; don't linger behind it. + if (boot.error && !previewing) { + return null + } + + // Real connect: once the fade finishes, get out of the way for good. + if (phase === 'gone' && !previewing) { + return null + } + + // Never showed (e.g. gateway already up on a warm reload) — stay out. + if (!previewing && !connecting && !shownRef.current) { + return null + } + + const leaving = phase !== 'live' + const overlayHidden = phase === 'overlay-out' || phase === 'gone' + + return ( +
+ + + {PREFIX} + {tail} + +
+ ) +} diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 530dba06c..3e06027fc 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -111,9 +111,13 @@ export class HermesGateway extends JsonRpcGatewayClient { } } -export async function listSessions(limit = 40, minMessages = 0): Promise { +export async function listSessions( + limit = 40, + minMessages = 0, + archived: 'exclude' | 'include' | 'only' = 'exclude' +): Promise { const result = await window.hermesDesktop.api({ - path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` + path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}` }) return { @@ -123,6 +127,14 @@ export async function listSessions(limit = 40, minMessages = 0): Promise { + return window.hermesDesktop.api<{ ok: boolean }>({ + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'PATCH', + body: { archived } + }) +} + export function searchSessions(query: string): Promise { return window.hermesDesktop.api({ path: `/api/sessions/search?q=${encodeURIComponent(query)}` diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index 54d71e770..d8f7f94b2 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -2,6 +2,8 @@ import { IconActivity as Activity, IconAlertCircle as AlertCircle, IconAlertTriangle as AlertTriangle, + IconArchive as Archive, + IconArchiveOff as ArchiveOff, IconArrowUp as ArrowUp, IconArrowUpRight as ArrowUpRight, IconAt as AtSign, @@ -98,6 +100,8 @@ export { Activity, AlertCircle, AlertTriangle, + Archive, + ArchiveOff, ArrowUp, ArrowUpRight, AtSign, diff --git a/apps/desktop/src/lib/media.ts b/apps/desktop/src/lib/media.ts index bf6fdf36a..d326b7a3a 100644 --- a/apps/desktop/src/lib/media.ts +++ b/apps/desktop/src/lib/media.ts @@ -58,6 +58,13 @@ export function mediaExternalUrl(path: string): string { return /^(?:https?|file):/i.test(path) ? path : `file://${path}` } +// Custom Electron scheme (registered in electron/main.cjs) that streams a local +// file with Range support. Used for audio/video so playback bypasses the data +// URL size cap and supports seeking. `path` may be a plain path or `file://…`. +export function mediaStreamUrl(path: string): string { + return `hermes-media://stream/${encodeURIComponent(filePathFromMediaPath(path))}` +} + export function mediaPathFromMarkdownHref(href?: string): string | null { if (!href?.startsWith('#media:')) { return null diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index b773c30e2..f3c1c0c93 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -240,6 +240,7 @@ export interface SessionCreateResponse { } export interface SessionInfo { + archived?: boolean cwd?: null | string ended_at: null | number id: string diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 4ff30f9e2..17ebd5639 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1131,22 +1131,51 @@ async def get_action_status(name: str, lines: int = 200): @app.get("/api/sessions") -async def get_sessions(limit: int = 20, offset: int = 0, min_messages: int = 0): +async def get_sessions( + limit: int = 20, + offset: int = 0, + min_messages: int = 0, + archived: str = "exclude", +): + """List sessions. + + ``archived`` controls how soft-archived sessions are treated: + ``exclude`` (default) hides them, ``only`` returns just the archived ones + (used by the desktop "Archived sessions" settings panel), and ``include`` + returns both. + """ + if archived not in ("exclude", "only", "include"): + raise HTTPException( + status_code=400, + detail="archived must be one of: exclude, only, include", + ) try: from hermes_state import SessionDB db = SessionDB() try: min_message_count = max(0, min_messages) + archived_only = archived == "only" + include_archived = archived == "include" sessions = db.list_sessions_rich( - limit=limit, offset=offset, min_message_count=min_message_count + limit=limit, + offset=offset, + min_message_count=min_message_count, + include_archived=include_archived, + archived_only=archived_only, + ) + total = db.session_count( + min_message_count=min_message_count, + include_archived=include_archived, + archived_only=archived_only, ) - total = db.session_count(min_message_count=min_message_count) now = time.time() for s in sessions: s["is_active"] = ( s.get("ended_at") is None and (now - s.get("last_active", s.get("started_at", 0))) < 300 ) + # SQLite stores the flag as 0/1; expose a real JSON boolean. + s["archived"] = bool(s.get("archived")) return {"sessions": sessions, "total": total, "limit": limit, "offset": offset} finally: db.close() @@ -3707,25 +3736,39 @@ async def delete_session_endpoint(session_id: str): class SessionRename(BaseModel): title: Optional[str] = None + archived: Optional[bool] = None @app.patch("/api/sessions/{session_id}") async def rename_session_endpoint(session_id: str, body: SessionRename): - """Rename a session (or clear its title when ``title`` is empty/null).""" + """Update a session: rename (or clear its title) and/or archive it. + + ``title`` renames (empty/null clears the title); ``archived`` soft-hides or + restores the session. Either field may be omitted. + """ from hermes_state import SessionDB db = SessionDB() try: sid = db.resolve_session_id(session_id) if not sid: raise HTTPException(status_code=404, detail="Session not found") - try: - updated = db.set_session_title(sid, body.title or "") - except ValueError as e: - # Title too long, invalid characters, or already in use. - raise HTTPException(status_code=400, detail=str(e)) - if not updated: - raise HTTPException(status_code=404, detail="Session not found") - return {"ok": True, "title": db.get_session_title(sid) or ""} + if body.title is None and body.archived is None: + raise HTTPException( + status_code=400, + detail="Nothing to update; provide 'title' and/or 'archived'.", + ) + if body.title is not None: + try: + db.set_session_title(sid, body.title or "") + except ValueError as e: + # Title too long, invalid characters, or already in use. + raise HTTPException(status_code=400, detail=str(e)) + if body.archived is not None: + db.set_session_archived(sid, body.archived) + result = {"ok": True, "title": db.get_session_title(sid) or ""} + if body.archived is not None: + result["archived"] = bool(body.archived) + return result finally: db.close() diff --git a/hermes_state.py b/hermes_state.py index 0a7b86195..6ee15ebd0 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -264,6 +264,7 @@ CREATE TABLE IF NOT EXISTS sessions ( handoff_platform TEXT, handoff_error TEXT, rewind_count INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (parent_session_id) REFERENCES sessions(id) ); @@ -1430,6 +1431,22 @@ class SessionDB: row = cursor.fetchone() return row["title"] if row else None + def set_session_archived(self, session_id: str, archived: bool) -> bool: + """Archive or unarchive a session. + + Archived sessions are hidden from the default session list but keep all + their messages — this is a soft hide, not a delete. Returns True when a + row was updated. + """ + def _do(conn): + cursor = conn.execute( + "UPDATE sessions SET archived = ? WHERE id = ?", + (1 if archived else 0, session_id), + ) + return cursor.rowcount + rowcount = self._execute_write(_do) + return rowcount > 0 + def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]: """Look up a session by exact title. Returns session dict or None.""" with self._lock: @@ -1549,6 +1566,8 @@ class SessionDB: min_message_count: int = 0, project_compression_tips: bool = True, order_by_last_active: bool = False, + include_archived: bool = False, + archived_only: bool = False, ) -> List[Dict[str, Any]]: """List sessions with preview (first user message) and last active timestamp. @@ -1604,6 +1623,10 @@ class SessionDB: if min_message_count > 0: where_clauses.append("s.message_count >= ?") params.append(min_message_count) + if archived_only: + where_clauses.append("s.archived = 1") + elif not include_archived: + where_clauses.append("s.archived = 0") where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else "" if order_by_last_active: @@ -3027,7 +3050,13 @@ class SessionDB: # Utility # ========================================================================= - def session_count(self, source: str = None, min_message_count: int = 0) -> int: + def session_count( + self, + source: str = None, + min_message_count: int = 0, + include_archived: bool = False, + archived_only: bool = False, + ) -> int: """Count sessions, optionally filtered by source.""" where_clauses = [] params = [] @@ -3038,6 +3067,10 @@ class SessionDB: if min_message_count > 0: where_clauses.append("message_count >= ?") params.append(min_message_count) + if archived_only: + where_clauses.append("archived = 1") + elif not include_archived: + where_clauses.append("archived = 0") where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else "" diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index d7a5c25a5..ce885f1b2 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -187,11 +187,11 @@ class TestWebServerEndpoints: def __init__(self, *args, **kwargs): pass - def list_sessions_rich(self, limit, offset, min_message_count=0): + def list_sessions_rich(self, limit, offset, min_message_count=0, **kwargs): captured["list"] = min_message_count return [] - def session_count(self, min_message_count=0): + def session_count(self, min_message_count=0, **kwargs): captured["count"] = min_message_count return 0 @@ -250,6 +250,76 @@ class TestWebServerEndpoints: resp = self.client.patch("/api/sessions/does-not-exist", json={"title": "x"}) assert resp.status_code == 404 + def test_archive_session_via_patch(self): + """PATCH archived=true soft-hides a session; archived=false restores it.""" + from hermes_state import SessionDB + + db = SessionDB() + try: + db.create_session(session_id="arch-me", source="cli") + db.append_message(session_id="arch-me", role="user", content="hi") + finally: + db.close() + + resp = self.client.patch("/api/sessions/arch-me", json={"archived": True}) + assert resp.status_code == 200 + assert resp.json()["archived"] is True + + # Hidden from the default list, surfaced by archived=only. + listed = self.client.get("/api/sessions").json() + assert all(s["id"] != "arch-me" for s in listed["sessions"]) + only = self.client.get("/api/sessions?archived=only").json() + assert any(s["id"] == "arch-me" for s in only["sessions"]) + + resp = self.client.patch("/api/sessions/arch-me", json={"archived": False}) + assert resp.status_code == 200 + restored = self.client.get("/api/sessions").json() + assert any(s["id"] == "arch-me" for s in restored["sessions"]) + + def test_patch_session_without_fields_is_400(self): + """An existing session + empty body is a bad request, not a 404.""" + from hermes_state import SessionDB + + db = SessionDB() + try: + db.create_session(session_id="no-fields", source="cli") + finally: + db.close() + + resp = self.client.patch("/api/sessions/no-fields", json={}) + assert resp.status_code == 400 + + def test_get_sessions_rejects_unknown_archived_value(self): + resp = self.client.get("/api/sessions?archived=bogus") + assert resp.status_code == 400 + + def test_get_sessions_archived_is_boolean(self): + from hermes_state import SessionDB + + db = SessionDB() + try: + db.create_session(session_id="bool-arch", source="cli") + db.append_message(session_id="bool-arch", role="user", content="hi") + finally: + db.close() + + row = next(s for s in self.client.get("/api/sessions").json()["sessions"] if s["id"] == "bool-arch") + assert row["archived"] is False + + def test_rename_response_omits_archived_when_not_set(self): + """Title-only PATCH keeps its legacy {ok, title} response shape.""" + from hermes_state import SessionDB + + db = SessionDB() + try: + db.create_session(session_id="title-only", source="cli") + finally: + db.close() + + resp = self.client.patch("/api/sessions/title-only", json={"title": "Hi"}) + assert resp.status_code == 200 + assert "archived" not in resp.json() + def test_audio_transcription_endpoint(self, monkeypatch): import tools.transcription_tools as transcription_tools diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index f083bf420..75f782ef6 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -3509,3 +3509,43 @@ class TestApplyWalProbe: assert any("journal_mode=WAL" in sql for sql in conn.executed), ( "set-pragma must fire when probe returns 'delete'" ) + + +class TestSessionArchive: + """Soft-archiving hides a session from default listings without deleting it.""" + + def _seed(self, db, sid, *, archived=False): + db.create_session(session_id=sid, source="cli") + db.append_message(session_id=sid, role="user", content=f"hello from {sid}") + if archived: + db.set_session_archived(sid, True) + + def test_set_session_archived_roundtrip(self, db): + self._seed(db, "s1") + assert db.set_session_archived("s1", True) is True + assert db.get_session("s1")["archived"] == 1 + assert db.set_session_archived("s1", False) is True + assert db.get_session("s1")["archived"] == 0 + + def test_set_session_archived_missing_row(self, db): + assert db.set_session_archived("nope", True) is False + + def test_archived_excluded_by_default(self, db): + self._seed(db, "live") + self._seed(db, "hidden", archived=True) + + ids = [s["id"] for s in db.list_sessions_rich()] + assert ids == ["live"] + assert db.session_count() == 1 + + def test_archived_only_and_include(self, db): + self._seed(db, "live") + self._seed(db, "hidden", archived=True) + + only = [s["id"] for s in db.list_sessions_rich(archived_only=True)] + assert only == ["hidden"] + assert db.session_count(archived_only=True) == 1 + + both = {s["id"] for s in db.list_sessions_rich(include_archived=True)} + assert both == {"live", "hidden"} + assert db.session_count(include_archived=True) == 2 diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 18a651e58..7d2172258 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -884,6 +884,73 @@ def test_session_title_queues_when_db_row_not_ready(monkeypatch): server._sessions.pop("sid", None) +def test_notification_event_routing_by_session_key(monkeypatch): + """Background-process events surface only in the session that owns them.""" + mine = _session(session_key="mine") + other = _session(session_key="other") + monkeypatch.setattr(server, "_sessions", {"a": mine, "b": other}) + + # My own event → handle it. + assert server._notification_event_belongs_elsewhere(mine, {"session_key": "mine"}) is False + # Global/system event with no owner → handle it. + assert server._notification_event_belongs_elsewhere(mine, {"session_key": ""}) is False + assert server._notification_event_belongs_elsewhere(mine, {}) is False + # Owned by another *live* session → defer to that session's poller. + assert server._notification_event_belongs_elsewhere(mine, {"session_key": "other"}) is True + # Owner is gone (not in _sessions) → handle as fallback so it isn't lost. + assert server._notification_event_belongs_elsewhere(mine, {"session_key": "ghost"}) is False + + +def test_session_create_does_not_persist_empty_row(monkeypatch): + """session.create must NOT eagerly write a DB row. + + Every TUI/desktop launch opens a session here just to paint the composer; + eagerly creating a row left an empty "Untitled" session behind for every + launch the user never typed into. The row is created lazily on first prompt. + """ + created = [] + + class _FakeDB: + def create_session(self, *args, **kwargs): + created.append((args, kwargs)) + + monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) + monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None) + monkeypatch.setattr( + server.threading, + "Timer", + lambda *a, **k: types.SimpleNamespace(daemon=False, start=lambda: None), + ) + + resp = server.handle_request( + {"id": "1", "method": "session.create", "params": {"cols": 80}} + ) + sid = resp["result"]["session_id"] + try: + assert resp["result"]["stored_session_id"] + assert created == [], "session.create should not persist an empty DB row" + finally: + 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.""" + 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)}) + + assert created == [ + {"key": "k1", "source": "tui", "model": "test-model", "cwd": str(tmp_path)} + ] + + def test_session_title_clears_pending_after_persist(monkeypatch): class _FakeDB: def __init__(self): diff --git a/tools/process_registry.py b/tools/process_registry.py index 4e8da5b7c..d9eb02a4a 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -880,6 +880,7 @@ class ProcessRegistry: self.completion_queue.put({ "type": "completion", "session_id": session.id, + "session_key": session.session_key, "command": session.command, "exit_code": session.exit_code, "output": output_tail, diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 132b16d10..83963f801 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -702,6 +702,32 @@ def _register_session_cwd(session: dict | None) -> None: pass +def _ensure_session_db_row(session: dict) -> None: + """Idempotently persist the session's DB row on first real activity. + + 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. + """ + key = session.get("session_key") + if not key: + return + db = _get_db() + if db is None: + return + try: + db.create_session( + key, + source="tui", + model=_resolve_model(), + cwd=_session_cwd(session), + ) + except Exception: + logger.debug("failed to persist desktop session row", exc_info=True) + + def _set_session_cwd(session: dict, cwd: str) -> str: resolved = os.path.abspath(os.path.expanduser(str(cwd))) if not os.path.isdir(resolved): @@ -2750,17 +2776,12 @@ def _(rid, params: dict) -> dict: "transport": current_transport() or _stdio_transport, } _register_session_cwd(_sessions[sid]) - db = _get_db() - if db is not None: - try: - db.create_session( - key, - source="tui", - model=_resolve_model(), - cwd=_sessions[sid]["cwd"], - ) - except Exception: - logger.debug("failed to pre-create desktop session row", exc_info=True) + # NOTE: we intentionally do NOT persist a DB row here. Every TUI/desktop + # launch (and every "New agent" / draft) opens a session here just to paint + # the composer, so eagerly creating a row left an "Untitled" empty session + # behind for every launch the user never typed into. The row is now created + # lazily on the first prompt (see _ensure_session_db_row + prompt.submit), + # and the AIAgent's own INSERT-OR-IGNORE persists it on the first turn too. # Return the lightweight session immediately so Ink can paint the composer # + skeleton panel, then build the real AIAgent just after this response is @@ -3841,6 +3862,8 @@ def _(rid, params: dict) -> dict: session["last_active"] = time.time() _start_inflight_turn(session, text) + # Persist the DB row lazily, now that the user has actually sent a message. + _ensure_session_db_row(session) _start_agent_build(sid, session) def run_after_agent_ready() -> None: @@ -3865,6 +3888,35 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"status": "streaming"}) +def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool: + """True if ``evt`` is owned by a *different* live session. + + Background-process events carry the ``session_key`` of the session that + started the process. Since all desktop sessions share one process-wide + completion queue, each poller must skip events it doesn't own so a + background job's completion surfaces in the session that launched it — not + whichever poller happened to dequeue first. Orphaned events (owner gone) + and global/system events (empty ``session_key``) return False so the + current poller still handles them rather than losing them. + """ + evt_key = str(evt.get("session_key") or "") + if not evt_key: + return False + if evt_key == str(session.get("session_key") or ""): + return False + try: + snapshot = list(_sessions.values()) + except Exception: + # If we can't safely enumerate live sessions, fail open so we don't + # crash the poller thread or drop the event. + return False + + return any( + s is not session and str(s.get("session_key") or "") == evt_key + for s in snapshot + ) + + def _notification_poller_loop( stop_event: threading.Event, sid: str, session: dict ) -> None: @@ -3887,6 +3939,16 @@ def _notification_poller_loop( except Exception: continue + # Multiple desktop sessions share this one process-wide queue. Only + # consume events that belong to *this* session — otherwise a background + # process started in session A would surface its completion in whichever + # session's poller happened to wake first (Ben's "reported in a + # different session" bug). Leave foreign events for their owner. + if _notification_event_belongs_elsewhere(session, evt): + process_registry.completion_queue.put(evt) + time.sleep(0.1) + continue + _evt_sid = evt.get("session_id", "") if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid): continue @@ -3917,12 +3979,17 @@ def _notification_poller_loop( session["running"] = False # Drain any remaining events after stop signal (process all pending - # before exiting so nothing is lost on shutdown). + # before exiting so nothing is lost on shutdown). Events owned by other + # live sessions are set aside and re-queued so their poller still sees them. + deferred: list = [] while not process_registry.completion_queue.empty(): try: evt = process_registry.completion_queue.get_nowait() except Exception: break + if _notification_event_belongs_elsewhere(session, evt): + deferred.append(evt) + continue _evt_sid = evt.get("session_id", "") if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid): continue @@ -3951,6 +4018,10 @@ def _notification_poller_loop( with session["history_lock"]: session["running"] = False + # Hand any other sessions' events back to the shared queue. + for evt in deferred: + process_registry.completion_queue.put(evt) + def _start_notification_poller(sid: str, session: dict) -> threading.Event: """Start the background notification poller for a TUI session."""