Merge pull request #39330 from NousResearch/bb/desktop-profile-support

feat(desktop): concurrent multi-profile sessions, cross-profile @session links
This commit is contained in:
brooklyn! 2026-06-04 20:50:34 -05:00 committed by GitHub
commit ff5652d0f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
76 changed files with 4411 additions and 1237 deletions

View file

@ -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 <name>
// 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,24 @@ 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)
// A backend touched within this window has a live renderer socket (the keepalive
// pings every 60s for every open profile). LRU eviction must spare these — a
// concurrent multi-profile session keeps several backends "fresh" at once, and
// killing one to honor the soft cap would abort a running agent.
const POOL_KEEPALIVE_FRESH_MS = 90_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 +1480,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 +3403,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 +3494,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 +3790,212 @@ 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 — but only
// ever evict backends without a live renderer socket (stale beyond the keepalive
// window). When every backend is actively kept alive we let the pool exceed the
// soft cap rather than kill a running session.
function evictLruPoolBackends(keep) {
if (backendPool.size <= keep) return
const now = Date.now()
const evictable = [...backendPool.entries()]
.filter(([, entry]) => now - (entry.lastActiveAt || 0) > POOL_KEEPALIVE_FRESH_MS)
.sort((a, b) => (a[1].lastActiveAt || 0) - (b[1].lastActiveAt || 0))
let removable = backendPool.size - Math.max(0, keep)
for (const [profile] of evictable) {
if (removable <= 0) break
rememberLog(`Evicting idle profile backend "${profile}" (LRU cap ${POOL_MAX_BACKENDS})`)
stopPoolBackend(profile)
removable -= 1
}
}
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 +4037,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 <name>` 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 +4289,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 +4374,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 +4406,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 +4947,7 @@ app.on('before-quit', () => {
if (hermesProcess && !hermesProcess.killed) {
hermesProcess.kill('SIGTERM')
}
stopAllPoolBackends()
})
app.on('window-all-closed', () => {

View file

@ -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'),

View file

@ -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({
<button
className="flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) font-normal text-(--ui-text-secondary) no-underline underline-offset-4 decoration-current/20 transition-colors hover:text-foreground hover:underline"
onClick={onClick}
title={title}
type="button"
>
{children}
@ -774,15 +774,16 @@ function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx })
return (
<div className="group/location flex min-w-0 items-center gap-1.5">
<div
className={cn(
'min-w-0 flex-1 truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)',
isLink ? 'font-normal' : 'font-mono'
)}
title={artifact.value}
>
{value}
</div>
<Tip label={artifact.value}>
<div
className={cn(
'min-w-0 flex-1 truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)',
isLink ? 'font-normal' : 'font-mono'
)}
>
{value}
</div>
</Tip>
<CopyButton
appearance="icon"
buttonSize="icon-xs"

View file

@ -1,26 +1,43 @@
import { useRef } from 'react'
import type { DragKind } from '@/app/chat/hooks/use-file-drop-zone'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
const COPY: Record<'files' | 'session', { icon: string; label: string }> = {
files: { icon: 'cloud-upload', label: 'Drop files to attach' },
session: { icon: 'comment-discussion', label: 'Drop to link this chat' }
}
/**
* Full-bleed affordance shown while files are dragged over the chat area. Always
* `pointer-events-none` so the drop lands on the real element underneath and the
* drop-zone handler claims it the overlay is purely visual. Mirrors the
* composer surface so the two read as one family.
* Full-bleed affordance shown while files or a session are dragged over the chat
* area. Always `pointer-events-none` so the drop lands on the real element
* underneath and the drop-zone handler claims it the overlay is purely visual.
* Copy adapts to whatever is being dragged; the last kind is held through the
* fade-out so the label doesn't blank.
*/
export function ChatDropOverlay({ active }: { active: boolean }) {
export function ChatDropOverlay({ kind }: { kind: DragKind }) {
const lastKind = useRef<'files' | 'session'>('files')
if (kind) {
lastKind.current = kind
}
const { icon, label } = COPY[kind ?? lastKind.current]
return (
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center p-4 transition-opacity duration-150 ease-out',
active ? 'opacity-100' : 'opacity-0'
kind ? 'opacity-100' : 'opacity-0'
)}
data-slot="chat-drop-overlay"
>
<div className="absolute inset-2 rounded-2xl border-2 border-dashed border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)] backdrop-blur-[2px] [-webkit-backdrop-filter:blur(2px)]" />
<div className="relative flex items-center gap-2 rounded-full border border-[color-mix(in_srgb,var(--dt-composer-ring)_45%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 text-[0.8125rem] font-medium text-foreground shadow-composer">
<Codicon className="text-(--ui-accent)" name="cloud-upload" size="1rem" />
Drop files to attach
<Codicon className="text-(--ui-accent)" name={icon} size="1rem" />
{label}
</div>
</div>
)

View file

@ -0,0 +1,45 @@
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
// Braille spinner frames — reads as a tiny ASCII loader in monospace.
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
// Shown over the conversation while the live gateway swaps to another profile's
// backend (lazily spawned). Keeps the last profile name through the fade-out so
// the label doesn't blank. Purely visual — pointer-events-none.
export function ChatSwapOverlay({ profile }: { profile: string | null }) {
const [frame, setFrame] = useState(0)
const [label, setLabel] = useState<null | string>(profile)
useEffect(() => {
if (profile) {
setLabel(profile)
}
}, [profile])
useEffect(() => {
if (!profile) {
return
}
const id = window.setInterval(() => setFrame(value => (value + 1) % FRAMES.length), 80)
return () => window.clearInterval(id)
}, [profile])
return (
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 z-50 flex items-center justify-center transition-opacity duration-150 ease-out',
profile ? 'opacity-100' : 'opacity-0'
)}
>
<div className="flex items-center gap-2 bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 font-mono text-[0.8125rem] text-foreground shadow-composer">
<span className="w-3 text-(--ui-accent)">{FRAMES[frame]}</span>
Waking up {label}
</div>
</div>
)
}

View file

@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import type { ComposerAttachment } from '@/store/composer'
@ -62,49 +63,49 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
}
return (
<div
className="group/attachment relative min-w-0 shrink-0"
title={attachment.path || attachment.detail || attachment.label}
>
<button
aria-label={canPreview ? `Preview ${attachment.label}` : attachment.label}
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
disabled={!canPreview}
onClick={() => void openPreview()}
title={canPreview ? `Preview ${attachment.label}` : attachment.label}
type="button"
>
{attachment.previewUrl && attachment.kind === 'image' ? (
<img
alt={attachment.label}
className="size-8 shrink-0 border border-border/70 object-cover"
draggable={false}
src={attachment.previewUrl}
/>
) : (
<span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground">
<Icon className="size-3.5" />
</span>
)}
<span className="min-w-0">
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
{attachment.label}
</span>
{detail && (
<span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">{detail}</span>
)}
</span>
</button>
{onRemove && (
<Tip label={attachment.path || attachment.detail || attachment.label}>
<div className="group/attachment relative min-w-0 shrink-0">
<button
aria-label={`Remove ${attachment.label}`}
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
onClick={() => onRemove(attachment.id)}
aria-label={canPreview ? `Preview ${attachment.label}` : attachment.label}
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
disabled={!canPreview}
onClick={() => void openPreview()}
type="button"
>
<Codicon name="close" size="0.625rem" />
{attachment.previewUrl && attachment.kind === 'image' ? (
<img
alt={attachment.label}
className="size-8 shrink-0 border border-border/70 object-cover"
draggable={false}
src={attachment.previewUrl}
/>
) : (
<span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground">
<Icon className="size-3.5" />
</span>
)}
<span className="min-w-0">
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
{attachment.label}
</span>
{detail && (
<span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">
{detail}
</span>
)}
</span>
</button>
)}
</div>
{onRemove && (
<button
aria-label={`Remove ${attachment.label}`}
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
onClick={() => onRemove(attachment.id)}
type="button"
>
<Codicon name="close" size="0.625rem" />
</button>
)}
</div>
</Tip>
)
}

View file

@ -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({
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
{showVoicePrimary ? (
<Button
aria-label="Start voice conversation"
className={PRIMARY_ICON_BTN}
disabled={disabled}
onClick={() => {
triggerHaptic('open')
conversation.onStart()
}}
size="icon"
title="Start voice conversation"
type="button"
>
<AudioLines size={17} />
</Button>
<Tip label="Start voice conversation">
<Button
aria-label="Start voice conversation"
className={PRIMARY_ICON_BTN}
disabled={disabled}
onClick={() => {
triggerHaptic('open')
conversation.onStart()
}}
size="icon"
type="button"
>
<AudioLines size={17} />
</Button>
</Tip>
) : (
<Button
aria-label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
className={PRIMARY_ICON_BTN}
disabled={disabled || !canSubmit}
title={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
type="submit"
>
{busy ? (
busyAction === 'queue' ? (
<Layers3 size={16} />
<Tip label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}>
<Button
aria-label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
className={PRIMARY_ICON_BTN}
disabled={disabled || !canSubmit}
type="submit"
>
{busy ? (
busyAction === 'queue' ? (
<Layers3 size={16} />
) : (
<span className="block size-3 rounded-[0.1875rem] bg-current" />
)
) : (
<span className="block size-3 rounded-[0.1875rem] bg-current" />
)
) : (
<Codicon name="arrow-up" size="1rem" />
)}
</Button>
<Codicon name="arrow-up" size="1rem" />
)}
</Button>
</Tip>
)}
</div>
)
@ -126,22 +129,23 @@ function ConversationPill({
return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<Button
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'}
aria-pressed={muted}
className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')}
disabled={disabled}
onClick={() => {
triggerHaptic('selection')
onToggleMute()
}}
size="icon"
title={muted ? 'Unmute microphone' : 'Mute microphone'}
type="button"
variant="ghost"
>
<Codicon name={muted ? 'mic-off' : 'mic'} size="1rem" />
</Button>
<Tip label={muted ? 'Unmute microphone' : 'Mute microphone'}>
<Button
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'}
aria-pressed={muted}
className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')}
disabled={disabled}
onClick={() => {
triggerHaptic('selection')
onToggleMute()
}}
size="icon"
type="button"
variant="ghost"
>
<Codicon name={muted ? 'mic-off' : 'mic'} size="1rem" />
</Button>
</Tip>
{listening && (
<Button
aria-label="Stop listening and send"
@ -151,7 +155,6 @@ function ConversationPill({
triggerHaptic('submit')
onStopTurn()
}}
title="Stop listening and send"
type="button"
variant="ghost"
>
@ -167,7 +170,6 @@ function ConversationPill({
triggerHaptic('close')
onEnd()
}}
title="End voice conversation"
type="button"
>
<ConversationIndicator level={level} listening={listening} speaking={speaking} />
@ -224,34 +226,35 @@ function DictationButton({
status === 'recording' ? 'Stop dictation' : status === 'transcribing' ? 'Transcribing dictation' : 'Voice dictation'
return (
<Button
aria-label={aria}
aria-pressed={active}
className={cn(
GHOST_ICON_BTN,
'p-0',
'data-[active=true]:bg-accent data-[active=true]:text-foreground',
status === 'recording' && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary',
status === 'transcribing' && 'bg-primary/10 text-primary'
)}
data-active={active}
disabled={disabled || !state.enabled || status === 'transcribing'}
onClick={() => {
triggerHaptic(active ? 'close' : 'open')
onToggle()
}}
size="icon"
title={aria}
type="button"
variant="ghost"
>
{status === 'recording' ? (
<Square className="fill-current" size={12} />
) : status === 'transcribing' ? (
<Loader2 className="animate-spin" size={16} />
) : (
<Codicon name="mic" size="1rem" />
)}
</Button>
<Tip label={aria}>
<Button
aria-label={aria}
aria-pressed={active}
className={cn(
GHOST_ICON_BTN,
'p-0',
'data-[active=true]:bg-accent data-[active=true]:text-foreground',
status === 'recording' && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary',
status === 'transcribing' && 'bg-primary/10 text-primary'
)}
data-active={active}
disabled={disabled || !state.enabled || status === 'transcribing'}
onClick={() => {
triggerHaptic(active ? 'close' : 'open')
onToggle()
}}
size="icon"
type="button"
variant="ghost"
>
{status === 'recording' ? (
<Square className="fill-current" size={12} />
) : status === 'transcribing' ? (
<Loader2 className="animate-spin" size={16} />
) : (
<Codicon name="mic" size="1rem" />
)}
</Button>
</Tip>
)
}

View file

@ -10,6 +10,8 @@
* steal focus from the composer effect.
*/
import type { InlineRefInput } from './inline-refs'
export type ComposerTarget = 'edit' | 'main'
export type ComposerInsertMode = 'block' | 'inline'
@ -23,8 +25,14 @@ interface InsertDetail {
text: string
}
interface InsertRefsDetail {
refs: InlineRefInput[]
target: ComposerTarget
}
const FOCUS_EVENT = 'hermes:composer-focus'
const INSERT_EVENT = 'hermes:composer-insert'
const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
let activeTarget: ComposerTarget = 'main'
@ -82,6 +90,20 @@ export const onComposerFocusRequest = (handler: (target: ComposerTarget) => void
export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) =>
subscribe<InsertDetail>(INSERT_EVENT, handler)
/** Insert typed ref chips (carrying a display label) into a composer the
* structured cousin of {@link requestComposerInsert}, used for session links. */
export const requestComposerInsertRefs = (
refs: InlineRefInput[],
{ target = 'active' }: { target?: ComposerTarget | 'active' } = {}
) => {
if (refs.length) {
dispatch<InsertRefsDetail>(INSERT_REFS_EVENT, { refs, target: resolve(target) })
}
}
export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) =>
subscribe<InsertRefsDetail>(INSERT_REFS_EVENT, handler)
/**
* Focus a composer input across React commit + browser focus restore.
*

View file

@ -45,6 +45,7 @@ import {
focusComposerInput,
markActiveComposer,
onComposerFocusRequest,
onComposerInsertRefsRequest,
onComposerInsertRequest
} from './focus'
import { HelpHint } from './help-hint'
@ -52,7 +53,12 @@ import { useAtCompletions } from './hooks/use-at-completions'
import { useSlashCompletions } from './hooks/use-slash-completions'
import { useVoiceConversation } from './hooks/use-voice-conversation'
import { useVoiceRecorder } from './hooks/use-voice-recorder'
import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from './inline-refs'
import {
dragHasAttachments,
droppedFileInlineRef,
type InlineRefInput,
insertInlineRefsIntoEditor
} from './inline-refs'
import { QueuePanel } from './queue-panel'
import {
composerPlainText,
@ -432,7 +438,7 @@ export function ChatBar({
requestMainFocus()
}
const insertInlineRefs = (refs: string[]) => {
const insertInlineRefs = (refs: InlineRefInput[]) => {
const editor = editorRef.current
if (!editor) {
@ -452,6 +458,19 @@ export function ChatBar({
return true
}
// Latest-closure ref so the (once-only) subscription always calls the current
// insertInlineRefs without re-subscribing every render.
const insertInlineRefsRef = useRef(insertInlineRefs)
insertInlineRefsRef.current = insertInlineRefs
useEffect(() => {
return onComposerInsertRefsRequest(({ refs, target }) => {
if (target === 'main') {
insertInlineRefsRef.current(refs)
}
})
}, [])
const selectSkinSlashCommand = (command: string) => {
draftRef.current = command
aui.composer().setText(command)

View file

@ -5,6 +5,49 @@ import type { DroppedFile } from '../hooks/use-composer-actions'
import { composerPlainText, escapeHtml, placeCaretEnd, refChipHtml } from './rich-editor'
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
export type InlineRefInput = string | { kind: string; label?: string; value: string }
/** MIME for an in-app session drag (sidebar row → composer). */
export const HERMES_SESSION_MIME = 'application/x-hermes-session'
export interface SessionDragPayload {
id: string
profile: string
title: string
}
export function writeSessionDrag(transfer: DataTransfer, payload: SessionDragPayload) {
transfer.setData(HERMES_SESSION_MIME, JSON.stringify(payload))
transfer.effectAllowed = 'copy'
}
export function dragHasSession(transfer: DataTransfer | null) {
return Boolean(transfer) && Array.from(transfer!.types || []).includes(HERMES_SESSION_MIME)
}
export function readSessionDrag(transfer: DataTransfer | null): null | SessionDragPayload {
const raw = transfer?.getData(HERMES_SESSION_MIME)
if (!raw) {
return null
}
try {
const parsed = JSON.parse(raw) as Partial<SessionDragPayload>
return parsed.id ? { id: parsed.id, profile: parsed.profile || 'default', title: parsed.title || '' } : null
} catch {
return null
}
}
/** Build a `@session:<profile>/<id>` chip. Value carries the metadata the agent
* needs to resolve the link (session_search); label shows the friendly title. */
export function sessionInlineRef({ id, profile, title }: SessionDragPayload): InlineRefInput {
return { kind: 'session', label: title || `chat ${id.slice(0, 8)}`, value: `${profile || 'default'}/${id}` }
}
export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) {
if (!transfer) {
return false
@ -40,13 +83,17 @@ export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null
return `@${kind}:${formatRefValue(rel)}`
}
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly string[]) {
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
if (!refs.length) {
return null
}
const refsHtml = refs
.map(ref => {
if (typeof ref !== 'string') {
return refChipHtml(ref.kind, ref.value, ref.label)
}
const match = ref.match(/^@([^:]+):(.+)$/)
return match ? refChipHtml(match[1], match[2]) : escapeHtml(ref)

View file

@ -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'
)}
>
<Button
aria-label="Edit queued turn"
className="h-5 w-5 rounded-md"
disabled={Boolean(editingId) && !isEditing}
onClick={() => onEdit(entry)}
size="icon-xs"
title="Edit queued turn"
type="button"
variant="ghost"
>
<Pencil size={11} />
</Button>
<Button
aria-label="Send queued turn now"
className="h-5 w-5 rounded-md"
disabled={busy || isEditing}
onClick={() => onSendNow(entry.id)}
size="icon-xs"
title="Send queued turn now"
type="button"
variant="ghost"
>
<ArrowUp size={11} />
</Button>
<Button
aria-label="Delete queued turn"
className="h-5 w-5 rounded-md"
onClick={() => onDelete(entry.id)}
size="icon-xs"
title="Delete queued turn"
type="button"
variant="ghost"
>
<Trash2 size={11} />
</Button>
<Tip label="Edit queued turn">
<Button
aria-label="Edit queued turn"
className="h-5 w-5 rounded-md"
disabled={Boolean(editingId) && !isEditing}
onClick={() => onEdit(entry)}
size="icon-xs"
type="button"
variant="ghost"
>
<Pencil size={11} />
</Button>
</Tip>
<Tip label="Send queued turn now">
<Button
aria-label="Send queued turn now"
className="h-5 w-5 rounded-md"
disabled={busy || isEditing}
onClick={() => onSendNow(entry.id)}
size="icon-xs"
type="button"
variant="ghost"
>
<ArrowUp size={11} />
</Button>
</Tip>
<Tip label="Delete queued turn">
<Button
aria-label="Delete queued turn"
className="h-5 w-5 rounded-md"
onClick={() => onDelete(entry.id)}
size="icon-xs"
type="button"
variant="ghost"
>
<Trash2 size={11} />
</Button>
</Tip>
</div>
</div>
)

View file

@ -15,7 +15,7 @@ import {
export const RICH_INPUT_SLOT = 'composer-rich-input'
export const REF_RE = /@(file|folder|url|image|tool|line|terminal):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
export const REF_RE = /@(file|folder|url|image|tool|line|terminal|session):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }
@ -52,14 +52,14 @@ export function quoteRefValue(value: string) {
return formatRefValue(value)
}
export function refChipHtml(kind: string, rawValue: string) {
export function refChipHtml(kind: string, rawValue: string, displayLabel?: string) {
const id = unquoteRef(rawValue)
const text = `@${kind}:${quoteRefValue(id)}`
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(refLabel(id))}</span></span>`
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(displayLabel || refLabel(id))}</span></span>`
}
export function refChipElement(kind: string, rawValue: string) {
export function refChipElement(kind: string, rawValue: string, displayLabel?: string) {
const id = unquoteRef(rawValue)
const text = `@${kind}:${quoteRefValue(id)}`
const chip = document.createElement('span')
@ -71,7 +71,7 @@ export function refChipElement(kind: string, rawValue: string) {
chip.dataset.refKind = kind
chip.className = DIRECTIVE_CHIP_CLASS
label.className = 'truncate'
label.textContent = refLabel(id)
label.textContent = displayLabel || refLabel(id)
chip.append(directiveIconElement(kind), label)
return chip

View file

@ -1,50 +1,71 @@
import { type DragEvent as ReactDragEvent, useCallback, useRef, useState } from 'react'
import { dragHasAttachments } from '@/app/chat/composer/inline-refs'
import {
dragHasAttachments,
dragHasSession,
readSessionDrag,
type SessionDragPayload
} from '@/app/chat/composer/inline-refs'
import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from './use-composer-actions'
const hasFiles = (event: ReactDragEvent) => dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)
export type DragKind = 'files' | 'session' | null
const dragKindOf = (event: ReactDragEvent): DragKind => {
if (dragHasSession(event.dataTransfer)) {
return 'session'
}
if (dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return 'files'
}
return null
}
interface FileDropZoneOptions {
/** When false the zone ignores drags entirely. */
enabled?: boolean
onDropFiles: (files: DroppedFile[]) => void
onDropSession?: (session: SessionDragPayload) => void
}
/**
* "Drop files anywhere in this region" affordance. An enter/leave depth counter
* keeps nested children from flickering the active state; `onDropCapture` clears
* it even when a nested target (the composer) handles the drop and stops
* propagation before our bubble-phase `onDrop` would fire.
* "Drop anywhere in this region" affordance for files *and* in-app session
* links. An enter/leave depth counter keeps nested children from flickering the
* active state; `onDropCapture` clears it even when a nested target (the
* composer) handles the drop and stops propagation before our bubble-phase
* `onDrop` would fire.
*
* Spread `dropHandlers` onto the container; render an overlay off `dragActive`.
* Spread `dropHandlers` onto the container; render an overlay off `dragKind`.
*/
export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOptions) {
const [dragActive, setDragActive] = useState(false)
export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }: FileDropZoneOptions) {
const [dragKind, setDragKind] = useState<DragKind>(null)
const depth = useRef(0)
const reset = useCallback(() => {
depth.current = 0
setDragActive(false)
setDragKind(null)
}, [])
const onDragEnter = useCallback(
(event: ReactDragEvent) => {
if (!enabled || !hasFiles(event)) {
const kind = enabled ? dragKindOf(event) : null
if (!kind) {
return
}
event.preventDefault()
depth.current += 1
setDragActive(true)
setDragKind(kind)
},
[enabled]
)
const onDragOver = useCallback(
(event: ReactDragEvent) => {
if (!enabled || !hasFiles(event)) {
if (!enabled || !dragKindOf(event)) {
return
}
@ -62,21 +83,36 @@ export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOpt
const onDrop = useCallback(
(event: ReactDragEvent) => {
if (!enabled || !hasFiles(event)) {
const kind = enabled ? dragKindOf(event) : null
if (!kind) {
return
}
event.preventDefault()
reset()
if (kind === 'session') {
const session = readSessionDrag(event.dataTransfer)
if (session) {
onDropSession?.(session)
}
return
}
const files = extractDroppedFiles(event.dataTransfer)
if (files.length) {
onDropFiles(files)
}
},
[enabled, onDropFiles, reset]
[enabled, onDropFiles, onDropSession, reset]
)
return { dragActive, dropHandlers: { onDragEnter, onDragLeave, onDragOver, onDrop, onDropCapture: reset } }
return {
dragKind,
dropHandlers: { onDragEnter, onDragLeave, onDragOver, onDrop, onDropCapture: reset }
}
}

View file

@ -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'
@ -23,6 +22,7 @@ import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-s
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
import { $pinnedSessionIds } from '@/store/layout'
import { $gatewaySwapTarget } from '@/store/profile'
import {
$activeSessionId,
$awaitingResponse,
@ -46,9 +46,10 @@ import { routeSessionId } from '../routes'
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
import { ChatDropOverlay } from './chat-drop-overlay'
import { ChatSwapOverlay } from './chat-swap-overlay'
import { ChatBar, ChatBarFallback } from './composer'
import { requestComposerInsert } from './composer/focus'
import { droppedFileInlineRef } from './composer/inline-refs'
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
import { droppedFileInlineRef, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
import type { ChatBarState } from './composer/types'
import type { DroppedFile } from './hooks/use-composer-actions'
import { useFileDropZone } from './hooks/use-file-drop-zone'
@ -179,6 +180,7 @@ export function ChatView({
const currentProvider = useStore($currentProvider)
const freshDraftReady = useStore($freshDraftReady)
const gatewayState = useStore($gatewayState)
const gatewaySwapTarget = useStore($gatewaySwapTarget)
const gatewayOpen = gatewayState === 'open'
const introPersonality = useStore($introPersonality)
const introSeed = useStore($introSeed)
@ -307,7 +309,13 @@ export function ChatView({
[currentCwd]
)
const { dragActive, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles })
// Dropping a sidebar session inserts an @session link the agent can resolve
// via session_search (carries the source profile, so cross-profile works).
const onDropSession = useCallback((session: SessionDragPayload) => {
requestComposerInsertRefs([sessionInlineRef(session)], { target: 'main' })
}, [])
const { dragKind, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles, onDropSession })
return (
<div
@ -325,7 +333,6 @@ export function ChatView({
selectedSessionId={selectedSessionId}
/>
<NotificationStack />
<PromptOverlays />
<div
@ -372,7 +379,8 @@ export function ChatView({
</Suspense>
)}
</AssistantRuntimeProvider>
<ChatDropOverlay active={dragActive} />
<ChatDropOverlay kind={dragKind} />
<ChatSwapOverlay profile={gatewaySwapTarget} />
</div>
</div>
)

View file

@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef } from 'react'
import { requestComposerInsert } from '@/app/chat/composer/focus'
import { CopyButton } from '@/components/ui/copy-button'
import { Tip } from '@/components/ui/tooltip'
import { PanelBottom, Send, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify } from '@/store/notifications'
@ -80,17 +81,18 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console
selected && 'border-border/60 bg-accent/40'
)}
>
<button
className={cn(
'mt-0.5 text-left uppercase opacity-70 transition-colors hover:opacity-100',
consoleLevelClass[log.level] ?? consoleLevelClass[0]
)}
onClick={onToggleSelect}
title={selected ? 'Deselect entry' : 'Select entry'}
type="button"
>
{consoleLevelLabel[log.level] || 'log'}
</button>
<Tip label={selected ? 'Deselect entry' : 'Select entry'}>
<button
className={cn(
'mt-0.5 text-left uppercase opacity-70 transition-colors hover:opacity-100',
consoleLevelClass[log.level] ?? consoleLevelClass[0]
)}
onClick={onToggleSelect}
type="button"
>
{consoleLevelLabel[log.level] || 'log'}
</button>
</Tip>
<div className="min-w-0" data-selectable-text="true">
<span className={cn('block wrap-break-word', consoleLevelClass[log.level] ?? consoleLevelClass[0])}>
{log.message}
@ -112,14 +114,15 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console
showLabel={false}
text={copyText}
/>
<button
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onSend}
title="Send this entry to chat"
type="button"
>
<Send className="size-3" />
</button>
<Tip label="Send this entry to chat">
<button
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onSend}
type="button"
>
<Send className="size-3" />
</button>
</Tip>
</span>
</div>
)
@ -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"
>
<Send className="size-3" />
@ -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"
>
<Trash2 className="size-3" />

View file

@ -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 && (
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
<div className="min-w-0 flex-1">
<a
className="pointer-events-auto inline max-w-full truncate text-left text-xs font-medium text-foreground underline-offset-4 decoration-current/20 transition-colors hover:text-primary hover:underline"
href={currentUrl}
rel="noreferrer"
target="_blank"
title={`Open ${currentUrl}`}
>
{previewLabel || 'Preview'}
</a>
<Tip label={`Open ${currentUrl}`}>
<a
className="pointer-events-auto inline max-w-full truncate text-left text-xs font-medium text-foreground underline-offset-4 decoration-current/20 transition-colors hover:text-primary hover:underline"
href={currentUrl}
rel="noreferrer"
target="_blank"
>
{previewLabel || 'Preview'}
</a>
</Tip>
</div>
</div>
)}

View file

@ -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 && (
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
)}
<button
aria-selected={active}
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
title={tab.label}
type="button"
>
<span className="block min-w-0 truncate">{tab.label}</span>
</button>
<Tip label={tab.label}>
<button
aria-selected={active}
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
type="button"
>
<span className="block min-w-0 truncate">{tab.label}</span>
</button>
</Tip>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
@ -135,7 +137,6 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
aria-label={`Close ${tab.label}`}
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
onClick={() => closeRightRailTab(tab.id)}
title={`Close ${tab.label}`}
type="button"
>
<Codicon name="close" size="0.75rem" />
@ -148,7 +149,6 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
aria-label="Close preview pane"
className="mr-1.5 grid size-6 shrink-0 self-center place-items-center rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring group-hover/rail-tabs:opacity-100 [-webkit-app-region:no-drag]"
onClick={closeRightRail}
title="Close preview pane"
type="button"
>
<Codicon name="close" size="0.75rem" />

View file

@ -17,7 +17,7 @@ import {
import { CSS } from '@dnd-kit/utilities'
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@ -34,7 +34,9 @@ import {
SidebarMenuItem
} from '@/components/ui/sidebar'
import { Skeleton } from '@/components/ui/skeleton'
import { Tip } from '@/components/ui/tooltip'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { profileColor } from '@/lib/profile-color'
import { sessionMatchesSearch } from '@/lib/session-search'
import { cn } from '@/lib/utils'
import {
@ -52,8 +54,17 @@ import {
SIDEBAR_SESSIONS_PAGE_SIZE,
unpinSession
} from '@/store/layout'
import {
$newChatProfile,
$profiles,
$profileScope,
ALL_PROFILES,
newSessionInProfile,
normalizeProfileKey
} from '@/store/profile'
import {
$selectedStoredSessionId,
$sessionProfileTotals,
$sessions,
$sessionsLoading,
$sessionsTotal,
@ -65,6 +76,7 @@ import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '..
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import type { SidebarNavItem } from '../../types'
import { ProfileRail } from './profile-switcher'
import { SidebarSessionRow } from './session-row'
import { VirtualSessionList } from './virtual-session-list'
@ -94,6 +106,9 @@ const SIDEBAR_NAV: SidebarNavItem[] = [
]
const WORKSPACE_PAGE = 5
// ALL-profiles view: show only the latest N per profile up front to keep the
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
const PROFILE_INITIAL_PAGE = 5
const WS_ID_PREFIX = 'workspace:'
const wsId = (id: string) => `${WS_ID_PREFIX}${id}`
@ -201,6 +216,7 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
currentView: AppView
onNavigate: (item: SidebarNavItem) => void
onLoadMoreSessions: () => void
onLoadMoreProfileSessions?: (profile: string) => Promise<void> | void
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onArchiveSession: (sessionId: string) => void
@ -211,6 +227,7 @@ export function ChatSidebar({
currentView,
onNavigate,
onLoadMoreSessions,
onLoadMoreProfileSessions,
onResumeSession,
onDeleteSession,
onArchiveSession,
@ -226,12 +243,23 @@ export function ChatSidebar({
const sessions = useStore($sessions)
const sessionsLoading = useStore($sessionsLoading)
const sessionsTotal = useStore($sessionsTotal)
const sessionProfileTotals = useStore($sessionProfileTotals)
const workingSessionIds = useStore($workingSessionIds)
const profiles = useStore($profiles)
const profileScope = useStore($profileScope)
// Only surface the profile switcher when more than one profile exists, so
// single-profile users see the unchanged sidebar.
const multiProfile = profiles.length > 1
// Gate ALL-profiles grouping on multiProfile too: if a user drops back to one
// profile while scope is still ALL (persisted), the rail is hidden and they'd
// otherwise be stuck in the grouped view with no way out.
const showAllProfiles = multiProfile && profileScope === ALL_PROFILES
const [agentOrderIds, setAgentOrderIds] = useState<string[]>([])
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})
const trimmedQuery = searchQuery.trim()
// Flash the ⌘N hint full-opacity (no transition) for the press, so hitting
@ -260,7 +288,19 @@ export function ChatSidebar({
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
const sortedSessions = useMemo(() => [...sessions].sort((a, b) => sessionTime(b) - sessionTime(a)), [sessions])
// Profile scope = the "workspace switcher" context. Concrete scope shows only
// that profile's sessions (clean rows, no per-row tags); ALL fans every
// profile in, grouped by profile below. Single-profile users land here with
// scope === their only profile, so nothing is filtered out.
const visibleSessions = useMemo(
() => (showAllProfiles ? sessions : sessions.filter(s => normalizeProfileKey(s.profile) === profileScope)),
[sessions, showAllProfiles, profileScope]
)
const sortedSessions = useMemo(
() => [...visibleSessions].sort((a, b) => sessionTime(b) - sessionTime(a)),
[visibleSessions]
)
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])
@ -269,7 +309,7 @@ export function ChatSidebar({
const sessionByAnyId = useMemo(() => {
const map = new Map<string, SessionInfo>()
for (const s of sessions) {
for (const s of visibleSessions) {
map.set(s.id, s)
if (s._lineage_root_id && !map.has(s._lineage_root_id)) {
@ -278,7 +318,7 @@ export function ChatSidebar({
}
return map
}, [sessions])
}, [visibleSessions])
const pinnedSessions = useMemo(() => {
const seen = new Set<string>()
@ -366,11 +406,87 @@ export function ChatSidebar({
[agentSessions, workspaceOrderIds]
)
const loadMoreForProfileGroup = useCallback(
(profile: string) => {
if (!onLoadMoreProfileSessions) {
return
}
setProfileLoadMorePending(prev => ({ ...prev, [profile]: true }))
void Promise.resolve(onLoadMoreProfileSessions(profile))
.catch(() => undefined)
.finally(() =>
setProfileLoadMorePending(({ [profile]: _done, ...rest }) => rest)
)
},
[onLoadMoreProfileSessions]
)
// ALL-profiles view: one collapsible group per profile, color on the header
// (not on every row). Default profile floats to the top, the rest alpha.
const profileGroups = useMemo<SidebarSessionGroup[] | undefined>(() => {
if (!showAllProfiles) {
return undefined
}
const groups = new Map<string, SidebarSessionGroup>()
for (const session of agentSessions) {
const key = normalizeProfileKey(session.profile)
const group = groups.get(key) ?? {
color: profileColor(key),
id: key,
label: key,
mode: 'profile',
path: null,
sessions: []
}
group.sessions.push(session)
groups.set(key, group)
}
return [...groups.values()]
.map(group => ({
...group,
loadingMore: Boolean(profileLoadMorePending[group.id]),
onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined,
totalCount: Math.max(group.sessions.length, sessionProfileTotals[group.id] ?? 0)
}))
// default (root) first, then the rest alphabetically.
.sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label)))
}, [
showAllProfiles,
agentSessions,
loadMoreForProfileGroup,
onLoadMoreProfileSessions,
profileLoadMorePending,
sessionProfileTotals
])
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
const knownSessionTotal = Math.max(sessionsTotal, sortedSessions.length)
const hasMoreSessions = knownSessionTotal > sortedSessions.length
const remainingSessionCount = Math.max(0, knownSessionTotal - sortedSessions.length)
// Pagination is scope-aware. In "All profiles" mode it tracks the global
// unified set. When scoped to one profile it must compare that profile's own
// loaded rows against that profile's total — otherwise a huge default profile
// keeps "Load more" stuck on while you browse a small one (the aggregator's
// total sums every profile). Per-profile totals come from the aggregator
// (children excluded); fall back to the global total / loaded count.
const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length
const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]
const knownSessionTotal = Math.max(
showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
loadedSessionCount
)
const hasMoreSessions = knownSessionTotal > loadedSessionCount
const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) {
@ -449,6 +565,8 @@ export function ChatSidebar({
(item.id === 'messaging' && currentView === 'messaging') ||
(item.id === 'artifacts' && currentView === 'artifacts')
const isNewSession = item.id === 'new-session'
return (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
@ -460,7 +578,17 @@ export function ChatSidebar({
!isInteractive &&
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
)}
onClick={() => onNavigate(item)}
onClick={() => {
// A plain new session lands in whatever profile the live
// gateway is on (= the active switcher context). null →
// no swap. The switcher header is the single place to
// change which profile that is.
if (isNewSession) {
$newChatProfile.set(null)
}
onNavigate(item)
}}
tooltip={item.label}
type="button"
>
@ -468,7 +596,7 @@ export function ChatSidebar({
{sidebarOpen && (
<>
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
{item.id === 'new-session' && (
{isNewSession && (
<KbdGroup
className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
keys={[...NEW_SESSION_KBD]}
@ -544,11 +672,19 @@ export function ChatSidebar({
{sidebarOpen && showSessionSections && !trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
contentClassName={cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75',
// Separate profile sections clearly in the ALL view; rows inside
// each group keep their own tight gap-px rhythm.
showAllProfiles ? 'gap-3' : 'gap-px'
)}
dndSensors={dndSensors}
emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />}
footer={
!agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
// Hide "load more" only when workspace-grouped (those groups page
// themselves). ALL-profiles now pages per-profile from each profile
// header; the global footer only applies to non-ALL views.
!showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
<SidebarLoadMoreRow
loading={sessionsLoading}
onClick={onLoadMoreSessions}
@ -557,37 +693,43 @@ export function ChatSidebar({
) : null
}
forceEmptyState={showSessionSkeletons}
groups={agentsGrouped ? agentGroups : undefined}
groups={showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined}
headerAction={
// Grouping operates on unpinned recents; if everything is
// pinned the toggle does nothing visible, so hide it to avoid
// a phantom click target.
agentSessions.length > 0 ? (
<Button
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
className={cn(
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
) : null
// Always reserve the icon-xs (size-6) slot so the header keeps the
// same height whether or not the toggle renders — otherwise the
// "Sessions" label jumps when switching to the ALL-profiles view.
// Grouping operates on unpinned recents; if everything is pinned
// the toggle does nothing, and it's irrelevant in the ALL-profiles
// view (always grouped by profile), so hide the button (not the slot).
<div className="grid size-6 shrink-0 place-items-center">
{!showAllProfiles && agentSessions.length > 0 ? (
<Tip label={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}>
<Button
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
className={cn(
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
</Tip>
) : null}
</div>
}
label="Sessions"
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
labelMeta={recentsMeta}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onNewSessionInWorkspace={onNewSessionInWorkspace}
onReorder={handleAgentDragEnd}
onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
onReorder={showAllProfiles ? undefined : handleAgentDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
onTogglePin={pinSession}
@ -595,10 +737,18 @@ export function ChatSidebar({
pinned={false}
rootClassName="min-h-0 flex-1 p-0"
sessions={agentSessions}
sortable={agentSessions.length > 1}
sortable={!showAllProfiles && agentSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{sidebarOpen && !showSessionSections && <div className="min-h-0 flex-1" />}
{sidebarOpen && (
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
<ProfileRail />
</div>
)}
</SidebarContent>
</Sidebar>
)
@ -667,6 +817,12 @@ interface SidebarSessionGroup {
label: string
path: null | string
sessions: SessionInfo[]
// Profile color for the ALL-profiles view; absent for workspace groups.
color?: null | string
loadingMore?: boolean
mode?: 'profile' | 'workspace'
onLoadMore?: () => void
totalCount?: number
}
interface SidebarSessionsSectionProps {
@ -850,38 +1006,65 @@ function SidebarWorkspaceGroup({
ref,
...rest
}: SidebarWorkspaceGroupProps) {
const isProfileGroup = group.mode === 'profile'
const pageStep = isProfileGroup ? PROFILE_INITIAL_PAGE : WORKSPACE_PAGE
const [open, setOpen] = useState(true)
const [visibleCount, setVisibleCount] = useState(WORKSPACE_PAGE)
const [visibleCount, setVisibleCount] = useState(pageStep)
const loadedCount = group.sessions.length
// Profile groups know their on-disk total (children excluded); workspace
// groups only ever page within what's already loaded.
const totalCount = isProfileGroup ? Math.max(group.totalCount ?? loadedCount, loadedCount) : loadedCount
const visibleSessions = group.sessions.slice(0, visibleCount)
const hiddenCount = Math.max(0, group.sessions.length - visibleSessions.length)
const nextCount = Math.min(WORKSPACE_PAGE, hiddenCount)
const hiddenCount = Math.max(0, totalCount - visibleSessions.length)
const nextCount = Math.min(pageStep, hiddenCount)
// Reveal already-loaded rows first; only hit the backend when the next page
// crosses what's been fetched for this profile.
const handleProfileLoadMore = () => {
const target = visibleCount + pageStep
setVisibleCount(target)
if (target > loadedCount && loadedCount < totalCount) {
group.onLoadMore?.()
}
}
return (
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
<div className="group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
<button
className="flex min-w-0 items-center gap-1 bg-transparent text-left hover:text-(--ui-text-secondary)"
className="flex min-w-0 items-center gap-1.5 bg-transparent text-left hover:text-(--ui-text-secondary)"
onClick={() => setOpen(value => !value)}
title={group.path ?? undefined}
type="button"
>
{group.color ? (
<span aria-hidden="true" className="size-2 shrink-0 rounded-full" style={{ backgroundColor: group.color }} />
) : null}
<span className="truncate">{group.label}</span>
<SidebarCount>{group.sessions.length}</SidebarCount>
<SidebarCount>
{isProfileGroup ? countLabel(visibleSessions.length, totalCount) : group.sessions.length}
</SidebarCount>
<DisclosureCaret
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
</button>
{onNewSession && (
<button
aria-label={`New session in ${group.label}`}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
onClick={() => onNewSession(group.path)}
title={`New session in ${group.label}`}
type="button"
>
<Codicon name="add" size="0.75rem" />
</button>
{(onNewSession || isProfileGroup) && (
<Tip label={`New session in ${group.label}`}>
<button
aria-label={`New session in ${group.label}`}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
// Profile groups start a fresh session in that profile but keep the
// all-profiles browse view (newSessionInProfile leaves the scope
// alone); workspace groups seed the new session's cwd from the path.
onClick={() => (isProfileGroup ? newSessionInProfile(group.id) : onNewSession?.(group.path))}
type="button"
>
<Codicon name="add" size="0.75rem" />
</button>
</Tip>
)}
{reorderable && (
<span
@ -904,17 +1087,21 @@ function SidebarWorkspaceGroup({
{open && (
<>
{renderRows(visibleSessions)}
{hiddenCount > 0 && (
<button
aria-label={`Show ${nextCount} more in ${group.label}`}
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
title={`Show ${nextCount} more in ${group.label}`}
type="button"
>
<Codicon name="ellipsis" size="0.75rem" />
</button>
)}
{hiddenCount > 0 &&
(isProfileGroup ? (
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
) : (
<Tip label={`Show ${nextCount} more in ${group.label}`}>
<button
aria-label={`Show ${nextCount} more in ${group.label}`}
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
type="button"
>
<Codicon name="ellipsis" size="0.75rem" />
</button>
</Tip>
))}
</>
)}
</div>
@ -961,12 +1148,16 @@ function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps)
return (
<button
className="flex min-h-5 items-center gap-1 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
disabled={loading}
onClick={onClick}
type="button"
>
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
{/* Seat the icon in the same w-3.5 column session rows use for their dot
so the chevron + label line up with the rows above. */}
<span className="grid w-3.5 shrink-0 place-items-center">
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
</span>
<span>{label}</span>
</button>
)

View file

@ -0,0 +1,491 @@
import {
closestCenter,
DndContext,
type DragEndEvent,
type DragOverEvent,
type DragStartEvent,
KeyboardSensor,
type Modifier,
PointerSensor,
useSensor,
useSensors
} from '@dnd-kit/core'
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
useSortable
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { triggerHaptic } from '@/lib/haptics'
import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
import {
$activeGatewayProfile,
$profileColors,
$profileOrder,
$profiles,
$profileScope,
ALL_PROFILES,
normalizeProfileKey,
refreshActiveProfile,
selectProfile,
setProfileColor,
setProfileOrder,
setShowAllProfiles,
sortByProfileOrder
} from '@/store/profile'
import type { ProfileInfo } from '@/types/hermes'
import { CreateProfileDialog } from '../../profiles/create-profile-dialog'
import { DeleteProfileDialog } from '../../profiles/delete-profile-dialog'
import { RenameProfileDialog } from '../../profiles/rename-profile-dialog'
import { PROFILES_ROUTE } from '../../routes'
const RAIL_GAP = 4 // px — matches gap-1 between squares.
// easeOutBack — a little overshoot so squares spring into their new slot rather
// than sliding in flat. Neighbors reflow on RAIL_TRANSITION; the dragged square
// glides between snapped cells on the snappier DRAG_TRANSITION.
const SPRING = 'cubic-bezier(0.34, 1.56, 0.64, 1)'
const RAIL_TRANSITION = { duration: 300, easing: SPRING }
const DRAG_TRANSITION = `transform 200ms ${SPRING}`
// The rail is a single horizontal strip of fixed cells. Pin drags to the x-axis
// (no cross-axis scrollbar), snap to whole cells so a square steps slot-to-slot
// instead of gliding, and clamp to the occupied strip so it can't float past the
// last profile onto the "+".
const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, transform }) => {
if (!draggingNodeRect || !containerNodeRect) {
return { ...transform, y: 0 }
}
const pitch = draggingNodeRect.width + RAIL_GAP
const minX = containerNodeRect.left - draggingNodeRect.left
const maxX = containerNodeRect.right - draggingNodeRect.right
const snapped = Math.round(transform.x / pitch) * pitch
return { ...transform, x: Math.min(maxX, Math.max(minX, snapped)), y: 0 }
}
// Arc-Spaces-style profile rail at the sidebar foot: a default↔all toggle pinned
// left, the colored named profiles scrolling between, and Manage pinned right.
// The active profile pops in its own color — the "where am I" cue. Single-
// profile users see only the "+" (create their first profile); everything else
// appears once a second profile exists.
export function ProfileRail() {
const profiles = useStore($profiles)
const scope = useStore($profileScope)
const gatewayProfile = useStore($activeGatewayProfile)
const order = useStore($profileOrder)
const colors = useStore($profileColors)
const navigate = useNavigate()
const [createOpen, setCreateOpen] = useState(false)
const [pendingRename, setPendingRename] = useState<null | ProfileInfo>(null)
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
const scrollRef = useRef<HTMLDivElement>(null)
// A plain mouse wheel only emits deltaY; map it to horizontal scroll so the
// rail is navigable without a trackpad. Trackpad x-scroll (deltaX) passes
// through. Native + non-passive so we can preventDefault and not bleed the
// gesture into the sessions list above.
useEffect(() => {
const el = scrollRef.current
if (!el) {
return
}
const onWheel = (event: WheelEvent) => {
if (el.scrollWidth <= el.clientWidth || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) {
return
}
el.scrollLeft += event.deltaY
event.preventDefault()
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => el.removeEventListener('wheel', onWheel)
}, [])
const isAll = scope === ALL_PROFILES
const activeKey = normalizeProfileKey(gatewayProfile)
const defaultProfile = profiles.find(profile => profile.is_default)
const onDefault = !isAll && activeKey === 'default'
const named = sortByProfileOrder(profiles.filter(profile => !profile.is_default), order)
const multiProfile = profiles.length > 1
// distance constraint: a small drag reorders, a tap still selects the profile.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
// Tick a haptic each time the drag crosses into a new cell, and a satisfying
// confirm on a committed reorder.
const lastOverRef = useRef<string | null>(null)
const handleDragStart = ({ active }: DragStartEvent) => {
lastOverRef.current = String(active.id)
}
const handleDragOver = ({ over }: DragOverEvent) => {
const id = over ? String(over.id) : null
if (id && id !== lastOverRef.current) {
lastOverRef.current = id
triggerHaptic('selection')
}
}
const handleDragEnd = ({ active, over }: DragEndEvent) => {
lastOverRef.current = null
if (!over || active.id === over.id) {
return
}
const ids = named.map(profile => profile.name)
const from = ids.indexOf(String(active.id))
const to = ids.indexOf(String(over.id))
if (from >= 0 && to >= 0) {
setProfileOrder(arrayMove(ids, from, to))
triggerHaptic('success')
}
}
// Re-pull the running profile + list on mount so a profile created elsewhere
// shows up; cheap and best-effort.
useEffect(() => {
void refreshActiveProfile()
}, [])
return (
<div aria-label="Profiles" className="flex items-center gap-0.5" role="tablist">
{/* One button toggles default all: home face when scoped to a profile,
layers face when showing everything. Pinned left like Manage is right.
Hidden until a second profile exists. */}
{multiProfile &&
(defaultProfile ? (
// On default → toggle to all. Anywhere else (all view or a named
// profile) → return to default. So leaving a profile never lands on all.
<ProfilePill
active={isAll || onDefault}
glyph={isAll ? 'layers' : 'home'}
label={onDefault ? 'Show all profiles' : `Switch to ${defaultProfile.name}`}
onSelect={() => (onDefault ? setShowAllProfiles(true) : selectProfile(defaultProfile.name))}
/>
) : (
<ProfilePill active={isAll} glyph="layers" label="All profiles" onSelect={() => setShowAllProfiles(true)} />
))}
{/* Single-profile: the active default's home icon next to the create +. */}
{!multiProfile && defaultProfile && (
<ProfilePill active glyph="home" label={defaultProfile.name} onSelect={() => selectProfile(defaultProfile.name)} />
)}
<div
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
ref={scrollRef}
>
{multiProfile && (
<DndContext
collisionDetection={closestCenter}
modifiers={[stepThroughCells]}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragStart={handleDragStart}
sensors={sensors}
>
<SortableContext items={named.map(profile => profile.name)} strategy={horizontalListSortingStrategy}>
{/* relative the strip is the dragged square's offsetParent, so the
clamp modifier bounds drags to the occupied cells (not the +). */}
<div className="relative flex items-center gap-1">
{named.map(profile => (
<ProfileSquare
active={!isAll && normalizeProfileKey(profile.name) === activeKey}
color={resolveProfileColor(profile.name, colors)}
key={profile.name}
label={profile.name}
onDelete={() => setPendingDelete(profile)}
onRecolor={color => setProfileColor(profile.name, color)}
onRename={() => setPendingRename(profile)}
onSelect={() => selectProfile(profile.name)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
<Tip label="New profile">
<button
aria-label="New profile"
className="grid size-5 shrink-0 place-items-center rounded-[3px] text-(--ui-text-tertiary) opacity-55 transition hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100"
onClick={() => setCreateOpen(true)}
type="button"
>
<Codicon name="add" size="0.75rem" />
</button>
</Tip>
</div>
{multiProfile && (
<ProfilePill active={false} glyph="ellipsis" label="Manage profiles…" onSelect={() => navigate(PROFILES_ROUTE)} />
)}
{/* Land in the new profile on a fresh chat (selectProfile triggers the
new-session reset), not stuck on the session you were just in. */}
<CreateProfileDialog
onClose={() => setCreateOpen(false)}
onCreated={async name => {
await refreshActiveProfile()
selectProfile(name)
}}
open={createOpen}
/>
<RenameProfileDialog
currentName={pendingRename?.name ?? ''}
onClose={() => setPendingRename(null)}
onRenamed={refreshActiveProfile}
open={pendingRename !== null}
/>
<DeleteProfileDialog
onClose={() => setPendingDelete(null)}
onDeleted={refreshActiveProfile}
open={pendingDelete !== null}
profile={pendingDelete}
/>
</div>
)
}
interface ProfilePillProps {
active: boolean
// home / All / Manage are glyph action buttons (navigation, not identity).
glyph: string
label: string
onSelect: () => void
}
function ProfilePill({ active, glyph, label, onSelect }: ProfilePillProps) {
return (
<Tip label={label}>
<Button
aria-label={label}
aria-pressed={active}
className={cn(
'bg-transparent text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
active && 'bg-(--ui-control-active-background) text-foreground'
)}
onClick={onSelect}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name={glyph} size="0.875rem" />
</Button>
</Tip>
)
}
interface ProfileSquareProps {
active: boolean
color: null | string
label: string
onSelect: () => void
onRecolor: (color: null | string) => void
onRename: () => void
onDelete: () => void
}
// Hold this long without moving (a drag would have started first) to open the
// color picker — the "hard press" gesture, distinct from tap-to-select.
const LONG_PRESS_MS = 450
// A profile *is* its colored square — no icon-button chrome. Soft profile-tint
// fill + the initial in the full color; the active one pops to full opacity with
// a color ring. These pack tightly so the rail reads as a strip of profiles,
// drag-sort to reorder (a tap below the drag threshold still selects), and
// right-click to rename/delete. The button carries both the tooltip and
// context-menu triggers via nested asChild Slots, so a single element keeps the
// dnd listeners, hover tip, and right-click menu.
function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, onSelect }: ProfileSquareProps) {
const hue = color ?? 'var(--ui-text-quaternary)'
const [pickerOpen, setPickerOpen] = useState(false)
const pressTimer = useRef<null | number>(null)
const suppressClick = useRef(false)
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({
id: label,
transition: RAIL_TRANSITION
})
const clearPress = () => {
if (pressTimer.current != null) {
clearTimeout(pressTimer.current)
pressTimer.current = null
}
}
// A real drag (movement past the dnd threshold) cancels the pending hold, so a
// reorder never doubles as a color pick. Also tidy up on unmount.
useEffect(() => {
if (isDragging) {
clearPress()
}
}, [isDragging])
useEffect(() => clearPress, [])
const base = CSS.Transform.toString(transform)
const ring = active ? `inset 0 0 0 1.5px ${hue}` : ''
const lift = isDragging ? '0 6px 16px -4px rgb(0 0 0 / 0.4)' : ''
const pickColor = (next: null | string) => {
onRecolor(next)
setPickerOpen(false)
triggerHaptic('selection')
}
return (
<Popover onOpenChange={setPickerOpen} open={pickerOpen}>
<ContextMenu>
<TooltipProvider delayDuration={0}>
<Tooltip>
<PopoverAnchor asChild>
<ContextMenuTrigger asChild>
<TooltipTrigger asChild>
<button
className={cn(
'grid size-5 shrink-0 cursor-grab touch-none select-none place-items-center rounded-[3px] text-[0.5625rem] font-semibold uppercase leading-none transition-opacity hover:opacity-100',
active ? 'opacity-100' : 'opacity-55',
isDragging && 'z-10 cursor-grabbing opacity-100'
)}
ref={setNodeRef}
style={{
backgroundColor: profileColorSoft(hue, active ? 30 : 22),
boxShadow: [ring, lift].filter(Boolean).join(', ') || undefined,
color: color ?? undefined,
// Glide the dragged square between snapped cells with a little
// overshoot (no scale — the overflow-x strip would clip it).
transform: base,
transition: isDragging ? DRAG_TRANSITION : transition
}}
type="button"
{...attributes}
{...listeners}
aria-label={label}
aria-pressed={active}
// Hold-to-recolor rides alongside the dnd pointer listener (call
// it first so drag tracking still arms), then a timer opens the
// picker and flags the trailing click so it doesn't also select.
onClick={() => {
if (suppressClick.current) {
suppressClick.current = false
return
}
onSelect()
}}
onPointerCancel={clearPress}
onPointerDown={event => {
listeners?.onPointerDown?.(event)
if (event.button !== 0) {
return
}
suppressClick.current = false
clearPress()
pressTimer.current = window.setTimeout(() => {
suppressClick.current = true
triggerHaptic('success')
setPickerOpen(true)
}, LONG_PRESS_MS)
}}
onPointerLeave={clearPress}
onPointerUp={clearPress}
>
{label.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
</button>
</TooltipTrigger>
</ContextMenuTrigger>
</PopoverAnchor>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* The rail sits at the very bottom, so pad off the chrome (esp. the
statusbar) Radix then flips the menu up instead of squishing it. */}
<ContextMenuContent
aria-label={`Actions for ${label}`}
className="w-40"
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
>
<ContextMenuItem onSelect={() => setPickerOpen(true)}>
<Codicon name="symbol-color" size="0.875rem" />
<span>Color</span>
</ContextMenuItem>
<ContextMenuItem onSelect={onRename}>
<Codicon name="edit" size="0.875rem" />
<span>Rename</span>
</ContextMenuItem>
<ContextMenuItem className="text-destructive focus:text-destructive" onSelect={onDelete} variant="destructive">
<Codicon name="trash" size="0.875rem" />
<span>Delete</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<PopoverContent
aria-label={`Color for ${label}`}
className="w-auto p-2"
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
side="top"
>
<div className="grid grid-cols-6 gap-1.5">
{PROFILE_SWATCHES.map(swatch => (
<button
aria-label={`Set color ${swatch}`}
className="size-5 rounded-full transition-transform hover:scale-110"
key={swatch}
onClick={() => pickColor(swatch)}
style={{
backgroundColor: swatch,
boxShadow: swatch === color ? '0 0 0 2px var(--ui-bg-elevated), 0 0 0 3.5px currentColor' : undefined,
color: swatch
}}
type="button"
/>
))}
</div>
<button
className="mt-2 flex w-full items-center justify-center gap-1.5 rounded-md py-1 text-xs text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={() => pickColor(null)}
type="button"
>
<Codicon name="sync" size="0.75rem" />
Auto
</button>
</PopoverContent>
</Popover>
)
}

View file

@ -25,6 +25,7 @@ interface SessionActions {
sessionId: string
title: string
pinned?: boolean
profile?: string
onPin?: () => void
onArchive?: () => void
onDelete?: () => void
@ -41,7 +42,7 @@ interface ItemSpec {
variant?: 'destructive'
}
function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive, onDelete }: SessionActions) {
function useSessionActions({ sessionId, title, pinned = false, profile, onPin, onArchive, onDelete }: SessionActions) {
const [renameOpen, setRenameOpen] = useState(false)
const items: ItemSpec[] = [
@ -113,7 +114,13 @@ function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive,
))
const renameDialog = (
<RenameSessionDialog currentTitle={title} onOpenChange={setRenameOpen} open={renameOpen} sessionId={sessionId} />
<RenameSessionDialog
currentTitle={title}
onOpenChange={setRenameOpen}
open={renameOpen}
profile={profile}
sessionId={sessionId}
/>
)
return { renameDialog, renderItems }
@ -170,9 +177,10 @@ interface RenameSessionDialogProps {
onOpenChange: (open: boolean) => void
sessionId: string
currentTitle: string
profile?: string
}
function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle }: RenameSessionDialogProps) {
function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, profile }: RenameSessionDialogProps) {
const [value, setValue] = useState(currentTitle)
const [submitting, setSubmitting] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
@ -200,7 +208,7 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle }: Re
setSubmitting(true)
try {
const result = await renameSession(sessionId, next)
const result = await renameSession(sessionId, next, profile)
const finalTitle = result.title || next || ''
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
notify({ durationMs: 2_000, kind: 'success', message: 'Renamed' })

View file

@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import type { SessionInfo } from '@/hermes'
@ -74,6 +75,7 @@ export function SidebarSessionRow({
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
profile={session.profile}
sessionId={session.id}
title={title}
>
@ -86,6 +88,22 @@ export function SidebarSessionRow({
className
)}
data-working={isWorking ? 'true' : undefined}
draggable
onDragStart={event => {
// Reorder drags belong to dnd-kit (the grab handle) — cancel the
// native drag so the two DnD systems don't fight.
if ((event.target as HTMLElement).closest('[data-reorder-handle]')) {
event.preventDefault()
return
}
writeSessionDrag(event.dataTransfer, {
id: session.id,
profile: session.profile || 'default',
title
})
}}
ref={ref}
style={style}
{...rest}
@ -123,12 +141,15 @@ export function SidebarSessionRow({
className={cn(
// Scope the dot↔grabber swap to a local group so the grabber
// only reveals when hovering/focusing the handle itself, not
// anywhere on the row.
'group/handle relative -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing',
// anywhere on the row. Width MUST match the non-reorderable dot
// column (w-3.5) so rows don't shift horizontally when reorder is
// toggled (e.g. scoped → ALL-profiles view).
'group/handle relative -my-0.5 grid w-3.5 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing',
// The quest-glow box-shadow extends past the dot; let it bleed
// out instead of being clipped by this handle's overflow-hidden.
needsInput && 'overflow-visible'
)}
data-reorder-handle
onClick={event => event.stopPropagation()}
>
<SidebarRowDot
@ -152,10 +173,10 @@ export function SidebarSessionRow({
needsInput ? 'overflow-visible' : 'overflow-hidden'
)}
>
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
)}
<span className="truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</span>
</button>
@ -170,6 +191,7 @@ export function SidebarSessionRow({
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
profile={session.profile}
sessionId={session.id}
title={title}
>

View file

@ -6,6 +6,7 @@ import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { SearchField } from '@/components/ui/search-field'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { Tip } from '@/components/ui/tooltip'
import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway, updateHermes } from '@/hermes'
import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
@ -93,17 +94,18 @@ function RowIconButton({
title: string
}) {
return (
<Button
aria-label={title}
className={cn('text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground', className)}
onClick={onClick}
size="icon-xs"
title={title}
type="button"
variant="ghost"
>
{children}
</Button>
<Tip label={title}>
<Button
aria-label={title}
className={cn('text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground', className)}
onClick={onClick}
size="icon-xs"
type="button"
variant="ghost"
>
{children}
</Button>
</Tip>
)
}
@ -574,20 +576,21 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp
const outputH = Math.round(((entry.output_tokens || 0) / maxTokens) * 96)
return (
<div
className="group relative flex h-24 min-w-0 flex-1 flex-col justify-end"
<Tip
key={entry.day}
title={`${entry.day} · in ${formatTokens(entry.input_tokens)} · out ${formatTokens(entry.output_tokens)}`}
label={`${entry.day} · in ${formatTokens(entry.input_tokens)} · out ${formatTokens(entry.output_tokens)}`}
>
<div
className="w-full rounded-t-[1px] bg-[color:var(--dt-primary)]/50"
style={{ height: Math.max(inputH, entry.input_tokens > 0 ? 1 : 0) }}
/>
<div
className="w-full bg-emerald-500/60"
style={{ height: Math.max(outputH, entry.output_tokens > 0 ? 1 : 0) }}
/>
</div>
<div className="group relative flex h-24 min-w-0 flex-1 flex-col justify-end">
<div
className="w-full rounded-t-[1px] bg-[color:var(--dt-primary)]/50"
style={{ height: Math.max(inputH, entry.input_tokens > 0 ? 1 : 0) }}
/>
<div
className="w-full bg-emerald-500/60"
style={{ height: Math.max(outputH, entry.output_tokens > 0 ? 1 : 0) }}
/>
</div>
</Tip>
)
})}
</div>

View file

@ -4,6 +4,7 @@ import { PageLoader } from '@/components/page-loader'
import { Badge, type BadgeProps } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import {
Dialog,
DialogContent,
@ -311,7 +312,6 @@ export function CronView({ onClose }: CronViewProps) {
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
const [deleting, setDeleting] = useState(false)
const refresh = useCallback(async () => {
try {
@ -372,25 +372,6 @@ export function CronView({ onClose }: CronViewProps) {
}
}
async function handleConfirmDelete() {
if (!pendingDelete) {
return
}
setDeleting(true)
try {
await deleteCronJob(pendingDelete.id)
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
notify({ kind: 'success', title: 'Cron deleted', message: truncate(jobTitle(pendingDelete), 60) })
setPendingDelete(null)
} catch (err) {
notifyError(err, 'Failed to delete cron job')
} finally {
setDeleting(false)
}
}
async function handleEditorSave(values: EditorValues) {
if (editor.mode === 'create') {
const created = await createCronJob({
@ -480,30 +461,33 @@ export function CronView({ onClose }: CronViewProps) {
</div>
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete cron job?</DialogTitle>
<DialogDescription>
{pendingDelete ? (
<>
This will remove{' '}
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span>{' '}
permanently. It will stop firing immediately.
</>
) : null}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
Cancel
</Button>
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
{deleting ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
busyLabel="Deleting…"
confirmLabel="Delete"
description={
pendingDelete ? (
<>
This will remove{' '}
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span> permanently.
It will stop firing immediately.
</>
) : null
}
destructive
doneLabel="Deleted"
onClose={() => setPendingDelete(null)}
onConfirm={async () => {
if (!pendingDelete) {
return
}
await deleteCronJob(pendingDelete.id)
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
notify({ kind: 'success', message: truncate(jobTitle(pendingDelete), 60), title: 'Cron deleted' })
}}
open={pendingDelete !== null}
title="Delete cron job?"
/>
</OverlayView>
)
}

View file

@ -11,7 +11,7 @@ import { Pane, PaneMain } from '@/components/pane-shell'
import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getSessionMessages, listSessions } from '../hermes'
import { getSessionMessages, listAllProfileSessions, type SessionInfo } from '../hermes'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import { toggleCommandPalette } from '../store/command-palette'
import {
@ -25,9 +25,11 @@ import {
pinSession,
SIDEBAR_DEFAULT_WIDTH,
SIDEBAR_MAX_WIDTH,
SIDEBAR_SESSIONS_PAGE_SIZE,
unpinSession
} from '../store/layout'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import { $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
import {
$activeSessionId,
$currentCwd,
@ -45,6 +47,7 @@ import {
setCurrentModel,
setCurrentProvider,
setMessages,
setSessionProfileTotals,
setSessions,
setSessionsLoading,
setSessionsTotal
@ -98,6 +101,26 @@ const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).P
const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView }))
const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView }))
// Rows a session refresh must preserve even if the aggregator omits them:
// in-flight first turns (message_count 0), pinned rows aged off the page, and
// the actively-viewed chat (its "working" flag clears a beat before the
// aggregator sees the persisted row). Pass `scope` to only keep the active row
// when it belongs to the profile being paged.
function sessionsToKeep(scope?: string): Set<string> {
const keep = new Set<string>([...$workingSessionIds.get(), ...$pinnedSessionIds.get()])
const active = $selectedStoredSessionId.get()
if (active) {
const session = scope ? $sessions.get().find(s => s.id === active) : null
if (!scope || !session || normalizeProfileKey(session.profile) === scope) {
keep.add(active)
}
}
return keep
}
export function DesktopController() {
const queryClient = useQueryClient()
const location = useLocation()
@ -201,9 +224,9 @@ export function DesktopController() {
}
}, [])
// Global chrome shortcuts (plain Cmd/Ctrl, no alt/shift): Cmd+K → command
// palette (the composer's "drain next queued" moved to Cmd+Shift+K), Cmd+. →
// command center (sessions / system / usage).
// Global chrome shortcuts (plain Cmd/Ctrl, no alt/shift): Cmd+K / Cmd+P →
// command palette (the composer's "drain next queued" moved to Cmd+Shift+K),
// Cmd+. → command center (sessions / system / usage).
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) {
@ -212,7 +235,7 @@ export function DesktopController() {
const key = event.key.toLowerCase()
if (key === 'k') {
if (key === 'k' || key === 'p') {
event.preventDefault()
toggleCommandPalette()
} else if (key === '.') {
@ -236,17 +259,15 @@ export function DesktopController() {
// Require at least one message so abandoned/empty "Untitled" drafts (one
// was created per TUI/desktop launch before the lazy-create fix) don't
// clutter the sidebar.
const result = await listSessions(limit, 1)
// Unified cross-profile list (served read-only off each profile's
// state.db; no per-profile backend is spawned). Single-profile users get
// the same rows tagged profile="default".
const result = await listAllProfileSessions(limit, 1)
if (refreshSessionsRequestRef.current === requestId) {
// Don't hard-replace. Two kinds of rows must survive a refresh the
// server didn't return: (1) sessions whose first turn is still in
// flight (message_count 0, so min_messages=1 omits them) and (2)
// pinned sessions that have aged off the most-recent page — otherwise
// the pin "disappears until you refresh". mergeSessionPage keeps both.
const keepIds = new Set<string>([...$workingSessionIds.get(), ...$pinnedSessionIds.get()])
setSessions(prev => mergeSessionPage(prev, result.sessions, keepIds))
setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep()))
setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
setSessionProfileTotals(result.profile_totals ?? {})
}
} finally {
if (refreshSessionsRequestRef.current === requestId) {
@ -260,6 +281,21 @@ export function DesktopController() {
void refreshSessions()
}, [refreshSessions])
// ALL-profiles view pages one profile at a time: fetch that profile's next
// page and merge it in place, leaving every other profile's rows untouched.
const loadMoreSessionsForProfile = useCallback(async (profile: string) => {
const key = normalizeProfileKey(profile)
const inKey = (s: SessionInfo) => normalizeProfileKey(s.profile) === key
const loaded = $sessions.get().filter(inKey).length
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key)
const keep = sessionsToKeep(key)
setSessions(prev => [...prev.filter(s => !inKey(s)), ...mergeSessionPage(prev.filter(inKey), result.sessions, keep)])
const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length
setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) }))
}, [])
const toggleSelectedPin = useCallback(() => {
const sessionId = $selectedStoredSessionId.get()
@ -349,9 +385,11 @@ export function DesktopController() {
return
}
const storedProfile = $sessions.get().find(session => session.id === storedSessionId)?.profile
for (let index = 0; index < Math.max(1, attempts); index += 1) {
try {
const latest = await getSessionMessages(storedSessionId)
const latest = await getSessionMessages(storedSessionId, storedProfile)
updateSessionState(
runtimeSessionId,
state => ({
@ -454,6 +492,20 @@ export function DesktopController() {
return () => window.removeEventListener('keydown', onKeyDown)
}, [startFreshSessionDraft])
// A profile switch/create drops to a fresh new-session draft so the previously
// open session doesn't bleed across contexts. Skip the initial value.
const freshSessionRequest = useStore($freshSessionRequest)
const lastFreshRef = useRef(freshSessionRequest)
useEffect(() => {
if (freshSessionRequest === lastFreshRef.current) {
return
}
lastFreshRef.current = freshSessionRequest
startFreshSessionDraft()
}, [freshSessionRequest, startFreshSessionDraft])
const composer = useComposerActions({
activeSessionId,
currentCwd,
@ -529,6 +581,7 @@ export function DesktopController() {
useEffect(() => {
if (gatewayState === 'open') {
void refreshCurrentModel()
void refreshActiveProfile()
void refreshSessions().catch(() => undefined)
}
}, [gatewayState, refreshCurrentModel, refreshSessions])
@ -571,6 +624,7 @@ export function DesktopController() {
currentView={currentView}
onArchiveSession={sessionId => void archiveSession(sessionId)}
onDeleteSession={sessionId => void removeSession(sessionId)}
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
onLoadMoreSessions={loadMoreSessions}
onNavigate={selectSidebarItem}
onNewSessionInWorkspace={startSessionInWorkspace}

View file

@ -10,9 +10,27 @@ import {
failDesktopBoot,
setDesktopBootStep
} from '@/store/boot'
import { setGateway } from '@/store/gateway'
import {
$gateway,
closeSecondaryGateways,
configureGatewayRegistry,
ensureGatewayForProfile,
pruneSecondaryGateways,
reconnectSecondaryGateways,
reportPrimaryGatewayState,
setPrimaryGateway,
touchSecondaryGateways
} from '@/store/gateway'
import { notify, notifyError } from '@/store/notifications'
import { $connection, setConnection, setGatewayState, setSessionsLoading } from '@/store/session'
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
import {
$attentionSessionIds,
$connection,
$sessions,
$workingSessionIds,
setConnection,
setSessionsLoading
} from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
interface GatewayBootOptions {
@ -76,6 +94,10 @@ export function useGatewayBoot({
let reconnecting = false
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let reconnectAttempt = 0
// Surface "sign in again" once per disconnect episode, not on every backoff
// tick — a stale OAuth ticket fails every attempt and would otherwise stack
// identical error toasts (and their haptics). Reset on the next clean open.
let reauthNotified = false
// Wrap the live getter in a call so TS control-flow analysis doesn't narrow
// `connectionState` to a constant across the early-return guards (the state
@ -97,7 +119,7 @@ export function useGatewayBoot({
reconnecting = true
try {
const conn = await desktop.getConnection()
const conn = await desktop.getConnection($activeGatewayProfile.get())
if (cancelled) {
return
@ -127,7 +149,8 @@ export function useGatewayBoot({
// again" message once instead of silently looping the backoff against a
// ticket that can never succeed. Transport failures fall through to the
// backoff in the finally block below.
if (!cancelled && isGatewayReauthRequired(err)) {
if (!cancelled && isGatewayReauthRequired(err) && !reauthNotified) {
reauthNotified = true
notifyError(err, 'Gateway sign-in required')
}
} finally {
@ -160,6 +183,7 @@ export function useGatewayBoot({
clearReconnectTimer()
reconnectAttempt = 0
reconnectSecondaryGateways()
if (!gatewayOpen()) {
void attemptReconnect()
@ -180,13 +204,18 @@ export function useGatewayBoot({
const gateway = new HermesGateway()
callbacksRef.current.onGatewayReady(gateway)
setGateway(gateway)
setPrimaryGateway(gateway, normalizeProfileKey($activeGatewayProfile.get()))
// Secondary (background-profile) sockets funnel into the same handler.
configureGatewayRegistry({ onEvent: event => callbacksRef.current.handleGatewayEvent(event) })
const offState = gateway.onState(st => {
setGatewayState(st)
// Mirror to the composer only while the primary is the active profile —
// a background secondary reconnect mustn't flip the foreground state.
reportPrimaryGatewayState(st)
if (st === 'open') {
reconnectAttempt = 0
reauthNotified = false
clearReconnectTimer()
} else if (bootCompleted && (st === 'closed' || st === 'error')) {
// The socket dropped after a healthy boot (typically sleep/wake). Try
@ -212,6 +241,34 @@ export function useGatewayBoot({
window.addEventListener('online', onOnline)
document.addEventListener('visibilitychange', onVisible)
// Keep live pool backends alive while this window is open (the main process
// can't observe the direct renderer↔backend WS). No-op for the primary.
const keepaliveTimer = setInterval(() => {
touchActiveGatewayBackend()
touchSecondaryGateways()
}, 60_000)
// Bound concurrency cost to live work: keep a background socket only while
// its profile has a running (working) or blocked (needs-input) session.
// Once that profile goes idle its socket is dropped and its backend is free
// to idle-reap. The active profile is always spared.
const recomputeKeptGateways = () => {
const live = new Set([...$workingSessionIds.get(), ...$attentionSessionIds.get()])
const keep = new Set<string>()
for (const session of $sessions.get()) {
if (live.has(session.id)) {
keep.add(normalizeProfileKey(session.profile))
}
}
pruneSecondaryGateways(keep)
}
const offWorking = $workingSessionIds.subscribe(() => recomputeKeptGateways())
const offAttention = $attentionSessionIds.subscribe(() => recomputeKeptGateways())
const offActiveProfile = $activeGatewayProfile.subscribe(() => recomputeKeptGateways())
const offWindowState = desktop.onWindowStateChanged?.(payload => {
const current = $connection.get()
@ -259,6 +316,19 @@ export function useGatewayBoot({
return
}
// Record which profile the primary (window) backend booted as, so
// same-profile resumes are no-op swaps and any reconnect targets the
// right backend. Best-effort: a missing preference means "default".
try {
const pref = await desktop.profile?.get?.()
const profileKey = (pref?.profile ?? '').trim() || 'default'
$activeGatewayProfile.set(profileKey)
setPrimaryGateway(gateway, profileKey)
void ensureGatewayForProfile(profileKey)
} catch {
$activeGatewayProfile.set('default')
}
setDesktopBootStep({
phase: 'renderer.config',
message: 'Loading Hermes settings',
@ -293,6 +363,10 @@ export function useGatewayBoot({
return () => {
cancelled = true
clearReconnectTimer()
clearInterval(keepaliveTimer)
offWorking()
offAttention()
offActiveProfile()
window.removeEventListener('online', onOnline)
document.removeEventListener('visibilitychange', onVisible)
offPowerResume?.()
@ -301,10 +375,12 @@ export function useGatewayBoot({
offExit()
offWindowState?.()
offBootProgress()
closeSecondaryGateways()
gateway.close()
publish(null)
callbacksRef.current.onGatewayReady(null)
setGateway(null)
setPrimaryGateway(null)
$gateway.set(null)
}
}, [])
}

View file

@ -3,6 +3,8 @@ import { useCallback, useEffect, useRef } from 'react'
import type { HermesGateway } from '@/hermes'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import { $gateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gateway'
import { $activeGatewayProfile } from '@/store/profile'
import { $gatewayState, setConnection } from '@/store/session'
export function useGatewayRequest() {
@ -24,6 +26,16 @@ export function useGatewayRequest() {
gatewayStateRef.current = gatewayState
}, [gatewayState])
// Track the active gateway (primary or a background profile's socket) so
// outbound requests and overlay props always target the focused profile.
useEffect(
() =>
$gateway.subscribe(gateway => {
gatewayRef.current = gateway as HermesGateway | null
}),
[]
)
const ensureGatewayOpen = useCallback(async () => {
const existing = gatewayRef.current
@ -49,7 +61,10 @@ export function useGatewayRequest() {
reauthErrorRef.current = null
try {
const conn = await desktop.getConnection()
// Reconnect to whichever profile the gateway is currently routed to (not
// always the primary), so a sleep/wake reconnect keeps the user on the
// profile they were chatting in.
const conn = await desktop.getConnection($activeGatewayProfile.get())
connectionRef.current = conn
setConnection(conn)
// Re-mint the WS URL before reconnecting. OAuth tickets are single-use
@ -95,7 +110,10 @@ export function useGatewayRequest() {
throw error
}
const recovered = await ensureGatewayOpen()
// Primary keeps the OAuth-aware reconnect (remote gateways re-mint a
// single-use ticket); background profiles are always local pool
// backends, so the registry handles their reconnect with no reauth.
const recovered = isActivePrimary() ? await ensureGatewayOpen() : await ensureActiveGatewayOpen()
if (!recovered) {
// Prefer the reauth error from the failed reconnect (OAuth session

View file

@ -18,11 +18,11 @@ import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { CREDENTIAL_CONTROL_CLASS } from '../settings/credential-key-ui'
import { ListRow } from '../settings/primitives'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PageSearchShell } from '../page-search-shell'
import { CREDENTIAL_CONTROL_CLASS } from '../settings/credential-key-ui'
import { ListRow } from '../settings/primitives'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { PlatformAvatar } from './platform-icon'

View file

@ -0,0 +1,158 @@
import { useEffect, useState } from 'react'
import { ActionStatus } from '@/components/ui/action-status'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { createProfile, updateProfileSoul } from '@/hermes'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
export const PROFILE_NAME_HINT =
'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.'
export function isValidProfileName(name: string): boolean {
return PROFILE_NAME_RE.test(name.trim())
}
// Self-contained create flow (name + clone toggle + optional SOUL.md). Owns the
// createProfile/updateProfileSoul calls so every caller just refreshes/selects
// via onCreated. SOUL left blank keeps the cloned/blank persona untouched.
export function CreateProfileDialog({
onClose,
onCreated,
open
}: {
onClose: () => void
onCreated?: (name: string) => Promise<void> | void
open: boolean
}) {
const [name, setName] = useState('')
const [cloneFromDefault, setCloneFromDefault] = useState(true)
const [soul, setSoul] = useState('')
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
const [error, setError] = useState<null | string>(null)
useEffect(() => {
if (!open) {
return
}
setName('')
setCloneFromDefault(true)
setSoul('')
setError(null)
setStatus('idle')
}, [open])
const trimmed = name.trim()
const invalid = trimmed !== '' && !isValidProfileName(trimmed)
const busy = status === 'saving' || status === 'done'
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!trimmed || invalid) {
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
return
}
setStatus('saving')
setError(null)
try {
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
if (soul.trim()) {
await updateProfileSoul(trimmed, soul)
}
await onCreated?.(trimmed)
setStatus('done')
window.setTimeout(onClose, 800)
} catch (err) {
setStatus('idle')
setError(err instanceof Error ? err.message : 'Failed to create profile')
}
}
return (
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>New profile</DialogTitle>
<DialogDescription>
Profiles are independent Hermes environments: separate config, skills, and SOUL.md.
</DialogDescription>
</DialogHeader>
<form className="grid gap-4" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-name">
Name
</label>
<Input
aria-invalid={invalid}
autoFocus
id="new-profile-name"
onChange={event => setName(event.target.value)}
placeholder="my-profile"
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{PROFILE_NAME_HINT}
</p>
</div>
<label className="flex cursor-pointer select-none items-start gap-2.5 px-0.5 py-1">
<Checkbox
checked={cloneFromDefault}
className="mt-0.5 shrink-0"
onCheckedChange={checked => setCloneFromDefault(checked === true)}
/>
<span className="grid gap-0.5 leading-snug">
<span className="text-sm font-medium">Clone from default</span>
<span className="text-xs text-muted-foreground">
Copy config, skills, and SOUL.md from your default profile.
</span>
</span>
</label>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-soul">
SOUL.md <span className="font-normal text-muted-foreground"> optional</span>
</label>
<Textarea
className="min-h-28 font-mono text-xs leading-5"
id="new-profile-soul"
onChange={event => setSoul(event.target.value)}
placeholder={`The system prompt / persona for this profile.\nLeave blank to keep the ${cloneFromDefault ? 'cloned' : 'empty'} default.`}
value={soul}
/>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={busy || !trimmed || invalid} type="submit">
<ActionStatus busy="Creating…" done="Created" idle="Create profile" state={status} />
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,58 @@
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { deleteProfile } from '@/hermes'
import { $activeGatewayProfile, normalizeProfileKey, selectProfile, setActiveProfile } from '@/store/profile'
// Thin wrapper over ConfirmDialog: owns the deleteProfile call, inherits
// Enter-to-confirm + busy/done/error from the shared dialog. The single choke
// point for every delete entry point (rail + Profiles view).
export function DeleteProfileDialog({
profile,
onClose,
onDeleted,
open
}: {
profile: { name: string; path: string } | null
onClose: () => void
onDeleted?: () => Promise<void> | void
open: boolean
}) {
return (
<ConfirmDialog
busyLabel="Deleting…"
confirmLabel="Delete"
description={
profile ? (
<>
This will delete <span className="font-medium text-foreground">{profile.name}</span> and remove its{' '}
<span className="font-mono text-xs">{profile.path}</span> directory. This cannot be undone.
</>
) : null
}
destructive
doneLabel="Deleted"
onClose={onClose}
onConfirm={async () => {
if (!profile) {
return
}
// Deleting the profile the live gateway is on strands it on a dead
// backend. Capture that before the delete; reset *after* the host's
// onDeleted refresh so our reset is the last write — a refreshActiveProfile
// racing the (still-dying) backend can't clobber the pill back to it.
const wasActive = normalizeProfileKey(profile.name) === normalizeProfileKey($activeGatewayProfile.get())
await deleteProfile(profile.name)
await onDeleted?.()
if (wasActive) {
// Swap gateway/sidebar to default and set the pill now — the primary
// backend is always default, so this is correct, not just optimistic.
selectProfile('default')
setActiveProfile('default')
}
}}
open={open}
title="Delete profile?"
/>
)
}

View file

@ -1,45 +1,57 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { ActionStatus } from '@/components/ui/action-status'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Textarea } from '@/components/ui/textarea'
import {
createProfile,
deleteProfile,
getProfiles,
getProfileSetupCommand,
getProfileSoul,
type ProfileInfo,
renameProfile,
updateProfileSoul
} from '@/hermes'
import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icons'
import { Tip } from '@/components/ui/tooltip'
import { createProfile, getProfiles, getProfileSoul, type ProfileInfo, updateProfileSoul } from '@/hermes'
import { AlertTriangle, Save, Users } from '@/lib/icons'
import { profileColor } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $activeProfile, switchProfile } from '@/store/profile'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayMain, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
import { CreateProfileDialog } from './create-profile-dialog'
import { DeleteProfileDialog } from './delete-profile-dialog'
import { RenameProfileDialog } from './rename-profile-dialog'
const PROFILE_NAME_HINT = 'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.'
// Pick a free "<source>-copy" name for a duplicated profile, appending a numeric
// suffix when the base is taken. Source is truncated to leave room for the
// suffix and to stay within the 64-char profile-name limit.
function uniqueCloneName(source: string, existing: Set<string>): string {
const base = `${source}-copy`.slice(0, 58)
function isValidProfileName(name: string): boolean {
return PROFILE_NAME_RE.test(name.trim())
if (!existing.has(base)) {
return base
}
for (let i = 2; i < 1000; i++) {
const candidate = `${base}-${i}`
if (!existing.has(candidate)) {
return candidate
}
}
return `${base}-${Date.now()}`
}
// Three-state affordance shared by every save/create/rename/delete button:
// spinner while pending, a check on success, then back to the idle icon+label.
interface ProfilesViewProps {
onClose: () => void
}
@ -48,13 +60,15 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
const [profiles, setProfiles] = useState<null | ProfileInfo[]>(null)
const [selectedName, setSelectedName] = useState<null | string>(null)
const [createOpen, setCreateOpen] = useState(false)
const [pendingRename, setPendingRename] = useState<null | ProfileInfo>(null)
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
const [deleting, setDeleting] = useState(false)
const [loadError, setLoadError] = useState<null | string>(null)
const refresh = useCallback(async () => {
try {
const { profiles: list } = await getProfiles()
setProfiles(list)
setLoadError(null)
setSelectedName(current => {
if (current && list.some(p => p.name === current)) {
return current
@ -63,7 +77,8 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
return list.find(p => p.is_default)?.name ?? list[0]?.name ?? null
})
} catch (err) {
notifyError(err, 'Failed to load profiles')
setLoadError(err instanceof Error ? err.message : 'Failed to load profiles')
setProfiles(prev => prev ?? [])
}
}, [])
@ -81,61 +96,31 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
return profiles.find(p => p.name === selectedName) ?? profiles[0] ?? null
}, [profiles, selectedName])
const handleCreate = useCallback(
async (name: string, cloneFromDefault: boolean) => {
const trimmed = name.trim()
const handleClone = useCallback(
async (source: ProfileInfo) => {
const existing = new Set((profiles ?? []).map(p => p.name))
const target = uniqueCloneName(source.name, existing)
if (!isValidProfileName(trimmed)) {
throw new Error(PROFILE_NAME_HINT)
try {
await createProfile({ name: target, clone_from: source.name })
setSelectedName(target)
await refresh()
} catch (err) {
setLoadError(err instanceof Error ? err.message : `Failed to duplicate ${source.name}`)
}
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
notify({ kind: 'success', title: 'Profile created', message: trimmed })
setSelectedName(trimmed)
await refresh()
},
[refresh]
[profiles, refresh]
)
const handleRename = useCallback(
async (from: string, to: string): Promise<void> => {
const target = to.trim()
if (target === from) {
return
}
if (!isValidProfileName(target)) {
throw new Error(PROFILE_NAME_HINT)
}
await renameProfile(from, target)
notify({ kind: 'success', title: 'Profile renamed', message: `${from}${target}` })
setSelectedName(target)
await refresh()
},
[refresh]
)
const handleConfirmDelete = useCallback(async () => {
if (!pendingDelete) {
return
}
setDeleting(true)
const handleMakeDefault = useCallback(async (profile: ProfileInfo) => {
try {
await deleteProfile(pendingDelete.name)
notify({ kind: 'success', title: 'Profile deleted', message: pendingDelete.name })
setPendingDelete(null)
setSelectedName(null)
await refresh()
// Relaunches the backend under this profile's HERMES_HOME and reloads the
// window, so control normally doesn't return here.
await switchProfile(profile.name)
} catch (err) {
notifyError(err, 'Failed to delete profile')
} finally {
setDeleting(false)
setLoadError(err instanceof Error ? err.message : `Failed to switch to ${profile.name}`)
}
}, [pendingDelete, refresh])
}, [])
return (
<OverlayView closeLabel="Close profiles" onClose={onClose}>
@ -158,10 +143,20 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
<Codicon name="add" size="0.875rem" />
</Button>
</div>
{loadError && (
<div className="mb-1 flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-2 py-1.5 text-[0.66rem] text-destructive">
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
<span>{loadError}</span>
</div>
)}
{profiles.map(profile => (
<ProfileRow
active={selected?.name === profile.name}
key={profile.name}
onClone={() => void handleClone(profile)}
onDelete={() => setPendingDelete(profile)}
onMakeDefault={() => void handleMakeDefault(profile)}
onRename={() => setPendingRename(profile)}
onSelect={() => setSelectedName(profile.name)}
profile={profile}
/>
@ -171,12 +166,7 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
<OverlayMain className="px-0">
{selected ? (
<ProfileDetail
key={selected.name}
onDelete={() => setPendingDelete(selected)}
onRename={newName => handleRename(selected.name, newName)}
profile={selected}
/>
<ProfileDetail key={selected.name} profile={selected} />
) : (
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
<div>
@ -191,126 +181,181 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
<CreateProfileDialog
onClose={() => setCreateOpen(false)}
onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)}
onCreated={async name => {
setSelectedName(name)
await refresh()
}}
open={createOpen}
/>
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete profile?</DialogTitle>
<DialogDescription>
{pendingDelete ? (
<>
This will delete <span className="font-medium text-foreground">{pendingDelete.name}</span> and remove
its <span className="font-mono text-xs">{pendingDelete.path}</span> directory. This cannot be undone.
</>
) : null}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
Cancel
</Button>
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
{deleting ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<RenameProfileDialog
currentName={pendingRename?.name ?? ''}
onClose={() => setPendingRename(null)}
onRenamed={async name => {
setSelectedName(name)
await refresh()
}}
open={pendingRename !== null}
/>
<DeleteProfileDialog
onClose={() => setPendingDelete(null)}
onDeleted={async () => {
setSelectedName(null)
await refresh()
}}
open={pendingDelete !== null}
profile={pendingDelete}
/>
</OverlayView>
)
}
function ProfileRow({ active, onSelect, profile }: { active: boolean; onSelect: () => void; profile: ProfileInfo }) {
function ProfileRow({
active,
onClone,
onDelete,
onMakeDefault,
onRename,
onSelect,
profile
}: {
active: boolean
onClone: () => void
onDelete: () => void
onMakeDefault: () => void
onRename: () => void
onSelect: () => void
profile: ProfileInfo
}) {
const running = useStore($activeProfile)
const isRunning = profile.name === running
return (
<button
<div
className={cn(
'flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors',
'group relative flex items-center rounded-md border transition-colors',
active
? 'bg-(--ui-row-active-background) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground'
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)'
: 'border-transparent hover:bg-(--chrome-action-hover)'
)}
onClick={onSelect}
type="button"
>
<span className="flex w-full items-center justify-between gap-2">
<span className="truncate text-sm font-medium">{profile.name}</span>
{profile.is_default && <span className="text-[0.6rem] text-primary">default</span>}
</span>
<span className="text-[0.66rem] text-muted-foreground">
{profile.skill_count} {profile.skill_count === 1 ? 'skill' : 'skills'}
{profile.has_env ? ' · env' : ''}
</span>
</button>
<button
className={cn(
'flex min-w-0 flex-1 flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left text-[length:var(--conversation-text-font-size)] transition-colors',
active ? 'text-foreground' : 'text-(--ui-text-secondary) group-hover:text-foreground'
)}
onClick={onSelect}
type="button"
>
<span className="flex w-full items-center gap-1.5 pr-6">
{profile.is_default ? null : (
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: profileColor(profile.name) ?? 'var(--ui-text-quaternary)' }}
/>
)}
<span className="truncate text-sm font-medium">{profile.name}</span>
{isRunning && (
<Tip label="Current default profile">
<Codicon className="shrink-0 text-(--ui-accent)" name="pass-filled" size="0.75rem" />
</Tip>
)}
</span>
<span className="text-[0.66rem] text-muted-foreground">
{isRunning ? 'default · ' : ''}
{profile.skill_count} {profile.skill_count === 1 ? 'skill' : 'skills'}
</span>
</button>
<ProfileActionsMenu
isRunning={isRunning}
onClone={onClone}
onDelete={onDelete}
onMakeDefault={onMakeDefault}
onRename={onRename}
profile={profile}
>
<Button
aria-label={`Actions for ${profile.name}`}
className="absolute right-1 top-1 size-6 bg-transparent text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground data-[state=open]:opacity-100"
size="icon-xs"
title="Profile actions"
variant="ghost"
>
<Codicon name="ellipsis" size="0.875rem" />
</Button>
</ProfileActionsMenu>
</div>
)
}
function ProfileDetail({
function ProfileActionsMenu({
children,
isRunning,
onClone,
onDelete,
onMakeDefault,
onRename,
profile
}: {
children: React.ReactNode
isRunning: boolean
onClone: () => void
onDelete: () => void
onRename: (newName: string) => Promise<void>
onMakeDefault: () => void
onRename: () => void
profile: ProfileInfo
}) {
const [renameOpen, setRenameOpen] = useState(false)
const [copying, setCopying] = useState(false)
const handleCopySetup = useCallback(async () => {
setCopying(true)
try {
const { command } = await getProfileSetupCommand(profile.name)
await navigator.clipboard.writeText(command)
notify({ kind: 'success', title: 'Setup command copied', message: command })
} catch (err) {
notifyError(err, 'Failed to copy setup command')
} finally {
setCopying(false)
}
}, [profile.name])
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent align="end" aria-label={`Actions for ${profile.name}`} className="w-44" sideOffset={6}>
<DropdownMenuItem disabled={isRunning} onSelect={onMakeDefault}>
<Codicon name="pass" size="0.875rem" />
<span>{isRunning ? 'Current default' : 'Make default'}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{!profile.is_default && (
<DropdownMenuItem onSelect={onRename}>
<Codicon name="edit" size="0.875rem" />
<span>Rename</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={onClone}>
<Codicon name="copy" size="0.875rem" />
<span>Duplicate</span>
</DropdownMenuItem>
{!profile.is_default && (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={onDelete}
variant="destructive"
>
<Codicon name="trash" size="0.875rem" />
<span>Delete</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
function ProfileDetail({ profile }: { profile: ProfileInfo }) {
return (
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<header className="space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-xl font-semibold tracking-tight">{profile.name}</h3>
{profile.is_default && <Badge>Default</Badge>}
{profile.has_env && <Badge variant="muted">.env</Badge>}
</div>
<p className="mt-1 font-mono text-[0.7rem] text-muted-foreground" title={profile.path}>
{profile.path}
</p>
</div>
<div className="flex shrink-0 items-center gap-3">
{!profile.is_default && (
<Button onClick={() => setRenameOpen(true)} size="sm" variant="text">
<Pencil />
Rename
</Button>
)}
<Button disabled={copying} onClick={() => void handleCopySetup()} size="sm" variant="text">
<Terminal />
{copying ? 'Copying...' : 'Copy setup'}
</Button>
{!profile.is_default && (
<Button
className="hover:text-destructive hover:no-underline"
onClick={onDelete}
size="sm"
variant="text"
>
<Trash2 />
Delete
</Button>
)}
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-xl font-semibold tracking-tight">{profile.name}</h3>
{profile.is_default && <Badge>Default</Badge>}
</div>
<Tip label={profile.path}>
<p className="mt-1 font-mono text-[0.7rem] text-muted-foreground">{profile.path}</p>
</Tip>
</div>
<dl className="grid gap-2 text-xs sm:grid-cols-2">
@ -331,16 +376,6 @@ function ProfileDetail({
<SoulEditor profileName={profile.name} />
</div>
</div>
<RenameProfileDialog
currentName={profile.name}
onClose={() => setRenameOpen(false)}
onRename={async newName => {
await onRename(newName)
setRenameOpen(false)
}}
open={renameOpen}
/>
</div>
)
}
@ -358,14 +393,16 @@ function SoulEditor({ profileName }: { profileName: string }) {
const [content, setContent] = useState('')
const [original, setOriginal] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [status, setStatus] = useState<'idle' | 'saved' | 'saving'>('idle')
const [error, setError] = useState<null | string>(null)
const requestRef = useRef<string>(profileName)
const savedTimerRef = useRef<null | number>(null)
useEffect(() => {
requestRef.current = profileName
setLoading(true)
setError(null)
setStatus('idle')
setContent('')
setOriginal('')
@ -389,21 +426,37 @@ function SoulEditor({ profileName }: { profileName: string }) {
})()
}, [profileName])
useEffect(
() => () => {
if (savedTimerRef.current !== null) {
window.clearTimeout(savedTimerRef.current)
}
},
[]
)
const dirty = content !== original
const isEmpty = !content.trim()
const saving = status === 'saving'
async function handleSave() {
setSaving(true)
setStatus('saving')
setError(null)
if (savedTimerRef.current !== null) {
window.clearTimeout(savedTimerRef.current)
}
try {
await updateProfileSoul(profileName, content)
setOriginal(content)
notify({ kind: 'success', title: 'SOUL.md saved', message: profileName })
setStatus('saved')
savedTimerRef.current = window.setTimeout(() => {
setStatus(current => (current === 'saved' ? 'idle' : current))
}, 2200)
} catch (err) {
setStatus('idle')
setError(err instanceof Error ? err.message : 'Failed to save SOUL.md')
} finally {
setSaving(false)
}
}
@ -438,230 +491,17 @@ function SoulEditor({ profileName }: { profileName: string }) {
)}
<div className="flex justify-end">
<Button disabled={!dirty || saving || loading} onClick={() => void handleSave()} size="sm">
<Save />
{saving ? 'Saving...' : 'Save SOUL.md'}
<Button disabled={loading || saving || !dirty} onClick={() => void handleSave()} size="sm">
<ActionStatus
busy="Saving…"
done="Saved"
idle="Save SOUL.md"
idleIcon={<Save />}
state={saving ? 'saving' : status === 'saved' && !dirty ? 'done' : 'idle'}
/>
</Button>
</div>
</section>
)
}
function CreateProfileDialog({
onClose,
onCreate,
open
}: {
onClose: () => void
onCreate: (name: string, cloneFromDefault: boolean) => Promise<void>
open: boolean
}) {
const [name, setName] = useState('')
const [cloneFromDefault, setCloneFromDefault] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
useEffect(() => {
if (!open) {
return
}
setName('')
setCloneFromDefault(true)
setError(null)
setSaving(false)
}, [open])
const trimmed = name.trim()
const invalid = trimmed !== '' && !isValidProfileName(trimmed)
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!trimmed || invalid) {
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
return
}
setSaving(true)
setError(null)
try {
await onCreate(trimmed, cloneFromDefault)
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create profile')
} finally {
setSaving(false)
}
}
return (
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>New profile</DialogTitle>
<DialogDescription>
Profiles are independent Hermes environments: separate config, skills, and SOUL.md.
</DialogDescription>
</DialogHeader>
<form className="grid gap-4" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-name">
Name
</label>
<Input
aria-invalid={invalid}
autoFocus
id="new-profile-name"
onChange={event => setName(event.target.value)}
placeholder="my-profile"
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{PROFILE_NAME_HINT}
</p>
</div>
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-sm">
<input
checked={cloneFromDefault}
className="size-4 accent-primary"
onChange={event => setCloneFromDefault(event.target.checked)}
type="checkbox"
/>
<span>
<span className="font-medium">Clone from default</span>
<span className="ml-2 text-xs text-muted-foreground">
Copy config, skills, and SOUL.md from your default profile.
</span>
</span>
</label>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
Cancel
</Button>
<Button disabled={saving || !trimmed || invalid} type="submit">
{saving ? 'Creating...' : 'Create profile'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function RenameProfileDialog({
currentName,
onClose,
onRename,
open
}: {
currentName: string
onClose: () => void
onRename: (newName: string) => Promise<void>
open: boolean
}) {
const [name, setName] = useState(currentName)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
useEffect(() => {
if (!open) {
return
}
setName(currentName)
setError(null)
setSaving(false)
}, [currentName, open])
const trimmed = name.trim()
const unchanged = trimmed === currentName
const invalid = trimmed !== '' && !unchanged && !isValidProfileName(trimmed)
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (unchanged) {
onClose()
return
}
if (!trimmed || invalid) {
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
return
}
setSaving(true)
setError(null)
try {
await onRename(trimmed)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to rename profile')
} finally {
setSaving(false)
}
}
return (
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Rename profile</DialogTitle>
<DialogDescription>
Renaming updates the profile directory and any wrapper scripts in{' '}
<span className="font-mono">~/.local/bin</span>.
</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="rename-profile-name">
New name
</label>
<Input
aria-invalid={invalid}
autoFocus
id="rename-profile-name"
onChange={event => setName(event.target.value)}
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{PROFILE_NAME_HINT}
</p>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
Cancel
</Button>
<Button disabled={saving || invalid || unchanged} type="submit">
{saving ? 'Renaming...' : 'Rename'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,121 @@
import { useEffect, useState } from 'react'
import { ActionStatus } from '@/components/ui/action-status'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { renameProfile } from '@/hermes'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { isValidProfileName, PROFILE_NAME_HINT } from './create-profile-dialog'
// Self-contained rename (owns the renameProfile call) so every caller just
// reacts via onRenamed. Unchanged name is a no-op close.
export function RenameProfileDialog({
currentName,
onClose,
onRenamed,
open
}: {
currentName: string
onClose: () => void
onRenamed?: (name: string) => Promise<void> | void
open: boolean
}) {
const [name, setName] = useState(currentName)
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
const [error, setError] = useState<null | string>(null)
useEffect(() => {
if (!open) {
return
}
setName(currentName)
setError(null)
setStatus('idle')
}, [currentName, open])
const trimmed = name.trim()
const unchanged = trimmed === currentName
const invalid = trimmed !== '' && !unchanged && !isValidProfileName(trimmed)
const busy = status === 'saving' || status === 'done'
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (unchanged) {
onClose()
return
}
if (!trimmed || invalid) {
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
return
}
setStatus('saving')
setError(null)
try {
await renameProfile(currentName, trimmed)
await onRenamed?.(trimmed)
setStatus('done')
window.setTimeout(onClose, 800)
} catch (err) {
setStatus('idle')
setError(err instanceof Error ? err.message : 'Failed to rename profile')
}
}
return (
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Rename profile</DialogTitle>
<DialogDescription>
Renaming updates the profile directory and any wrapper scripts in{' '}
<span className="font-mono">~/.local/bin</span>.
</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="rename-profile-name">
New name
</label>
<Input
aria-invalid={invalid}
autoFocus
id="rename-profile-name"
onChange={event => setName(event.target.value)}
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{PROFILE_NAME_HINT}
</p>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={busy || invalid || unchanged} type="submit">
<ActionStatus busy="Renaming…" done="Renamed" idle="Rename" state={status} />
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View file

@ -5,6 +5,7 @@ import { ErrorBoundary } from '@/components/error-boundary'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
@ -148,21 +149,21 @@ function RightSidebarChrome({
<div className="flex items-center gap-2 px-2.5 py-1">
<nav aria-label="Right sidebar panels" className="flex min-w-0 items-center gap-1">
{tabs.map(tab => (
<Button
aria-label={tab.label}
aria-pressed={tab.id === activeTab}
className={cn(
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
)}
key={tab.id}
onClick={() => setRightSidebarTab(tab.id)}
size="icon-xs"
title={tab.label}
variant="ghost"
>
<Codicon name={tab.icon} size="0.875rem" />
</Button>
<Tip key={tab.id} label={tab.label}>
<Button
aria-label={tab.label}
aria-pressed={tab.id === activeTab}
className={cn(
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
)}
onClick={() => setRightSidebarTab(tab.id)}
size="icon-xs"
variant="ghost"
>
<Codicon name={tab.icon} size="0.875rem" />
</Button>
</Tip>
))}
</nav>
@ -216,21 +217,21 @@ function FilesystemTab({
return (
<div className="group/project-header flex min-h-0 flex-1 flex-col">
<RightSidebarSectionHeader>
<button
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
onClick={() => void onChangeFolder()}
title={hasCwd ? `${cwd} — click to change folder` : 'Open a folder'}
type="button"
>
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</button>
<Tip label={hasCwd ? `${cwd} — click to change folder` : 'Open a folder'}>
<button
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
onClick={() => void onChangeFolder()}
type="button"
>
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</button>
</Tip>
<Button
aria-label="Refresh tree"
className={HEADER_ACTION_CLASS}
disabled={!hasCwd || loading}
onClick={onRefresh}
size="icon-xs"
title="Refresh tree"
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
@ -240,7 +241,6 @@ function FilesystemTab({
className={HEADER_ACTION_CLASS}
onClick={() => void onChangeFolder()}
size="icon-xs"
title={hasCwd ? 'Open a different folder' : 'Open a folder'}
variant="ghost"
>
<Codicon name="folder-opened" size="0.8125rem" />
@ -251,7 +251,6 @@ function FilesystemTab({
disabled={!hasCwd || !canCollapse}
onClick={onCollapseAll}
size="icon-xs"
title="Collapse all folders"
variant="ghost"
>
<Codicon name="collapse-all" size="0.8125rem" />

View file

@ -5,6 +5,7 @@ import { useStore } from '@nanostores/react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import { $terminalTakeover, setRightSidebarTab, setTerminalTakeover } from '../store'
@ -39,17 +40,18 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
<div className="flex h-8 shrink-0 items-center gap-2 px-2.5">
<SidebarPanelLabel className="text-white!">{shellName}</SidebarPanelLabel>
<Button
aria-label={label}
className="ml-auto size-6 rounded-md text-white!"
onClick={toggleTakeover}
size="icon"
title={label}
type="button"
variant="ghost"
>
<Codicon name={takeover ? 'screen-normal' : 'screen-full'} size="0.875rem" />
</Button>
<Tip label={label}>
<Button
aria-label={label}
className="ml-auto size-6 rounded-md text-white!"
onClick={toggleTakeover}
size="icon"
type="button"
variant="ghost"
>
<Codicon name={takeover ? 'screen-normal' : 'screen-full'} size="0.875rem" />
</Button>
</Tip>
</div>
<div className="relative min-h-0 flex-1 bg-[#002b36] p-2">
{status === 'starting' && (

View file

@ -752,12 +752,11 @@ export function useMessageStream({
return
}
// Turn ended — drop any blocking prompt that's still open (e.g. the
// agent was interrupted, or the approval already resolved). Prevents a
// stale overlay from outliving the turn that raised it.
if (isActiveEvent) {
clearAllPrompts()
}
// Turn ended — drop any blocking prompt still open for THIS session
// (e.g. interrupted, or the approval already resolved). Scoped to the
// session so a background turn finishing can't wipe the active chat's
// prompt, and vice versa.
clearAllPrompts(sessionId)
flushQueuedDeltas(sessionId)
@ -842,37 +841,34 @@ export function useMessageStream({
}
}
} else if (event.type === 'approval.request') {
if (!isActiveEvent) {
return
}
// Dangerous-command / execute_code approval. The Python side is
// blocked in _await_gateway_decision() until approval.respond lands;
// without this the agent stalls until its 5-min timeout and the tool
// is BLOCKED. Approval is session-keyed (no request_id) — the overlay
// sends back {choice, session_id}.
// Dangerous-command / execute_code approval. The Python side is blocked
// in _await_gateway_decision() until approval.respond lands; without
// this the agent stalls until its 5-min timeout and the tool is BLOCKED.
// Park it per-session (like clarify) so a *background* profile's turn can
// raise it and wait — the sidebar flags "needs input" and the inline bar
// surfaces once the user focuses that chat.
setApprovalRequest({
command: typeof payload?.command === 'string' ? payload.command : '',
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
sessionId: sessionId ?? null
})
} else if (event.type === 'sudo.request') {
if (!isActiveEvent) {
return
}
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
} else if (event.type === 'sudo.request') {
// Sudo password capture (tools/terminal_tool.py). Blocked on
// sudo.respond {request_id, password}.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
if (requestId) {
setSudoRequest({ requestId })
setSudoRequest({ requestId, sessionId: sessionId ?? null })
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
}
} else if (event.type === 'secret.request') {
if (!isActiveEvent) {
return
}
// Skill credential capture (tools/skills_tool.py). Blocked on
// secret.respond {request_id, value}.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
@ -881,18 +877,23 @@ export function useMessageStream({
setSecretRequest({
requestId,
envVar: typeof payload?.env_var === 'string' ? payload.env_var : '',
prompt: typeof payload?.prompt === 'string' ? payload.prompt : ''
prompt: typeof payload?.prompt === 'string' ? payload.prompt : '',
sessionId: sessionId ?? null
})
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
}
} else if (event.type === 'error') {
const errorMessage = payload?.message || 'Hermes reported an error'
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
// A turn that errors out has also ended — drop any open blocking
// prompt so an approval/sudo/secret overlay can't linger past the
// failed turn (same intent as the message.complete clear).
if (isActiveEvent) {
clearAllPrompts()
// A turn that errors out has also ended — drop any open blocking prompt
// for this session so an approval/sudo/secret overlay can't linger past
// the failed turn (same intent as the message.complete clear).
if (sessionId) {
clearAllPrompts(sessionId)
}
if (looksLikeProviderSetup) {

View file

@ -1,7 +1,7 @@
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
import { type MutableRefObject, useCallback } from 'react'
import { transcribeAudio } from '@/hermes'
import { getProfiles, transcribeAudio } from '@/hermes'
import { appendTextPart, branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import {
attachmentDisplayText,
@ -30,6 +30,7 @@ import {
} from '@/store/composer'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$busy,
$messages,
@ -443,6 +444,51 @@ export function usePromptActions({
return
}
// /profile selects which profile new chats open in — no app relaunch.
// A profile is per-session now, so an existing thread can't change its
// profile mid-stream; `/profile <name>` instead points the next new chat
// (and the current empty draft) at that profile's backend.
if (normalizedName === 'profile') {
const target = arg.trim()
const current = normalizeProfileKey($activeGatewayProfile.get())
if (!target) {
notify({
kind: 'success',
message: `Profile: ${current}. Use /profile <name> or the "New session" picker to start a chat in another profile.`
})
return
}
try {
const { profiles } = await getProfiles()
const match = profiles.find(profile => profile.name === target)
if (!match) {
notify({
kind: 'error',
title: 'Unknown profile',
message: `No profile named "${target}". Available: ${profiles.map(profile => profile.name).join(', ')}`
})
return
}
const key = normalizeProfileKey(match.name)
$newChatProfile.set(key)
// Swap the live gateway now so an empty draft sends into this
// profile immediately; an existing thread keeps its own profile.
await ensureGatewayProfile(key)
notify({ kind: 'success', message: `New chats will use profile ${match.name}.` })
} catch (err) {
notifyError(err, 'Failed to set profile')
}
return
}
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
if (!sessionId) {

View file

@ -12,6 +12,7 @@ import { clearQueuedPrompts } from '@/store/composer-queue'
import { $pinnedSessionIds } from '@/store/layout'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$currentCwd,
$messages,
@ -173,6 +174,10 @@ function upsertOptimisticSession(
preview: string | null = null
) {
const now = Date.now() / 1000
// Stamp the profile the session was just created on (= the live gateway's
// profile) so the scoped sidebar shows the new row immediately instead of
// filtering it out as "default" until the aggregator re-fetches.
const profileKey = normalizeProfileKey($activeGatewayProfile.get())
const session: SessionInfo = {
cwd: created.info?.cwd ?? null,
@ -180,11 +185,13 @@ function upsertOptimisticSession(
id,
input_tokens: 0,
is_active: true,
is_default_profile: profileKey === 'default',
last_active: now,
message_count: created.message_count ?? created.messages?.length ?? 0,
model: created.info?.model ?? null,
output_tokens: 0,
preview,
profile: profileKey,
source: 'tui',
started_at: now,
title,
@ -320,6 +327,9 @@ export function useSessionActions({
creatingSessionRef.current = true
try {
// Route the new chat to the chosen profile's backend (null = primary,
// so single-profile users are unaffected).
await ensureGatewayProfile($newChatProfile.get())
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
const stored = created.stored_session_id ?? null
@ -420,6 +430,12 @@ export function useSessionActions({
const isCurrentResume = () =>
resumeRequestRef.current === requestId && selectedStoredSessionIdRef.current === storedSessionId
// Swap the single live gateway to this session's profile before any
// gateway call (no-op when it's already on that profile / single-profile).
const storedForProfile = $sessions.get().find(session => session.id === storedSessionId)
const sessionProfile = storedForProfile?.profile
await ensureGatewayProfile(sessionProfile)
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId)
@ -482,7 +498,7 @@ export function useSessionActions({
let localSnapshot = $messages.get()
try {
const storedMessages = await getSessionMessages(storedSessionId)
const storedMessages = await getSessionMessages(storedSessionId, sessionProfile)
if (isCurrentResume()) {
localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get())
@ -552,7 +568,7 @@ export function useSessionActions({
return
}
const fallback = await getSessionMessages(storedSessionId)
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
if (!isCurrentResume()) {
return

View file

@ -1,6 +1,7 @@
import { IconDownload, IconRefresh, IconUpload } from '@tabler/icons-react'
import { useRef } from 'react'
import { Tip } from '@/components/ui/tooltip'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
@ -173,28 +174,32 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
onClick={() => setActiveView('about')}
/>
<div className="mt-auto flex items-center gap-1 pt-2">
<OverlayIconButton onClick={() => void exportConfig()} title="Export config">
<IconDownload className="size-3.5" />
</OverlayIconButton>
<OverlayIconButton
onClick={() => {
triggerHaptic('open')
importInputRef.current?.click()
}}
title="Import config"
>
<IconUpload className="size-3.5" />
</OverlayIconButton>
<OverlayIconButton
className="hover:text-destructive"
onClick={() => {
triggerHaptic('warning')
void resetConfig()
}}
title="Reset to defaults"
>
<IconRefresh className="size-3.5" />
</OverlayIconButton>
<Tip label="Export config">
<OverlayIconButton onClick={() => void exportConfig()}>
<IconDownload className="size-3.5" />
</OverlayIconButton>
</Tip>
<Tip label="Import config">
<OverlayIconButton
onClick={() => {
triggerHaptic('open')
importInputRef.current?.click()
}}
>
<IconUpload className="size-3.5" />
</OverlayIconButton>
</Tip>
<Tip label="Reset to defaults">
<OverlayIconButton
className="hover:text-destructive"
onClick={() => {
triggerHaptic('warning')
void resetConfig()
}}
>
<IconRefresh className="size-3.5" />
</OverlayIconButton>
</Tip>
</div>
</OverlaySidebar>

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Tip } from '@/components/ui/tooltip'
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
@ -134,18 +135,19 @@ export function SessionsSettings() {
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />}
<span>Unarchive</span>
</Button>
<Button
aria-label="Delete permanently"
className="text-muted-foreground hover:text-destructive"
disabled={busy}
onClick={() => void remove(session)}
size="icon"
title="Delete permanently"
type="button"
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
<Tip label="Delete permanently">
<Button
aria-label="Delete permanently"
className="text-muted-foreground hover:text-destructive"
disabled={busy}
onClick={() => void remove(session)}
size="icon"
type="button"
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
</Tip>
</div>
}
description={session.preview || undefined}

View file

@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import type { CSSProperties, ReactNode } from 'react'
import { useSyncExternalStore } from 'react'
import { NotificationStack } from '@/components/notifications'
import { PaneShell } from '@/components/pane-shell'
import { SidebarProvider } from '@/components/ui/sidebar'
import {
@ -153,6 +154,10 @@ export function AppShell({
</main>
{overlays}
{/* Mounted at the shell root (after overlays) so success/error toasts
surface above every route and overlay not just the chat view. */}
<NotificationStack />
</SidebarProvider>
)
}

View file

@ -2,6 +2,7 @@ import { IconLayoutDashboard } from '@tabler/icons-react'
import { StatusDot, type StatusTone } from '@/components/status-dot'
import { Button } from '@/components/ui/button'
import { Tip } from '@/components/ui/tooltip'
import { Activity, AlertCircle } from '@/lib/icons'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { cn } from '@/lib/utils'
@ -76,16 +77,17 @@ export function GatewayMenuPanel({
</span>
</div>
<div className="flex items-center">
<Button
aria-label="Open system panel"
className="text-muted-foreground hover:text-foreground"
onClick={onOpenSystem}
size="icon-sm"
title="Open system panel"
variant="ghost"
>
<IconLayoutDashboard />
</Button>
<Tip label="Open system panel">
<Button
aria-label="Open system panel"
className="text-muted-foreground hover:text-foreground"
onClick={onOpenSystem}
size="icon-sm"
variant="ghost"
>
<IconLayoutDashboard />
</Button>
</Tip>
</div>
</div>
@ -99,13 +101,11 @@ export function GatewayMenuPanel({
<SectionLabel>Recent activity</SectionLabel>
<ul className="mt-1.5 space-y-0.5">
{recentLogs.map((line, index) => (
<li
className="truncate font-mono text-[0.68rem] text-muted-foreground/85"
key={`${index}:${line}`}
title={line.trim()}
>
{trimLogLine(line) || '\u00A0'}
</li>
<Tip key={`${index}:${line}`} label={line.trim()}>
<li className="truncate font-mono text-[0.68rem] text-muted-foreground/85">
{trimLogLine(line) || '\u00A0'}
</li>
</Tip>
))}
</ul>
<button

View file

@ -91,18 +91,11 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
</>
)
const title = item.title ?? (typeof item.label === 'string' ? item.label : undefined)
if (item.variant === 'menu' && (item.menuContent || (item.menuItems && item.menuItems.length > 0))) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
disabled={item.disabled}
title={title}
type="button"
>
<button className={cn(STATUSBAR_ACTION_CLASS, item.className)} disabled={item.disabled} type="button">
{content}
</button>
</DropdownMenuTrigger>
@ -135,7 +128,6 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
href={menuItem.href}
rel="noreferrer"
target="_blank"
title={menuItem.title ?? menuItem.label}
>
{menuItem.icon}
<span className="truncate">{menuItem.label}</span>
@ -168,13 +160,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
if (item.href || item.variant === 'link') {
return (
<a
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
href={item.href}
rel="noreferrer"
target="_blank"
title={title}
>
<a className={cn(STATUSBAR_ACTION_CLASS, item.className)} href={item.href} rel="noreferrer" target="_blank">
{content}
</a>
)
@ -191,7 +177,6 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
item.onSelect?.()
}}
title={title}
type="button"
>
{content}

View file

@ -4,14 +4,6 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
@ -24,7 +16,7 @@ import {
toggleSidebarOpen
} from '@/store/layout'
import { appViewForPath, isOverlayView, PROFILES_ROUTE } from '../routes'
import { appViewForPath, isOverlayView } from '../routes'
import { titlebarButtonClass } from './titlebar'
@ -185,7 +177,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
{visibleSystemToolsBeforeSettings.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
))}
<ProfilesMenuButton navigate={navigate} />
{settingsTool && <TitlebarToolButton navigate={navigate} tool={settingsTool} />}
<TitlebarToolButton navigate={navigate} tool={rightSidebarTool} />
</div>
@ -193,47 +184,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
)
}
function ProfilesMenuButton({ navigate }: { navigate: ReturnType<typeof useNavigate> }) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Profiles"
className={cn(titlebarButtonClass, 'bg-transparent select-none')}
onPointerDown={event => event.stopPropagation()}
size="icon-titlebar"
title="Profiles"
type="button"
variant="ghost"
>
{/* Optical bump: the `account` glyph has more internal padding than
`search`/`settings-gear`, so at the shared 0.875rem it reads small.
Nudge just this glyph to visually match its neighbours. */}
<Codicon name="account" size="1rem" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64" sideOffset={8}>
<DropdownMenuLabel>
<div className="text-sm font-medium text-foreground">Profiles</div>
<div className="mt-1 text-xs font-normal leading-4 text-muted-foreground">
Advanced Hermes environments for separate personas, config, skills, and SOUL.md.
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
triggerHaptic('open')
navigate(PROFILES_ROUTE)
}}
>
<Codicon name="account" size="1rem" />
<span>Manage profiles</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof useNavigate>; tool: TitlebarTool }) {
// Titlebar actions never show an active background — state reads from the
// icon itself (e.g. the mute/unmute glyph). aria-pressed still carries it
@ -249,7 +199,6 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
onPointerDown={event => event.stopPropagation()}
rel="noreferrer"
target="_blank"
title={tool.title ?? tool.label}
>
{tool.icon}
</a>
@ -272,7 +221,6 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
}}
onPointerDown={event => event.stopPropagation()}
size="icon-titlebar"
title={tool.title ?? tool.label}
type="button"
variant="ghost"
>

View file

@ -8,7 +8,7 @@ import { Fragment, useEffect, useMemo, useState } from 'react'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { extractEmbeddedImages } from '@/lib/embedded-images'
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal'] as const
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal', 'session'] as const
type HermesRefType = (typeof HERMES_REF_TYPES)[number]
/** Single source of truth for chip icon glyphs (Tabler outline @ 24×24).
@ -38,7 +38,12 @@ const ICON_PATHS: Record<HermesRefType, string[]> = {
],
tool: ['M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5'],
line: ['M5 9l14 0', 'M5 15l14 0', 'M11 4l-4 16', 'M17 4l-4 16'],
terminal: ['M5 7l5 5l-5 5', 'M12 19l7 0']
terminal: ['M5 7l5 5l-5 5', 'M12 19l7 0'],
session: [
'M8 9h8',
'M8 13h6',
'M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3z'
]
}
const ICON_FALLBACK = ['M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', 'M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28']
@ -98,7 +103,7 @@ const DirectiveIcon: FC<{ type: string }> = ({ type }) => (
* raw HTML composer chips in `rich-editor.ts`. Neutral subtle wash + plain
* muted-foreground text so chips read as quiet tags on any bubble color. */
export const DIRECTIVE_CHIP_CLASS =
'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded px-1.5 py-0.5 align-[0.02em] text-[0.86em] font-normal leading-none bg-[color-mix(in_srgb,currentColor_8%,transparent)] text-muted-foreground'
'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded px-1.5 py-0.5 align-middle text-[0.86em] font-normal leading-none bg-[color-mix(in_srgb,currentColor_8%,transparent)] text-muted-foreground'
/**
* Parses our composer's `@type:value` references into directive segments
@ -113,7 +118,7 @@ export const DIRECTIVE_CHIP_CLASS =
const CANONICAL_DIRECTIVE_RE = /:([\w-]{1,64})\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/g
const HERMES_DIRECTIVE_RE = new RegExp(
'@(file|folder|url|image|tool|line|terminal):(' + '`[^`\\n]+`' + '|"[^"\\n]+"' + "|'[^'\\n]+'" + '|\\S+' + ')',
'@(file|folder|url|image|tool|line|terminal|session):(' + '`[^`\\n]+`' + '|"[^"\\n]+"' + "|'[^'\\n]+'" + '|\\S+' + ')',
'g'
)
@ -263,6 +268,14 @@ function shortLabel(type: HermesRefType, id: string): string {
}
}
// `@session:<profile>/<id>` — show a short id; the composer chip carries the
// friendly title, but once sent the wire form only has the id.
if (type === 'session') {
const sid = id.split('/').filter(Boolean).pop() || id
return sid.length > 10 ? `${sid.slice(0, 8)}` : sid
}
const tail = id.split(/[\\/]/).filter(Boolean).pop()
return tail || id

View file

@ -2,7 +2,8 @@ import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime }
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $approvalRequest } from '@/store/prompts'
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
import { $activeSessionId } from '@/store/session'
import { $toolDisclosureStates } from '@/store/tool-view'
import { Thread } from './thread'
@ -120,13 +121,15 @@ function GroupHarness({ message }: { message: ThreadMessage }) {
}
beforeEach(() => {
$approvalRequest.set(null)
clearAllPrompts()
$activeSessionId.set('sess-1')
$toolDisclosureStates.set({})
})
afterEach(() => {
cleanup()
$approvalRequest.set(null)
clearAllPrompts()
$activeSessionId.set(null)
})
describe('ToolGroupSlot approval surfacing', () => {
@ -143,7 +146,7 @@ describe('ToolGroupSlot approval surfacing', () => {
})
it('force-opens the group body so the approval surfaces without expanding', async () => {
$approvalRequest.set({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)

View file

@ -3,7 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HermesGateway } from '@/hermes'
import { $gateway } from '@/store/gateway'
import { $approvalRequest } from '@/store/prompts'
import { $approvalRequest, clearAllPrompts, setApprovalRequest } from '@/store/prompts'
import { $activeSessionId } from '@/store/session'
import { PendingToolApproval } from './tool-approval'
import type { ToolPart } from './tool-fallback-model'
@ -13,7 +14,8 @@ function part(toolName: string): ToolPart {
}
function setRequest(command = 'rm -rf /tmp/x') {
$approvalRequest.set({ command, description: 'dangerous command', sessionId: 'sess-1' })
$activeSessionId.set('sess-1')
setApprovalRequest({ command, description: 'dangerous command', sessionId: 'sess-1' })
}
function mockGateway() {
@ -25,7 +27,8 @@ function mockGateway() {
afterEach(() => {
cleanup()
$approvalRequest.set(null)
clearAllPrompts()
$activeSessionId.set(null)
$gateway.set(null)
})

View file

@ -81,7 +81,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
session_id: request.sessionId ?? undefined
})
triggerHaptic(choice === 'deny' ? 'cancel' : 'submit')
clearApprovalRequest()
clearApprovalRequest(request.sessionId)
} catch (error) {
notifyError(error, 'Could not send approval response')
setSubmitting(null)

View file

@ -3,6 +3,7 @@
import { type ComponentPropsWithRef, forwardRef } from 'react'
import { Button } from '@/components/ui/button'
import { Tip } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
export interface TooltipIconButtonProps extends ComponentPropsWithRef<typeof Button> {
@ -11,19 +12,20 @@ export interface TooltipIconButtonProps extends ComponentPropsWithRef<typeof But
}
export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButtonProps>(
({ children, tooltip, side: _side = 'bottom', className, ...rest }, ref) => {
({ children, tooltip, side = 'bottom', className, ...rest }, ref) => {
return (
<Button
size="icon-xs"
variant="ghost"
{...rest}
aria-label={tooltip}
className={cn('aui-button-icon', className)}
ref={ref}
title={tooltip}
>
{children}
</Button>
<Tip label={tooltip} side={side}>
<Button
size="icon-xs"
variant="ghost"
{...rest}
aria-label={tooltip}
className={cn('aui-button-icon', className)}
ref={ref}
>
{children}
</Button>
</Tip>
)
}
)

View file

@ -1,5 +1,6 @@
import { useStore } from '@nanostores/react'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Codicon } from '@/components/ui/codicon'
@ -63,10 +64,16 @@ export function NotificationStack() {
const [latest, ...olderNotifications] = notifications
const overflowCount = olderNotifications.length
return (
// Portaled to <body> with a z above the Radix dialog layer (overlay z-[120],
// content z-[130]). Without the portal the stack lives inside the React root
// subtree, which any body-level dialog/overlay portal paints over — so a
// success toast fired while a dialog is open (or over an OverlayView page)
// was invisible. The titlebar-height var only exists inside the app shell
// scope, so fall back to its constant (34px) when mounted on <body>.
return createPortal(
<div
aria-label="Notifications"
className="pointer-events-none absolute left-1/2 top-[calc(var(--titlebar-height)+0.75rem)] z-1050 flex w-[min(32rem,calc(100%-2rem))] -translate-x-1/2 flex-col gap-2"
className="pointer-events-none fixed left-1/2 top-[calc(var(--titlebar-height,34px)+0.75rem)] z-[200] flex w-[min(32rem,calc(100%-2rem))] -translate-x-1/2 flex-col gap-2"
role="region"
>
<NotificationItem notification={latest} />
@ -81,7 +88,8 @@ export function NotificationStack() {
</button>
</div>
)}
</div>
</div>,
document.body
)
}

View file

@ -64,7 +64,7 @@ function SudoDialog() {
request_id: request.requestId
})
triggerHaptic('submit')
clearSudoRequest(request.requestId)
clearSudoRequest(request.sessionId, request.requestId)
} catch (error) {
notifyError(error, 'Could not send sudo password')
setSubmitting(false)
@ -163,7 +163,7 @@ function SecretDialog() {
value: secret
})
triggerHaptic('submit')
clearSecretRequest(request.requestId)
clearSecretRequest(request.sessionId, request.requestId)
} catch (error) {
notifyError(error, 'Could not send secret')
setSubmitting(false)

View file

@ -0,0 +1,25 @@
import type { ReactNode } from 'react'
import { Check, Loader2 } from '@/lib/icons'
// idle → saving → done label+icon for action buttons (create / rename / delete…).
export function ActionStatus({
state,
idle,
busy,
done,
idleIcon = null
}: {
state: 'done' | 'idle' | 'saving'
idle: string
busy: string
done: string
idleIcon?: ReactNode
}) {
return (
<>
{state === 'saving' ? <Loader2 className="size-4 animate-spin" /> : state === 'done' ? <Check /> : idleIcon}
{state === 'saving' ? busy : state === 'done' ? done : idle}
</>
)
}

View file

@ -0,0 +1,103 @@
import type { ReactNode } from 'react'
import { useEffect, useState } from 'react'
import { ActionStatus } from '@/components/ui/action-status'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { AlertTriangle } from '@/lib/icons'
interface ConfirmDialogProps {
open: boolean
onClose: () => void
// Does the work. Throw to surface an inline error and keep the dialog open.
onConfirm: () => Promise<void> | void
title: ReactNode
description?: ReactNode
confirmLabel?: string
busyLabel?: string
doneLabel?: string
cancelLabel?: string
destructive?: boolean
}
// Shared confirmation dialog: Enter confirms (from anywhere in the dialog),
// Esc/Cancel/backdrop dismiss. Owns the pending → done → close beat and inline
// error, so callers pass only an async onConfirm that does the work.
export function ConfirmDialog({
open,
onClose,
onConfirm,
title,
description,
confirmLabel = 'Confirm',
busyLabel = 'Working…',
doneLabel = 'Done',
cancelLabel = 'Cancel',
destructive = false
}: ConfirmDialogProps) {
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
const [error, setError] = useState<null | string>(null)
const busy = status === 'saving' || status === 'done'
useEffect(() => {
if (open) {
setStatus('idle')
setError(null)
}
}, [open])
async function run() {
if (busy) {
return
}
setStatus('saving')
setError(null)
try {
await onConfirm()
setStatus('done')
window.setTimeout(onClose, 600)
} catch (err) {
setStatus('idle')
setError(err instanceof Error ? err.message : 'Something went wrong')
}
}
return (
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
<DialogContent
className="max-w-md"
onKeyDown={event => {
// Enter/Space confirm regardless of which button holds focus
// (preventDefault stops a focused Cancel from swallowing it).
if ((event.key === 'Enter' || event.key === ' ') && !busy) {
event.preventDefault()
void run()
}
}}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
{cancelLabel}
</Button>
<Button disabled={busy} onClick={() => void run()} variant={destructive ? 'destructive' : 'default'}>
<ActionStatus busy={busyLabel} done={doneLabel} idle={confirmLabel} state={status} />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -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 (
<button
aria-label={ariaLabel}
className={cn(
'grid size-6 place-items-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent/55 hover:text-foreground focus-visible:opacity-100 group-hover/tool-row:opacity-100 disabled:opacity-40',
className
)}
disabled={disabled}
onClick={event => void copy(event)}
title={feedbackLabel}
type="button"
>
{icon}
</button>
<Tip label={feedbackLabel}>
<button
aria-label={ariaLabel}
className={cn(
'grid size-6 place-items-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent/55 hover:text-foreground focus-visible:opacity-100 group-hover/tool-row:opacity-100 disabled:opacity-40',
className
)}
disabled={disabled}
onClick={event => void copy(event)}
type="button"
>
{icon}
</button>
</Tip>
)
}
return (
const button = (
<Button
aria-label={ariaLabel}
className={className}
disabled={disabled}
onClick={event => void copy(event)}
size={buttonSize ?? (appearance === 'icon' ? 'icon' : 'default')}
title={feedbackLabel}
type="button"
variant={buttonVariant}
>
{content}
</Button>
)
// Only icon-only buttons need a tooltip; the text variant already shows its label.
return appearance === 'icon' ? <Tip label={feedbackLabel}>{button}</Tip> : button
}

View file

@ -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 && (
<DialogPrimitive.Close
className="absolute right-2.5 top-2.5 rounded-md p-1 text-(--ui-text-tertiary) opacity-70 transition-opacity hover:bg-(--chrome-action-hover) hover:text-foreground hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none"
data-slot="dialog-close-button"
>
<Codicon name="close" size="1rem" />
<span className="sr-only">Close</span>
<DialogPrimitive.Close asChild data-slot="dialog-close-button">
<Button
aria-label="Close"
className="absolute right-2.5 top-2.5 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
size="icon-xs"
variant="ghost"
>
<Codicon name="close" size="1rem" />
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>

View file

@ -0,0 +1,44 @@
import { Popover as PopoverPrimitive } from 'radix-ui'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverContent({
align = 'center',
className,
collisionPadding = 8,
sideOffset = 6,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
// Mirrors DropdownMenuContent: themed elevated surface, viewport-aware
// (Radix flips/shifts off edges), with the standard open/close motion.
className={cn(
'z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-2 text-popover-foreground shadow-md backdrop-blur-md outline-hidden data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
collisionPadding={collisionPadding}
data-slot="popover-content"
sideOffset={sideOffset}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }

View file

@ -17,15 +17,18 @@ function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimiti
function TooltipContent({
className,
sideOffset = 0,
sideOffset = 6,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
// Instant, no transition (the Provider's delayDuration=0 + no animate-*
// classes). bg-foreground/text-background auto-inverts per theme: white
// on near-black in light mode, black on white in dark.
className={cn(
'z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
'z-[200] w-fit bg-foreground px-1.5 py-1 text-[11px] font-bold leading-none text-background select-none [font-family:Arial,sans-serif]',
className
)}
data-slot="tooltip-content"
@ -33,10 +36,34 @@ function TooltipContent({
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_0.125rem)] rotate-45 rounded-[0.125rem] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
interface TipProps extends Omit<React.ComponentProps<typeof TooltipPrimitive.Content>, '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 (
<TooltipProvider delayDuration={delayDuration}>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent {...props}>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

View file

@ -3,8 +3,14 @@ export {}
declare global {
interface Window {
hermesDesktop: {
getConnection: () => Promise<HermesConnection>
getGatewayWsUrl: () => Promise<string>
// 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<HermesConnection>
// 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<string>
getBootProgress: () => Promise<DesktopBootProgress>
getConnectionConfig: () => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
@ -13,6 +19,13 @@ declare global {
probeConnectionConfig: (remoteUrl: string) => Promise<DesktopConnectionProbeResult>
oauthLoginConnectionConfig: (remoteUrl: string) => Promise<DesktopOauthLoginResult>
oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise<DesktopOauthLogoutResult>
profile: {
get: () => Promise<DesktopActiveProfile>
// 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<DesktopActiveProfile>
}
api: <T>(request: HermesApiRequest) => Promise<T>
notify: (payload: HermesNotification) => Promise<boolean>
requestMicrophoneAccess: () => Promise<boolean>
@ -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 {

View file

@ -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<PaginatedSessions> {
const result = await window.hermesDesktop.api<PaginatedSessions>({
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<SessionSearchResponse> {
})
}
export function getSessionMessages(id: string): Promise<SessionMessagesResponse> {
// `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<SessionMessagesResponse> {
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
return window.hermesDesktop.api<SessionMessagesResponse>({
path: `/api/sessions/${encodeURIComponent(id)}/messages`
path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}`
})
}
@ -155,16 +197,21 @@ 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 } : {}) }
})
}
export function getGlobalModelInfo(): Promise<ModelInfoResponse> {
return window.hermesDesktop.api<ModelInfoResponse>({
...profileScoped(),
path: '/api/model/info'
})
}
@ -202,36 +249,42 @@ export function getLogs(params: {
const suffix = query.toString()
return window.hermesDesktop.api<LogsResponse>({
...profileScoped(),
path: suffix ? `/api/logs?${suffix}` : '/api/logs'
})
}
export function getHermesConfig(): Promise<HermesConfig> {
return window.hermesDesktop.api<HermesConfig>({
...profileScoped(),
path: '/api/config'
})
}
export function getHermesConfigRecord(): Promise<HermesConfigRecord> {
return window.hermesDesktop.api<HermesConfigRecord>({
...profileScoped(),
path: '/api/config'
})
}
export function getHermesConfigDefaults(): Promise<HermesConfigRecord> {
return window.hermesDesktop.api<HermesConfigRecord>({
...profileScoped(),
path: '/api/config/defaults'
})
}
export function getHermesConfigSchema(): Promise<ConfigSchemaResponse> {
return window.hermesDesktop.api<ConfigSchemaResponse>({
...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 +293,14 @@ export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: bool
export function getEnvVars(): Promise<Record<string, EnvVarInfo>> {
return window.hermesDesktop.api<Record<string, EnvVarInfo>>({
...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 +312,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 +321,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 +330,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 +339,14 @@ export function revealEnvVar(key: string): Promise<{ key: string; value: string
export function listOAuthProviders(): Promise<OAuthProvidersResponse> {
return window.hermesDesktop.api<OAuthProvidersResponse>({
...profileScoped(),
path: '/api/providers/oauth'
})
}
export function startOAuthLogin(providerId: string): Promise<OAuthStartResponse> {
return window.hermesDesktop.api<OAuthStartResponse>({
...profileScoped(),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
method: 'POST',
body: {}
@ -295,6 +355,7 @@ export function startOAuthLogin(providerId: string): Promise<OAuthStartResponse>
export function submitOAuthCode(providerId: string, sessionId: string, code: string): Promise<OAuthSubmitResponse> {
return window.hermesDesktop.api<OAuthSubmitResponse>({
...profileScoped(),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
method: 'POST',
body: { session_id: sessionId, code }
@ -303,12 +364,14 @@ export function submitOAuthCode(providerId: string, sessionId: string, code: str
export function pollOAuthSession(providerId: string, sessionId: string): Promise<OAuthPollResponse> {
return window.hermesDesktop.api<OAuthPollResponse>({
...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 +379,14 @@ export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }>
export function getSkills(): Promise<SkillInfo[]> {
return window.hermesDesktop.api<SkillInfo[]>({
...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 +395,7 @@ export function toggleSkill(name: string, enabled: boolean): Promise<{ ok: boole
export function getToolsets(): Promise<ToolsetInfo[]> {
return window.hermesDesktop.api<ToolsetInfo[]>({
...profileScoped(),
path: '/api/tools/toolsets'
})
}
@ -339,6 +405,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 +414,7 @@ export function toggleToolset(
export function getToolsetConfig(name: string): Promise<ToolsetConfig> {
return window.hermesDesktop.api<ToolsetConfig>({
...profileScoped(),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/config`
})
}
@ -356,6 +424,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 +554,16 @@ export function updateProfileSoul(name: string, content: string): Promise<{ ok:
})
}
export function getProfileSetupCommand(name: string): Promise<ProfileSetupCommand> {
return window.hermesDesktop.api<ProfileSetupCommand>({
path: `/api/profiles/${encodeURIComponent(name)}/setup-command`
})
}
export function getUsageAnalytics(days = 30): Promise<AnalyticsResponse> {
return window.hermesDesktop.api<AnalyticsResponse>({
...profileScoped(),
path: `/api/analytics/usage?days=${Math.max(1, Math.floor(days))}`
})
}
export function getGlobalModelOptions(): Promise<ModelOptionsResponse> {
return window.hermesDesktop.api<ModelOptionsResponse>({
...profileScoped(),
path: '/api/model/options'
})
}
@ -515,6 +580,7 @@ export interface RecommendedDefaultModel {
// free user gets a free model instead of a paid default.
export function getRecommendedDefaultModel(provider: string): Promise<RecommendedDefaultModel> {
return window.hermesDesktop.api<RecommendedDefaultModel>({
...profileScoped(),
path: `/api/model/recommended-default?provider=${encodeURIComponent(provider)}`
})
}
@ -524,6 +590,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 +603,14 @@ export function setGlobalModel(
export function getAuxiliaryModels(): Promise<AuxiliaryModelsResponse> {
return window.hermesDesktop.api<AuxiliaryModelsResponse>({
...profileScoped(),
path: '/api/model/auxiliary'
})
}
export function setModelAssignment(body: ModelAssignmentRequest): Promise<ModelAssignmentResponse> {
return window.hermesDesktop.api<ModelAssignmentResponse>({
...profileScoped(),
path: '/api/model/set',
method: 'POST',
body

View file

@ -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',

View file

@ -25,8 +25,10 @@ import type { HermesConnection } from '@/global'
* transport failure.
*/
export interface ResolveGatewayWsUrlDeps {
/** `window.hermesDesktop.getGatewayWsUrl`, if the preload exposes it. */
getGatewayWsUrl?: () => Promise<string>
/** `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<string>
}
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<HermesConnection, 'authMode' | 'wsUrl'>
conn: Pick<HermesConnection, 'authMode' | 'profile' | 'wsUrl'>
): Promise<string> {
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

View file

@ -87,6 +87,15 @@ export type HapticTrigger = (input?: HapticInput, options?: TriggerOptions) => P
let registeredTrigger: HapticTrigger | null = null
let lastSelectionAt = 0
// Global rolling rate-limit. A runaway upstream loop (auth-expiry error-toast
// storms, reconnect flaps) can request dozens of haptics a second, which the
// trackpad actuator renders as a frantic "clickity" buzz. Cap firings to
// RATE_LIMIT per RATE_WINDOW so no source can machine-gun the actuator;
// intentional UI haptics are human-paced and never approach the ceiling.
const RATE_WINDOW = 1000
const RATE_LIMIT = 5
let recentFires: number[] = []
export function registerHapticTrigger(trigger: HapticTrigger | null) {
registeredTrigger = trigger
}
@ -106,6 +115,14 @@ export function triggerHaptic(intent: HapticIntent = 'selection') {
lastSelectionAt = now
}
recentFires = recentFires.filter(t => now - t < RATE_WINDOW)
if (recentFires.length >= RATE_LIMIT) {
return
}
recentFires.push(now)
const config = HAPTIC_INTENTS[intent]
void registeredTrigger(config.pattern, config.options)?.catch(() => undefined)

View file

@ -0,0 +1,58 @@
// 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}%)`
}
// A profile's effective color: a user-picked override wins, else the
// deterministic hue. Default/empty stays neutral (null) regardless.
export function resolveProfileColor(
name: null | string | undefined,
overrides: Record<string, string>
): null | string {
const key = (name ?? '').trim()
if (!key || key === 'default') {
return null
}
return overrides[key] ?? profileColor(key)
}
// Curated swatches for the rail color picker — evenly spaced hues at the same
// saturation/lightness as the deterministic palette, so picks stay cohesive.
export const PROFILE_SWATCHES: readonly string[] = Array.from(
{ length: 12 },
(_, index) => `hsl(${index * 30} ${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)`
}

View file

@ -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
}
}
})

View file

@ -64,6 +64,36 @@ export function persistStringArray(key: string, value: string[]) {
}
}
export function storedStringRecord(key: string): Record<string, string> {
try {
const value = window.localStorage.getItem(key)
if (!value) {
return {}
}
const parsed = JSON.parse(value)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {}
}
return Object.fromEntries(
Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
)
} catch {
return {}
}
}
export function persistStringRecord(key: string, value: Record<string, string>) {
try {
window.localStorage.setItem(key, JSON.stringify(value))
} catch {
// Local preference; restricted storage should not break the app.
}
}
export function arraysEqual(left: string[], right: string[]) {
return left.length === right.length && left.every((item, index) => item === right[index])
}

View file

@ -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(
<StrictMode>
<ErrorBoundary label="root">

View file

@ -1,16 +1,290 @@
import type { ConnectionState, GatewayEvent } from '@hermes/shared'
import { atom } from 'nanostores'
import type { HermesGateway } from '@/hermes'
import { HermesGateway } from '@/hermes'
import { resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import { setGatewayState } from '@/store/session'
// ── Multi-profile gateway routing ──────────────────────────────────────────
// Concurrent sessions across profiles need concurrent sockets: the renderer's
// event handler is already session-keyed, so the only thing stopping two
// profiles streaming at once was the single swapping socket. We keep that one
// socket as the PRIMARY (window) backend — owned by use-gateway-boot, with all
// its boot-progress / sleep-wake machinery — and add one persistent SECONDARY
// socket per *other* profile that has live work. Every socket feeds the same
// handleGatewayEvent, so background sessions keep painting. Single-profile users
// only ever have the primary, so their path is byte-for-byte unchanged.
const normKey = (profile: string | null | undefined): string => (profile ?? '').trim() || 'default'
// Read connection state through a call so TS control-flow analysis doesn't
// narrow the getter to a constant across guards (it genuinely changes).
const isOpen = (gateway: HermesGateway | null): boolean => gateway?.connectionState === 'open'
// The active gateway instance, exposed for inline message-stream components
// (e.g. inline ClarifyTool) that need to call gateway methods without having
// the instance threaded down through props from `ChatView`.
// (e.g. inline ClarifyTool, model overlays) that call gateway methods without
// the instance threaded down through props.
export const $gateway = atom<HermesGateway | null>(null)
export function setGateway(gateway: HermesGateway | null): void {
if ($gateway.get() === gateway) {
interface RegistryConfig {
onEvent: (event: GatewayEvent) => void
}
let config: RegistryConfig | null = null
export function configureGatewayRegistry(cfg: RegistryConfig): void {
config = cfg
}
// ── Primary (window) backend ───────────────────────────────────────────────
let primaryGateway: HermesGateway | null = null
let primaryProfile = 'default'
export function setPrimaryGateway(gateway: HermesGateway | null, profile = 'default'): void {
primaryGateway = gateway
primaryProfile = normKey(profile)
}
// ── Secondary (pool) backends ──────────────────────────────────────────────
interface Secondary {
profile: string
gateway: HermesGateway
offEvent: () => void
offState: () => void
reconnectTimer: ReturnType<typeof setTimeout> | null
reconnectAttempt: number
reconnecting: boolean
// While true the entry auto-reconnects on drop; pruning flips it off so a
// deliberate close doesn't trigger the backoff loop.
wantOpen: boolean
}
const secondaries = new Map<string, Secondary>()
let activeKey = 'default'
export function isActivePrimary(): boolean {
return activeKey === primaryProfile
}
export function activeGateway(): HermesGateway | null {
if (activeKey === primaryProfile) {
return primaryGateway
}
return secondaries.get(activeKey)?.gateway ?? primaryGateway
}
// Mirror a backend's connection state into the global composer state, but only
// when that backend is the one the user is currently looking at. Lets the
// composer reflect the active profile's socket without a background reconnect
// flipping the foreground enabled/disabled state.
function reportGatewayState(profile: string, state: ConnectionState): void {
if (normKey(profile) === activeKey) {
setGatewayState(state)
}
}
export function reportPrimaryGatewayState(state: ConnectionState): void {
reportGatewayState(primaryProfile, state)
}
function setActive(profile: string): void {
activeKey = normKey(profile)
const gateway = activeGateway()
$gateway.set(gateway)
setGatewayState(gateway?.connectionState ?? 'closed')
}
function clearTimer(entry: Secondary): void {
if (entry.reconnectTimer !== null) {
clearTimeout(entry.reconnectTimer)
entry.reconnectTimer = null
}
}
async function openSecondary(entry: Secondary): Promise<void> {
const desktop = window.hermesDesktop
if (!desktop) {
return
}
$gateway.set(gateway)
const conn = await desktop.getConnection(entry.profile)
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await entry.gateway.connect(wsUrl)
void desktop.touchBackend?.(entry.profile).catch(() => undefined)
}
function scheduleReconnect(entry: Secondary): void {
if (entry.reconnecting || entry.reconnectTimer !== null || !entry.wantOpen) {
return
}
// 1s, 2s, 4s … capped at 15s — same backoff shape as the primary.
const delay = Math.min(15_000, 1_000 * 2 ** Math.min(entry.reconnectAttempt, 4))
entry.reconnectAttempt += 1
entry.reconnectTimer = setTimeout(() => {
entry.reconnectTimer = null
void reconnectSecondary(entry)
}, delay)
}
async function reconnectSecondary(entry: Secondary): Promise<void> {
if (entry.reconnecting || !entry.wantOpen || isOpen(entry.gateway)) {
return
}
entry.reconnecting = true
try {
await openSecondary(entry)
entry.reconnectAttempt = 0
} catch {
// Transport failure → fall through to the backoff below.
} finally {
entry.reconnecting = false
if (entry.wantOpen && !isOpen(entry.gateway)) {
scheduleReconnect(entry)
}
}
}
function createSecondary(profile: string): Secondary {
const gateway = new HermesGateway()
const entry: Secondary = {
profile,
gateway,
offEvent: () => {},
offState: () => {},
reconnectTimer: null,
reconnectAttempt: 0,
reconnecting: false,
wantOpen: true
}
entry.offEvent = gateway.onEvent(event => config?.onEvent(event))
entry.offState = gateway.onState(state => {
reportGatewayState(profile, state)
if (state === 'open') {
entry.reconnectAttempt = 0
clearTimer(entry)
} else if ((state === 'closed' || state === 'error') && entry.wantOpen) {
scheduleReconnect(entry)
}
})
secondaries.set(profile, entry)
return entry
}
// Make `profile` the active gateway, lazily opening its socket if needed. The
// primary is a no-op fast path. Background sockets are never closed here.
export async function ensureGatewayForProfile(profile: string): Promise<void> {
const key = normKey(profile)
if (key === primaryProfile) {
setActive(key)
return
}
let entry = secondaries.get(key)
if (!entry) {
entry = createSecondary(key)
}
entry.wantOpen = true
if (!isOpen(entry.gateway)) {
clearTimer(entry)
entry.reconnectAttempt = 0
try {
await openSecondary(entry)
} catch {
scheduleReconnect(entry)
}
}
setActive(key)
}
// Reconnect the active gateway after a transient request failure. Primary
// reconnects are owned by use-gateway-boot, so we only drive secondaries here.
export async function ensureActiveGatewayOpen(): Promise<HermesGateway | null> {
if (activeKey === primaryProfile) {
return primaryGateway
}
const entry = secondaries.get(activeKey)
if (!entry) {
return null
}
if (!isOpen(entry.gateway)) {
await reconnectSecondary(entry)
}
return isOpen(entry.gateway) ? entry.gateway : null
}
// Wake signal (sleep/network/visibility): nudge every live secondary back open.
export function reconnectSecondaryGateways(): void {
for (const entry of secondaries.values()) {
if (!entry.wantOpen || isOpen(entry.gateway)) {
continue
}
entry.reconnectAttempt = 0
clearTimer(entry)
void reconnectSecondary(entry)
}
}
// Keep the idle reaper from killing a backend we still need: ping every live
// secondary. The active one is pinged separately (touchActiveGatewayBackend).
export function touchSecondaryGateways(): void {
const desktop = window.hermesDesktop
for (const entry of secondaries.values()) {
if (entry.wantOpen) {
void desktop?.touchBackend?.(entry.profile).catch(() => undefined)
}
}
}
// Close + evict secondaries whose profile is neither active nor in `keep`
// (profiles with a running / needs-input session). Bounds cost to live work.
export function pruneSecondaryGateways(keep: Set<string>): void {
for (const [key, entry] of [...secondaries]) {
if (key === activeKey || keep.has(key)) {
continue
}
entry.wantOpen = false
clearTimer(entry)
entry.offEvent()
entry.offState()
entry.gateway.close()
secondaries.delete(key)
}
}
export function closeSecondaryGateways(): void {
for (const entry of secondaries.values()) {
entry.wantOpen = false
clearTimer(entry)
entry.offEvent()
entry.offState()
entry.gateway.close()
}
secondaries.clear()
}

View file

@ -0,0 +1,299 @@
import { atom, computed } from 'nanostores'
import { getProfiles, setApiRequestProfile } from '@/hermes'
import { queryClient } from '@/lib/query-client'
import {
arraysEqual,
persistBoolean,
persistStringArray,
persistStringRecord,
storedBoolean,
storedStringArray,
storedStringRecord
} from '@/lib/storage'
import { $gateway, ensureGatewayForProfile } from '@/store/gateway'
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<string>('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<ProfileInfo[]>([])
export function setActiveProfile(name: string): void {
$activeProfile.set(name || 'default')
}
// ── Rail order ─────────────────────────────────────────────────────────────
// User-defined order for the named (non-default) profile squares in the rail.
// Names absent from the list fall back to alphabetical, appended at the tail —
// so a freshly created profile lands at the end until the user drags it.
const PROFILE_ORDER_STORAGE_KEY = 'hermes.desktop.profileOrder'
export const $profileOrder = atom<string[]>(storedStringArray(PROFILE_ORDER_STORAGE_KEY))
$profileOrder.subscribe(value => persistStringArray(PROFILE_ORDER_STORAGE_KEY, [...value]))
export function setProfileOrder(names: string[]): void {
if (!arraysEqual($profileOrder.get(), names)) {
$profileOrder.set(names)
}
}
// Sort items by the stored order; unordered names alphabetise at the tail.
export function sortByProfileOrder<T extends { name: string }>(items: T[], order: string[]): T[] {
const rank = new Map(order.map((name, index) => [name, index]))
return [...items].sort((a, b) => {
const ra = rank.get(a.name)
const rb = rank.get(b.name)
if (ra != null && rb != null) {
return ra - rb
}
return ra != null ? -1 : rb != null ? 1 : a.name.localeCompare(b.name)
})
}
// ── Rail colors ────────────────────────────────────────────────────────────
// Optional per-profile color override (long-press a rail square to pick). Absent
// names fall back to the deterministic hue from profileColor(); a local-only
// cosmetic preference, so single-profile users never touch it.
const PROFILE_COLORS_STORAGE_KEY = 'hermes.desktop.profileColors'
export const $profileColors = atom<Record<string, string>>(storedStringRecord(PROFILE_COLORS_STORAGE_KEY))
$profileColors.subscribe(value => persistStringRecord(PROFILE_COLORS_STORAGE_KEY, value))
// Set (or, with null, clear) a profile's color override.
export function setProfileColor(name: string, color: null | string): void {
const key = normalizeProfileKey(name)
const next = { ...$profileColors.get() }
if (color) {
next[key] = color
} else {
delete next[key]
}
$profileColors.set(next)
}
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<void> {
try {
const res = await window.hermesDesktop.api<ActiveProfileResponse>({ 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<void> {
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<string>('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<string | null>(null)
// Bumped whenever the profile context actually changes (switch or create). The
// chat controller subscribes and drops to a fresh new-session draft, so the
// session you were in doesn't stay sticky across a profile switch.
export const $freshSessionRequest = atom(0)
function requestFreshSession(): void {
$freshSessionRequest.set($freshSessionRequest.get() + 1)
}
// 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
})
// Target profile while a gateway swap is mid-flight (spawning/reconnecting that
// profile's backend), else null. Drives the chat's "waking up <profile>" loader
// so a lazy spawn doesn't read as a hang. Single-profile users never swap.
export const $gatewaySwapTarget = atom<string | null>(null)
let gatewaySwitch: Promise<void> | null = null
// Make `profile`'s backend the active gateway, lazily opening its socket if it
// isn't live yet. Unlike the old single-socket swap, background profiles keep
// their sockets — so their sessions keep streaming concurrently. A null/empty
// target means "no explicit profile" → keep the current gateway (a plain new
// chat stays put; single-profile users never leave the primary).
export async function ensureGatewayProfile(profile: string | null | undefined): Promise<void> {
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 && $gateway.get()) {
return
}
// Serialize concurrent activations so two rapid session switches don't race
// the active pointer.
if (gatewaySwitch) {
await gatewaySwitch.catch(() => undefined)
if (normalizeProfileKey($activeGatewayProfile.get()) === target && $gateway.get()) {
return
}
}
$gatewaySwapTarget.set(target)
gatewaySwitch = (async () => {
// ensureGatewayForProfile opens (or reuses) the target's socket and points
// the active gateway at it — without closing the profile you came from.
await ensureGatewayForProfile(target)
$activeGatewayProfile.set(target)
})()
try {
await gatewaySwitch
} finally {
gatewaySwitch = null
$gatewaySwapTarget.set(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<boolean>(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)
// Switching profiles (or coming back from the all-profiles browse view) starts
// fresh; re-tapping the profile you're already in leaves your session be.
const switching = $showAllProfiles.get() || target !== normalizeProfileKey($activeGatewayProfile.get())
$showAllProfiles.set(false)
$newChatProfile.set(target)
if (switching) {
requestFreshSession()
}
void ensureGatewayProfile(target)
}
// Start a fresh session in `name` WITHOUT collapsing the "All profiles" browse
// view. Unlike selectProfile, it leaves $showAllProfiles untouched, so the
// unified sidebar stays put — used by the per-profile "+" in the all-profiles
// session list, where switching scope would throw away the browse state the user
// is in. Points new chats at the profile and opens its backend so the next
// message lands in the right place.
export function newSessionInProfile(name: string): void {
const target = normalizeProfileKey(name)
$newChatProfile.set(target)
requestFreshSession()
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)
}

View file

@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
$approvalRequest,
@ -12,13 +12,21 @@ import {
setSecretRequest,
setSudoRequest
} from './prompts'
import { $activeSessionId } from './session'
// Prompts are parked per-session; the exported $*Request views are scoped to the
// active session, so each test focuses the session it's asserting on.
beforeEach(() => {
$activeSessionId.set('s1')
})
afterEach(() => {
clearAllPrompts()
$activeSessionId.set(null)
})
describe('approval prompt store', () => {
it('holds the most recent session-keyed approval request', () => {
it('holds the active session-keyed approval request', () => {
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'recursive delete', sessionId: 's1' })
expect($approvalRequest.get()).toEqual({
@ -28,9 +36,20 @@ describe('approval prompt store', () => {
})
})
it('clears unconditionally (approval is session-keyed, no request id)', () => {
it('parks a background session prompt out of the active view', () => {
setApprovalRequest({ command: 'x', description: 'd', sessionId: 's2' })
// Not visible while s1 is focused …
expect($approvalRequest.get()).toBeNull()
// … but surfaces once the user switches to the session that raised it.
$activeSessionId.set('s2')
expect($approvalRequest.get()?.sessionId).toBe('s2')
})
it('clears the active session prompt', () => {
setApprovalRequest({ command: 'x', description: 'd', sessionId: 's1' })
clearApprovalRequest()
clearApprovalRequest('s1')
expect($approvalRequest.get()).toBeNull()
})
@ -38,21 +57,21 @@ describe('approval prompt store', () => {
describe('sudo prompt store', () => {
it('clears only when the request id matches the in-flight prompt', () => {
setSudoRequest({ requestId: 'abc' })
setSudoRequest({ requestId: 'abc', sessionId: 's1' })
// A stale clear for a different request must NOT drop the live prompt —
// otherwise a late response to a prior sudo ask would dismiss the current
// one and leave the agent blocked.
clearSudoRequest('stale')
expect($sudoRequest.get()).toEqual({ requestId: 'abc' })
clearSudoRequest('s1', 'stale')
expect($sudoRequest.get()).toEqual({ requestId: 'abc', sessionId: 's1' })
clearSudoRequest('abc')
clearSudoRequest('s1', 'abc')
expect($sudoRequest.get()).toBeNull()
})
it('clears unconditionally when no request id is given', () => {
setSudoRequest({ requestId: 'abc' })
clearSudoRequest()
setSudoRequest({ requestId: 'abc', sessionId: 's1' })
clearSudoRequest('s1')
expect($sudoRequest.get()).toBeNull()
})
@ -60,32 +79,43 @@ describe('sudo prompt store', () => {
describe('secret prompt store', () => {
it('carries env var and prompt, and clears on id match', () => {
setSecretRequest({ requestId: 'r1', envVar: 'OPENAI_API_KEY', prompt: 'Paste your key' })
setSecretRequest({ requestId: 'r1', envVar: 'OPENAI_API_KEY', prompt: 'Paste your key', sessionId: 's1' })
expect($secretRequest.get()).toEqual({
requestId: 'r1',
envVar: 'OPENAI_API_KEY',
prompt: 'Paste your key'
prompt: 'Paste your key',
sessionId: 's1'
})
clearSecretRequest('mismatch')
clearSecretRequest('s1', 'mismatch')
expect($secretRequest.get()).not.toBeNull()
clearSecretRequest('r1')
clearSecretRequest('s1', 'r1')
expect($secretRequest.get()).toBeNull()
})
})
describe('clearAllPrompts', () => {
it('drops every in-flight prompt at once (turn end / interrupt)', () => {
it('drops every kind for one session at once (turn end / interrupt)', () => {
setApprovalRequest({ command: 'x', description: 'd', sessionId: 's1' })
setSudoRequest({ requestId: 'abc' })
setSecretRequest({ requestId: 'r1', envVar: 'E', prompt: 'p' })
setSudoRequest({ requestId: 'abc', sessionId: 's1' })
setSecretRequest({ requestId: 'r1', envVar: 'E', prompt: 'p', sessionId: 's1' })
clearAllPrompts()
clearAllPrompts('s1')
expect($approvalRequest.get()).toBeNull()
expect($sudoRequest.get()).toBeNull()
expect($secretRequest.get()).toBeNull()
})
it('leaves other sessions parked prompts intact', () => {
setApprovalRequest({ command: 'x', description: 'd', sessionId: 's1' })
setApprovalRequest({ command: 'y', description: 'e', sessionId: 's2' })
clearAllPrompts('s1')
$activeSessionId.set('s2')
expect($approvalRequest.get()?.command).toBe('y')
})
})

View file

@ -1,86 +1,115 @@
import { atom } from 'nanostores'
import { atom, computed, type ReadableAtom } from 'nanostores'
import { $activeSessionId } from './session'
// Blocking interactive prompts the gateway raises mid-turn. Each maps to a
// `*.request` event the Python side emits while it blocks the agent thread
// waiting for a `*.respond` RPC. Without a renderer for these, the agent
// silently stalls until its timeout (default 5 min) and the tool is BLOCKED
// — the desktop app previously handled clarify.request but not these three,
// so dangerous-command approval, sudo, and secret prompts never surfaced.
// silently stalls until its timeout (default 5 min) and the tool is BLOCKED.
//
// Like clarify, every prompt is parked under the runtime session id that raised
// it (not one shared slot), so a *background* session running concurrently can
// raise an approval/sudo/secret prompt and have it wait — surfaced via the
// sidebar "needs input" badge — until the user switches to that chat. The
// exported $*Request view is scoped to the active session, so a background
// prompt never hijacks the foreground.
export interface ApprovalRequest {
command: string
description: string
const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
interface KeyedPrompt {
sessionId: string | null
}
// Approval is session-keyed on the backend (one in-flight approval per
// session, resolved via approval.respond {choice, session_id}). It carries
// no request_id, unlike sudo/secret which are _block()-style request/response.
export const $approvalRequest = atom<ApprovalRequest | null>(null)
export function setApprovalRequest(request: ApprovalRequest): void {
$approvalRequest.set(request)
interface PromptStore<T extends KeyedPrompt> {
$active: ReadableAtom<null | T>
clear: (sessionId?: string | null, requestId?: string) => void
reset: () => void
set: (request: T) => void
}
export function clearApprovalRequest(): void {
$approvalRequest.set(null)
// One per-session prompt kind: a map keyed by session, plus an active-session
// view for the overlays. `clear` drops one session's entry (a request-id
// mismatch is a no-op so a stale resolve can't wipe a newer prompt); with no
// session hint it drops every entry, optionally filtered by request id.
function keyedPromptStore<T extends KeyedPrompt>(): PromptStore<T> {
const $all = atom<Record<string, T>>({})
const idOf = (value: T): string | undefined => (value as { requestId?: string }).requestId
return {
$active: computed([$all, $activeSessionId], (all, activeId) => all[keyFor(activeId)] ?? null),
reset: () => $all.set({}),
set: request => $all.set({ ...$all.get(), [keyFor(request.sessionId)]: request }),
clear(sessionId, requestId) {
const all = $all.get()
if (sessionId !== undefined) {
const key = keyFor(sessionId)
const current = all[key]
if (current && !(requestId && idOf(current) !== requestId)) {
const next = { ...all }
delete next[key]
$all.set(next)
}
return
}
const next = Object.fromEntries(Object.entries(all).filter(([, v]) => requestId && idOf(v) !== requestId))
if (Object.keys(next).length !== Object.keys(all).length) {
$all.set(next as Record<string, T>)
}
}
}
}
export interface SudoRequest {
// Approval is session-keyed on the backend (one in-flight approval per session,
// resolved via approval.respond {choice, session_id}). It carries no request_id,
// unlike sudo/secret which are _block()-style request/response.
export interface ApprovalRequest extends KeyedPrompt {
command: string
description: string
}
export interface SudoRequest extends KeyedPrompt {
requestId: string
}
export const $sudoRequest = atom<SudoRequest | null>(null)
export function setSudoRequest(request: SudoRequest): void {
$sudoRequest.set(request)
}
export function clearSudoRequest(requestId?: string): void {
const current = $sudoRequest.get()
if (!current) {
return
}
if (requestId && current.requestId !== requestId) {
return
}
$sudoRequest.set(null)
}
export interface SecretRequest {
requestId: string
export interface SecretRequest extends KeyedPrompt {
envVar: string
prompt: string
requestId: string
}
export const $secretRequest = atom<SecretRequest | null>(null)
const approval = keyedPromptStore<ApprovalRequest>()
const sudo = keyedPromptStore<SudoRequest>()
const secret = keyedPromptStore<SecretRequest>()
export function setSecretRequest(request: SecretRequest): void {
$secretRequest.set(request)
}
export const $approvalRequest = approval.$active
export const setApprovalRequest = approval.set
export const clearApprovalRequest = approval.clear
export function clearSecretRequest(requestId?: string): void {
const current = $secretRequest.get()
export const $sudoRequest = sudo.$active
export const setSudoRequest = sudo.set
export const clearSudoRequest = sudo.clear
export const $secretRequest = secret.$active
export const setSecretRequest = secret.set
export const clearSecretRequest = secret.clear
// Drop in-flight prompts for `sessionId` (a turn ended) across all three kinds —
// or every parked prompt when no session is given (global reset / tests).
export function clearAllPrompts(sessionId?: string | null): void {
if (sessionId === undefined) {
approval.reset()
sudo.reset()
secret.reset()
if (!current) {
return
}
if (requestId && current.requestId !== requestId) {
return
}
$secretRequest.set(null)
}
// Drop every in-flight prompt. Called when a turn ends (message.complete /
// error) so a stale overlay can't linger past the turn that raised it — e.g.
// if the agent was interrupted while a prompt was open.
export function clearAllPrompts(): void {
$approvalRequest.set(null)
$sudoRequest.set(null)
$secretRequest.set(null)
approval.clear(sessionId)
sudo.clear(sessionId)
secret.clear(sessionId)
}

View file

@ -76,6 +76,11 @@ export const $connection = atom<HermesConnection | null>(null)
export const $gatewayState = atom('idle')
export const $sessions = atom<SessionInfo[]>([])
export const $sessionsTotal = atom<number>(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<Record<string, number>>({})
export const $sessionsLoading = atom(true)
export const $workingSessionIds = atom<string[]>([])
export const $activeSessionId = atom<string | null>(null)
@ -114,6 +119,8 @@ export const setConnection = (next: Updater<HermesConnection | null>) => updateA
export const setGatewayState = (next: Updater<string>) => updateAtom($gatewayState, next)
export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($sessions, next)
export const setSessionsTotal = (next: Updater<number>) => updateAtom($sessionsTotal, next)
export const setSessionProfileTotals = (next: Updater<Record<string, number>>) =>
updateAtom($sessionProfileTotals, next)
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
export const setWorkingSessionIds = (next: Updater<string[]>) => updateAtom($workingSessionIds, next)
export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($activeSessionId, next)

View file

@ -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<string, number>
/** 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<T = unknown> {
@ -277,6 +285,12 @@ 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
}
export interface SessionMessage {
@ -435,6 +449,8 @@ export interface CronJobUpdates {
}
export interface ProfileCreatePayload {
clone_all?: boolean
clone_from?: string
clone_from_default?: boolean
name: string
no_skills?: boolean
@ -450,10 +466,6 @@ export interface ProfileInfo {
skill_count: number
}
export interface ProfileSetupCommand {
command: string
}
export interface ProfileSoul {
content: string
exists: boolean

View file

@ -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:

View file

@ -1604,6 +1604,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:
@ -1621,6 +1622,114 @@ 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:
# Read-only: this loop runs on every sidebar refresh, so it must
# never DDL/write-lock another profile's live DB (see SessionDB
# read_only docstring).
db = SessionDB(db_path=db_path, read_only=True)
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):
"""Search sessions by ID plus full-text message content using FTS5.
@ -5080,15 +5189,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()
@ -5108,9 +5233,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:
@ -5136,6 +5260,9 @@ async def delete_session_endpoint(session_id: str):
class SessionRename(BaseModel):
title: Optional[str] = None
archived: Optional[bool] = 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}")
@ -5143,10 +5270,10 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):
"""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.
restores the session. Either 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:
@ -6573,6 +6700,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
@ -6733,13 +6865,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,
)

View file

@ -396,15 +396,35 @@ class SessionDB:
# Attempt a PASSIVE WAL checkpoint every N successful writes.
_CHECKPOINT_EVERY_N_WRITES = 50
def __init__(self, db_path: Path = None):
def __init__(self, db_path: Path = None, read_only: bool = False):
self.db_path = db_path or DEFAULT_DB_PATH
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self.read_only = read_only
self._lock = threading.Lock()
self._write_count = 0
self._fts_enabled = False
self._fts_unavailable_warned = False
try:
if read_only:
# Read-only attach for cross-profile aggregation: SELECT-only,
# so we skip schema init entirely (no DDL, no FTS probe, no
# column reconcile). Crucially this takes NO write lock, so
# polling another profile's live DB on every sidebar refresh
# never contends with that profile's running backend. The DB
# must already exist + be initialised (callers guard on
# db_path.exists()); a SELECT against an empty file raises and
# the caller degrades per-profile.
self._conn = sqlite3.connect(
f"file:{self.db_path}?mode=ro",
uri=True,
check_same_thread=False,
timeout=1.0,
isolation_level=None,
)
self._conn.row_factory = sqlite3.Row
return
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(
str(self.db_path),
check_same_thread=False,
@ -3172,26 +3192,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:

View file

@ -381,6 +381,30 @@ class TestWebServerEndpoints:
resp = self.client.patch("/api/sessions/no-fields", json={})
assert resp.status_code == 400
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
@ -1772,6 +1796,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

View file

@ -399,3 +399,124 @@ class TestShapePrecedence:
_seed_modpack_sessions(db)
result = json.loads(session_search(query=None, db=db)) # type: ignore
assert result["mode"] == "browse"
def test_session_id_without_anchor_reads(self, db):
_seed_modpack_sessions(db)
# session_id alone (no anchor, no query) → read shape, not browse.
result = json.loads(session_search(session_id="s_oldest", db=db))
assert result["mode"] == "read"
# =========================================================================
# Read shape — dump a whole session by id (serves @session links)
# =========================================================================
class TestReadShape:
def test_read_returns_full_session(self, db):
_seed_modpack_sessions(db)
result = json.loads(session_search(session_id="s_oldest", db=db))
assert result["success"] is True
assert result["mode"] == "read"
assert result["session_id"] == "s_oldest"
assert result["message_count"] == 5
assert result["truncated"] is False
assert len(result["messages"]) == 5
assert result["session_meta"]["title"] == "Building the Modpack"
def test_read_unknown_session_errors(self, db):
result = json.loads(session_search(session_id="ghost", db=db))
assert result["success"] is False
def test_read_truncates_large_session(self, db):
db.create_session("s_big", source="cli")
for i in range(50):
db.append_message("s_big", role="user" if i % 2 == 0 else "assistant", content=f"m{i}")
db._conn.commit()
result = json.loads(session_search(session_id="s_big", db=db))
assert result["mode"] == "read"
assert result["message_count"] == 50
assert result["truncated"] is True
assert len(result["messages"]) == 30 # head 20 + tail 10
# =========================================================================
# Cross-profile read — `profile` swaps in another profile's DB (read-only)
# =========================================================================
class TestCrossProfileRead:
def _patch_profiles(self, monkeypatch, home, exists=True):
from hermes_cli import profiles as profiles_mod
monkeypatch.setattr(profiles_mod, "normalize_profile_name", lambda n: n)
monkeypatch.setattr(profiles_mod, "validate_profile_name", lambda n: None)
monkeypatch.setattr(profiles_mod, "profile_exists", lambda n: exists)
monkeypatch.setattr(profiles_mod, "get_profile_dir", lambda n: home)
def test_profile_param_reads_other_db(self, db, tmp_path, monkeypatch):
other_home = tmp_path / "other_home"
other_home.mkdir()
other = SessionDB(other_home / "state.db")
other.create_session("s_other", source="cli")
other._conn.execute(
"UPDATE sessions SET title = ? WHERE id = ?", ("Other Profile Chat", "s_other")
)
other.append_message("s_other", role="user", content="hello from the other profile")
other._conn.commit()
self._patch_profiles(monkeypatch, other_home)
# s_other lives only in the other profile; the current `db` lacks it.
result = json.loads(session_search(session_id="s_other", profile="other", db=db))
assert result["success"] is True
assert result["mode"] == "read"
assert result["session_meta"]["title"] == "Other Profile Chat"
def test_bare_id_locates_across_profiles(self, db, tmp_path, monkeypatch):
# The real-world failure: model dropped the owning profile and passed a
# bare id. The tool must scan profiles and find it anyway.
other_home = tmp_path / "asdf_home"
other_home.mkdir()
other = SessionDB(other_home / "state.db")
other.create_session("s_far", source="cli")
other.append_message("s_far", role="user", content="hi")
other._conn.commit()
from collections import namedtuple
from hermes_cli import profiles as profiles_mod
Info = namedtuple("Info", "name path")
monkeypatch.setattr(profiles_mod, "get_profile_dir", lambda n: tmp_path / "default_home")
monkeypatch.setattr(profiles_mod, "list_profiles", lambda: [Info("asdf", other_home)])
# `db` (current profile) lacks s_far; no profile passed → scan finds it.
result = json.loads(session_search(session_id="s_far", db=db))
assert result["success"] is True
assert result["mode"] == "read"
assert result["profile"] == "asdf"
def test_unknown_profile_errors(self, db, monkeypatch, tmp_path):
self._patch_profiles(monkeypatch, tmp_path, exists=False)
result = json.loads(session_search(session_id="x", profile="ghost", db=db))
assert result["success"] is False
assert "ghost" in result.get("error", "")
def test_combined_value_autosplits(self, db, tmp_path, monkeypatch):
# Agent passed the raw "@session:<profile>/<id>" value as session_id with
# no separate profile — the tool should recover both.
other_home = tmp_path / "other_home"
other_home.mkdir()
other = SessionDB(other_home / "state.db")
other.create_session("s_other", source="cli")
other.append_message("s_other", role="user", content="hi")
other._conn.commit()
self._patch_profiles(monkeypatch, other_home)
# Every permutation the model might send must resolve to (asdf, s_other).
for kwargs in (
{"session_id": "asdf/s_other"}, # full value, no profile
{"session_id": "asdf/s_other", "profile": "asdf"}, # full value AND profile
{"session_id": "s_other", "profile": "asdf"}, # bare id + profile
):
result = json.loads(session_search(db=db, **kwargs))
assert result["success"] is True, kwargs
assert result["mode"] == "read"
assert result["session_id"] == "s_other"

View file

@ -107,6 +107,122 @@ def _shape_message(m: Dict[str, Any], anchor_id: Optional[int] = None) -> Dict[s
return {k: v for k, v in entry.items() if v is not None or k in ("content",)}
def _resolve_profile_db(profile: str):
"""Open another profile's ``state.db`` read-only, or None for the current one.
The desktop's ``@session:<profile>/<id>`` links always carry the source
profile, so a linked session from profile B can be read while the agent
runs in profile A. ``read_only=True`` (mode=ro) takes no write lock safe
to point at a live profile's DB, including our own. Returns None when no
profile is given (use the caller's default db).
"""
if profile is None or not str(profile).strip():
return None
from hermes_cli import profiles as profiles_mod
from hermes_state import SessionDB
canon = profiles_mod.normalize_profile_name(profile)
profiles_mod.validate_profile_name(canon)
if not profiles_mod.profile_exists(canon):
raise ValueError(f"profile '{canon}' does not exist")
return SessionDB(db_path=profiles_mod.get_profile_dir(canon) / "state.db", read_only=True)
def _locate_session_db(session_id: str):
"""Scan every profile's ``state.db`` (read-only) for a session id.
Returns ``(db, profile_name)`` for the first profile that owns the id, or
``(None, None)``. Session ids are globally unique (timestamp + random hex),
so the first hit is authoritative. This is the safety net for linked-session
reads where the model dropped the owning profile from the link and passed a
bare id we find it wherever it actually lives instead of failing.
"""
from pathlib import Path
try:
from hermes_cli import profiles as profiles_mod
from hermes_state import SessionDB
except Exception:
return None, None
targets = [("default", profiles_mod.get_profile_dir("default"))]
try:
targets += [(info.name, info.path) for info in profiles_mod.list_profiles()]
except Exception:
logging.debug("list_profiles failed during session locate", exc_info=True)
seen: set = set()
for name, home in targets:
db_path = Path(home) / "state.db"
key = str(db_path)
if key in seen or not db_path.exists():
continue
seen.add(key)
try:
pdb = SessionDB(db_path=db_path, read_only=True)
except Exception:
continue
try:
if pdb.get_session(session_id):
return pdb, name
except Exception:
logging.debug("get_session probe failed for %s in %s", session_id, name, exc_info=True)
pdb.close()
return None, None
def _read_session(db, session_id: str, head: int = 20, tail: int = 10) -> str:
"""Read shape: dump a whole session by id (head + tail when large).
Serves the linked-session case the user dropped an @session reference and
the agent wants the transcript. Bounded payload: small sessions return in
full, large ones return the first ``head`` and last ``tail`` messages with a
pointer to scroll the middle.
"""
try:
meta = db.get_session(session_id) or {}
except Exception as e:
logging.debug("get_session failed for %s: %s", session_id, e, exc_info=True)
meta = {}
if not meta:
return tool_error(f"session_id not found: {session_id}", success=False)
try:
rows = db.get_messages(session_id)
except Exception as e:
logging.error("get_messages failed for %s: %s", session_id, e, exc_info=True)
return tool_error(f"failed to load session: {e}", success=False)
shaped = [_shape_message(m) for m in rows]
total = len(shaped)
truncated = total > head + tail
window = shaped[:head] + shaped[-tail:] if truncated else shaped
response = {
"success": True,
"mode": "read",
"session_id": session_id,
"session_meta": {
"when": _format_timestamp(meta.get("started_at")),
"source": meta.get("source"),
"model": meta.get("model"),
"title": meta.get("title"),
},
"message_count": total,
"truncated": truncated,
"messages": window,
}
if truncated:
response["message"] = (
f"Session has {total} messages; showing first {head} + last {tail}. "
"Pass around_message_id (any id above) to scroll the middle."
)
return json.dumps(response, ensure_ascii=False)
def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str:
"""Return metadata for the most recent sessions (no LLM calls, no FTS5)."""
try:
@ -387,15 +503,19 @@ def session_search(
window: int = 5,
# Discovery shape
sort: str = None,
# Cross-profile (any shape)
profile: str = None,
) -> str:
"""Single-shape tool. Mode inferred from which args are set.
Discovery: pass ``query``.
Scroll: pass ``session_id`` + ``around_message_id``.
Read: pass ``session_id`` (no anchor) dumps the whole session.
Browse: pass nothing.
Scroll wins over discovery when both are set the agent has explicitly
asked for a slice of a known session.
Pass ``profile`` to read another profile's sessions (e.g. resolving an
``@session:<profile>/<id>`` link). Scroll wins over read/discovery when an
anchor is set the agent has asked for a specific slice.
"""
if db is None:
try:
@ -406,6 +526,30 @@ def session_search(
from hermes_state import format_session_db_unavailable
return tool_error(format_session_db_unavailable(), success=False)
# Normalise a raw `@session:<profile>/<id>` link value passed as session_id.
# Session ids never contain "/", so a slash unambiguously means profile/id —
# always strip the prefix off the id, and adopt the embedded profile only
# when one wasn't passed explicitly. Handles every permutation the model
# might send (full value as id, with or without a separate profile=).
if isinstance(session_id, str) and "/" in session_id:
emb_profile, _, emb_id = session_id.partition("/")
if emb_id:
session_id = emb_id
if emb_profile and (profile is None or not str(profile).strip()):
profile = emb_profile
# Cross-profile read: swap in the named profile's DB (read-only) for every
# shape below. The current-session-lineage guards no longer apply across
# profiles, but they key off ids that won't collide, so they stay inert.
if profile is not None and str(profile).strip():
try:
profile_db = _resolve_profile_db(profile)
except Exception as e:
return tool_error(f"profile '{profile}': {e}", success=False)
if profile_db is not None:
db = profile_db
current_session_id = None
# Scroll shape takes precedence — explicit anchor beats any query.
if (isinstance(session_id, str) and session_id.strip()) and around_message_id is not None:
return _scroll(
@ -416,6 +560,27 @@ def session_search(
current_session_id=current_session_id,
)
# Read shape: a session_id with no anchor → dump the whole session.
if isinstance(session_id, str) and session_id.strip():
sid = session_id.strip()
result = _read_session(db, sid)
if json.loads(result).get("success"):
return result
# Miss in the target profile — the model may have dropped the owning
# profile from the link. Scan every profile and read it from wherever
# it lives, tagging the profile it was found in.
located, owner = _locate_session_db(sid)
if located is not None:
try:
found = json.loads(_read_session(located, sid))
finally:
located.close()
if found.get("success"):
found["profile"] = owner
return json.dumps(found, ensure_ascii=False)
return result
# Limit clamp [1, 10]
if not isinstance(limit, int):
try:
@ -465,7 +630,7 @@ SESSION_SEARCH_SCHEMA = {
"Search past sessions stored in the local session DB, or scroll inside one. "
"FTS5-backed retrieval over the SQLite message store. No LLM calls — every "
"shape returns actual messages from the DB.\n\n"
"THREE CALLING SHAPES\n\n"
"FOUR CALLING SHAPES\n\n"
" 1) DISCOVERY — pass `query`:\n"
" session_search(query=\"auth refactor\", limit=3)\n"
" Runs FTS5, dedupes hits by session lineage, returns the top N sessions. "
@ -491,7 +656,13 @@ SESSION_SEARCH_SCHEMA = {
" - The boundary message appears in both windows — orientation marker.\n"
" - When messages_before or messages_after is < window, you're at the "
"start or end of the session.\n\n"
" 3) BROWSE — no args:\n"
" 3) READ — pass `session_id` only (no around_message_id):\n"
" session_search(session_id=\"...\", profile=\"work\")\n"
" Dumps the whole session by id (first 20 + last 10 messages when "
"large). This is how you resolve an `@session:<profile>/<id>` link the "
"user dropped into the chat: split the value on `/` into profile + id "
"and call session_search(session_id=id, profile=profile).\n\n"
" 4) BROWSE — no args:\n"
" session_search()\n"
" Returns recent sessions chronologically: titles, previews, timestamps. "
"Use when the user asks \"what was I working on\" without naming a topic.\n\n"
@ -573,6 +744,15 @@ SESSION_SEARCH_SCHEMA = {
"behaviour) or 'tool' to search tool output only."
),
},
"profile": {
"type": "string",
"description": (
"Optional. Read sessions from another Hermes profile's database "
"(read-only). Use when resolving an `@session:<profile>/<id>` link: "
"pass the profile segment here with session_id as the id segment. "
"Omit to use the current profile."
),
},
},
"required": [],
},
@ -594,6 +774,7 @@ registry.register(
around_message_id=args.get("around_message_id"),
window=args.get("window", 5),
sort=args.get("sort"),
profile=args.get("profile"),
db=kw.get("db"),
current_session_id=kw.get("current_session_id"),
),