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:
@ -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', () => {
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
45
apps/desktop/src/app/chat/chat-swap-overlay.tsx
Normal file
45
apps/desktop/src/app/chat/chat-swap-overlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
491
apps/desktop/src/app/chat/sidebar/profile-switcher.tsx
Normal file
491
apps/desktop/src/app/chat/sidebar/profile-switcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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' })
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
158
apps/desktop/src/app/profiles/create-profile-dialog.tsx
Normal file
158
apps/desktop/src/app/profiles/create-profile-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
apps/desktop/src/app/profiles/delete-profile-dialog.tsx
Normal file
58
apps/desktop/src/app/profiles/delete-profile-dialog.tsx
Normal 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?"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
121
apps/desktop/src/app/profiles/rename-profile-dialog.tsx
Normal file
121
apps/desktop/src/app/profiles/rename-profile-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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" />
|
||||
|
||||
@ -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' && (
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()} />)
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
25
apps/desktop/src/components/ui/action-status.tsx
Normal file
25
apps/desktop/src/components/ui/action-status.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
103
apps/desktop/src/components/ui/confirm-dialog.tsx
Normal file
103
apps/desktop/src/components/ui/confirm-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
44
apps/desktop/src/components/ui/popover.tsx
Normal file
44
apps/desktop/src/components/ui/popover.tsx
Normal 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 }
|
||||
@ -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 }
|
||||
|
||||
30
apps/desktop/src/global.d.ts
vendored
30
apps/desktop/src/global.d.ts
vendored
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
58
apps/desktop/src/lib/profile-color.ts
Normal file
58
apps/desktop/src/lib/profile-color.ts
Normal 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)`
|
||||
}
|
||||
13
apps/desktop/src/lib/query-client.ts
Normal file
13
apps/desktop/src/lib/query-client.ts
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -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])
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
299
apps/desktop/src/store/profile.ts
Normal file
299
apps/desktop/src/store/profile.ts
Normal 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)
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user