From b94b3622b5faabadf36d8d51f5804c0a655553e7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 4 Jun 2026 16:35:34 -0500 Subject: [PATCH] feat(desktop): per-session profile switching + cross-profile sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add first-class profile support to the desktop app without app reloads. - Swap the single live gateway onto a session's profile lazily (spawned on demand by the Electron backend pool), so one backend serves the active profile and others stay cold — no OOM with many profiles. - Aggregate sessions across profiles by reading each profile's state.db read-only; unified "All profiles" view groups sessions per profile with per-profile pagination, while the default view stays scoped to one profile. - Add an Arc-style profile rail at the sidebar foot: a default<->all toggle pinned left, colored named-profile squares scrolling between, Manage pinned right. Profile identity is a deterministic per-name color. - Route profile-scoped REST (config/env/skills/tools/model) to the active gateway profile and invalidate React Query caches on swap. Single-profile users never trigger a swap, so their path is unchanged. Backend: - web_server: profile-aware active/list endpoints + per-profile session totals; hermes_state: session_count(exclude_children); main.py: honor --profile over HERMES_HOME env for pooled backends. UI primitives: - Add a position-aware Tip tooltip (instant, themed) as a drop-in for native title=, and strip redundant tooltips from self-descriptive chrome. --- apps/desktop/electron/main.cjs | 330 ++++++++++++- apps/desktop/electron/preload.cjs | 9 +- apps/desktop/src/app/artifacts/index.tsx | 21 +- .../src/app/chat/composer/attachments.tsx | 81 +-- .../src/app/chat/composer/controls.tsx | 157 +++--- .../src/app/chat/composer/queue-panel.tsx | 74 +-- apps/desktop/src/app/chat/index.tsx | 2 - .../app/chat/right-rail/preview-console.tsx | 47 +- .../src/app/chat/right-rail/preview-pane.tsx | 20 +- .../src/app/chat/right-rail/preview.tsx | 24 +- apps/desktop/src/app/chat/sidebar/index.tsx | 308 +++++++++--- .../src/app/chat/sidebar/profile-switcher.tsx | 143 ++++++ .../app/chat/sidebar/session-actions-menu.tsx | 121 ++++- .../src/app/chat/sidebar/session-row.tsx | 21 +- apps/desktop/src/app/command-center/index.tsx | 49 +- apps/desktop/src/app/cron/index.tsx | 26 +- apps/desktop/src/app/desktop-controller.tsx | 60 ++- .../src/app/gateway/hooks/use-gateway-boot.ts | 19 +- .../app/gateway/hooks/use-gateway-request.ts | 6 +- apps/desktop/src/app/messaging/index.tsx | 32 +- apps/desktop/src/app/profiles/index.tsx | 467 +++++++++++++----- apps/desktop/src/app/right-sidebar/index.tsx | 51 +- .../src/app/right-sidebar/terminal/index.tsx | 24 +- .../app/session/hooks/use-prompt-actions.ts | 48 +- .../app/session/hooks/use-session-actions.ts | 20 +- .../src/app/settings/env-credentials.tsx | 30 +- apps/desktop/src/app/settings/index.tsx | 49 +- .../src/app/settings/sessions-settings.tsx | 26 +- .../src/app/settings/toolset-config-panel.tsx | 25 +- apps/desktop/src/app/shell/app-shell.tsx | 5 + .../src/app/shell/gateway-menu-panel.tsx | 34 +- .../src/app/shell/statusbar-controls.tsx | 19 +- .../src/app/shell/titlebar-controls.tsx | 54 +- .../assistant-ui/tooltip-icon-button.tsx | 26 +- apps/desktop/src/components/notifications.tsx | 14 +- .../desktop/src/components/ui/copy-button.tsx | 35 +- apps/desktop/src/components/ui/dialog.tsx | 17 +- apps/desktop/src/components/ui/tooltip.tsx | 35 +- apps/desktop/src/global.d.ts | 30 +- apps/desktop/src/hermes.ts | 107 +++- .../desktop/src/lib/desktop-slash-commands.ts | 2 +- apps/desktop/src/lib/gateway-ws-url.ts | 16 +- apps/desktop/src/lib/profile-color.ts | 36 ++ apps/desktop/src/lib/query-client.ts | 13 + apps/desktop/src/main.tsx | 12 +- apps/desktop/src/store/profile.ts | 217 ++++++++ apps/desktop/src/store/session.ts | 7 + apps/desktop/src/types/hermes.ts | 23 +- hermes_cli/main.py | 17 +- hermes_cli/web_server.py | 176 ++++++- hermes_state.py | 50 +- tests/hermes_cli/test_web_server.py | 78 +++ 52 files changed, 2517 insertions(+), 796 deletions(-) create mode 100644 apps/desktop/src/app/chat/sidebar/profile-switcher.tsx create mode 100644 apps/desktop/src/lib/profile-color.ts create mode 100644 apps/desktop/src/lib/query-client.ts create mode 100644 apps/desktop/src/store/profile.ts diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index bffe5019c..9f4796a46 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -220,6 +220,16 @@ const BOOTSTRAP_MARKER_SCHEMA_VERSION = 1 const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json') const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json') +// active-profile.json records which Hermes profile the desktop launches its +// local backend as. When set, startHermes() passes `hermes --profile +// dashboard …`, which deterministically pins HERMES_HOME (see +// _apply_profile_override in hermes_cli/main.py) and bypasses the sticky +// ~/.hermes/active_profile file. Unset (null) preserves the legacy behavior: +// no --profile flag, so the backend honors active_profile / default. +const DESKTOP_PROFILE_CONFIG_PATH = path.join(app.getPath('userData'), 'active-profile.json') +// Mirrors hermes_cli.profiles._PROFILE_ID_RE so we never hand the backend a +// value its profile resolver would reject and exit on. +const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/ // Branch we track for self-update. The GUI work has merged to main, so this // tracks main. User can also override at runtime via // hermesDesktop.updates.setBranch(). @@ -459,6 +469,19 @@ function registerMediaProtocol() { let mainWindow = null let hermesProcess = null let connectionPromise = null +// Additional per-profile backends, keyed by profile name. The PRIMARY backend +// (the desktop's launch profile) stays managed by hermesProcess + +// connectionPromise + startHermes(); this pool only holds EXTRA profile +// backends spawned lazily when a session belongs to a different profile. A user +// with no named profiles never populates this map, so their experience is +// byte-for-byte the single-backend behavior. +const backendPool = new Map() // profile -> { process, port, token, connectionPromise, lastActiveAt } +// Keep the pool light: cap concurrent profile backends (LRU eviction) and reap +// idle ones. A user idles at exactly the primary backend; pool backends only +// exist while a non-primary profile is actively being chatted through. +const POOL_MAX_BACKENDS = Math.max(1, Number(process.env.HERMES_DESKTOP_POOL_MAX) || 3) +const POOL_IDLE_MS = Math.max(60_000, Number(process.env.HERMES_DESKTOP_POOL_IDLE_MS) || 10 * 60_000) +let poolIdleReaper = null // Auto-reload budget for renderer crashes. A deterministic startup crash would // otherwise loop forever (reload → crash → reload), pinning CPU and spamming // logs. Allow a few reloads per rolling window, then stop and leave the dead @@ -1452,8 +1475,20 @@ async function applyUpdatesPosixInApp() { // reap must spare it. Hand the live backend's PID to the update process; // _kill_stale_dashboard_processes reads HERMES_DESKTOP_CHILD_PID and excludes // it while still reaping any genuinely-orphaned dashboards. (#37532) + // Exclude every desktop-managed backend (primary + all pool profiles) from + // the update reaper. _kill_stale_dashboard_processes accepts a comma-separated + // list (a single int still parses for back-compat). + const desktopChildPids = [] if (hermesProcess && Number.isInteger(hermesProcess.pid)) { - env.HERMES_DESKTOP_CHILD_PID = String(hermesProcess.pid) + desktopChildPids.push(hermesProcess.pid) + } + for (const entry of backendPool.values()) { + if (entry.process && Number.isInteger(entry.process.pid)) { + desktopChildPids.push(entry.process.pid) + } + } + if (desktopChildPids.length) { + env.HERMES_DESKTOP_CHILD_PID = desktopChildPids.join(',') } // Branch-pin so a non-main checkout doesn't get switched to main (and self-heal @@ -3363,8 +3398,14 @@ async function mintGatewayWsTicket(baseUrl) { // calls this immediately before every gateway.connect() so each WS upgrade // carries a freshly-minted ticket. For local/token connections this just // reuses the static token (no minting needed). -async function freshGatewayWsUrl() { - const connection = await startHermes() +async function freshGatewayWsUrl(profile) { + // Mint for the requested profile's backend, NOT always the primary. The + // renderer re-mints right before every gateway.connect(); when swapping to a + // pooled profile we must return THAT backend's ws URL, otherwise the connect + // silently lands back on the primary (default) backend and writes sessions to + // the wrong profile's DB. A null/empty profile resolves to the primary, so + // legacy callers and single-profile users are unchanged. + const connection = await ensureBackend(profile) if (connection.authMode === 'oauth') { const ticket = await mintGatewayWsTicket(connection.baseUrl) return buildGatewayWsUrlWithTicket(connection.baseUrl, ticket) @@ -3448,6 +3489,38 @@ function writeDesktopConnectionConfig(config) { connectionConfigCacheMtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs } +// Returns the desktop's chosen profile name, or null when unset. "default" is +// a valid stored value (pins the root HERMES_HOME explicitly); null means "no +// preference" and preserves the legacy launch (no --profile flag). +function readActiveDesktopProfile() { + try { + const raw = fs.readFileSync(DESKTOP_PROFILE_CONFIG_PATH, 'utf8') + const parsed = JSON.parse(raw) + const name = parsed && typeof parsed.profile === 'string' ? parsed.profile.trim() : '' + + if (name && (name === 'default' || PROFILE_NAME_RE.test(name))) { + return name + } + } catch { + // Missing or malformed → no preference. + } + + return null +} + +function writeActiveDesktopProfile(name) { + const value = typeof name === 'string' ? name.trim() : '' + + if (value && value !== 'default' && !PROFILE_NAME_RE.test(value)) { + throw new Error(`Invalid profile name: ${value}`) + } + + fs.mkdirSync(path.dirname(DESKTOP_PROFILE_CONFIG_PATH), { recursive: true }) + writeFileAtomic(DESKTOP_PROFILE_CONFIG_PATH, JSON.stringify({ profile: value || null }, null, 2)) + + return value || null +} + async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) { const remoteToken = decryptDesktopSecret(config.remote?.token) const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token' @@ -3712,6 +3785,206 @@ function resetHermesConnection() { resetBootProgressForReconnect() } +// Re-home the primary backend: reset connection state, then wait for the live +// dashboard process to actually exit (SIGKILL after 5s) so the next +// startHermes() spawns fresh instead of racing the dying one. Shared by the +// connection-config and profile switch flows. +async function teardownPrimaryBackendAndWait() { + // Capture the reference before resetHermesConnection() nulls hermesProcess. + const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null + resetHermesConnection() + + if (!dying) { + return + } + + await new Promise(resolve => { + const timer = setTimeout(() => { + try { + dying.kill('SIGKILL') + } catch { + // Already gone. + } + resolve() + }, 5000) + dying.once('exit', () => { + clearTimeout(timer) + resolve() + }) + }) +} + +// The profile the primary (window) backend runs as. readActiveDesktopProfile() +// returns the desktop's stored preference, or null when unset (legacy launch +// that defers to active_profile / default). +function primaryProfileKey() { + return readActiveDesktopProfile() || 'default' +} + +// Resolve a backend connection for the given profile. Routes the primary +// profile to startHermes() (the window backend: boot UI, bootstrap, remote +// mode), and any OTHER profile to a lazily-spawned pool backend. An empty / +// unknown profile resolves to the primary, so all legacy callers are unchanged. +async function ensureBackend(profile) { + const key = profile && String(profile).trim() ? String(profile).trim() : primaryProfileKey() + + if (key === primaryProfileKey()) { + return startHermes() + } + + const existing = backendPool.get(key) + if (existing) { + existing.lastActiveAt = Date.now() + return existing.connectionPromise + } + + evictLruPoolBackends(POOL_MAX_BACKENDS - 1) + + const entry = { process: null, port: null, token: null, connectionPromise: null, lastActiveAt: Date.now() } + entry.connectionPromise = spawnPoolBackend(key, entry).catch(error => { + backendPool.delete(key) + throw error + }) + backendPool.set(key, entry) + startPoolIdleReaper() + return entry.connectionPromise +} + +// Mark a pool profile as recently used so the idle reaper spares it. The +// renderer calls this when it opens a profile's chat WS and periodically while +// streaming, since the main process can't see the direct renderer↔backend WS. +function touchPoolBackend(profile) { + const key = profile && String(profile).trim() ? String(profile).trim() : null + if (!key) return + const entry = backendPool.get(key) + if (entry) entry.lastActiveAt = Date.now() +} + +// Evict least-recently-used pool backends until at most `keep` remain. +function evictLruPoolBackends(keep) { + if (backendPool.size <= keep) return + const ordered = [...backendPool.entries()].sort( + (a, b) => (a[1].lastActiveAt || 0) - (b[1].lastActiveAt || 0) + ) + while (ordered.length > Math.max(0, keep)) { + const [profile] = ordered.shift() + rememberLog(`Evicting idle profile backend "${profile}" (LRU cap ${POOL_MAX_BACKENDS})`) + stopPoolBackend(profile) + } +} + +function startPoolIdleReaper() { + if (poolIdleReaper) return + poolIdleReaper = setInterval(() => { + const now = Date.now() + for (const [profile, entry] of [...backendPool.entries()]) { + if (now - (entry.lastActiveAt || 0) > POOL_IDLE_MS) { + rememberLog(`Reaping idle profile backend "${profile}" (idle > ${Math.round(POOL_IDLE_MS / 1000)}s)`) + stopPoolBackend(profile) + } + } + if (backendPool.size === 0 && poolIdleReaper) { + clearInterval(poolIdleReaper) + poolIdleReaper = null + } + }, 60_000) + if (typeof poolIdleReaper.unref === 'function') poolIdleReaper.unref() +} + +// Spawn an additional dashboard backend pinned to a named profile. Mirrors the +// local-spawn portion of startHermes() but without the boot-progress UI, +// bootstrap, or remote handling (those belong to the primary backend only). +async function spawnPoolBackend(profile, entry) { + // Remote deployments are single-tenant; profiles only apply to local backends. + const remote = await resolveRemoteBackend() + if (remote) { + throw new Error('Profiles are unavailable when connected to a remote Hermes backend.') + } + + const port = await pickPort() + const token = crypto.randomBytes(32).toString('base64url') + // --profile wins over the inherited HERMES_HOME env (see _apply_profile_override + // step 3 in hermes_cli/main.py), so the child re-homes to this profile. + const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)] + const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs)) + const hermesCwd = resolveHermesCwd() + const webDist = resolveWebDist() + + rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`) + + const child = spawn(backend.command, backend.args, { + cwd: hermesCwd, + env: { + ...process.env, + HERMES_HOME, + ...backend.env, + HERMES_DASHBOARD_SESSION_TOKEN: token, + HERMES_WEB_DIST: webDist + }, + shell: backend.shell, + stdio: ['ignore', 'pipe', 'pipe'] + }) + entry.process = child + entry.port = port + entry.token = token + + child.stdout.on('data', rememberLog) + child.stderr.on('data', rememberLog) + + let ready = false + let rejectStart = null + const startFailed = new Promise((_resolve, reject) => { + rejectStart = reject + }) + child.once('error', error => { + rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`) + backendPool.delete(profile) + rejectStart?.(error) + }) + child.once('exit', (code, signal) => { + rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`) + backendPool.delete(profile) + if (!ready) { + rejectStart?.(new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`)) + } + }) + + const baseUrl = `http://127.0.0.1:${port}` + await Promise.race([waitForHermes(baseUrl, token), startFailed]) + ready = true + + return { + baseUrl, + mode: 'local', + source: 'local', + authMode: 'token', + token, + profile, + wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`, + logs: hermesLog.slice(-80), + ...getWindowState() + } +} + +function stopPoolBackend(profile) { + const entry = backendPool.get(profile) + if (!entry) return + backendPool.delete(profile) + if (entry.process && !entry.process.killed) { + try { + entry.process.kill('SIGTERM') + } catch { + // Already gone. + } + } +} + +function stopAllPoolBackends() { + for (const profile of [...backendPool.keys()]) { + stopPoolBackend(profile) + } +} + async function startHermes() { // Latched-failure short-circuit: once bootstrap has failed in this // process, every subsequent startHermes() call re-throws the same error @@ -3753,6 +4026,15 @@ async function startHermes() { const port = await pickPort() const token = crypto.randomBytes(32).toString('base64url') const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)] + // Pin the desktop's chosen profile via the global --profile flag. This is + // deterministic (it wins over the sticky ~/.hermes/active_profile file) and + // resolves HERMES_HOME the same way `hermes -p ` does on the CLI. An + // unset preference keeps the legacy launch so existing installs are + // unaffected. + const activeProfile = readActiveDesktopProfile() + if (activeProfile) { + dashboardArgs.unshift('--profile', activeProfile) + } await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28) const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs)) const hermesCwd = resolveHermesCwd() @@ -3996,8 +4278,12 @@ function createWindow() { }) } -ipcMain.handle('hermes:connection', async () => startHermes()) -ipcMain.handle('hermes:gateway:ws-url', async () => freshGatewayWsUrl()) +ipcMain.handle('hermes:connection', async (_event, profile) => ensureBackend(profile)) +ipcMain.handle('hermes:backend:touch', async (_event, profile) => { + touchPoolBackend(profile) + return { ok: true } +}) +ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile)) ipcMain.handle('hermes:bootstrap:reset', async () => { // Renderer's "Reload and retry" path. Clear the latched failure and // reset connection state so the next startHermes() call restarts the @@ -4077,28 +4363,25 @@ ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => { const config = coerceDesktopConnectionConfig(payload) writeDesktopConnectionConfig(config) - // Capture the reference before resetHermesConnection() nulls hermesProcess, - // so we can wait for actual exit rather than assuming a fixed delay is enough. - const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null - resetHermesConnection() - - if (dying) { - await new Promise(resolve => { - const timer = setTimeout(() => { - try { dying.kill('SIGKILL') } catch {} - resolve() - }, 5000) - dying.once('exit', () => { - clearTimeout(timer) - resolve() - }) - }) - } + await teardownPrimaryBackendAndWait() mainWindow?.reload() return sanitizeDesktopConnectionConfig(config) }) +ipcMain.handle('hermes:profile:get', async () => ({ profile: readActiveDesktopProfile() })) +ipcMain.handle('hermes:profile:set', async (_event, name) => { + const next = writeActiveDesktopProfile(name) + + // Switching profiles is a backend re-home: relaunch the dashboard under the + // new HERMES_HOME. Pool backends keep their own homes, so only the primary + // is torn down. + await teardownPrimaryBackendAndWait() + mainWindow?.reload() + + return { profile: next } +}) + ipcMain.on('hermes:previewShortcutActive', (_event, active) => { previewShortcutActive = Boolean(active) }) @@ -4112,7 +4395,7 @@ ipcMain.handle('hermes:requestMicrophoneAccess', async () => { }) ipcMain.handle('hermes:api', async (_event, request) => { - const connection = await startHermes() + const connection = await ensureBackend(request?.profile) const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) const url = `${connection.baseUrl}${request.path}` // OAuth gateways authenticate REST via the HttpOnly session cookie held in @@ -4653,6 +4936,7 @@ app.on('before-quit', () => { if (hermesProcess && !hermesProcess.killed) { hermesProcess.kill('SIGTERM') } + stopAllPoolBackends() }) app.on('window-all-closed', () => { diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 65fc591e8..2fcf96e4b 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -1,8 +1,9 @@ const { contextBridge, ipcRenderer, webUtils } = require('electron') contextBridge.exposeInMainWorld('hermesDesktop', { - getConnection: () => ipcRenderer.invoke('hermes:connection'), - getGatewayWsUrl: () => ipcRenderer.invoke('hermes:gateway:ws-url'), + getConnection: profile => ipcRenderer.invoke('hermes:connection', profile), + touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile), + getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile), getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'), getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'), saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload), @@ -11,6 +12,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', { probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl), oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl), oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl), + profile: { + get: () => ipcRenderer.invoke('hermes:profile:get'), + set: name => ipcRenderer.invoke('hermes:profile:set', name) + }, api: request => ipcRenderer.invoke('hermes:api', request), notify: payload => ipcRenderer.invoke('hermes:notify', payload), requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'), diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index c5e8183c2..109ffba07 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -16,6 +16,7 @@ import { PaginationPrevious } from '@/components/ui/pagination' import { TextTab, TextTabMeta } from '@/components/ui/text-tab' +import { Tip } from '@/components/ui/tooltip' import { getSessionMessages, listSessions } from '@/hermes' import { sessionTitle } from '@/lib/chat-runtime' import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link' @@ -736,7 +737,6 @@ function ArtifactCellAction({ - {onRemove && ( + +
- )} -
+ {onRemove && ( + + )} + +
) } diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index bd4b140b4..7e98456f9 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -1,5 +1,6 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' +import { Tip } from '@/components/ui/tooltip' import { triggerHaptic } from '@/lib/haptics' import { AudioLines, Layers3, Loader2, Square } from '@/lib/icons' import { cn } from '@/lib/utils' @@ -64,38 +65,40 @@ export function ComposerControls({
{showVoicePrimary ? ( - + + + ) : ( - + + )} + + )}
) @@ -126,22 +129,23 @@ function ConversationPill({ return (
- + + + {listening && ( + + + ) } diff --git a/apps/desktop/src/app/chat/composer/queue-panel.tsx b/apps/desktop/src/app/chat/composer/queue-panel.tsx index 18e95d044..812e1f9a7 100644 --- a/apps/desktop/src/app/chat/composer/queue-panel.tsx +++ b/apps/desktop/src/app/chat/composer/queue-panel.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { Tip } from '@/components/ui/tooltip' import { ArrowUp, Pencil, Trash2 } from '@/lib/icons' import { cn } from '@/lib/utils' import type { QueuedPromptEntry } from '@/store/composer-queue' @@ -80,41 +81,44 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN : 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100' )} > - - - + + + + + + + + +
) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 38c2cff07..0c14ab5ca 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -12,7 +12,6 @@ import { useLocation } from 'react-router-dom' import { Thread } from '@/components/assistant-ui/thread' import { Backdrop } from '@/components/Backdrop' -import { NotificationStack } from '@/components/notifications' import { PromptOverlays } from '@/components/prompt-overlays' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' @@ -325,7 +324,6 @@ export function ChatView({ selectedSessionId={selectedSessionId} /> -
- + + +
{log.message} @@ -112,14 +114,15 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console showLabel={false} text={copyText} /> - + + +
) @@ -225,11 +228,6 @@ export function PreviewConsolePanel({ className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40" disabled={sendableLogs.length === 0} onClick={() => sendLogsToComposer(sendableLogs)} - title={ - visibleSelection.length > 0 - ? `Send ${visibleSelection.length} selected to chat` - : 'Send all log entries to chat' - } type="button" > @@ -250,7 +248,6 @@ export function PreviewConsolePanel({ className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40" disabled={logs.length === 0} onClick={consoleState.clear} - title="Clear console" type="button" > diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx index 4788344dd..0c8a5bb29 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -3,6 +3,7 @@ import type { PointerEvent as ReactPointerEvent } from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls' +import { Tip } from '@/components/ui/tooltip' import { Bug } from '@/lib/icons' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' @@ -607,15 +608,16 @@ export function PreviewPane({ {!embedded && ( )} diff --git a/apps/desktop/src/app/chat/right-rail/preview.tsx b/apps/desktop/src/app/chat/right-rail/preview.tsx index b53acc955..b6825ff6f 100644 --- a/apps/desktop/src/app/chat/right-rail/preview.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo } from 'react' import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls' import { Codicon } from '@/components/ui/codicon' +import { Tip } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { $rightRailActiveTabId, @@ -117,16 +118,17 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP {active && (
+ , + document.body ) } diff --git a/apps/desktop/src/components/ui/copy-button.tsx b/apps/desktop/src/components/ui/copy-button.tsx index a28f1c703..8cdc832e1 100644 --- a/apps/desktop/src/components/ui/copy-button.tsx +++ b/apps/desktop/src/components/ui/copy-button.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import { Button } from '@/components/ui/button' import { DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { Tip } from '@/components/ui/tooltip' import { triggerHaptic } from '@/lib/haptics' import { Check, Copy, X } from '@/lib/icons' import { cn } from '@/lib/utils' @@ -178,7 +179,6 @@ export function CopyButton({ )} disabled={disabled} onClick={event => void copy(event)} - title={feedbackLabel} type="button" > {content} @@ -188,34 +188,37 @@ export function CopyButton({ if (appearance === 'tool-row') { return ( - + + + ) } - return ( + const button = ( ) + + // Only icon-only buttons need a tooltip; the text variant already shows its label. + return appearance === 'icon' ? {button} : button } diff --git a/apps/desktop/src/components/ui/dialog.tsx b/apps/desktop/src/components/ui/dialog.tsx index 66e93103b..099bef305 100644 --- a/apps/desktop/src/components/ui/dialog.tsx +++ b/apps/desktop/src/components/ui/dialog.tsx @@ -1,6 +1,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui' import * as React from 'react' +import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { cn } from '@/lib/utils' @@ -57,12 +58,16 @@ function DialogContent({ > {children} {showCloseButton && ( - - - Close + + )} diff --git a/apps/desktop/src/components/ui/tooltip.tsx b/apps/desktop/src/components/ui/tooltip.tsx index 2f727fdbe..b3e012d97 100644 --- a/apps/desktop/src/components/ui/tooltip.tsx +++ b/apps/desktop/src/components/ui/tooltip.tsx @@ -17,15 +17,18 @@ function TooltipTrigger({ ...props }: React.ComponentProps) { return ( {children} - ) } -export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } +interface TipProps extends Omit, 'content'> { + label: React.ReactNode + children: React.ReactNode + delayDuration?: number +} + +// Drop-in replacement for native `title=`: wrap any single element. Instant, +// position-aware, themed. Self-contained (carries its own Provider) so it works +// anywhere without a provider ancestor. Renders the child untouched when label +// is falsy. +function Tip({ label, children, delayDuration = 0, ...props }: TipProps) { + if (!label) { + return <>{children} + } + + return ( + + + {children} + {label} + + + ) +} + +export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index e576a9c6e..6cbb35994 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -3,8 +3,14 @@ export {} declare global { interface Window { hermesDesktop: { - getConnection: () => Promise - getGatewayWsUrl: () => Promise + // Resolve a backend connection. Omit `profile` (or pass the primary) for + // the window's backend; pass a named profile to lazily spawn/reuse that + // profile's backend from the pool. + getConnection: (profile?: string | null) => Promise + // Keepalive: mark a pool profile backend as recently used so the idle + // reaper spares it while its chat is active. + touchBackend: (profile?: string | null) => Promise<{ ok: boolean }> + getGatewayWsUrl: (profile?: null | string) => Promise getBootProgress: () => Promise getConnectionConfig: () => Promise saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise @@ -13,6 +19,13 @@ declare global { probeConnectionConfig: (remoteUrl: string) => Promise oauthLoginConnectionConfig: (remoteUrl: string) => Promise oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise + profile: { + get: () => Promise + // Persists the desktop's profile choice and relaunches the local + // backend under the new HERMES_HOME (reloads the window). Pass null to + // clear the preference. + set: (name: string | null) => Promise + } api: (request: HermesApiRequest) => Promise notify: (payload: HermesNotification) => Promise requestMicrophoneAccess: () => Promise @@ -151,6 +164,9 @@ export interface HermesConnection { token: string wsUrl: string logs: string[] + // Set for pool (non-primary) backends so the renderer knows which profile a + // connection belongs to. + profile?: string windowButtonPosition: { x: number; y: number } | null } @@ -165,6 +181,12 @@ export interface HermesWindowState { windowButtonPosition: { x: number; y: number } | null } +export interface DesktopActiveProfile { + // The desktop's stored profile preference, or null when unset (legacy launch + // that defers to the sticky active_profile / default). + profile: string | null +} + export interface DesktopConnectionConfig { envOverride: boolean mode: 'local' | 'remote' @@ -293,6 +315,10 @@ export interface HermesApiRequest { method?: string body?: unknown timeoutMs?: number + // Route this REST call to a specific profile's backend. Omit for the primary + // (window) backend. Read-only cross-profile data is served by the primary, so + // this is only needed for profile-scoped live/settings calls. + profile?: string | null } export interface HermesNotification { diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index b96cfceb6..f0c12d27e 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -29,7 +29,6 @@ import type { OAuthSubmitResponse, PaginatedSessions, ProfileCreatePayload, - ProfileSetupCommand, ProfileSoul, ProfilesResponse, SessionMessagesResponse, @@ -81,7 +80,6 @@ export type { PaginatedSessions, ProfileCreatePayload, ProfileInfo, - ProfileSetupCommand, ProfileSoul, ProfilesResponse, RpcEvent, @@ -111,6 +109,22 @@ export class HermesGateway extends JsonRpcGatewayClient { } } +// Profile that profile-scoped REST settings (config/env/skills/tools/model/…) +// should target. Mirrors $activeGatewayProfile, pushed in from the store via +// setApiRequestProfile so this module needs no store import (avoids a cycle). +// Electron main consumes request.profile to pick which backend *process* serves +// the call; each pooled backend already has its own HERMES_HOME, so no backend +// change is needed. Null → primary, so single-profile users are unaffected. +let _apiProfile: null | string = null + +export function setApiRequestProfile(profile: null | string): void { + _apiProfile = profile || null +} + +function profileScoped(): { profile?: string } { + return _apiProfile ? { profile: _apiProfile } : {} +} + export async function listSessions( limit = 40, minMessages = 0, @@ -128,6 +142,30 @@ export async function listSessions( } } +// Unified, read-only session list aggregated across ALL profiles. Served by the +// primary backend straight off each profile's state.db — no per-profile backend +// is spawned. Single-profile users get the same rows as listSessions(), tagged +// profile="default". +export async function listAllProfileSessions( + limit = 40, + minMessages = 0, + archived: 'exclude' | 'include' | 'only' = 'exclude', + order: 'created' | 'recent' = 'recent', + profile: 'all' | (string & {}) = 'all' +): Promise { + const result = await window.hermesDesktop.api({ + path: + `/api/profiles/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` + + `&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}` + }) + + return { + ...result, + sessions: result.sessions.slice(0, limit), + offset: 0 + } +} + export function setSessionArchived(id: string, archived: boolean): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ path: `/api/sessions/${encodeURIComponent(id)}`, @@ -142,9 +180,13 @@ export function searchSessions(query: string): Promise { }) } -export function getSessionMessages(id: string): Promise { +// `profile` reads another profile's transcript straight off its state.db via the +// primary backend (no spawn). Omit for the current/default profile. +export function getSessionMessages(id: string, profile?: string | null): Promise { + const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : '' + return window.hermesDesktop.api({ - path: `/api/sessions/${encodeURIComponent(id)}/messages` + path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}` }) } @@ -155,16 +197,35 @@ export function deleteSession(id: string): Promise<{ ok: boolean }> { }) } -export function renameSession(id: string, title: string): Promise<{ ok: boolean; title: string }> { +export function renameSession( + id: string, + title: string, + profile?: string | null +): Promise<{ ok: boolean; title: string }> { return window.hermesDesktop.api<{ ok: boolean; title: string }>({ path: `/api/sessions/${encodeURIComponent(id)}`, method: 'PATCH', - body: { title } + body: { title, ...(profile ? { profile } : {}) } + }) +} + +// Set ("" clears) a per-session icon glyph. `profile` targets another profile's +// session (the backend opens its state.db). Omit for the current profile. +export function setSessionIcon( + id: string, + icon: string, + profile?: string | null +): Promise<{ ok: boolean; icon: string | null }> { + return window.hermesDesktop.api<{ ok: boolean; icon: string | null }>({ + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'PATCH', + body: { icon, ...(profile ? { profile } : {}) } }) } export function getGlobalModelInfo(): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: '/api/model/info' }) } @@ -202,36 +263,42 @@ export function getLogs(params: { const suffix = query.toString() return window.hermesDesktop.api({ + ...profileScoped(), path: suffix ? `/api/logs?${suffix}` : '/api/logs' }) } export function getHermesConfig(): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: '/api/config' }) } export function getHermesConfigRecord(): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: '/api/config' }) } export function getHermesConfigDefaults(): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: '/api/config/defaults' }) } export function getHermesConfigSchema(): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: '/api/config/schema' }) } export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ + ...profileScoped(), path: '/api/config', method: 'PUT', body: { config } @@ -240,12 +307,14 @@ export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: bool export function getEnvVars(): Promise> { return window.hermesDesktop.api>({ + ...profileScoped(), path: '/api/env' }) } export function setEnvVar(key: string, value: string): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ + ...profileScoped(), path: '/api/env', method: 'PUT', body: { key, value } @@ -257,6 +326,7 @@ export function validateProviderCredential( value: string ): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> { return window.hermesDesktop.api<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({ + ...profileScoped(), path: '/api/providers/validate', method: 'POST', body: { key, value } @@ -265,6 +335,7 @@ export function validateProviderCredential( export function deleteEnvVar(key: string): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ + ...profileScoped(), path: '/api/env', method: 'DELETE', body: { key } @@ -273,6 +344,7 @@ export function deleteEnvVar(key: string): Promise<{ ok: boolean }> { export function revealEnvVar(key: string): Promise<{ key: string; value: string }> { return window.hermesDesktop.api<{ key: string; value: string }>({ + ...profileScoped(), path: '/api/env/reveal', method: 'POST', body: { key } @@ -281,12 +353,14 @@ export function revealEnvVar(key: string): Promise<{ key: string; value: string export function listOAuthProviders(): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: '/api/providers/oauth' }) } export function startOAuthLogin(providerId: string): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`, method: 'POST', body: {} @@ -295,6 +369,7 @@ export function startOAuthLogin(providerId: string): Promise export function submitOAuthCode(providerId: string, sessionId: string, code: string): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`, method: 'POST', body: { session_id: sessionId, code } @@ -303,12 +378,14 @@ export function submitOAuthCode(providerId: string, sessionId: string, code: str export function pollOAuthSession(providerId: string, sessionId: string): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}` }) } export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ + ...profileScoped(), path: `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`, method: 'DELETE' }) @@ -316,12 +393,14 @@ export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }> export function getSkills(): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: '/api/skills' }) } export function toggleSkill(name: string, enabled: boolean): Promise<{ ok: boolean; name: string; enabled: boolean }> { return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({ + ...profileScoped(), path: '/api/skills/toggle', method: 'PUT', body: { name, enabled } @@ -330,6 +409,7 @@ export function toggleSkill(name: string, enabled: boolean): Promise<{ ok: boole export function getToolsets(): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: '/api/tools/toolsets' }) } @@ -339,6 +419,7 @@ export function toggleToolset( enabled: boolean ): Promise<{ ok: boolean; name: string; enabled: boolean }> { return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({ + ...profileScoped(), path: `/api/tools/toolsets/${encodeURIComponent(name)}`, method: 'PUT', body: { enabled } @@ -347,6 +428,7 @@ export function toggleToolset( export function getToolsetConfig(name: string): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: `/api/tools/toolsets/${encodeURIComponent(name)}/config` }) } @@ -356,6 +438,7 @@ export function selectToolsetProvider( provider: string ): Promise<{ ok: boolean; name: string; provider: string }> { return window.hermesDesktop.api<{ ok: boolean; name: string; provider: string }>({ + ...profileScoped(), path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`, method: 'PUT', body: { provider } @@ -485,20 +568,16 @@ export function updateProfileSoul(name: string, content: string): Promise<{ ok: }) } -export function getProfileSetupCommand(name: string): Promise { - return window.hermesDesktop.api({ - path: `/api/profiles/${encodeURIComponent(name)}/setup-command` - }) -} - export function getUsageAnalytics(days = 30): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: `/api/analytics/usage?days=${Math.max(1, Math.floor(days))}` }) } export function getGlobalModelOptions(): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: '/api/model/options' }) } @@ -515,6 +594,7 @@ export interface RecommendedDefaultModel { // free user gets a free model instead of a paid default. export function getRecommendedDefaultModel(provider: string): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: `/api/model/recommended-default?provider=${encodeURIComponent(provider)}` }) } @@ -524,6 +604,7 @@ export function setGlobalModel( model: string ): Promise<{ ok: boolean; provider: string; model: string }> { return window.hermesDesktop.api<{ ok: boolean; provider: string; model: string }>({ + ...profileScoped(), path: '/api/model/set', method: 'POST', body: { @@ -536,12 +617,14 @@ export function setGlobalModel( export function getAuxiliaryModels(): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: '/api/model/auxiliary' }) } export function setModelAssignment(body: ModelAssignmentRequest): Promise { return window.hermesDesktop.api({ + ...profileScoped(), path: '/api/model/set', method: 'POST', body diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index 1a83eee8b..424eba758 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -31,6 +31,7 @@ const DESKTOP_COMMAND_META = [ ['/goal', 'Manage the standing goal for this session'], ['/help', 'Show desktop slash commands'], ['/new', 'Start a new desktop chat'], + ['/profile', 'Switch the active Hermes profile'], ['/queue', 'Queue a prompt for the next turn'], ['/resume', 'Resume a saved session'], ['/retry', 'Retry the last user message'], @@ -111,7 +112,6 @@ const ADVANCED_COMMANDS = new Set([ '/insights', '/kanban', '/personality', - '/profile', '/reasoning', '/reload-mcp', '/reload-skills', diff --git a/apps/desktop/src/lib/gateway-ws-url.ts b/apps/desktop/src/lib/gateway-ws-url.ts index 68726d496..db483be71 100644 --- a/apps/desktop/src/lib/gateway-ws-url.ts +++ b/apps/desktop/src/lib/gateway-ws-url.ts @@ -25,8 +25,10 @@ import type { HermesConnection } from '@/global' * transport failure. */ export interface ResolveGatewayWsUrlDeps { - /** `window.hermesDesktop.getGatewayWsUrl`, if the preload exposes it. */ - getGatewayWsUrl?: () => Promise + /** `window.hermesDesktop.getGatewayWsUrl`, if the preload exposes it. The + * optional profile selects which backend to mint for — critical when swapping + * to a pooled profile, since the default mint resolves the primary backend. */ + getGatewayWsUrl?: (profile?: null | string) => Promise } export class GatewayReauthRequiredError extends Error { @@ -47,9 +49,13 @@ export function isGatewayReauthRequired(error: unknown): error is GatewayReauthR export async function resolveGatewayWsUrl( desktop: ResolveGatewayWsUrlDeps, - conn: Pick + conn: Pick ): Promise { const mint = desktop.getGatewayWsUrl + // Mint for THIS connection's profile, not the primary. Without it a pooled + // profile swap re-mints the default backend's URL and connects to the wrong + // backend. + const profile = conn.profile ?? null if (conn.authMode === 'oauth') { if (!mint) { @@ -62,7 +68,7 @@ export async function resolveGatewayWsUrl( } try { - return await mint() + return await mint(profile) } catch (error) { throw new GatewayReauthRequiredError( 'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.', @@ -74,7 +80,7 @@ export async function resolveGatewayWsUrl( // token / local: the URL carries a long-lived token. Re-mint when available // (cheap, keeps parity), but the cached URL is a safe fallback. if (mint) { - const fresh = await mint().catch(() => null) + const fresh = await mint(profile).catch(() => null) if (fresh) { return fresh diff --git a/apps/desktop/src/lib/profile-color.ts b/apps/desktop/src/lib/profile-color.ts new file mode 100644 index 000000000..ab528e0c1 --- /dev/null +++ b/apps/desktop/src/lib/profile-color.ts @@ -0,0 +1,36 @@ +// Deterministic per-profile color so a profile is glanceable across the app +// (the sidebar profile rail). The default/root profile has no color — named +// profiles get a stable hue derived from the name, so the same profile always +// reads the same color without persisting anything. + +const PROFILE_TAG_SATURATION = 68 +const PROFILE_TAG_LIGHTNESS = 58 + +function hashString(value: string): number { + let hash = 0 + + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0 + } + + return hash +} + +// Returns an hsl() string for a named profile, or null for default/empty +// (rendered neutral / untagged). +export function profileColor(name: null | string | undefined): null | string { + const key = (name ?? '').trim() + + if (!key || key === 'default') { + return null + } + + const hue = hashString(key) % 360 + + return `hsl(${hue} ${PROFILE_TAG_SATURATION}% ${PROFILE_TAG_LIGHTNESS}%)` +} + +// Translucent fill derived from a profile color, for tag backgrounds. +export function profileColorSoft(color: string, percent = 16): string { + return `color-mix(in srgb, ${color} ${percent}%, transparent)` +} diff --git a/apps/desktop/src/lib/query-client.ts b/apps/desktop/src/lib/query-client.ts new file mode 100644 index 000000000..e59c62cb2 --- /dev/null +++ b/apps/desktop/src/lib/query-client.ts @@ -0,0 +1,13 @@ +import { QueryClient } from '@tanstack/react-query' + +// Shared React Query client. Lives in its own module (not main.tsx) so non-React +// code — e.g. the profile store on a gateway swap — can invalidate cached, +// profile-scoped settings without importing the app entry point. +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + staleTime: 60_000 + } + } +}) diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 59341df1a..9559f11a6 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -1,6 +1,6 @@ import './styles.css' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClientProvider } from '@tanstack/react-query' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { HashRouter } from 'react-router-dom' @@ -9,6 +9,7 @@ import App from './app' import { ErrorBoundary } from './components/error-boundary' import { HapticsProvider } from './components/haptics-provider' import { installClipboardShim } from './lib/clipboard' +import { queryClient } from './lib/query-client' import { ThemeProvider } from './themes/context' installClipboardShim() @@ -22,15 +23,6 @@ if (import.meta.env.MODE !== 'production') { import('./app/chat/perf-probe') } -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - staleTime: 60_000 - } - } -}) - createRoot(document.getElementById('root')!).render( diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts new file mode 100644 index 000000000..0c8d58184 --- /dev/null +++ b/apps/desktop/src/store/profile.ts @@ -0,0 +1,217 @@ +import { atom, computed } from 'nanostores' + +import { getProfiles, setApiRequestProfile } from '@/hermes' +import { resolveGatewayWsUrl } from '@/lib/gateway-ws-url' +import { queryClient } from '@/lib/query-client' +import { persistBoolean, storedBoolean } from '@/lib/storage' +import { $gateway } from '@/store/gateway' +import { setConnection } from '@/store/session' +import type { ProfileInfo } from '@/types/hermes' + +// Canonical key for a profile: trimmed, empty → "default". Used everywhere we +// compare a session's owning profile against the live gateway's profile. +export function normalizeProfileKey(name: string | null | undefined): string { + const value = (name ?? '').trim() + + return value || 'default' +} + +// The profile the running local backend is actually scoped to (mirrors +// /api/profiles/active `current`). "default" is the root ~/.hermes. This is the +// display source of truth for the statusbar pill; the desktop's *stored* +// preference (which may be unset) lives in the Electron main process. +export const $activeProfile = atom('default') + +// Cached profile list for the picker. Refreshed lazily; the dropdown also +// re-fetches on open so a profile created elsewhere shows up. +export const $profiles = atom([]) + +export function setActiveProfile(name: string): void { + $activeProfile.set(name || 'default') +} + +interface ActiveProfileResponse { + active: string + current: string +} + +// Pull the running backend's current profile + the available profile list. +// Best-effort: failures (backend not up yet) leave the prior values intact. +export async function refreshActiveProfile(): Promise { + try { + const res = await window.hermesDesktop.api({ path: '/api/profiles/active' }) + + setActiveProfile(res.current || 'default') + } catch { + // Backend may not be ready; keep the last known value. + } + + try { + const { profiles } = await getProfiles() + $profiles.set(profiles) + } catch { + // Leave the cached list in place. + } +} + +// Persist the choice and relaunch the backend under the new HERMES_HOME. The +// main process reloads the window, so this normally never returns to the caller +// (the renderer is torn down). We optimistically reflect the selection first so +// the pill updates instantly if the reload is delayed. +export async function switchProfile(name: string): Promise { + if (!name || name === $activeProfile.get()) { + return + } + + setActiveProfile(name) + await window.hermesDesktop.profile.set(name) +} + +// ── Swap-minimal gateway routing ────────────────────────────────────────── +// One live gateway at a time. When the user opens/sends a session whose profile +// differs from the gateway's current profile, we lazily reconnect the single +// gateway to that profile's backend (spawned on demand by the Electron pool). +// A single-profile user never triggers a swap, so their path is unchanged. + +// The profile the live gateway WebSocket is currently connected to. Initialized +// to the primary (window) backend's profile on boot. +export const $activeGatewayProfile = atom('default') + +// Profile for the NEXT new chat (chosen via the new-chat picker). null = primary +// / default, so single-profile users are unaffected. +export const $newChatProfile = atom(null) + +// Route profile-scoped REST settings (config/env/skills/tools/model/…) to the +// profile the live gateway is currently on, and drop cached settings from the +// previous profile so pages refetch against the right backend. Fires once +// immediately (no real change → no invalidation), so single-profile users just +// get "default" (→ the primary backend) with no extra fetches. +let _lastRoutedProfile: string | null = null + +$activeGatewayProfile.subscribe(value => { + const key = normalizeProfileKey(value) + setApiRequestProfile(key) + + if (_lastRoutedProfile !== null && _lastRoutedProfile !== key) { + // Profile-scoped settings + the unified session list are now stale. + void queryClient.invalidateQueries() + } + + _lastRoutedProfile = key +}) + +let gatewaySwitch: Promise | null = null + +// Reconnect the single live gateway to `profile`'s backend if it isn't already +// there. A null/empty target means "no explicit profile" → keep the gateway on +// whatever profile it's currently on (so a plain new chat stays put and a +// single-profile user never swaps). No-op fast path when already on target. +export async function ensureGatewayProfile(profile: string | null | undefined): Promise { + if (profile == null || !String(profile).trim()) { + // "No explicit profile" = use the current gateway. But if an explicit swap + // (e.g. the user just picked a profile in the switcher) is still in flight, + // let it settle first so a new chat doesn't race session.create against a + // half-open socket and land on the wrong backend. + if (gatewaySwitch) { + await gatewaySwitch.catch(() => undefined) + } + + return + } + + const target = normalizeProfileKey(profile) + + if (normalizeProfileKey($activeGatewayProfile.get()) === target) { + return + } + + // Serialize concurrent swaps so two rapid session switches don't fight over + // the single socket. + if (gatewaySwitch) { + await gatewaySwitch.catch(() => undefined) + + if (normalizeProfileKey($activeGatewayProfile.get()) === target) { + return + } + } + + gatewaySwitch = (async () => { + const desktop = window.hermesDesktop + const gateway = $gateway.get() + + if (!desktop || !gateway) { + return + } + + // getConnection lazily spawns/reuses the profile's pool backend (or returns + // the primary when target is the primary's profile). + const conn = await desktop.getConnection(target) + setConnection(conn) + const wsUrl = await resolveGatewayWsUrl(desktop, conn) + // The single socket is still OPEN to the *previous* profile's backend, and + // gateway.connect() no-ops on an already-open socket. Drop it first so the + // reconnect actually re-points at the target profile's backend — otherwise + // the swap silently stays on the old backend (session.create writes to the + // wrong profile's DB). close() nulls the socket without emitting a 'closed' + // state, so it doesn't trip the boot auto-reconnect. + gateway.close() + await gateway.connect(wsUrl) + $activeGatewayProfile.set(target) + void desktop.touchBackend?.(target).catch(() => undefined) + })() + + try { + await gatewaySwitch + } finally { + gatewaySwitch = null + } +} + +// ── Sidebar profile scope (the "workspace switcher" model) ───────────────── +// Mirrors how Slack/VS Code/Linear do multi-context: you're "in" one profile at +// a time and the sidebar shows only that profile's sessions (clean rows, no +// per-row tags). The lone exception is an explicit "All profiles" mode that +// fans every profile's sessions into one grouped, browsable list. + +export const ALL_PROFILES = '__all__' + +const SHOW_ALL_PROFILES_STORAGE_KEY = 'hermes.desktop.showAllProfiles' + +// Opt-in unified view. When false, scope follows the live gateway profile, so +// single-profile users (who never see the switcher) are completely unaffected. +export const $showAllProfiles = atom(storedBoolean(SHOW_ALL_PROFILES_STORAGE_KEY, false)) + +$showAllProfiles.subscribe(value => persistBoolean(SHOW_ALL_PROFILES_STORAGE_KEY, value)) + +// The profile context the sidebar is currently showing: a concrete profile key, +// or ALL_PROFILES for the unified grouped view. Concrete scope is tied to the +// gateway so opening/selecting a profile (which swaps the gateway) moves the +// whole sidebar with it — a real context switch, not a separate filter to keep +// in sync. +export const $profileScope = computed([$showAllProfiles, $activeGatewayProfile], (showAll, gateway) => + showAll ? ALL_PROFILES : normalizeProfileKey(gateway) +) + +// Switch the active context to `name`: leave "All profiles" mode, point new +// chats at it, and swap the single live gateway onto its backend (which moves +// $activeGatewayProfile → name, so $profileScope follows). +export function selectProfile(name: string): void { + const target = normalizeProfileKey(name) + $showAllProfiles.set(false) + $newChatProfile.set(target) + void ensureGatewayProfile(target) +} + +export function setShowAllProfiles(value: boolean): void { + $showAllProfiles.set(value) +} + +// Keepalive ping for the active pool backend so the main-process idle reaper +// (which can't see the direct renderer↔backend WS) spares it. No-op for the +// primary/default backend, which is never pooled. +export function touchActiveGatewayBackend(): void { + // Always ping: the main process no-ops for non-pool (primary) backends, so we + // don't need to know which profile is primary from here. + const target = normalizeProfileKey($activeGatewayProfile.get()) + void window.hermesDesktop?.touchBackend?.(target).catch(() => undefined) +} diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 8106b002f..d60b22e6b 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -76,6 +76,11 @@ export const $connection = atom(null) export const $gatewayState = atom('idle') export const $sessions = atom([]) export const $sessionsTotal = atom(0) +// Listable conversation count per profile (children excluded), keyed by profile +// name. Lets the sidebar scope its "Load more" footer to the active profile so a +// huge default profile doesn't keep "Load more" visible while browsing a small +// one. Empty for single-profile users (fall back to $sessionsTotal). +export const $sessionProfileTotals = atom>({}) export const $sessionsLoading = atom(true) export const $workingSessionIds = atom([]) export const $activeSessionId = atom(null) @@ -114,6 +119,8 @@ export const setConnection = (next: Updater) => updateA export const setGatewayState = (next: Updater) => updateAtom($gatewayState, next) export const setSessions = (next: Updater) => updateAtom($sessions, next) export const setSessionsTotal = (next: Updater) => updateAtom($sessionsTotal, next) +export const setSessionProfileTotals = (next: Updater>) => + updateAtom($sessionProfileTotals, next) export const setSessionsLoading = (next: Updater) => updateAtom($sessionsLoading, next) export const setWorkingSessionIds = (next: Updater) => updateAtom($workingSessionIds, next) export const setActiveSessionId = (next: Updater) => updateAtom($activeSessionId, next) diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index d5819ea24..6bc4e44f8 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -241,6 +241,14 @@ export interface PaginatedSessions { offset: number sessions: SessionInfo[] total: number + /** Listable conversation count per profile (children excluded), keyed by + * profile name. Lets the sidebar scope its "Load more" footer to the active + * profile instead of the global total. Present only on + * `/api/profiles/sessions`. */ + profile_totals?: Record + /** Per-profile read failures from the cross-profile aggregator (e.g. a locked + * or corrupt state.db). Present only on `/api/profiles/sessions`. */ + errors?: Array<{ profile: string; error: string }> } export interface RpcEvent { @@ -277,6 +285,15 @@ export interface SessionInfo { started_at: number title: null | string tool_call_count: number + /** Owning profile name, set by the cross-profile aggregator + * (`/api/profiles/sessions`). Absent on legacy single-profile responses, + * which the UI treats as the default profile. */ + profile?: string + /** True when {@link profile} is the default profile. */ + is_default_profile?: boolean + /** Optional per-session glyph the user picked so sessions from different + * profiles are visually distinguishable in the unified list. */ + icon?: null | string } export interface SessionMessage { @@ -435,6 +452,8 @@ export interface CronJobUpdates { } export interface ProfileCreatePayload { + clone_all?: boolean + clone_from?: string clone_from_default?: boolean name: string no_skills?: boolean @@ -450,10 +469,6 @@ export interface ProfileInfo { skill_count: number } -export interface ProfileSetupCommand { - command: string -} - export interface ProfileSoul { content: string exists: boolean diff --git a/hermes_cli/main.py b/hermes_cli/main.py index aba90fb3e..efe3c22d3 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7820,10 +7820,19 @@ def _kill_stale_dashboard_processes( exclude: set[int] | None = None raw_pid = os.environ.get("HERMES_DESKTOP_CHILD_PID") if raw_pid: - try: - exclude = {int(raw_pid)} - except (ValueError, TypeError): - pass + # The desktop may manage several backends (one per active profile) and + # passes them comma-separated; a lone int still parses for back-compat. + parsed: set[int] = set() + for part in raw_pid.split(","): + part = part.strip() + if not part: + continue + try: + parsed.add(int(part)) + except (ValueError, TypeError): + pass + if parsed: + exclude = parsed pids = _find_stale_dashboard_pids(exclude_pids=exclude) if not pids: diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index d25ca1643..d0eb8a3ce 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1592,6 +1592,7 @@ async def get_sessions( min_message_count=min_message_count, include_archived=include_archived, archived_only=archived_only, + exclude_children=True, ) now = time.time() for s in sessions: @@ -1609,6 +1610,111 @@ async def get_sessions( raise HTTPException(status_code=500, detail="Internal server error") +@app.get("/api/profiles/sessions") +async def get_profiles_sessions( + limit: int = 20, + offset: int = 0, + min_messages: int = 0, + archived: str = "exclude", + order: str = "recent", + profile: str = "all", +): + """Unified, read-only session list aggregated across ALL profiles. + + Intentionally process-light: this opens each profile's ``state.db`` directly + from disk — it does NOT spawn a dashboard backend per profile. Each returned + session is tagged with its owning ``profile`` so the desktop renders one + browsable list and only spins up a profile's backend when the user actually + interacts (sends a message). A user with a single (default) profile gets the + same rows as ``/api/sessions``, just tagged ``profile="default"``. + """ + 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") + + from hermes_state import SessionDB + from hermes_cli import profiles as profiles_mod + + targets: List[Tuple[str, Path]] = [] + if profile and profile != "all": + name, home = _cron_profile_home(profile) + targets.append((name, home)) + else: + try: + infos = profiles_mod.list_profiles() + targets = [(info.name, info.path) for info in infos] + except Exception: + _log.exception("GET /api/profiles/sessions: list_profiles failed") + targets = [] + if not targets: + targets.append(("default", profiles_mod.get_profile_dir("default"))) + + min_message_count = max(0, min_messages) + archived_only = archived == "only" + include_archived = archived == "include" + # Over-fetch per profile so the merged+sorted window is correct for the + # requested page. Capped so a huge profile can't blow up the response. + per_profile = min(max(limit + offset, limit), 500) + + merged: List[Dict[str, Any]] = [] + total = 0 + profile_totals: Dict[str, int] = {} + errors: List[Dict[str, str]] = [] + now = time.time() + for name, home in targets: + db_path = Path(home) / "state.db" + if not db_path.exists(): + continue + try: + db = SessionDB(db_path=db_path) + except Exception as exc: + errors.append({"profile": name, "error": str(exc)}) + continue + try: + rows = db.list_sessions_rich( + limit=per_profile, + offset=0, + min_message_count=min_message_count, + include_archived=include_archived, + archived_only=archived_only, + order_by_last_active=order == "recent", + ) + profile_total = db.session_count( + min_message_count=min_message_count, + include_archived=include_archived, + archived_only=archived_only, + exclude_children=True, + ) + total += profile_total + profile_totals[name] = profile_total + for s in rows: + s["profile"] = name + s["is_default_profile"] = name == "default" + s["is_active"] = ( + s.get("ended_at") is None + and (now - s.get("last_active", s.get("started_at", 0))) < 300 + ) + s["archived"] = bool(s.get("archived")) + merged.append(s) + except Exception as exc: + errors.append({"profile": name, "error": str(exc)}) + finally: + db.close() + + sort_key = "last_active" if order == "recent" else "started_at" + merged.sort(key=lambda s: s.get(sort_key) or s.get("started_at") or 0, reverse=True) + window = merged[offset:offset + limit] + return { + "sessions": window, + "total": total, + "profile_totals": profile_totals, + "limit": limit, + "offset": offset, + "errors": errors, + } + + @app.get("/api/sessions/search") async def search_sessions(q: str = "", limit: int = 20): """Full-text search across session message content using FTS5. @@ -4663,15 +4769,31 @@ async def get_session_stats(): db.close() -@app.get("/api/sessions/{session_id}") -async def get_session_detail(session_id: str): +def _open_session_db_for_profile(profile: Optional[str]): + """Open a SessionDB for read paths, optionally for another profile. + + ``profile`` None/empty → this process's own ``state.db`` (the common, + single-profile case). A named profile opens that profile's on-disk + ``state.db`` directly so the primary backend can serve cross-profile reads + (transcripts, detail) without spawning that profile's backend. + """ from hermes_state import SessionDB - db = SessionDB() + if not profile: + return SessionDB() + _name, home = _cron_profile_home(profile) + return SessionDB(db_path=Path(home) / "state.db") + + +@app.get("/api/sessions/{session_id}") +async def get_session_detail(session_id: str, profile: Optional[str] = None): + db = _open_session_db_for_profile(profile) try: sid = db.resolve_session_id(session_id) session = db.get_session(sid) if sid else None if not session: raise HTTPException(status_code=404, detail="Session not found") + if profile: + session["profile"] = _cron_profile_home(profile)[0] return session finally: db.close() @@ -4691,9 +4813,8 @@ async def get_session_latest_descendant(session_id: str): } @app.get("/api/sessions/{session_id}/messages") -async def get_session_messages(session_id: str): - from hermes_state import SessionDB - db = SessionDB() +async def get_session_messages(session_id: str, profile: Optional[str] = None): + db = _open_session_db_for_profile(profile) try: sid = db.resolve_session_id(session_id) if not sid: @@ -4719,25 +4840,31 @@ async def delete_session_endpoint(session_id: str): class SessionRename(BaseModel): title: Optional[str] = None archived: Optional[bool] = None + # Tri-state via the client contract: omit to leave unchanged, "" to clear, + # a short glyph to set. (None == omitted here.) + icon: Optional[str] = None + # Mutate a session belonging to another profile (opens its state.db). Omit + # for the current/default profile. + profile: Optional[str] = None @app.patch("/api/sessions/{session_id}") async def rename_session_endpoint(session_id: str, body: SessionRename): - """Update a session: rename (or clear its title) and/or archive it. + """Update a session: rename (or clear its title), archive, and/or set icon. ``title`` renames (empty/null clears the title); ``archived`` soft-hides or - restores the session. Either field may be omitted. + restores the session; ``icon`` sets a per-session glyph ("" clears it). Any + field may be omitted. ``profile`` targets another profile's session. """ - from hermes_state import SessionDB - db = SessionDB() + db = _open_session_db_for_profile(body.profile) try: sid = db.resolve_session_id(session_id) if not sid: raise HTTPException(status_code=404, detail="Session not found") - if body.title is None and body.archived is None: + if body.title is None and body.archived is None and body.icon is None: raise HTTPException( status_code=400, - detail="Nothing to update; provide 'title' and/or 'archived'.", + detail="Nothing to update; provide 'title', 'archived', and/or 'icon'.", ) if body.title is not None: try: @@ -4747,9 +4874,13 @@ async def rename_session_endpoint(session_id: str, body: SessionRename): raise HTTPException(status_code=400, detail=str(e)) if body.archived is not None: db.set_session_archived(sid, body.archived) + if body.icon is not None: + db.set_session_icon(sid, body.icon) result = {"ok": True, "title": db.get_session_title(sid) or ""} if body.archived is not None: result["archived"] = bool(body.archived) + if body.icon is not None: + result["icon"] = (body.icon or "").strip()[:16] or None return result finally: db.close() @@ -6156,6 +6287,11 @@ class ProfileCreate(BaseModel): clone_all: bool = False no_skills: bool = False description: Optional[str] = None + # Explicit source profile to clone from (e.g. duplicating an existing + # profile). When set, it takes precedence over ``clone_from_default``, + # which always sources from "default". ``clone_all`` still selects a full + # state copytree vs. a config/skills/SOUL copy. + clone_from: Optional[str] = None provider: Optional[str] = None model: Optional[str] = None @@ -6316,13 +6452,23 @@ async def list_profiles_endpoint(): @app.post("/api/profiles") async def create_profile_endpoint(body: ProfileCreate): from hermes_cli import profiles as profiles_mod - clone = body.clone_from_default or body.clone_all + explicit_source = (body.clone_from or "").strip() + if explicit_source: + # Duplicating a specific profile: clone its config/skills/SOUL (or full + # state when clone_all) from the named source rather than "default". + clone = True + clone_from = explicit_source + clone_config = not body.clone_all + else: + clone = body.clone_from_default or body.clone_all + clone_from = "default" if clone else None + clone_config = body.clone_from_default and not body.clone_all try: path = profiles_mod.create_profile( name=body.name, - clone_from="default" if clone else None, + clone_from=clone_from, clone_all=body.clone_all, - clone_config=body.clone_from_default and not body.clone_all, + clone_config=clone_config, no_skills=body.no_skills, description=body.description, ) diff --git a/hermes_state.py b/hermes_state.py index f08acdce2..c8fd3a827 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -265,6 +265,7 @@ CREATE TABLE IF NOT EXISTS sessions ( handoff_error TEXT, rewind_count INTEGER NOT NULL DEFAULT 0, archived INTEGER NOT NULL DEFAULT 0, + icon TEXT, FOREIGN KEY (parent_session_id) REFERENCES sessions(id) ); @@ -1444,6 +1445,23 @@ class SessionDB: rowcount = self._execute_write(_do) return rowcount > 0 + def set_session_icon(self, session_id: str, icon: Optional[str]) -> bool: + """Set or clear a session's user-chosen icon glyph. + + ``icon`` is a short display string (an emoji or a couple of chars); + passing None/"" clears it. Returns True when a row was updated. + """ + cleaned = (icon or "").strip()[:16] or None + + def _do(conn): + cursor = conn.execute( + "UPDATE sessions SET icon = ? WHERE id = ?", + (cleaned, 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: @@ -3053,26 +3071,46 @@ class SessionDB: min_message_count: int = 0, include_archived: bool = False, archived_only: bool = False, + exclude_children: bool = False, ) -> int: - """Count sessions, optionally filtered by source.""" + """Count sessions, optionally filtered by source. + + Pass ``exclude_children=True`` to count only the conversations that + ``list_sessions_rich`` surfaces (root + branch sessions), hiding + sub-agent runs and compression continuations. Use it whenever the count + is paired with a ``list_sessions_rich`` page (e.g. sidebar "load more" + totals) so the total matches the number of listable rows — otherwise the + raw row count is inflated by children and "load more" never settles. + """ where_clauses = [] params = [] + if exclude_children: + # Mirror list_sessions_rich's child-exclusion clause exactly so the + # count lines up with the rows: roots (no parent) plus branch + # children (parent ended with end_reason='branched'). + where_clauses.append( + "(s.parent_session_id IS NULL" + " OR EXISTS (SELECT 1 FROM sessions p" + " WHERE p.id = s.parent_session_id" + " AND p.end_reason = 'branched'" + " AND s.started_at >= p.ended_at))" + ) if source: - where_clauses.append("source = ?") + where_clauses.append("s.source = ?") params.append(source) if min_message_count > 0: - where_clauses.append("message_count >= ?") + where_clauses.append("s.message_count >= ?") params.append(min_message_count) if archived_only: - where_clauses.append("archived = 1") + where_clauses.append("s.archived = 1") elif not include_archived: - where_clauses.append("archived = 0") + where_clauses.append("s.archived = 0") where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else "" with self._lock: - cursor = self._conn.execute(f"SELECT COUNT(*) FROM sessions{where_sql}", params) + cursor = self._conn.execute(f"SELECT COUNT(*) FROM sessions s{where_sql}", params) return cursor.fetchone()[0] def message_count(self, session_id: str = None) -> int: diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 1b898526d..6a1d20db2 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -381,6 +381,60 @@ class TestWebServerEndpoints: resp = self.client.patch("/api/sessions/no-fields", json={}) assert resp.status_code == 400 + def test_set_and_clear_session_icon_via_patch(self): + """PATCH icon sets a per-session glyph; "" clears it back to None.""" + from hermes_state import SessionDB + + db = SessionDB() + try: + db.create_session(session_id="icon-me", source="cli") + finally: + db.close() + + resp = self.client.patch("/api/sessions/icon-me", json={"icon": "🦊"}) + assert resp.status_code == 200 + assert resp.json()["icon"] == "🦊" + + db = SessionDB() + try: + assert db.get_session("icon-me")["icon"] == "🦊" + finally: + db.close() + + resp = self.client.patch("/api/sessions/icon-me", json={"icon": ""}) + assert resp.status_code == 200 + assert resp.json()["icon"] is None + + db = SessionDB() + try: + assert db.get_session("icon-me")["icon"] is None + finally: + db.close() + + def test_profiles_sessions_tags_default_profile(self): + """The cross-profile aggregator returns the default profile's rows + tagged profile="default" (single-profile parity with /api/sessions).""" + from hermes_state import SessionDB + + db = SessionDB() + try: + db.create_session(session_id="agg-me", source="cli") + db.append_message(session_id="agg-me", role="user", content="hi") + finally: + db.close() + + resp = self.client.get("/api/profiles/sessions?limit=20&min_messages=0") + assert resp.status_code == 200 + data = resp.json() + row = next(s for s in data["sessions"] if s["id"] == "agg-me") + assert row["profile"] == "default" + assert row["is_default_profile"] is True + assert isinstance(data.get("errors"), list) + + def test_profiles_sessions_rejects_unknown_archived_value(self): + resp = self.client.get("/api/profiles/sessions?archived=bogus") + 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 @@ -1603,6 +1657,30 @@ class TestNewEndpoints: profiles = {p["name"]: p for p in self.client.get("/api/profiles").json()["profiles"]} assert profiles["cloned"]["skill_count"] == 1 + def test_profiles_create_with_clone_from_duplicates_source(self, monkeypatch): + from hermes_constants import get_hermes_home + import hermes_cli.profiles as profiles_mod + + monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None) + + # Create a source profile and give it a distinctive skill. + assert self.client.post("/api/profiles", json={"name": "source-prof"}).status_code == 200 + source_skill = get_hermes_home() / "profiles" / "source-prof" / "skills" / "custom" / "src-skill" + source_skill.mkdir(parents=True) + (source_skill / "SKILL.md").write_text("---\nname: src-skill\n---\n", encoding="utf-8") + + # Duplicate it via an explicit clone_from source (not "default"). + resp = self.client.post( + "/api/profiles", + json={"name": "source-prof-copy", "clone_from": "source-prof"}, + ) + + assert resp.status_code == 200 + cloned_skill = ( + get_hermes_home() / "profiles" / "source-prof-copy" / "skills" / "custom" / "src-skill" / "SKILL.md" + ) + assert cloned_skill.exists() + def test_profiles_create_without_clone_seeds_bundled_skills(self, monkeypatch): from hermes_constants import get_hermes_home import hermes_cli.profiles as profiles_mod