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
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"),
),