diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index bffe5019c..ded844ebe 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -220,6 +220,16 @@ const BOOTSTRAP_MARKER_SCHEMA_VERSION = 1 const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json') const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json') +// active-profile.json records which Hermes profile the desktop launches its +// local backend as. When set, startHermes() passes `hermes --profile +// dashboard …`, which deterministically pins HERMES_HOME (see +// _apply_profile_override in hermes_cli/main.py) and bypasses the sticky +// ~/.hermes/active_profile file. Unset (null) preserves the legacy behavior: +// no --profile flag, so the backend honors active_profile / default. +const DESKTOP_PROFILE_CONFIG_PATH = path.join(app.getPath('userData'), 'active-profile.json') +// Mirrors hermes_cli.profiles._PROFILE_ID_RE so we never hand the backend a +// value its profile resolver would reject and exit on. +const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/ // Branch we track for self-update. The GUI work has merged to main, so this // tracks main. User can also override at runtime via // hermesDesktop.updates.setBranch(). @@ -459,6 +469,24 @@ function registerMediaProtocol() { let mainWindow = null let hermesProcess = null let connectionPromise = null +// Additional per-profile backends, keyed by profile name. The PRIMARY backend +// (the desktop's launch profile) stays managed by hermesProcess + +// connectionPromise + startHermes(); this pool only holds EXTRA profile +// backends spawned lazily when a session belongs to a different profile. A user +// with no named profiles never populates this map, so their experience is +// byte-for-byte the single-backend behavior. +const backendPool = new Map() // profile -> { process, port, token, connectionPromise, lastActiveAt } +// Keep the pool light: cap concurrent profile backends (LRU eviction) and reap +// idle ones. A user idles at exactly the primary backend; pool backends only +// exist while a non-primary profile is actively being chatted through. +const POOL_MAX_BACKENDS = Math.max(1, Number(process.env.HERMES_DESKTOP_POOL_MAX) || 3) +const POOL_IDLE_MS = Math.max(60_000, Number(process.env.HERMES_DESKTOP_POOL_IDLE_MS) || 10 * 60_000) +// A backend touched within this window has a live renderer socket (the keepalive +// pings every 60s for every open profile). LRU eviction must spare these — a +// concurrent multi-profile session keeps several backends "fresh" at once, and +// killing one to honor the soft cap would abort a running agent. +const POOL_KEEPALIVE_FRESH_MS = 90_000 +let poolIdleReaper = null // Auto-reload budget for renderer crashes. A deterministic startup crash would // otherwise loop forever (reload → crash → reload), pinning CPU and spamming // logs. Allow a few reloads per rolling window, then stop and leave the dead @@ -1452,8 +1480,20 @@ async function applyUpdatesPosixInApp() { // reap must spare it. Hand the live backend's PID to the update process; // _kill_stale_dashboard_processes reads HERMES_DESKTOP_CHILD_PID and excludes // it while still reaping any genuinely-orphaned dashboards. (#37532) + // Exclude every desktop-managed backend (primary + all pool profiles) from + // the update reaper. _kill_stale_dashboard_processes accepts a comma-separated + // list (a single int still parses for back-compat). + const desktopChildPids = [] if (hermesProcess && Number.isInteger(hermesProcess.pid)) { - env.HERMES_DESKTOP_CHILD_PID = String(hermesProcess.pid) + desktopChildPids.push(hermesProcess.pid) + } + for (const entry of backendPool.values()) { + if (entry.process && Number.isInteger(entry.process.pid)) { + desktopChildPids.push(entry.process.pid) + } + } + if (desktopChildPids.length) { + env.HERMES_DESKTOP_CHILD_PID = desktopChildPids.join(',') } // Branch-pin so a non-main checkout doesn't get switched to main (and self-heal @@ -3363,8 +3403,14 @@ async function mintGatewayWsTicket(baseUrl) { // calls this immediately before every gateway.connect() so each WS upgrade // carries a freshly-minted ticket. For local/token connections this just // reuses the static token (no minting needed). -async function freshGatewayWsUrl() { - const connection = await startHermes() +async function freshGatewayWsUrl(profile) { + // Mint for the requested profile's backend, NOT always the primary. The + // renderer re-mints right before every gateway.connect(); when swapping to a + // pooled profile we must return THAT backend's ws URL, otherwise the connect + // silently lands back on the primary (default) backend and writes sessions to + // the wrong profile's DB. A null/empty profile resolves to the primary, so + // legacy callers and single-profile users are unchanged. + const connection = await ensureBackend(profile) if (connection.authMode === 'oauth') { const ticket = await mintGatewayWsTicket(connection.baseUrl) return buildGatewayWsUrlWithTicket(connection.baseUrl, ticket) @@ -3448,6 +3494,38 @@ function writeDesktopConnectionConfig(config) { connectionConfigCacheMtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs } +// Returns the desktop's chosen profile name, or null when unset. "default" is +// a valid stored value (pins the root HERMES_HOME explicitly); null means "no +// preference" and preserves the legacy launch (no --profile flag). +function readActiveDesktopProfile() { + try { + const raw = fs.readFileSync(DESKTOP_PROFILE_CONFIG_PATH, 'utf8') + const parsed = JSON.parse(raw) + const name = parsed && typeof parsed.profile === 'string' ? parsed.profile.trim() : '' + + if (name && (name === 'default' || PROFILE_NAME_RE.test(name))) { + return name + } + } catch { + // Missing or malformed → no preference. + } + + return null +} + +function writeActiveDesktopProfile(name) { + const value = typeof name === 'string' ? name.trim() : '' + + if (value && value !== 'default' && !PROFILE_NAME_RE.test(value)) { + throw new Error(`Invalid profile name: ${value}`) + } + + fs.mkdirSync(path.dirname(DESKTOP_PROFILE_CONFIG_PATH), { recursive: true }) + writeFileAtomic(DESKTOP_PROFILE_CONFIG_PATH, JSON.stringify({ profile: value || null }, null, 2)) + + return value || null +} + async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) { const remoteToken = decryptDesktopSecret(config.remote?.token) const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token' @@ -3712,6 +3790,212 @@ function resetHermesConnection() { resetBootProgressForReconnect() } +// Re-home the primary backend: reset connection state, then wait for the live +// dashboard process to actually exit (SIGKILL after 5s) so the next +// startHermes() spawns fresh instead of racing the dying one. Shared by the +// connection-config and profile switch flows. +async function teardownPrimaryBackendAndWait() { + // Capture the reference before resetHermesConnection() nulls hermesProcess. + const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null + resetHermesConnection() + + if (!dying) { + return + } + + await new Promise(resolve => { + const timer = setTimeout(() => { + try { + dying.kill('SIGKILL') + } catch { + // Already gone. + } + resolve() + }, 5000) + dying.once('exit', () => { + clearTimeout(timer) + resolve() + }) + }) +} + +// The profile the primary (window) backend runs as. readActiveDesktopProfile() +// returns the desktop's stored preference, or null when unset (legacy launch +// that defers to active_profile / default). +function primaryProfileKey() { + return readActiveDesktopProfile() || 'default' +} + +// Resolve a backend connection for the given profile. Routes the primary +// profile to startHermes() (the window backend: boot UI, bootstrap, remote +// mode), and any OTHER profile to a lazily-spawned pool backend. An empty / +// unknown profile resolves to the primary, so all legacy callers are unchanged. +async function ensureBackend(profile) { + const key = profile && String(profile).trim() ? String(profile).trim() : primaryProfileKey() + + if (key === primaryProfileKey()) { + return startHermes() + } + + const existing = backendPool.get(key) + if (existing) { + existing.lastActiveAt = Date.now() + return existing.connectionPromise + } + + evictLruPoolBackends(POOL_MAX_BACKENDS - 1) + + const entry = { process: null, port: null, token: null, connectionPromise: null, lastActiveAt: Date.now() } + entry.connectionPromise = spawnPoolBackend(key, entry).catch(error => { + backendPool.delete(key) + throw error + }) + backendPool.set(key, entry) + startPoolIdleReaper() + return entry.connectionPromise +} + +// Mark a pool profile as recently used so the idle reaper spares it. The +// renderer calls this when it opens a profile's chat WS and periodically while +// streaming, since the main process can't see the direct renderer↔backend WS. +function touchPoolBackend(profile) { + const key = profile && String(profile).trim() ? String(profile).trim() : null + if (!key) return + const entry = backendPool.get(key) + if (entry) entry.lastActiveAt = Date.now() +} + +// Evict least-recently-used pool backends until at most `keep` remain — but only +// ever evict backends without a live renderer socket (stale beyond the keepalive +// window). When every backend is actively kept alive we let the pool exceed the +// soft cap rather than kill a running session. +function evictLruPoolBackends(keep) { + if (backendPool.size <= keep) return + const now = Date.now() + const evictable = [...backendPool.entries()] + .filter(([, entry]) => now - (entry.lastActiveAt || 0) > POOL_KEEPALIVE_FRESH_MS) + .sort((a, b) => (a[1].lastActiveAt || 0) - (b[1].lastActiveAt || 0)) + let removable = backendPool.size - Math.max(0, keep) + for (const [profile] of evictable) { + if (removable <= 0) break + rememberLog(`Evicting idle profile backend "${profile}" (LRU cap ${POOL_MAX_BACKENDS})`) + stopPoolBackend(profile) + removable -= 1 + } +} + +function startPoolIdleReaper() { + if (poolIdleReaper) return + poolIdleReaper = setInterval(() => { + const now = Date.now() + for (const [profile, entry] of [...backendPool.entries()]) { + if (now - (entry.lastActiveAt || 0) > POOL_IDLE_MS) { + rememberLog(`Reaping idle profile backend "${profile}" (idle > ${Math.round(POOL_IDLE_MS / 1000)}s)`) + stopPoolBackend(profile) + } + } + if (backendPool.size === 0 && poolIdleReaper) { + clearInterval(poolIdleReaper) + poolIdleReaper = null + } + }, 60_000) + if (typeof poolIdleReaper.unref === 'function') poolIdleReaper.unref() +} + +// Spawn an additional dashboard backend pinned to a named profile. Mirrors the +// local-spawn portion of startHermes() but without the boot-progress UI, +// bootstrap, or remote handling (those belong to the primary backend only). +async function spawnPoolBackend(profile, entry) { + // Remote deployments are single-tenant; profiles only apply to local backends. + const remote = await resolveRemoteBackend() + if (remote) { + throw new Error('Profiles are unavailable when connected to a remote Hermes backend.') + } + + const port = await pickPort() + const token = crypto.randomBytes(32).toString('base64url') + // --profile wins over the inherited HERMES_HOME env (see _apply_profile_override + // step 3 in hermes_cli/main.py), so the child re-homes to this profile. + const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)] + const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs)) + const hermesCwd = resolveHermesCwd() + const webDist = resolveWebDist() + + rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`) + + const child = spawn(backend.command, backend.args, { + cwd: hermesCwd, + env: { + ...process.env, + HERMES_HOME, + ...backend.env, + HERMES_DASHBOARD_SESSION_TOKEN: token, + HERMES_WEB_DIST: webDist + }, + shell: backend.shell, + stdio: ['ignore', 'pipe', 'pipe'] + }) + entry.process = child + entry.port = port + entry.token = token + + child.stdout.on('data', rememberLog) + child.stderr.on('data', rememberLog) + + let ready = false + let rejectStart = null + const startFailed = new Promise((_resolve, reject) => { + rejectStart = reject + }) + child.once('error', error => { + rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`) + backendPool.delete(profile) + rejectStart?.(error) + }) + child.once('exit', (code, signal) => { + rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`) + backendPool.delete(profile) + if (!ready) { + rejectStart?.(new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`)) + } + }) + + const baseUrl = `http://127.0.0.1:${port}` + await Promise.race([waitForHermes(baseUrl, token), startFailed]) + ready = true + + return { + baseUrl, + mode: 'local', + source: 'local', + authMode: 'token', + token, + profile, + wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`, + logs: hermesLog.slice(-80), + ...getWindowState() + } +} + +function stopPoolBackend(profile) { + const entry = backendPool.get(profile) + if (!entry) return + backendPool.delete(profile) + if (entry.process && !entry.process.killed) { + try { + entry.process.kill('SIGTERM') + } catch { + // Already gone. + } + } +} + +function stopAllPoolBackends() { + for (const profile of [...backendPool.keys()]) { + stopPoolBackend(profile) + } +} + async function startHermes() { // Latched-failure short-circuit: once bootstrap has failed in this // process, every subsequent startHermes() call re-throws the same error @@ -3753,6 +4037,15 @@ async function startHermes() { const port = await pickPort() const token = crypto.randomBytes(32).toString('base64url') const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)] + // Pin the desktop's chosen profile via the global --profile flag. This is + // deterministic (it wins over the sticky ~/.hermes/active_profile file) and + // resolves HERMES_HOME the same way `hermes -p ` does on the CLI. An + // unset preference keeps the legacy launch so existing installs are + // unaffected. + const activeProfile = readActiveDesktopProfile() + if (activeProfile) { + dashboardArgs.unshift('--profile', activeProfile) + } await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28) const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs)) const hermesCwd = resolveHermesCwd() @@ -3996,8 +4289,12 @@ function createWindow() { }) } -ipcMain.handle('hermes:connection', async () => startHermes()) -ipcMain.handle('hermes:gateway:ws-url', async () => freshGatewayWsUrl()) +ipcMain.handle('hermes:connection', async (_event, profile) => ensureBackend(profile)) +ipcMain.handle('hermes:backend:touch', async (_event, profile) => { + touchPoolBackend(profile) + return { ok: true } +}) +ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile)) ipcMain.handle('hermes:bootstrap:reset', async () => { // Renderer's "Reload and retry" path. Clear the latched failure and // reset connection state so the next startHermes() call restarts the @@ -4077,28 +4374,25 @@ ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => { const config = coerceDesktopConnectionConfig(payload) writeDesktopConnectionConfig(config) - // Capture the reference before resetHermesConnection() nulls hermesProcess, - // so we can wait for actual exit rather than assuming a fixed delay is enough. - const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null - resetHermesConnection() - - if (dying) { - await new Promise(resolve => { - const timer = setTimeout(() => { - try { dying.kill('SIGKILL') } catch {} - resolve() - }, 5000) - dying.once('exit', () => { - clearTimeout(timer) - resolve() - }) - }) - } + await teardownPrimaryBackendAndWait() mainWindow?.reload() return sanitizeDesktopConnectionConfig(config) }) +ipcMain.handle('hermes:profile:get', async () => ({ profile: readActiveDesktopProfile() })) +ipcMain.handle('hermes:profile:set', async (_event, name) => { + const next = writeActiveDesktopProfile(name) + + // Switching profiles is a backend re-home: relaunch the dashboard under the + // new HERMES_HOME. Pool backends keep their own homes, so only the primary + // is torn down. + await teardownPrimaryBackendAndWait() + mainWindow?.reload() + + return { profile: next } +}) + ipcMain.on('hermes:previewShortcutActive', (_event, active) => { previewShortcutActive = Boolean(active) }) @@ -4112,7 +4406,7 @@ ipcMain.handle('hermes:requestMicrophoneAccess', async () => { }) ipcMain.handle('hermes:api', async (_event, request) => { - const connection = await startHermes() + const connection = await ensureBackend(request?.profile) const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) const url = `${connection.baseUrl}${request.path}` // OAuth gateways authenticate REST via the HttpOnly session cookie held in @@ -4653,6 +4947,7 @@ app.on('before-quit', () => { if (hermesProcess && !hermesProcess.killed) { hermesProcess.kill('SIGTERM') } + stopAllPoolBackends() }) app.on('window-all-closed', () => { diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 65fc591e8..2fcf96e4b 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -1,8 +1,9 @@ const { contextBridge, ipcRenderer, webUtils } = require('electron') contextBridge.exposeInMainWorld('hermesDesktop', { - getConnection: () => ipcRenderer.invoke('hermes:connection'), - getGatewayWsUrl: () => ipcRenderer.invoke('hermes:gateway:ws-url'), + getConnection: profile => ipcRenderer.invoke('hermes:connection', profile), + touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile), + getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile), getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'), getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'), saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload), @@ -11,6 +12,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', { probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl), oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl), oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl), + profile: { + get: () => ipcRenderer.invoke('hermes:profile:get'), + set: name => ipcRenderer.invoke('hermes:profile:set', name) + }, api: request => ipcRenderer.invoke('hermes:api', request), notify: payload => ipcRenderer.invoke('hermes:notify', payload), requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'), diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index c5e8183c2..109ffba07 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -16,6 +16,7 @@ import { PaginationPrevious } from '@/components/ui/pagination' import { TextTab, TextTabMeta } from '@/components/ui/text-tab' +import { Tip } from '@/components/ui/tooltip' import { getSessionMessages, listSessions } from '@/hermes' import { sessionTitle } from '@/lib/chat-runtime' import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link' @@ -736,7 +737,6 @@ function ArtifactCellAction({ - {onRemove && ( + +
- )} -
+ {onRemove && ( + + )} + +
) } diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index bd4b140b4..7e98456f9 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -1,5 +1,6 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' +import { Tip } from '@/components/ui/tooltip' import { triggerHaptic } from '@/lib/haptics' import { AudioLines, Layers3, Loader2, Square } from '@/lib/icons' import { cn } from '@/lib/utils' @@ -64,38 +65,40 @@ export function ComposerControls({
{showVoicePrimary ? ( - + + + ) : ( - + + )} + + )}
) @@ -126,22 +129,23 @@ function ConversationPill({ return (
- + + + {listening && ( + + + ) } diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index bf9e72b4b..ae1560e96 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -10,6 +10,8 @@ * steal focus from the composer effect. */ +import type { InlineRefInput } from './inline-refs' + export type ComposerTarget = 'edit' | 'main' export type ComposerInsertMode = 'block' | 'inline' @@ -23,8 +25,14 @@ interface InsertDetail { text: string } +interface InsertRefsDetail { + refs: InlineRefInput[] + target: ComposerTarget +} + const FOCUS_EVENT = 'hermes:composer-focus' const INSERT_EVENT = 'hermes:composer-insert' +const INSERT_REFS_EVENT = 'hermes:composer-insert-refs' let activeTarget: ComposerTarget = 'main' @@ -82,6 +90,20 @@ export const onComposerFocusRequest = (handler: (target: ComposerTarget) => void export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) => subscribe(INSERT_EVENT, handler) +/** Insert typed ref chips (carrying a display label) into a composer — the + * structured cousin of {@link requestComposerInsert}, used for session links. */ +export const requestComposerInsertRefs = ( + refs: InlineRefInput[], + { target = 'active' }: { target?: ComposerTarget | 'active' } = {} +) => { + if (refs.length) { + dispatch(INSERT_REFS_EVENT, { refs, target: resolve(target) }) + } +} + +export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) => + subscribe(INSERT_REFS_EVENT, handler) + /** * Focus a composer input across React commit + browser focus restore. * diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 7f14286e8..4997014e8 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -45,6 +45,7 @@ import { focusComposerInput, markActiveComposer, onComposerFocusRequest, + onComposerInsertRefsRequest, onComposerInsertRequest } from './focus' import { HelpHint } from './help-hint' @@ -52,7 +53,12 @@ import { useAtCompletions } from './hooks/use-at-completions' import { useSlashCompletions } from './hooks/use-slash-completions' import { useVoiceConversation } from './hooks/use-voice-conversation' import { useVoiceRecorder } from './hooks/use-voice-recorder' -import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from './inline-refs' +import { + dragHasAttachments, + droppedFileInlineRef, + type InlineRefInput, + insertInlineRefsIntoEditor +} from './inline-refs' import { QueuePanel } from './queue-panel' import { composerPlainText, @@ -432,7 +438,7 @@ export function ChatBar({ requestMainFocus() } - const insertInlineRefs = (refs: string[]) => { + const insertInlineRefs = (refs: InlineRefInput[]) => { const editor = editorRef.current if (!editor) { @@ -452,6 +458,19 @@ export function ChatBar({ return true } + // Latest-closure ref so the (once-only) subscription always calls the current + // insertInlineRefs without re-subscribing every render. + const insertInlineRefsRef = useRef(insertInlineRefs) + insertInlineRefsRef.current = insertInlineRefs + + useEffect(() => { + return onComposerInsertRefsRequest(({ refs, target }) => { + if (target === 'main') { + insertInlineRefsRef.current(refs) + } + }) + }, []) + const selectSkinSlashCommand = (command: string) => { draftRef.current = command aui.composer().setText(command) diff --git a/apps/desktop/src/app/chat/composer/inline-refs.ts b/apps/desktop/src/app/chat/composer/inline-refs.ts index bb59a9c68..c8fa48d6e 100644 --- a/apps/desktop/src/app/chat/composer/inline-refs.ts +++ b/apps/desktop/src/app/chat/composer/inline-refs.ts @@ -5,6 +5,49 @@ import type { DroppedFile } from '../hooks/use-composer-actions' import { composerPlainText, escapeHtml, placeCaretEnd, refChipHtml } from './rich-editor' +/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */ +export type InlineRefInput = string | { kind: string; label?: string; value: string } + +/** MIME for an in-app session drag (sidebar row → composer). */ +export const HERMES_SESSION_MIME = 'application/x-hermes-session' + +export interface SessionDragPayload { + id: string + profile: string + title: string +} + +export function writeSessionDrag(transfer: DataTransfer, payload: SessionDragPayload) { + transfer.setData(HERMES_SESSION_MIME, JSON.stringify(payload)) + transfer.effectAllowed = 'copy' +} + +export function dragHasSession(transfer: DataTransfer | null) { + return Boolean(transfer) && Array.from(transfer!.types || []).includes(HERMES_SESSION_MIME) +} + +export function readSessionDrag(transfer: DataTransfer | null): null | SessionDragPayload { + const raw = transfer?.getData(HERMES_SESSION_MIME) + + if (!raw) { + return null + } + + try { + const parsed = JSON.parse(raw) as Partial + + return parsed.id ? { id: parsed.id, profile: parsed.profile || 'default', title: parsed.title || '' } : null + } catch { + return null + } +} + +/** Build a `@session:/` chip. Value carries the metadata the agent + * needs to resolve the link (session_search); label shows the friendly title. */ +export function sessionInlineRef({ id, profile, title }: SessionDragPayload): InlineRefInput { + return { kind: 'session', label: title || `chat ${id.slice(0, 8)}`, value: `${profile || 'default'}/${id}` } +} + export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) { if (!transfer) { return false @@ -40,13 +83,17 @@ export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null return `@${kind}:${formatRefValue(rel)}` } -export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly string[]) { +export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) { if (!refs.length) { return null } const refsHtml = refs .map(ref => { + if (typeof ref !== 'string') { + return refChipHtml(ref.kind, ref.value, ref.label) + } + const match = ref.match(/^@([^:]+):(.+)$/) return match ? refChipHtml(match[1], match[2]) : escapeHtml(ref) diff --git a/apps/desktop/src/app/chat/composer/queue-panel.tsx b/apps/desktop/src/app/chat/composer/queue-panel.tsx index 18e95d044..812e1f9a7 100644 --- a/apps/desktop/src/app/chat/composer/queue-panel.tsx +++ b/apps/desktop/src/app/chat/composer/queue-panel.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { Tip } from '@/components/ui/tooltip' import { ArrowUp, Pencil, Trash2 } from '@/lib/icons' import { cn } from '@/lib/utils' import type { QueuedPromptEntry } from '@/store/composer-queue' @@ -80,41 +81,44 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN : 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100' )} > - - - + + + + + + + + +
) diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index 3a45028e7..38ab85d0f 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -15,7 +15,7 @@ import { export const RICH_INPUT_SLOT = 'composer-rich-input' -export const REF_RE = /@(file|folder|url|image|tool|line|terminal):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g +export const REF_RE = /@(file|folder|url|image|tool|line|terminal|session):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g const ESC: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } @@ -52,14 +52,14 @@ export function quoteRefValue(value: string) { return formatRefValue(value) } -export function refChipHtml(kind: string, rawValue: string) { +export function refChipHtml(kind: string, rawValue: string, displayLabel?: string) { const id = unquoteRef(rawValue) const text = `@${kind}:${quoteRefValue(id)}` - return `${directiveIconSvg(kind)}${escapeHtml(refLabel(id))}` + return `${directiveIconSvg(kind)}${escapeHtml(displayLabel || refLabel(id))}` } -export function refChipElement(kind: string, rawValue: string) { +export function refChipElement(kind: string, rawValue: string, displayLabel?: string) { const id = unquoteRef(rawValue) const text = `@${kind}:${quoteRefValue(id)}` const chip = document.createElement('span') @@ -71,7 +71,7 @@ export function refChipElement(kind: string, rawValue: string) { chip.dataset.refKind = kind chip.className = DIRECTIVE_CHIP_CLASS label.className = 'truncate' - label.textContent = refLabel(id) + label.textContent = displayLabel || refLabel(id) chip.append(directiveIconElement(kind), label) return chip diff --git a/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts b/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts index 3254cbf04..10b3cfe40 100644 --- a/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts +++ b/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts @@ -1,50 +1,71 @@ import { type DragEvent as ReactDragEvent, useCallback, useRef, useState } from 'react' -import { dragHasAttachments } from '@/app/chat/composer/inline-refs' +import { + dragHasAttachments, + dragHasSession, + readSessionDrag, + type SessionDragPayload +} from '@/app/chat/composer/inline-refs' import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from './use-composer-actions' -const hasFiles = (event: ReactDragEvent) => dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME) +export type DragKind = 'files' | 'session' | null + +const dragKindOf = (event: ReactDragEvent): DragKind => { + if (dragHasSession(event.dataTransfer)) { + return 'session' + } + + if (dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return 'files' + } + + return null +} interface FileDropZoneOptions { /** When false the zone ignores drags entirely. */ enabled?: boolean onDropFiles: (files: DroppedFile[]) => void + onDropSession?: (session: SessionDragPayload) => void } /** - * "Drop files anywhere in this region" affordance. An enter/leave depth counter - * keeps nested children from flickering the active state; `onDropCapture` clears - * it even when a nested target (the composer) handles the drop and stops - * propagation before our bubble-phase `onDrop` would fire. + * "Drop anywhere in this region" affordance for files *and* in-app session + * links. An enter/leave depth counter keeps nested children from flickering the + * active state; `onDropCapture` clears it even when a nested target (the + * composer) handles the drop and stops propagation before our bubble-phase + * `onDrop` would fire. * - * Spread `dropHandlers` onto the container; render an overlay off `dragActive`. + * Spread `dropHandlers` onto the container; render an overlay off `dragKind`. */ -export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOptions) { - const [dragActive, setDragActive] = useState(false) +export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }: FileDropZoneOptions) { + const [dragKind, setDragKind] = useState(null) const depth = useRef(0) const reset = useCallback(() => { depth.current = 0 - setDragActive(false) + setDragKind(null) }, []) const onDragEnter = useCallback( (event: ReactDragEvent) => { - if (!enabled || !hasFiles(event)) { + const kind = enabled ? dragKindOf(event) : null + + if (!kind) { return } event.preventDefault() depth.current += 1 - setDragActive(true) + setDragKind(kind) }, [enabled] ) const onDragOver = useCallback( (event: ReactDragEvent) => { - if (!enabled || !hasFiles(event)) { + if (!enabled || !dragKindOf(event)) { return } @@ -62,21 +83,36 @@ export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOpt const onDrop = useCallback( (event: ReactDragEvent) => { - if (!enabled || !hasFiles(event)) { + const kind = enabled ? dragKindOf(event) : null + + if (!kind) { return } event.preventDefault() reset() + if (kind === 'session') { + const session = readSessionDrag(event.dataTransfer) + + if (session) { + onDropSession?.(session) + } + + return + } + const files = extractDroppedFiles(event.dataTransfer) if (files.length) { onDropFiles(files) } }, - [enabled, onDropFiles, reset] + [enabled, onDropFiles, onDropSession, reset] ) - return { dragActive, dropHandlers: { onDragEnter, onDragLeave, onDragOver, onDrop, onDropCapture: reset } } + return { + dragKind, + dropHandlers: { onDragEnter, onDragLeave, onDragOver, onDrop, onDropCapture: reset } + } } diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 38c2cff07..ec8f9df3f 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -12,7 +12,6 @@ import { useLocation } from 'react-router-dom' import { Thread } from '@/components/assistant-ui/thread' import { Backdrop } from '@/components/Backdrop' -import { NotificationStack } from '@/components/notifications' import { PromptOverlays } from '@/components/prompt-overlays' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' @@ -23,6 +22,7 @@ import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-s import { cn } from '@/lib/utils' import type { ComposerAttachment } from '@/store/composer' import { $pinnedSessionIds } from '@/store/layout' +import { $gatewaySwapTarget } from '@/store/profile' import { $activeSessionId, $awaitingResponse, @@ -46,9 +46,10 @@ import { routeSessionId } from '../routes' import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar' import { ChatDropOverlay } from './chat-drop-overlay' +import { ChatSwapOverlay } from './chat-swap-overlay' import { ChatBar, ChatBarFallback } from './composer' -import { requestComposerInsert } from './composer/focus' -import { droppedFileInlineRef } from './composer/inline-refs' +import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus' +import { droppedFileInlineRef, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs' import type { ChatBarState } from './composer/types' import type { DroppedFile } from './hooks/use-composer-actions' import { useFileDropZone } from './hooks/use-file-drop-zone' @@ -179,6 +180,7 @@ export function ChatView({ const currentProvider = useStore($currentProvider) const freshDraftReady = useStore($freshDraftReady) const gatewayState = useStore($gatewayState) + const gatewaySwapTarget = useStore($gatewaySwapTarget) const gatewayOpen = gatewayState === 'open' const introPersonality = useStore($introPersonality) const introSeed = useStore($introSeed) @@ -307,7 +309,13 @@ export function ChatView({ [currentCwd] ) - const { dragActive, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles }) + // Dropping a sidebar session inserts an @session link the agent can resolve + // via session_search (carries the source profile, so cross-profile works). + const onDropSession = useCallback((session: SessionDragPayload) => { + requestComposerInsertRefs([sessionInlineRef(session)], { target: 'main' }) + }, []) + + const { dragKind, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles, onDropSession }) return (
-
)} - + +
) diff --git a/apps/desktop/src/app/chat/right-rail/preview-console.tsx b/apps/desktop/src/app/chat/right-rail/preview-console.tsx index 93b867a28..70a973322 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-console.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-console.tsx @@ -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' )} > - + + +
{log.message} @@ -112,14 +114,15 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console showLabel={false} text={copyText} /> - + + +
) @@ -225,11 +228,6 @@ export function PreviewConsolePanel({ className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40" disabled={sendableLogs.length === 0} onClick={() => sendLogsToComposer(sendableLogs)} - title={ - visibleSelection.length > 0 - ? `Send ${visibleSelection.length} selected to chat` - : 'Send all log entries to chat' - } type="button" > @@ -250,7 +248,6 @@ export function PreviewConsolePanel({ className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40" disabled={logs.length === 0} onClick={consoleState.clear} - title="Clear console" type="button" > diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx index 4788344dd..0c8a5bb29 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -3,6 +3,7 @@ import type { PointerEvent as ReactPointerEvent } from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls' +import { Tip } from '@/components/ui/tooltip' import { Bug } from '@/lib/icons' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' @@ -607,15 +608,16 @@ export function PreviewPane({ {!embedded && ( )} diff --git a/apps/desktop/src/app/chat/right-rail/preview.tsx b/apps/desktop/src/app/chat/right-rail/preview.tsx index b53acc955..b6825ff6f 100644 --- a/apps/desktop/src/app/chat/right-rail/preview.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo } from 'react' import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls' import { Codicon } from '@/components/ui/codicon' +import { Tip } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { $rightRailActiveTabId, @@ -117,16 +118,17 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP {active && (