feat(desktop): per-session profile switching + cross-profile sessions
Add first-class profile support to the desktop app without app reloads. - Swap the single live gateway onto a session's profile lazily (spawned on demand by the Electron backend pool), so one backend serves the active profile and others stay cold — no OOM with many profiles. - Aggregate sessions across profiles by reading each profile's state.db read-only; unified "All profiles" view groups sessions per profile with per-profile pagination, while the default view stays scoped to one profile. - Add an Arc-style profile rail at the sidebar foot: a default<->all toggle pinned left, colored named-profile squares scrolling between, Manage pinned right. Profile identity is a deterministic per-name color. - Route profile-scoped REST (config/env/skills/tools/model) to the active gateway profile and invalidate React Query caches on swap. Single-profile users never trigger a swap, so their path is unchanged. Backend: - web_server: profile-aware active/list endpoints + per-profile session totals; hermes_state: session_count(exclude_children); main.py: honor --profile over HERMES_HOME env for pooled backends. UI primitives: - Add a position-aware Tip tooltip (instant, themed) as a drop-in for native title=, and strip redundant tooltips from self-descriptive chrome.
This commit is contained in:
parent
62f0cfd902
commit
b94b3622b5
52 changed files with 2517 additions and 796 deletions
|
|
@ -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,19 @@ function registerMediaProtocol() {
|
|||
let mainWindow = null
|
||||
let hermesProcess = null
|
||||
let connectionPromise = null
|
||||
// Additional per-profile backends, keyed by profile name. The PRIMARY backend
|
||||
// (the desktop's launch profile) stays managed by hermesProcess +
|
||||
// connectionPromise + startHermes(); this pool only holds EXTRA profile
|
||||
// backends spawned lazily when a session belongs to a different profile. A user
|
||||
// with no named profiles never populates this map, so their experience is
|
||||
// byte-for-byte the single-backend behavior.
|
||||
const backendPool = new Map() // profile -> { process, port, token, connectionPromise, lastActiveAt }
|
||||
// Keep the pool light: cap concurrent profile backends (LRU eviction) and reap
|
||||
// idle ones. A user idles at exactly the primary backend; pool backends only
|
||||
// exist while a non-primary profile is actively being chatted through.
|
||||
const POOL_MAX_BACKENDS = Math.max(1, Number(process.env.HERMES_DESKTOP_POOL_MAX) || 3)
|
||||
const POOL_IDLE_MS = Math.max(60_000, Number(process.env.HERMES_DESKTOP_POOL_IDLE_MS) || 10 * 60_000)
|
||||
let poolIdleReaper = null
|
||||
// Auto-reload budget for renderer crashes. A deterministic startup crash would
|
||||
// otherwise loop forever (reload → crash → reload), pinning CPU and spamming
|
||||
// logs. Allow a few reloads per rolling window, then stop and leave the dead
|
||||
|
|
@ -1452,8 +1475,20 @@ async function applyUpdatesPosixInApp() {
|
|||
// reap must spare it. Hand the live backend's PID to the update process;
|
||||
// _kill_stale_dashboard_processes reads HERMES_DESKTOP_CHILD_PID and excludes
|
||||
// it while still reaping any genuinely-orphaned dashboards. (#37532)
|
||||
// Exclude every desktop-managed backend (primary + all pool profiles) from
|
||||
// the update reaper. _kill_stale_dashboard_processes accepts a comma-separated
|
||||
// list (a single int still parses for back-compat).
|
||||
const desktopChildPids = []
|
||||
if (hermesProcess && Number.isInteger(hermesProcess.pid)) {
|
||||
env.HERMES_DESKTOP_CHILD_PID = String(hermesProcess.pid)
|
||||
desktopChildPids.push(hermesProcess.pid)
|
||||
}
|
||||
for (const entry of backendPool.values()) {
|
||||
if (entry.process && Number.isInteger(entry.process.pid)) {
|
||||
desktopChildPids.push(entry.process.pid)
|
||||
}
|
||||
}
|
||||
if (desktopChildPids.length) {
|
||||
env.HERMES_DESKTOP_CHILD_PID = desktopChildPids.join(',')
|
||||
}
|
||||
|
||||
// Branch-pin so a non-main checkout doesn't get switched to main (and self-heal
|
||||
|
|
@ -3363,8 +3398,14 @@ async function mintGatewayWsTicket(baseUrl) {
|
|||
// calls this immediately before every gateway.connect() so each WS upgrade
|
||||
// carries a freshly-minted ticket. For local/token connections this just
|
||||
// reuses the static token (no minting needed).
|
||||
async function freshGatewayWsUrl() {
|
||||
const connection = await startHermes()
|
||||
async function freshGatewayWsUrl(profile) {
|
||||
// Mint for the requested profile's backend, NOT always the primary. The
|
||||
// renderer re-mints right before every gateway.connect(); when swapping to a
|
||||
// pooled profile we must return THAT backend's ws URL, otherwise the connect
|
||||
// silently lands back on the primary (default) backend and writes sessions to
|
||||
// the wrong profile's DB. A null/empty profile resolves to the primary, so
|
||||
// legacy callers and single-profile users are unchanged.
|
||||
const connection = await ensureBackend(profile)
|
||||
if (connection.authMode === 'oauth') {
|
||||
const ticket = await mintGatewayWsTicket(connection.baseUrl)
|
||||
return buildGatewayWsUrlWithTicket(connection.baseUrl, ticket)
|
||||
|
|
@ -3448,6 +3489,38 @@ function writeDesktopConnectionConfig(config) {
|
|||
connectionConfigCacheMtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs
|
||||
}
|
||||
|
||||
// Returns the desktop's chosen profile name, or null when unset. "default" is
|
||||
// a valid stored value (pins the root HERMES_HOME explicitly); null means "no
|
||||
// preference" and preserves the legacy launch (no --profile flag).
|
||||
function readActiveDesktopProfile() {
|
||||
try {
|
||||
const raw = fs.readFileSync(DESKTOP_PROFILE_CONFIG_PATH, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
const name = parsed && typeof parsed.profile === 'string' ? parsed.profile.trim() : ''
|
||||
|
||||
if (name && (name === 'default' || PROFILE_NAME_RE.test(name))) {
|
||||
return name
|
||||
}
|
||||
} catch {
|
||||
// Missing or malformed → no preference.
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function writeActiveDesktopProfile(name) {
|
||||
const value = typeof name === 'string' ? name.trim() : ''
|
||||
|
||||
if (value && value !== 'default' && !PROFILE_NAME_RE.test(value)) {
|
||||
throw new Error(`Invalid profile name: ${value}`)
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(DESKTOP_PROFILE_CONFIG_PATH), { recursive: true })
|
||||
writeFileAtomic(DESKTOP_PROFILE_CONFIG_PATH, JSON.stringify({ profile: value || null }, null, 2))
|
||||
|
||||
return value || null
|
||||
}
|
||||
|
||||
async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) {
|
||||
const remoteToken = decryptDesktopSecret(config.remote?.token)
|
||||
const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
|
|
@ -3712,6 +3785,206 @@ function resetHermesConnection() {
|
|||
resetBootProgressForReconnect()
|
||||
}
|
||||
|
||||
// Re-home the primary backend: reset connection state, then wait for the live
|
||||
// dashboard process to actually exit (SIGKILL after 5s) so the next
|
||||
// startHermes() spawns fresh instead of racing the dying one. Shared by the
|
||||
// connection-config and profile switch flows.
|
||||
async function teardownPrimaryBackendAndWait() {
|
||||
// Capture the reference before resetHermesConnection() nulls hermesProcess.
|
||||
const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null
|
||||
resetHermesConnection()
|
||||
|
||||
if (!dying) {
|
||||
return
|
||||
}
|
||||
|
||||
await new Promise(resolve => {
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
dying.kill('SIGKILL')
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
resolve()
|
||||
}, 5000)
|
||||
dying.once('exit', () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// The profile the primary (window) backend runs as. readActiveDesktopProfile()
|
||||
// returns the desktop's stored preference, or null when unset (legacy launch
|
||||
// that defers to active_profile / default).
|
||||
function primaryProfileKey() {
|
||||
return readActiveDesktopProfile() || 'default'
|
||||
}
|
||||
|
||||
// Resolve a backend connection for the given profile. Routes the primary
|
||||
// profile to startHermes() (the window backend: boot UI, bootstrap, remote
|
||||
// mode), and any OTHER profile to a lazily-spawned pool backend. An empty /
|
||||
// unknown profile resolves to the primary, so all legacy callers are unchanged.
|
||||
async function ensureBackend(profile) {
|
||||
const key = profile && String(profile).trim() ? String(profile).trim() : primaryProfileKey()
|
||||
|
||||
if (key === primaryProfileKey()) {
|
||||
return startHermes()
|
||||
}
|
||||
|
||||
const existing = backendPool.get(key)
|
||||
if (existing) {
|
||||
existing.lastActiveAt = Date.now()
|
||||
return existing.connectionPromise
|
||||
}
|
||||
|
||||
evictLruPoolBackends(POOL_MAX_BACKENDS - 1)
|
||||
|
||||
const entry = { process: null, port: null, token: null, connectionPromise: null, lastActiveAt: Date.now() }
|
||||
entry.connectionPromise = spawnPoolBackend(key, entry).catch(error => {
|
||||
backendPool.delete(key)
|
||||
throw error
|
||||
})
|
||||
backendPool.set(key, entry)
|
||||
startPoolIdleReaper()
|
||||
return entry.connectionPromise
|
||||
}
|
||||
|
||||
// Mark a pool profile as recently used so the idle reaper spares it. The
|
||||
// renderer calls this when it opens a profile's chat WS and periodically while
|
||||
// streaming, since the main process can't see the direct renderer↔backend WS.
|
||||
function touchPoolBackend(profile) {
|
||||
const key = profile && String(profile).trim() ? String(profile).trim() : null
|
||||
if (!key) return
|
||||
const entry = backendPool.get(key)
|
||||
if (entry) entry.lastActiveAt = Date.now()
|
||||
}
|
||||
|
||||
// Evict least-recently-used pool backends until at most `keep` remain.
|
||||
function evictLruPoolBackends(keep) {
|
||||
if (backendPool.size <= keep) return
|
||||
const ordered = [...backendPool.entries()].sort(
|
||||
(a, b) => (a[1].lastActiveAt || 0) - (b[1].lastActiveAt || 0)
|
||||
)
|
||||
while (ordered.length > Math.max(0, keep)) {
|
||||
const [profile] = ordered.shift()
|
||||
rememberLog(`Evicting idle profile backend "${profile}" (LRU cap ${POOL_MAX_BACKENDS})`)
|
||||
stopPoolBackend(profile)
|
||||
}
|
||||
}
|
||||
|
||||
function startPoolIdleReaper() {
|
||||
if (poolIdleReaper) return
|
||||
poolIdleReaper = setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [profile, entry] of [...backendPool.entries()]) {
|
||||
if (now - (entry.lastActiveAt || 0) > POOL_IDLE_MS) {
|
||||
rememberLog(`Reaping idle profile backend "${profile}" (idle > ${Math.round(POOL_IDLE_MS / 1000)}s)`)
|
||||
stopPoolBackend(profile)
|
||||
}
|
||||
}
|
||||
if (backendPool.size === 0 && poolIdleReaper) {
|
||||
clearInterval(poolIdleReaper)
|
||||
poolIdleReaper = null
|
||||
}
|
||||
}, 60_000)
|
||||
if (typeof poolIdleReaper.unref === 'function') poolIdleReaper.unref()
|
||||
}
|
||||
|
||||
// Spawn an additional dashboard backend pinned to a named profile. Mirrors the
|
||||
// local-spawn portion of startHermes() but without the boot-progress UI,
|
||||
// bootstrap, or remote handling (those belong to the primary backend only).
|
||||
async function spawnPoolBackend(profile, entry) {
|
||||
// Remote deployments are single-tenant; profiles only apply to local backends.
|
||||
const remote = await resolveRemoteBackend()
|
||||
if (remote) {
|
||||
throw new Error('Profiles are unavailable when connected to a remote Hermes backend.')
|
||||
}
|
||||
|
||||
const port = await pickPort()
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
|
||||
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
|
||||
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
|
||||
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||||
const hermesCwd = resolveHermesCwd()
|
||||
const webDist = resolveWebDist()
|
||||
|
||||
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
|
||||
|
||||
const child = spawn(backend.command, backend.args, {
|
||||
cwd: hermesCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME,
|
||||
...backend.env,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
HERMES_WEB_DIST: webDist
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
entry.process = child
|
||||
entry.port = port
|
||||
entry.token = token
|
||||
|
||||
child.stdout.on('data', rememberLog)
|
||||
child.stderr.on('data', rememberLog)
|
||||
|
||||
let ready = false
|
||||
let rejectStart = null
|
||||
const startFailed = new Promise((_resolve, reject) => {
|
||||
rejectStart = reject
|
||||
})
|
||||
child.once('error', error => {
|
||||
rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`)
|
||||
backendPool.delete(profile)
|
||||
rejectStart?.(error)
|
||||
})
|
||||
child.once('exit', (code, signal) => {
|
||||
rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`)
|
||||
backendPool.delete(profile)
|
||||
if (!ready) {
|
||||
rejectStart?.(new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`))
|
||||
}
|
||||
})
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await Promise.race([waitForHermes(baseUrl, token), startFailed])
|
||||
ready = true
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'local',
|
||||
source: 'local',
|
||||
authMode: 'token',
|
||||
token,
|
||||
profile,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
}
|
||||
|
||||
function stopPoolBackend(profile) {
|
||||
const entry = backendPool.get(profile)
|
||||
if (!entry) return
|
||||
backendPool.delete(profile)
|
||||
if (entry.process && !entry.process.killed) {
|
||||
try {
|
||||
entry.process.kill('SIGTERM')
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopAllPoolBackends() {
|
||||
for (const profile of [...backendPool.keys()]) {
|
||||
stopPoolBackend(profile)
|
||||
}
|
||||
}
|
||||
|
||||
async function startHermes() {
|
||||
// Latched-failure short-circuit: once bootstrap has failed in this
|
||||
// process, every subsequent startHermes() call re-throws the same error
|
||||
|
|
@ -3753,6 +4026,15 @@ async function startHermes() {
|
|||
const port = await pickPort()
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
|
||||
// Pin the desktop's chosen profile via the global --profile flag. This is
|
||||
// deterministic (it wins over the sticky ~/.hermes/active_profile file) and
|
||||
// resolves HERMES_HOME the same way `hermes -p <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 +4278,12 @@ function createWindow() {
|
|||
})
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:connection', async () => startHermes())
|
||||
ipcMain.handle('hermes:gateway:ws-url', async () => freshGatewayWsUrl())
|
||||
ipcMain.handle('hermes:connection', async (_event, profile) => ensureBackend(profile))
|
||||
ipcMain.handle('hermes:backend:touch', async (_event, profile) => {
|
||||
touchPoolBackend(profile)
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile))
|
||||
ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||
// Renderer's "Reload and retry" path. Clear the latched failure and
|
||||
// reset connection state so the next startHermes() call restarts the
|
||||
|
|
@ -4077,28 +4363,25 @@ ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
|
|||
const config = coerceDesktopConnectionConfig(payload)
|
||||
writeDesktopConnectionConfig(config)
|
||||
|
||||
// Capture the reference before resetHermesConnection() nulls hermesProcess,
|
||||
// so we can wait for actual exit rather than assuming a fixed delay is enough.
|
||||
const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null
|
||||
resetHermesConnection()
|
||||
|
||||
if (dying) {
|
||||
await new Promise(resolve => {
|
||||
const timer = setTimeout(() => {
|
||||
try { dying.kill('SIGKILL') } catch {}
|
||||
resolve()
|
||||
}, 5000)
|
||||
dying.once('exit', () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
await teardownPrimaryBackendAndWait()
|
||||
|
||||
mainWindow?.reload()
|
||||
return sanitizeDesktopConnectionConfig(config)
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:profile:get', async () => ({ profile: readActiveDesktopProfile() }))
|
||||
ipcMain.handle('hermes:profile:set', async (_event, name) => {
|
||||
const next = writeActiveDesktopProfile(name)
|
||||
|
||||
// Switching profiles is a backend re-home: relaunch the dashboard under the
|
||||
// new HERMES_HOME. Pool backends keep their own homes, so only the primary
|
||||
// is torn down.
|
||||
await teardownPrimaryBackendAndWait()
|
||||
mainWindow?.reload()
|
||||
|
||||
return { profile: next }
|
||||
})
|
||||
|
||||
ipcMain.on('hermes:previewShortcutActive', (_event, active) => {
|
||||
previewShortcutActive = Boolean(active)
|
||||
})
|
||||
|
|
@ -4112,7 +4395,7 @@ ipcMain.handle('hermes:requestMicrophoneAccess', async () => {
|
|||
})
|
||||
|
||||
ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
const connection = await startHermes()
|
||||
const connection = await ensureBackend(request?.profile)
|
||||
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||
const url = `${connection.baseUrl}${request.path}`
|
||||
// OAuth gateways authenticate REST via the HttpOnly session cookie held in
|
||||
|
|
@ -4653,6 +4936,7 @@ app.on('before-quit', () => {
|
|||
if (hermesProcess && !hermesProcess.killed) {
|
||||
hermesProcess.kill('SIGTERM')
|
||||
}
|
||||
stopAllPoolBackends()
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
|
|
|
|||
|
|
@ -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,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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import { useLocation } from 'react-router-dom'
|
|||
|
||||
import { Thread } from '@/components/assistant-ui/thread'
|
||||
import { Backdrop } from '@/components/Backdrop'
|
||||
import { NotificationStack } from '@/components/notifications'
|
||||
import { PromptOverlays } from '@/components/prompt-overlays'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
|
|
@ -325,7 +324,6 @@ export function ChatView({
|
|||
selectedSessionId={selectedSessionId}
|
||||
/>
|
||||
|
||||
<NotificationStack />
|
||||
<PromptOverlays />
|
||||
|
||||
<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 { cn } from '@/lib/utils'
|
||||
import {
|
||||
$panesFlipped,
|
||||
|
|
@ -51,8 +53,10 @@ import {
|
|||
SIDEBAR_SESSIONS_PAGE_SIZE,
|
||||
unpinSession
|
||||
} from '@/store/layout'
|
||||
import { $newChatProfile, $profiles, $profileScope, ALL_PROFILES, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$selectedStoredSessionId,
|
||||
$sessionProfileTotals,
|
||||
$sessions,
|
||||
$sessionsLoading,
|
||||
$sessionsTotal,
|
||||
|
|
@ -64,6 +68,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'
|
||||
|
||||
|
|
@ -93,6 +98,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}`
|
||||
|
|
@ -200,6 +208,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
|
||||
|
|
@ -210,6 +219,7 @@ export function ChatSidebar({
|
|||
currentView,
|
||||
onNavigate,
|
||||
onLoadMoreSessions,
|
||||
onLoadMoreProfileSessions,
|
||||
onResumeSession,
|
||||
onDeleteSession,
|
||||
onArchiveSession,
|
||||
|
|
@ -225,12 +235,20 @@ 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
|
||||
const showAllProfiles = 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
|
||||
|
|
@ -259,7 +277,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])
|
||||
|
||||
|
|
@ -268,7 +298,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)) {
|
||||
|
|
@ -277,7 +307,7 @@ export function ChatSidebar({
|
|||
}
|
||||
|
||||
return map
|
||||
}, [sessions])
|
||||
}, [visibleSessions])
|
||||
|
||||
const pinnedSessions = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
|
|
@ -366,11 +396,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 +555,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 +568,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 +586,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 +662,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 +683,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 +727,16 @@ 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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{multiProfile && sidebarOpen && (
|
||||
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
|
||||
<ProfileRail />
|
||||
</div>
|
||||
)}
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
)
|
||||
|
|
@ -667,6 +805,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 +994,62 @@ 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>
|
||||
<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"
|
||||
onClick={() => onNewSession(group.path)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="add" size="0.75rem" />
|
||||
</button>
|
||||
</Tip>
|
||||
)}
|
||||
{reorderable && (
|
||||
<span
|
||||
|
|
@ -904,17 +1072,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 +1133,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>
|
||||
)
|
||||
|
|
|
|||
143
apps/desktop/src/app/chat/sidebar/profile-switcher.tsx
Normal file
143
apps/desktop/src/app/chat/sidebar/profile-switcher.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { profileColor, profileColorSoft } from '@/lib/profile-color'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
$profiles,
|
||||
$profileScope,
|
||||
ALL_PROFILES,
|
||||
normalizeProfileKey,
|
||||
refreshActiveProfile,
|
||||
selectProfile,
|
||||
setShowAllProfiles
|
||||
} from '@/store/profile'
|
||||
|
||||
import { PROFILES_ROUTE } from '../../routes'
|
||||
|
||||
// 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. Only mounted
|
||||
// when >1 profile exists, so single-profile users never see it.
|
||||
export function ProfileRail() {
|
||||
const profiles = useStore($profiles)
|
||||
const scope = useStore($profileScope)
|
||||
const gatewayProfile = useStore($activeGatewayProfile)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const isAll = scope === ALL_PROFILES
|
||||
const activeKey = normalizeProfileKey(gatewayProfile)
|
||||
const defaultProfile = profiles.find(profile => profile.is_default)
|
||||
const onDefault = !isAll && activeKey === 'default'
|
||||
|
||||
const named = profiles.filter(profile => !profile.is_default).sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
// 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. */}
|
||||
{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)} />
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{named.map(profile => (
|
||||
<ProfileSquare
|
||||
active={!isAll && normalizeProfileKey(profile.name) === activeKey}
|
||||
color={profileColor(profile.name)}
|
||||
key={profile.name}
|
||||
label={profile.name}
|
||||
onSelect={() => selectProfile(profile.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ProfilePill active={false} glyph="ellipsis" label="Manage profiles…" onSelect={() => navigate(PROFILES_ROUTE)} />
|
||||
</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
|
||||
}
|
||||
|
||||
// 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.
|
||||
function ProfileSquare({ active, color, label, onSelect }: ProfileSquareProps) {
|
||||
const hue = color ?? 'var(--ui-text-quaternary)'
|
||||
|
||||
return (
|
||||
<Tip label={label}>
|
||||
<button
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'grid size-5 shrink-0 place-items-center rounded-[3px] text-[0.5625rem] font-semibold uppercase leading-none transition-opacity hover:opacity-100',
|
||||
active ? 'opacity-100' : 'opacity-55'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
style={{
|
||||
backgroundColor: profileColorSoft(hue, active ? 30 : 22),
|
||||
boxShadow: active ? `inset 0 0 0 1.5px ${hue}` : undefined,
|
||||
color: color ?? undefined
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{label.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
|
||||
</button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
|
@ -15,16 +15,27 @@ import {
|
|||
} from '@/components/ui/dialog'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { renameSession } from '@/hermes'
|
||||
import { renameSession, setSessionIcon } from '@/hermes'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { setSessions } from '@/store/session'
|
||||
|
||||
// Curated glyphs for the per-session icon picker. Kept short so sessions from
|
||||
// different profiles stay visually distinguishable at a glance.
|
||||
const SESSION_ICON_CHOICES = [
|
||||
'🦊', '🐙', '🦉', '🐝', '🐢', '🦄', '🚀', '⚡',
|
||||
'🔥', '🌙', '⭐', '🎯', '🧪', '🔭', '🛠️', '🔒',
|
||||
'💼', '📦', '🎨', '🧠'
|
||||
] as const
|
||||
|
||||
interface SessionActions {
|
||||
sessionId: string
|
||||
title: string
|
||||
pinned?: boolean
|
||||
profile?: string
|
||||
icon?: null | string
|
||||
onPin?: () => void
|
||||
onArchive?: () => void
|
||||
onDelete?: () => void
|
||||
|
|
@ -41,8 +52,18 @@ interface ItemSpec {
|
|||
variant?: 'destructive'
|
||||
}
|
||||
|
||||
function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive, onDelete }: SessionActions) {
|
||||
function useSessionActions({
|
||||
sessionId,
|
||||
title,
|
||||
pinned = false,
|
||||
profile,
|
||||
icon,
|
||||
onPin,
|
||||
onArchive,
|
||||
onDelete
|
||||
}: SessionActions) {
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
const [iconOpen, setIconOpen] = useState(false)
|
||||
|
||||
const items: ItemSpec[] = [
|
||||
{
|
||||
|
|
@ -82,6 +103,15 @@ function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive,
|
|||
setRenameOpen(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'symbol-color',
|
||||
label: 'Icon',
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
setIconOpen(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
disabled: !onArchive,
|
||||
icon: 'archive',
|
||||
|
|
@ -113,7 +143,23 @@ 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}
|
||||
/>
|
||||
<IconPickerDialog
|
||||
currentIcon={icon ?? null}
|
||||
onOpenChange={setIconOpen}
|
||||
open={iconOpen}
|
||||
profile={profile}
|
||||
sessionId={sessionId}
|
||||
title={title}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
return { renameDialog, renderItems }
|
||||
|
|
@ -170,9 +216,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 +247,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' })
|
||||
|
|
@ -247,3 +294,67 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle }: Re
|
|||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
interface IconPickerDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
sessionId: string
|
||||
title: string
|
||||
currentIcon: null | string
|
||||
profile?: string
|
||||
}
|
||||
|
||||
function IconPickerDialog({ open, onOpenChange, sessionId, title, currentIcon, profile }: IconPickerDialogProps) {
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const apply = async (icon: string) => {
|
||||
if (!sessionId || submitting) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
const result = await setSessionIcon(sessionId, icon, profile)
|
||||
const finalIcon = result.icon ?? null
|
||||
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, icon: finalIcon } : s)))
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not set icon')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Session icon</DialogTitle>
|
||||
<DialogDescription>Pick a glyph for “{title || 'this session'}” so it stands out in the list.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-8 gap-1.5">
|
||||
{SESSION_ICON_CHOICES.map(glyph => (
|
||||
<button
|
||||
className={cn(
|
||||
'grid aspect-square place-items-center rounded-md border border-transparent text-lg transition-colors hover:bg-(--ui-control-hover-background)',
|
||||
currentIcon === glyph && 'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background)'
|
||||
)}
|
||||
disabled={submitting}
|
||||
key={glyph}
|
||||
onClick={() => void apply(glyph)}
|
||||
type="button"
|
||||
>
|
||||
{glyph}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button disabled={submitting || !currentIcon} onClick={() => void apply('')} type="button" variant="ghost">
|
||||
Clear icon
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,10 +70,12 @@ export function SidebarSessionRow({
|
|||
|
||||
return (
|
||||
<SessionContextMenu
|
||||
icon={session.icon}
|
||||
onArchive={onArchive}
|
||||
onDelete={onDelete}
|
||||
onPin={onPin}
|
||||
pinned={isPinned}
|
||||
profile={session.profile}
|
||||
sessionId={session.id}
|
||||
title={title}
|
||||
>
|
||||
|
|
@ -123,8 +125,10 @@ 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'
|
||||
|
|
@ -152,10 +156,15 @@ 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">
|
||||
{session.icon ? (
|
||||
<span aria-hidden="true" className="shrink-0 text-xs leading-none">
|
||||
{session.icon}
|
||||
</span>
|
||||
) : null}
|
||||
<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>
|
||||
|
|
@ -166,10 +175,12 @@ export function SidebarSessionRow({
|
|||
</span>
|
||||
)}
|
||||
<SessionActionsMenu
|
||||
icon={session.icon}
|
||||
onArchive={onArchive}
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { Input } from '@/components/ui/input'
|
|||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import {
|
||||
createCronJob,
|
||||
type CronJob,
|
||||
|
|
@ -591,16 +592,23 @@ function CronJobRow({
|
|||
)
|
||||
}
|
||||
|
||||
function IconAction({ children, className, ...props }: Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'>) {
|
||||
function IconAction({
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'>) {
|
||||
return (
|
||||
<Button
|
||||
className={cn('text-muted-foreground hover:text-foreground', className)}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
<Tip label={title}>
|
||||
<Button
|
||||
className={cn('text-muted-foreground hover:text-foreground', className)}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { 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()
|
||||
|
|
@ -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 => ({
|
||||
|
|
@ -528,6 +566,7 @@ export function DesktopController() {
|
|||
useEffect(() => {
|
||||
if (gatewayState === 'open') {
|
||||
void refreshCurrentModel()
|
||||
void refreshActiveProfile()
|
||||
void refreshSessions().catch(() => undefined)
|
||||
}
|
||||
}, [gatewayState, refreshCurrentModel, refreshSessions])
|
||||
|
|
@ -570,6 +609,7 @@ export function DesktopController() {
|
|||
currentView={currentView}
|
||||
onArchiveSession={sessionId => void archiveSession(sessionId)}
|
||||
onDeleteSession={sessionId => void removeSession(sessionId)}
|
||||
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
|
||||
onLoadMoreSessions={loadMoreSessions}
|
||||
onNavigate={selectSidebarItem}
|
||||
onNewSessionInWorkspace={startSessionInWorkspace}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '@/store/boot'
|
||||
import { setGateway } from '@/store/gateway'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $activeGatewayProfile, touchActiveGatewayBackend } from '@/store/profile'
|
||||
import { $connection, setConnection, setGatewayState, setSessionsLoading } from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
|
|
@ -97,7 +98,7 @@ export function useGatewayBoot({
|
|||
reconnecting = true
|
||||
|
||||
try {
|
||||
const conn = await desktop.getConnection()
|
||||
const conn = await desktop.getConnection($activeGatewayProfile.get())
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
|
|
@ -212,6 +213,11 @@ export function useGatewayBoot({
|
|||
window.addEventListener('online', onOnline)
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
|
||||
// Keep the active pool backend alive while this window is open (the main
|
||||
// process can't observe the direct renderer↔backend WS). No-op for the
|
||||
// primary backend.
|
||||
const keepaliveTimer = setInterval(() => touchActiveGatewayBackend(), 60_000)
|
||||
|
||||
const offWindowState = desktop.onWindowStateChanged?.(payload => {
|
||||
const current = $connection.get()
|
||||
|
||||
|
|
@ -259,6 +265,16 @@ 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?.()
|
||||
$activeGatewayProfile.set((pref?.profile ?? '').trim() || 'default')
|
||||
} catch {
|
||||
$activeGatewayProfile.set('default')
|
||||
}
|
||||
|
||||
setDesktopBootStep({
|
||||
phase: 'renderer.config',
|
||||
message: 'Loading Hermes settings',
|
||||
|
|
@ -293,6 +309,7 @@ export function useGatewayBoot({
|
|||
return () => {
|
||||
cancelled = true
|
||||
clearReconnectTimer()
|
||||
clearInterval(keepaliveTimer)
|
||||
window.removeEventListener('online', onOnline)
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
offPowerResume?.()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from 'react'
|
|||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import { $gatewayState, setConnection } from '@/store/session'
|
||||
|
||||
export function useGatewayRequest() {
|
||||
|
|
@ -49,7 +50,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
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import {
|
||||
getMessagingPlatforms,
|
||||
type MessagingEnvVarInfo,
|
||||
|
|
@ -659,22 +660,25 @@ function MessagingField({
|
|||
value={edits[field.key] || ''}
|
||||
/>
|
||||
{field.url && (
|
||||
<Button asChild size="icon-sm" title="Open docs" variant="ghost">
|
||||
<a href={field.url} rel="noreferrer" target="_blank">
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
<Tip label="Open docs">
|
||||
<Button asChild size="icon-sm" variant="ghost">
|
||||
<a href={field.url} rel="noreferrer" target="_blank">
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
{field.is_set && (
|
||||
<Button
|
||||
disabled={saving === `clear:${field.key}`}
|
||||
onClick={() => onClear(field.key)}
|
||||
size="icon-sm"
|
||||
title={`Clear ${field.key}`}
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<Tip label={`Clear ${field.key}`}>
|
||||
<Button
|
||||
disabled={saving === `clear:${field.key}`}
|
||||
onClick={() => onClear(field.key)}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
{copy.help && <p className="text-xs leading-5 text-muted-foreground">{copy.help}</p>}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -12,21 +14,29 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import {
|
||||
createProfile,
|
||||
deleteProfile,
|
||||
getProfiles,
|
||||
getProfileSetupCommand,
|
||||
getProfileSoul,
|
||||
type ProfileInfo,
|
||||
renameProfile,
|
||||
updateProfileSoul
|
||||
} from '@/hermes'
|
||||
import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icons'
|
||||
import { AlertTriangle, Check, Loader2, 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'
|
||||
|
|
@ -40,6 +50,50 @@ function isValidProfileName(name: string): boolean {
|
|||
return PROFILE_NAME_RE.test(name.trim())
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
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.
|
||||
function ActionStatus({
|
||||
state,
|
||||
idle,
|
||||
busy,
|
||||
done,
|
||||
idleIcon = null
|
||||
}: {
|
||||
state: 'done' | 'idle' | 'saving'
|
||||
idle: string
|
||||
busy: string
|
||||
done: string
|
||||
idleIcon?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{state === 'saving' ? <Loader2 className="size-4 animate-spin" /> : state === 'done' ? <Check /> : idleIcon}
|
||||
{state === 'saving' ? busy : state === 'done' ? done : idle}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProfilesViewProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
|
@ -48,13 +102,18 @@ 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 [deleted, setDeleted] = useState(false)
|
||||
const [deleteError, setDeleteError] = useState<null | string>(null)
|
||||
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,10 +122,18 @@ 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 ?? [])
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingDelete) {
|
||||
setDeleted(false)
|
||||
setDeleteError(null)
|
||||
}
|
||||
}, [pendingDelete])
|
||||
|
||||
useRefreshHotkey(refresh)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -90,7 +157,6 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
|||
}
|
||||
|
||||
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
|
||||
notify({ kind: 'success', title: 'Profile created', message: trimmed })
|
||||
setSelectedName(trimmed)
|
||||
await refresh()
|
||||
},
|
||||
|
|
@ -110,32 +176,60 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
|||
}
|
||||
|
||||
await renameProfile(from, target)
|
||||
notify({ kind: 'success', title: 'Profile renamed', message: `${from} → ${target}` })
|
||||
setSelectedName(target)
|
||||
await refresh()
|
||||
},
|
||||
[refresh]
|
||||
)
|
||||
|
||||
const handleClone = useCallback(
|
||||
async (source: ProfileInfo) => {
|
||||
const existing = new Set((profiles ?? []).map(p => p.name))
|
||||
const target = uniqueCloneName(source.name, existing)
|
||||
|
||||
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}`)
|
||||
}
|
||||
},
|
||||
[profiles, refresh]
|
||||
)
|
||||
|
||||
const handleMakeDefault = useCallback(async (profile: ProfileInfo) => {
|
||||
try {
|
||||
// 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) {
|
||||
setLoadError(err instanceof Error ? err.message : `Failed to switch to ${profile.name}`)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!pendingDelete) {
|
||||
if (!pendingDelete || deleting || deleted) {
|
||||
return
|
||||
}
|
||||
|
||||
setDeleting(true)
|
||||
setDeleteError(null)
|
||||
|
||||
try {
|
||||
await deleteProfile(pendingDelete.name)
|
||||
notify({ kind: 'success', title: 'Profile deleted', message: pendingDelete.name })
|
||||
setPendingDelete(null)
|
||||
setSelectedName(null)
|
||||
await refresh()
|
||||
setDeleted(true)
|
||||
window.setTimeout(() => {
|
||||
setPendingDelete(null)
|
||||
setSelectedName(null)
|
||||
void refresh()
|
||||
}, 700)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to delete profile')
|
||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete profile')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}, [pendingDelete, refresh])
|
||||
}, [deleted, deleting, pendingDelete, refresh])
|
||||
|
||||
return (
|
||||
<OverlayView closeLabel="Close profiles" onClose={onClose}>
|
||||
|
|
@ -158,10 +252,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 +275,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>
|
||||
|
|
@ -195,7 +294,21 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
|||
open={createOpen}
|
||||
/>
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
<RenameProfileDialog
|
||||
currentName={pendingRename?.name ?? ''}
|
||||
onClose={() => setPendingRename(null)}
|
||||
onRename={async newName => {
|
||||
if (pendingRename) {
|
||||
await handleRename(pendingRename.name, newName)
|
||||
}
|
||||
}}
|
||||
open={pendingRename !== null}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
onOpenChange={open => !open && !deleting && !deleted && setPendingDelete(null)}
|
||||
open={pendingDelete !== null}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete profile?</DialogTitle>
|
||||
|
|
@ -208,12 +321,25 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
|||
) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{deleteError && (
|
||||
<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>{deleteError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
|
||||
<Button disabled={deleting || deleted} onClick={() => setPendingDelete(null)} type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
<Button disabled={deleting || deleted} onClick={() => void handleConfirmDelete()} variant="destructive">
|
||||
<ActionStatus
|
||||
busy="Deleting…"
|
||||
done="Deleted"
|
||||
idle="Delete"
|
||||
state={deleted ? 'done' : deleting ? 'saving' : 'idle'}
|
||||
/>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
@ -222,95 +348,151 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
|||
)
|
||||
}
|
||||
|
||||
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 +513,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 +530,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 +563,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,9 +628,14 @@ 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>
|
||||
|
|
@ -458,7 +653,7 @@ function CreateProfileDialog({
|
|||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [cloneFromDefault, setCloneFromDefault] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -469,11 +664,12 @@ function CreateProfileDialog({
|
|||
setName('')
|
||||
setCloneFromDefault(true)
|
||||
setError(null)
|
||||
setSaving(false)
|
||||
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()
|
||||
|
|
@ -484,21 +680,21 @@ function CreateProfileDialog({
|
|||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setStatus('saving')
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onCreate(trimmed, cloneFromDefault)
|
||||
onClose()
|
||||
setStatus('done')
|
||||
window.setTimeout(onClose, 800)
|
||||
} catch (err) {
|
||||
setStatus('idle')
|
||||
setError(err instanceof Error ? err.message : 'Failed to create profile')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
|
||||
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New profile</DialogTitle>
|
||||
|
|
@ -525,16 +721,15 @@ function CreateProfileDialog({
|
|||
</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
|
||||
<label className="flex cursor-pointer select-none items-start gap-2.5 px-0.5 py-1">
|
||||
<Checkbox
|
||||
checked={cloneFromDefault}
|
||||
className="size-4 accent-primary"
|
||||
onChange={event => setCloneFromDefault(event.target.checked)}
|
||||
type="checkbox"
|
||||
className="mt-0.5 shrink-0"
|
||||
onCheckedChange={checked => setCloneFromDefault(checked === true)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">Clone from default</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
<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>
|
||||
|
|
@ -548,11 +743,11 @@ function CreateProfileDialog({
|
|||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
|
||||
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={saving || !trimmed || invalid} type="submit">
|
||||
{saving ? 'Creating...' : 'Create profile'}
|
||||
<Button disabled={busy || !trimmed || invalid} type="submit">
|
||||
<ActionStatus busy="Creating…" done="Created" idle="Create profile" state={status} />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
|
@ -573,7 +768,7 @@ function RenameProfileDialog({
|
|||
open: boolean
|
||||
}) {
|
||||
const [name, setName] = useState(currentName)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -583,12 +778,13 @@ function RenameProfileDialog({
|
|||
|
||||
setName(currentName)
|
||||
setError(null)
|
||||
setSaving(false)
|
||||
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()
|
||||
|
|
@ -605,20 +801,21 @@ function RenameProfileDialog({
|
|||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setStatus('saving')
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onRename(trimmed)
|
||||
setStatus('done')
|
||||
window.setTimeout(onClose, 800)
|
||||
} catch (err) {
|
||||
setStatus('idle')
|
||||
setError(err instanceof Error ? err.message : 'Failed to rename profile')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
|
||||
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename profile</DialogTitle>
|
||||
|
|
@ -653,11 +850,11 @@ function RenameProfileDialog({
|
|||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
|
||||
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={saving || invalid || unchanged} type="submit">
|
||||
{saving ? 'Renaming...' : 'Rename'}
|
||||
<Button disabled={busy || invalid || unchanged} type="submit">
|
||||
<ActionStatus busy="Renaming…" done="Renamed" idle="Rename" state={status} />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -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' && (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -440,6 +441,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
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
|
||||
import { Check, Eye, EyeOff, type IconComponent, Save, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -45,35 +46,28 @@ function EnvActions({
|
|||
return (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{info.url && (
|
||||
<Button asChild size="xs" title="Open provider docs" variant="ghost">
|
||||
<Button asChild size="xs" variant="ghost">
|
||||
<a href={info.url} rel="noreferrer" target="_blank">
|
||||
Docs
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{info.is_set && showReveal && (
|
||||
<Button
|
||||
onClick={() => onReveal(varKey)}
|
||||
size="icon-xs"
|
||||
title={isRevealed ? 'Hide value' : 'Reveal value'}
|
||||
variant="ghost"
|
||||
>
|
||||
{isRevealed ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
<Tip label={isRevealed ? 'Hide value' : 'Reveal value'}>
|
||||
<Button onClick={() => onReveal(varKey)} size="icon-xs" variant="ghost">
|
||||
{isRevealed ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
<Button onClick={onEdit} size="xs" variant="outline">
|
||||
{info.is_set ? 'Replace' : 'Set'}
|
||||
</Button>
|
||||
{info.is_set && (
|
||||
<Button
|
||||
disabled={saving === varKey}
|
||||
onClick={() => onClear(varKey)}
|
||||
size="icon-xs"
|
||||
title="Clear value"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
<Tip label="Clear value">
|
||||
<Button disabled={saving === varKey} onClick={() => onClear(varKey)} size="icon-xs" variant="ghost">
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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, Sparkles, Wrench, Zap } from '@/lib/icons'
|
||||
|
|
@ -149,28 +150,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}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes'
|
||||
import { Check, ExternalLink, Eye, EyeOff, Loader2, Save, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -110,7 +111,7 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
|||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{envVar.url && (
|
||||
<Button asChild size="xs" title="Open provider docs" variant="ghost">
|
||||
<Button asChild size="xs" variant="ghost">
|
||||
<a href={envVar.url} rel="noreferrer" target="_blank">
|
||||
Docs
|
||||
<ExternalLink className="size-3" />
|
||||
|
|
@ -118,23 +119,21 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
|||
</Button>
|
||||
)}
|
||||
{isSet && (
|
||||
<Button onClick={() => void handleReveal()} size="icon-xs" title="Reveal value" variant="ghost">
|
||||
{revealed !== null ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
<Tip label="Reveal value">
|
||||
<Button onClick={() => void handleReveal()} size="icon-xs" variant="ghost">
|
||||
{revealed !== null ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
<Button onClick={() => setEditing(e => !e)} size="xs" variant="textStrong">
|
||||
{isSet ? 'Replace' : 'Set'}
|
||||
</Button>
|
||||
{isSet && (
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => void handleClear()}
|
||||
size="icon-xs"
|
||||
title="Clear value"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
<Tip label="Clear value">
|
||||
<Button disabled={busy} onClick={() => void handleClear()} size="icon-xs" variant="ghost">
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,35 @@ export function deleteSession(id: string): Promise<{ ok: boolean }> {
|
|||
})
|
||||
}
|
||||
|
||||
export function renameSession(id: string, title: string): Promise<{ ok: boolean; title: string }> {
|
||||
export function renameSession(
|
||||
id: string,
|
||||
title: string,
|
||||
profile?: string | null
|
||||
): Promise<{ ok: boolean; title: string }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; title: string }>({
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { title }
|
||||
body: { title, ...(profile ? { profile } : {}) }
|
||||
})
|
||||
}
|
||||
|
||||
// Set ("" clears) a per-session icon glyph. `profile` targets another profile's
|
||||
// session (the backend opens its state.db). Omit for the current profile.
|
||||
export function setSessionIcon(
|
||||
id: string,
|
||||
icon: string,
|
||||
profile?: string | null
|
||||
): Promise<{ ok: boolean; icon: string | null }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; icon: string | null }>({
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { icon, ...(profile ? { profile } : {}) }
|
||||
})
|
||||
}
|
||||
|
||||
export function getGlobalModelInfo(): Promise<ModelInfoResponse> {
|
||||
return window.hermesDesktop.api<ModelInfoResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/model/info'
|
||||
})
|
||||
}
|
||||
|
|
@ -202,36 +263,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 +307,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 +326,7 @@ export function validateProviderCredential(
|
|||
value: string
|
||||
): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({
|
||||
...profileScoped(),
|
||||
path: '/api/providers/validate',
|
||||
method: 'POST',
|
||||
body: { key, value }
|
||||
|
|
@ -265,6 +335,7 @@ export function validateProviderCredential(
|
|||
|
||||
export function deleteEnvVar(key: string): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...profileScoped(),
|
||||
path: '/api/env',
|
||||
method: 'DELETE',
|
||||
body: { key }
|
||||
|
|
@ -273,6 +344,7 @@ export function deleteEnvVar(key: string): Promise<{ ok: boolean }> {
|
|||
|
||||
export function revealEnvVar(key: string): Promise<{ key: string; value: string }> {
|
||||
return window.hermesDesktop.api<{ key: string; value: string }>({
|
||||
...profileScoped(),
|
||||
path: '/api/env/reveal',
|
||||
method: 'POST',
|
||||
body: { key }
|
||||
|
|
@ -281,12 +353,14 @@ export function revealEnvVar(key: string): Promise<{ key: string; value: string
|
|||
|
||||
export function listOAuthProviders(): Promise<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 +369,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 +378,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 +393,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 +409,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 +419,7 @@ export function toggleToolset(
|
|||
enabled: boolean
|
||||
): Promise<{ ok: boolean; name: string; enabled: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({
|
||||
...profileScoped(),
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}`,
|
||||
method: 'PUT',
|
||||
body: { enabled }
|
||||
|
|
@ -347,6 +428,7 @@ export function toggleToolset(
|
|||
|
||||
export function getToolsetConfig(name: string): Promise<ToolsetConfig> {
|
||||
return window.hermesDesktop.api<ToolsetConfig>({
|
||||
...profileScoped(),
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}/config`
|
||||
})
|
||||
}
|
||||
|
|
@ -356,6 +438,7 @@ export function selectToolsetProvider(
|
|||
provider: string
|
||||
): Promise<{ ok: boolean; name: string; provider: string }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; name: string; provider: string }>({
|
||||
...profileScoped(),
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`,
|
||||
method: 'PUT',
|
||||
body: { provider }
|
||||
|
|
@ -485,20 +568,16 @@ export function updateProfileSoul(name: string, content: string): Promise<{ ok:
|
|||
})
|
||||
}
|
||||
|
||||
export function getProfileSetupCommand(name: string): Promise<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 +594,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 +604,7 @@ export function setGlobalModel(
|
|||
model: string
|
||||
): Promise<{ ok: boolean; provider: string; model: string }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; provider: string; model: string }>({
|
||||
...profileScoped(),
|
||||
path: '/api/model/set',
|
||||
method: 'POST',
|
||||
body: {
|
||||
|
|
@ -536,12 +617,14 @@ export function setGlobalModel(
|
|||
|
||||
export function getAuxiliaryModels(): Promise<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
|
||||
|
|
|
|||
36
apps/desktop/src/lib/profile-color.ts
Normal file
36
apps/desktop/src/lib/profile-color.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Deterministic per-profile color so a profile is glanceable across the app
|
||||
// (the sidebar profile rail). The default/root profile has no color — named
|
||||
// profiles get a stable hue derived from the name, so the same profile always
|
||||
// reads the same color without persisting anything.
|
||||
|
||||
const PROFILE_TAG_SATURATION = 68
|
||||
const PROFILE_TAG_LIGHTNESS = 58
|
||||
|
||||
function hashString(value: string): number {
|
||||
let hash = 0
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0
|
||||
}
|
||||
|
||||
return hash
|
||||
}
|
||||
|
||||
// Returns an hsl() string for a named profile, or null for default/empty
|
||||
// (rendered neutral / untagged).
|
||||
export function profileColor(name: null | string | undefined): null | string {
|
||||
const key = (name ?? '').trim()
|
||||
|
||||
if (!key || key === 'default') {
|
||||
return null
|
||||
}
|
||||
|
||||
const hue = hashString(key) % 360
|
||||
|
||||
return `hsl(${hue} ${PROFILE_TAG_SATURATION}% ${PROFILE_TAG_LIGHTNESS}%)`
|
||||
}
|
||||
|
||||
// Translucent fill derived from a profile color, for tag backgrounds.
|
||||
export function profileColorSoft(color: string, percent = 16): string {
|
||||
return `color-mix(in srgb, ${color} ${percent}%, transparent)`
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
217
apps/desktop/src/store/profile.ts
Normal file
217
apps/desktop/src/store/profile.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { getProfiles, setApiRequestProfile } from '@/hermes'
|
||||
import { resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import { queryClient } from '@/lib/query-client'
|
||||
import { persistBoolean, storedBoolean } from '@/lib/storage'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { setConnection } from '@/store/session'
|
||||
import type { ProfileInfo } from '@/types/hermes'
|
||||
|
||||
// Canonical key for a profile: trimmed, empty → "default". Used everywhere we
|
||||
// compare a session's owning profile against the live gateway's profile.
|
||||
export function normalizeProfileKey(name: string | null | undefined): string {
|
||||
const value = (name ?? '').trim()
|
||||
|
||||
return value || 'default'
|
||||
}
|
||||
|
||||
// The profile the running local backend is actually scoped to (mirrors
|
||||
// /api/profiles/active `current`). "default" is the root ~/.hermes. This is the
|
||||
// display source of truth for the statusbar pill; the desktop's *stored*
|
||||
// preference (which may be unset) lives in the Electron main process.
|
||||
export const $activeProfile = atom<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')
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Route profile-scoped REST settings (config/env/skills/tools/model/…) to the
|
||||
// profile the live gateway is currently on, and drop cached settings from the
|
||||
// previous profile so pages refetch against the right backend. Fires once
|
||||
// immediately (no real change → no invalidation), so single-profile users just
|
||||
// get "default" (→ the primary backend) with no extra fetches.
|
||||
let _lastRoutedProfile: string | null = null
|
||||
|
||||
$activeGatewayProfile.subscribe(value => {
|
||||
const key = normalizeProfileKey(value)
|
||||
setApiRequestProfile(key)
|
||||
|
||||
if (_lastRoutedProfile !== null && _lastRoutedProfile !== key) {
|
||||
// Profile-scoped settings + the unified session list are now stale.
|
||||
void queryClient.invalidateQueries()
|
||||
}
|
||||
|
||||
_lastRoutedProfile = key
|
||||
})
|
||||
|
||||
let gatewaySwitch: Promise<void> | null = null
|
||||
|
||||
// Reconnect the single live gateway to `profile`'s backend if it isn't already
|
||||
// there. A null/empty target means "no explicit profile" → keep the gateway on
|
||||
// whatever profile it's currently on (so a plain new chat stays put and a
|
||||
// single-profile user never swaps). No-op fast path when already on target.
|
||||
export async function ensureGatewayProfile(profile: string | null | undefined): Promise<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) {
|
||||
return
|
||||
}
|
||||
|
||||
// Serialize concurrent swaps so two rapid session switches don't fight over
|
||||
// the single socket.
|
||||
if (gatewaySwitch) {
|
||||
await gatewaySwitch.catch(() => undefined)
|
||||
|
||||
if (normalizeProfileKey($activeGatewayProfile.get()) === target) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
gatewaySwitch = (async () => {
|
||||
const desktop = window.hermesDesktop
|
||||
const gateway = $gateway.get()
|
||||
|
||||
if (!desktop || !gateway) {
|
||||
return
|
||||
}
|
||||
|
||||
// getConnection lazily spawns/reuses the profile's pool backend (or returns
|
||||
// the primary when target is the primary's profile).
|
||||
const conn = await desktop.getConnection(target)
|
||||
setConnection(conn)
|
||||
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
|
||||
// The single socket is still OPEN to the *previous* profile's backend, and
|
||||
// gateway.connect() no-ops on an already-open socket. Drop it first so the
|
||||
// reconnect actually re-points at the target profile's backend — otherwise
|
||||
// the swap silently stays on the old backend (session.create writes to the
|
||||
// wrong profile's DB). close() nulls the socket without emitting a 'closed'
|
||||
// state, so it doesn't trip the boot auto-reconnect.
|
||||
gateway.close()
|
||||
await gateway.connect(wsUrl)
|
||||
$activeGatewayProfile.set(target)
|
||||
void desktop.touchBackend?.(target).catch(() => undefined)
|
||||
})()
|
||||
|
||||
try {
|
||||
await gatewaySwitch
|
||||
} finally {
|
||||
gatewaySwitch = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sidebar profile scope (the "workspace switcher" model) ─────────────────
|
||||
// Mirrors how Slack/VS Code/Linear do multi-context: you're "in" one profile at
|
||||
// a time and the sidebar shows only that profile's sessions (clean rows, no
|
||||
// per-row tags). The lone exception is an explicit "All profiles" mode that
|
||||
// fans every profile's sessions into one grouped, browsable list.
|
||||
|
||||
export const ALL_PROFILES = '__all__'
|
||||
|
||||
const SHOW_ALL_PROFILES_STORAGE_KEY = 'hermes.desktop.showAllProfiles'
|
||||
|
||||
// Opt-in unified view. When false, scope follows the live gateway profile, so
|
||||
// single-profile users (who never see the switcher) are completely unaffected.
|
||||
export const $showAllProfiles = atom<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)
|
||||
$showAllProfiles.set(false)
|
||||
$newChatProfile.set(target)
|
||||
void ensureGatewayProfile(target)
|
||||
}
|
||||
|
||||
export function setShowAllProfiles(value: boolean): void {
|
||||
$showAllProfiles.set(value)
|
||||
}
|
||||
|
||||
// Keepalive ping for the active pool backend so the main-process idle reaper
|
||||
// (which can't see the direct renderer↔backend WS) spares it. No-op for the
|
||||
// primary/default backend, which is never pooled.
|
||||
export function touchActiveGatewayBackend(): void {
|
||||
// Always ping: the main process no-ops for non-pool (primary) backends, so we
|
||||
// don't need to know which profile is primary from here.
|
||||
const target = normalizeProfileKey($activeGatewayProfile.get())
|
||||
void window.hermesDesktop?.touchBackend?.(target).catch(() => undefined)
|
||||
}
|
||||
|
|
@ -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,15 @@ export interface SessionInfo {
|
|||
started_at: number
|
||||
title: null | string
|
||||
tool_call_count: number
|
||||
/** Owning profile name, set by the cross-profile aggregator
|
||||
* (`/api/profiles/sessions`). Absent on legacy single-profile responses,
|
||||
* which the UI treats as the default profile. */
|
||||
profile?: string
|
||||
/** True when {@link profile} is the default profile. */
|
||||
is_default_profile?: boolean
|
||||
/** Optional per-session glyph the user picked so sessions from different
|
||||
* profiles are visually distinguishable in the unified list. */
|
||||
icon?: null | string
|
||||
}
|
||||
|
||||
export interface SessionMessage {
|
||||
|
|
@ -435,6 +452,8 @@ export interface CronJobUpdates {
|
|||
}
|
||||
|
||||
export interface ProfileCreatePayload {
|
||||
clone_all?: boolean
|
||||
clone_from?: string
|
||||
clone_from_default?: boolean
|
||||
name: string
|
||||
no_skills?: boolean
|
||||
|
|
@ -450,10 +469,6 @@ export interface ProfileInfo {
|
|||
skill_count: number
|
||||
}
|
||||
|
||||
export interface ProfileSetupCommand {
|
||||
command: string
|
||||
}
|
||||
|
||||
export interface ProfileSoul {
|
||||
content: string
|
||||
exists: boolean
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1592,6 +1592,7 @@ async def get_sessions(
|
|||
min_message_count=min_message_count,
|
||||
include_archived=include_archived,
|
||||
archived_only=archived_only,
|
||||
exclude_children=True,
|
||||
)
|
||||
now = time.time()
|
||||
for s in sessions:
|
||||
|
|
@ -1609,6 +1610,111 @@ async def get_sessions(
|
|||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@app.get("/api/profiles/sessions")
|
||||
async def get_profiles_sessions(
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
min_messages: int = 0,
|
||||
archived: str = "exclude",
|
||||
order: str = "recent",
|
||||
profile: str = "all",
|
||||
):
|
||||
"""Unified, read-only session list aggregated across ALL profiles.
|
||||
|
||||
Intentionally process-light: this opens each profile's ``state.db`` directly
|
||||
from disk — it does NOT spawn a dashboard backend per profile. Each returned
|
||||
session is tagged with its owning ``profile`` so the desktop renders one
|
||||
browsable list and only spins up a profile's backend when the user actually
|
||||
interacts (sends a message). A user with a single (default) profile gets the
|
||||
same rows as ``/api/sessions``, just tagged ``profile="default"``.
|
||||
"""
|
||||
if archived not in ("exclude", "only", "include"):
|
||||
raise HTTPException(status_code=400, detail="archived must be one of: exclude, only, include")
|
||||
if order not in ("created", "recent"):
|
||||
raise HTTPException(status_code=400, detail="order must be one of: created, recent")
|
||||
|
||||
from hermes_state import SessionDB
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
|
||||
targets: List[Tuple[str, Path]] = []
|
||||
if profile and profile != "all":
|
||||
name, home = _cron_profile_home(profile)
|
||||
targets.append((name, home))
|
||||
else:
|
||||
try:
|
||||
infos = profiles_mod.list_profiles()
|
||||
targets = [(info.name, info.path) for info in infos]
|
||||
except Exception:
|
||||
_log.exception("GET /api/profiles/sessions: list_profiles failed")
|
||||
targets = []
|
||||
if not targets:
|
||||
targets.append(("default", profiles_mod.get_profile_dir("default")))
|
||||
|
||||
min_message_count = max(0, min_messages)
|
||||
archived_only = archived == "only"
|
||||
include_archived = archived == "include"
|
||||
# Over-fetch per profile so the merged+sorted window is correct for the
|
||||
# requested page. Capped so a huge profile can't blow up the response.
|
||||
per_profile = min(max(limit + offset, limit), 500)
|
||||
|
||||
merged: List[Dict[str, Any]] = []
|
||||
total = 0
|
||||
profile_totals: Dict[str, int] = {}
|
||||
errors: List[Dict[str, str]] = []
|
||||
now = time.time()
|
||||
for name, home in targets:
|
||||
db_path = Path(home) / "state.db"
|
||||
if not db_path.exists():
|
||||
continue
|
||||
try:
|
||||
db = SessionDB(db_path=db_path)
|
||||
except Exception as exc:
|
||||
errors.append({"profile": name, "error": str(exc)})
|
||||
continue
|
||||
try:
|
||||
rows = db.list_sessions_rich(
|
||||
limit=per_profile,
|
||||
offset=0,
|
||||
min_message_count=min_message_count,
|
||||
include_archived=include_archived,
|
||||
archived_only=archived_only,
|
||||
order_by_last_active=order == "recent",
|
||||
)
|
||||
profile_total = db.session_count(
|
||||
min_message_count=min_message_count,
|
||||
include_archived=include_archived,
|
||||
archived_only=archived_only,
|
||||
exclude_children=True,
|
||||
)
|
||||
total += profile_total
|
||||
profile_totals[name] = profile_total
|
||||
for s in rows:
|
||||
s["profile"] = name
|
||||
s["is_default_profile"] = name == "default"
|
||||
s["is_active"] = (
|
||||
s.get("ended_at") is None
|
||||
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
||||
)
|
||||
s["archived"] = bool(s.get("archived"))
|
||||
merged.append(s)
|
||||
except Exception as exc:
|
||||
errors.append({"profile": name, "error": str(exc)})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
sort_key = "last_active" if order == "recent" else "started_at"
|
||||
merged.sort(key=lambda s: s.get(sort_key) or s.get("started_at") or 0, reverse=True)
|
||||
window = merged[offset:offset + limit]
|
||||
return {
|
||||
"sessions": window,
|
||||
"total": total,
|
||||
"profile_totals": profile_totals,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/sessions/search")
|
||||
async def search_sessions(q: str = "", limit: int = 20):
|
||||
"""Full-text search across session message content using FTS5.
|
||||
|
|
@ -4663,15 +4769,31 @@ async def get_session_stats():
|
|||
db.close()
|
||||
|
||||
|
||||
@app.get("/api/sessions/{session_id}")
|
||||
async def get_session_detail(session_id: str):
|
||||
def _open_session_db_for_profile(profile: Optional[str]):
|
||||
"""Open a SessionDB for read paths, optionally for another profile.
|
||||
|
||||
``profile`` None/empty → this process's own ``state.db`` (the common,
|
||||
single-profile case). A named profile opens that profile's on-disk
|
||||
``state.db`` directly so the primary backend can serve cross-profile reads
|
||||
(transcripts, detail) without spawning that profile's backend.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
if not profile:
|
||||
return SessionDB()
|
||||
_name, home = _cron_profile_home(profile)
|
||||
return SessionDB(db_path=Path(home) / "state.db")
|
||||
|
||||
|
||||
@app.get("/api/sessions/{session_id}")
|
||||
async def get_session_detail(session_id: str, profile: Optional[str] = None):
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
sid = db.resolve_session_id(session_id)
|
||||
session = db.get_session(sid) if sid else None
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if profile:
|
||||
session["profile"] = _cron_profile_home(profile)[0]
|
||||
return session
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -4691,9 +4813,8 @@ async def get_session_latest_descendant(session_id: str):
|
|||
}
|
||||
|
||||
@app.get("/api/sessions/{session_id}/messages")
|
||||
async def get_session_messages(session_id: str):
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
async def get_session_messages(session_id: str, profile: Optional[str] = None):
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
sid = db.resolve_session_id(session_id)
|
||||
if not sid:
|
||||
|
|
@ -4719,25 +4840,31 @@ async def delete_session_endpoint(session_id: str):
|
|||
class SessionRename(BaseModel):
|
||||
title: Optional[str] = None
|
||||
archived: Optional[bool] = None
|
||||
# Tri-state via the client contract: omit to leave unchanged, "" to clear,
|
||||
# a short glyph to set. (None == omitted here.)
|
||||
icon: Optional[str] = None
|
||||
# Mutate a session belonging to another profile (opens its state.db). Omit
|
||||
# for the current/default profile.
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
@app.patch("/api/sessions/{session_id}")
|
||||
async def rename_session_endpoint(session_id: str, body: SessionRename):
|
||||
"""Update a session: rename (or clear its title) and/or archive it.
|
||||
"""Update a session: rename (or clear its title), archive, and/or set icon.
|
||||
|
||||
``title`` renames (empty/null clears the title); ``archived`` soft-hides or
|
||||
restores the session. Either field may be omitted.
|
||||
restores the session; ``icon`` sets a per-session glyph ("" clears it). Any
|
||||
field may be omitted. ``profile`` targets another profile's session.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
db = _open_session_db_for_profile(body.profile)
|
||||
try:
|
||||
sid = db.resolve_session_id(session_id)
|
||||
if not sid:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if body.title is None and body.archived is None:
|
||||
if body.title is None and body.archived is None and body.icon is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Nothing to update; provide 'title' and/or 'archived'.",
|
||||
detail="Nothing to update; provide 'title', 'archived', and/or 'icon'.",
|
||||
)
|
||||
if body.title is not None:
|
||||
try:
|
||||
|
|
@ -4747,9 +4874,13 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):
|
|||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if body.archived is not None:
|
||||
db.set_session_archived(sid, body.archived)
|
||||
if body.icon is not None:
|
||||
db.set_session_icon(sid, body.icon)
|
||||
result = {"ok": True, "title": db.get_session_title(sid) or ""}
|
||||
if body.archived is not None:
|
||||
result["archived"] = bool(body.archived)
|
||||
if body.icon is not None:
|
||||
result["icon"] = (body.icon or "").strip()[:16] or None
|
||||
return result
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -6156,6 +6287,11 @@ class ProfileCreate(BaseModel):
|
|||
clone_all: bool = False
|
||||
no_skills: bool = False
|
||||
description: Optional[str] = None
|
||||
# Explicit source profile to clone from (e.g. duplicating an existing
|
||||
# profile). When set, it takes precedence over ``clone_from_default``,
|
||||
# which always sources from "default". ``clone_all`` still selects a full
|
||||
# state copytree vs. a config/skills/SOUL copy.
|
||||
clone_from: Optional[str] = None
|
||||
provider: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
|
||||
|
|
@ -6316,13 +6452,23 @@ async def list_profiles_endpoint():
|
|||
@app.post("/api/profiles")
|
||||
async def create_profile_endpoint(body: ProfileCreate):
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
clone = body.clone_from_default or body.clone_all
|
||||
explicit_source = (body.clone_from or "").strip()
|
||||
if explicit_source:
|
||||
# Duplicating a specific profile: clone its config/skills/SOUL (or full
|
||||
# state when clone_all) from the named source rather than "default".
|
||||
clone = True
|
||||
clone_from = explicit_source
|
||||
clone_config = not body.clone_all
|
||||
else:
|
||||
clone = body.clone_from_default or body.clone_all
|
||||
clone_from = "default" if clone else None
|
||||
clone_config = body.clone_from_default and not body.clone_all
|
||||
try:
|
||||
path = profiles_mod.create_profile(
|
||||
name=body.name,
|
||||
clone_from="default" if clone else None,
|
||||
clone_from=clone_from,
|
||||
clone_all=body.clone_all,
|
||||
clone_config=body.clone_from_default and not body.clone_all,
|
||||
clone_config=clone_config,
|
||||
no_skills=body.no_skills,
|
||||
description=body.description,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -265,6 +265,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||
handoff_error TEXT,
|
||||
rewind_count INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
icon TEXT,
|
||||
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
|
||||
);
|
||||
|
||||
|
|
@ -1444,6 +1445,23 @@ class SessionDB:
|
|||
rowcount = self._execute_write(_do)
|
||||
return rowcount > 0
|
||||
|
||||
def set_session_icon(self, session_id: str, icon: Optional[str]) -> bool:
|
||||
"""Set or clear a session's user-chosen icon glyph.
|
||||
|
||||
``icon`` is a short display string (an emoji or a couple of chars);
|
||||
passing None/"" clears it. Returns True when a row was updated.
|
||||
"""
|
||||
cleaned = (icon or "").strip()[:16] or None
|
||||
|
||||
def _do(conn):
|
||||
cursor = conn.execute(
|
||||
"UPDATE sessions SET icon = ? WHERE id = ?",
|
||||
(cleaned, session_id),
|
||||
)
|
||||
return cursor.rowcount
|
||||
rowcount = self._execute_write(_do)
|
||||
return rowcount > 0
|
||||
|
||||
def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
|
||||
"""Look up a session by exact title. Returns session dict or None."""
|
||||
with self._lock:
|
||||
|
|
@ -3053,26 +3071,46 @@ class SessionDB:
|
|||
min_message_count: int = 0,
|
||||
include_archived: bool = False,
|
||||
archived_only: bool = False,
|
||||
exclude_children: bool = False,
|
||||
) -> int:
|
||||
"""Count sessions, optionally filtered by source."""
|
||||
"""Count sessions, optionally filtered by source.
|
||||
|
||||
Pass ``exclude_children=True`` to count only the conversations that
|
||||
``list_sessions_rich`` surfaces (root + branch sessions), hiding
|
||||
sub-agent runs and compression continuations. Use it whenever the count
|
||||
is paired with a ``list_sessions_rich`` page (e.g. sidebar "load more"
|
||||
totals) so the total matches the number of listable rows — otherwise the
|
||||
raw row count is inflated by children and "load more" never settles.
|
||||
"""
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
if exclude_children:
|
||||
# Mirror list_sessions_rich's child-exclusion clause exactly so the
|
||||
# count lines up with the rows: roots (no parent) plus branch
|
||||
# children (parent ended with end_reason='branched').
|
||||
where_clauses.append(
|
||||
"(s.parent_session_id IS NULL"
|
||||
" OR EXISTS (SELECT 1 FROM sessions p"
|
||||
" WHERE p.id = s.parent_session_id"
|
||||
" AND p.end_reason = 'branched'"
|
||||
" AND s.started_at >= p.ended_at))"
|
||||
)
|
||||
if source:
|
||||
where_clauses.append("source = ?")
|
||||
where_clauses.append("s.source = ?")
|
||||
params.append(source)
|
||||
if min_message_count > 0:
|
||||
where_clauses.append("message_count >= ?")
|
||||
where_clauses.append("s.message_count >= ?")
|
||||
params.append(min_message_count)
|
||||
if archived_only:
|
||||
where_clauses.append("archived = 1")
|
||||
where_clauses.append("s.archived = 1")
|
||||
elif not include_archived:
|
||||
where_clauses.append("archived = 0")
|
||||
where_clauses.append("s.archived = 0")
|
||||
|
||||
where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
||||
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(f"SELECT COUNT(*) FROM sessions{where_sql}", params)
|
||||
cursor = self._conn.execute(f"SELECT COUNT(*) FROM sessions s{where_sql}", params)
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
def message_count(self, session_id: str = None) -> int:
|
||||
|
|
|
|||
|
|
@ -381,6 +381,60 @@ class TestWebServerEndpoints:
|
|||
resp = self.client.patch("/api/sessions/no-fields", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_set_and_clear_session_icon_via_patch(self):
|
||||
"""PATCH icon sets a per-session glyph; "" clears it back to None."""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
db.create_session(session_id="icon-me", source="cli")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
resp = self.client.patch("/api/sessions/icon-me", json={"icon": "🦊"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["icon"] == "🦊"
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
assert db.get_session("icon-me")["icon"] == "🦊"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
resp = self.client.patch("/api/sessions/icon-me", json={"icon": ""})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["icon"] is None
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
assert db.get_session("icon-me")["icon"] is None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_profiles_sessions_tags_default_profile(self):
|
||||
"""The cross-profile aggregator returns the default profile's rows
|
||||
tagged profile="default" (single-profile parity with /api/sessions)."""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
db.create_session(session_id="agg-me", source="cli")
|
||||
db.append_message(session_id="agg-me", role="user", content="hi")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
resp = self.client.get("/api/profiles/sessions?limit=20&min_messages=0")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
row = next(s for s in data["sessions"] if s["id"] == "agg-me")
|
||||
assert row["profile"] == "default"
|
||||
assert row["is_default_profile"] is True
|
||||
assert isinstance(data.get("errors"), list)
|
||||
|
||||
def test_profiles_sessions_rejects_unknown_archived_value(self):
|
||||
resp = self.client.get("/api/profiles/sessions?archived=bogus")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_get_sessions_rejects_unknown_archived_value(self):
|
||||
resp = self.client.get("/api/sessions?archived=bogus")
|
||||
assert resp.status_code == 400
|
||||
|
|
@ -1603,6 +1657,30 @@ class TestNewEndpoints:
|
|||
profiles = {p["name"]: p for p in self.client.get("/api/profiles").json()["profiles"]}
|
||||
assert profiles["cloned"]["skill_count"] == 1
|
||||
|
||||
def test_profiles_create_with_clone_from_duplicates_source(self, monkeypatch):
|
||||
from hermes_constants import get_hermes_home
|
||||
import hermes_cli.profiles as profiles_mod
|
||||
|
||||
monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None)
|
||||
|
||||
# Create a source profile and give it a distinctive skill.
|
||||
assert self.client.post("/api/profiles", json={"name": "source-prof"}).status_code == 200
|
||||
source_skill = get_hermes_home() / "profiles" / "source-prof" / "skills" / "custom" / "src-skill"
|
||||
source_skill.mkdir(parents=True)
|
||||
(source_skill / "SKILL.md").write_text("---\nname: src-skill\n---\n", encoding="utf-8")
|
||||
|
||||
# Duplicate it via an explicit clone_from source (not "default").
|
||||
resp = self.client.post(
|
||||
"/api/profiles",
|
||||
json={"name": "source-prof-copy", "clone_from": "source-prof"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
cloned_skill = (
|
||||
get_hermes_home() / "profiles" / "source-prof-copy" / "skills" / "custom" / "src-skill" / "SKILL.md"
|
||||
)
|
||||
assert cloned_skill.exists()
|
||||
|
||||
def test_profiles_create_without_clone_seeds_bundled_skills(self, monkeypatch):
|
||||
from hermes_constants import get_hermes_home
|
||||
import hermes_cli.profiles as profiles_mod
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue