diff --git a/agent/agent_init.py b/agent/agent_init.py index 938e13f612..20e44607bb 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -913,6 +913,10 @@ def init_agent( # Model response configuration agent.max_tokens = max_tokens # None = use model default agent.reasoning_config = reasoning_config # None = use default (medium for OpenRouter) + # Per-provider reasoning_content echo opt-in (see _reasoning_echo_opt_in). + # Read once at init; switch_model / try_activate_fallback / restore + # keep it in sync with the active provider. + agent._reasoning_echo_flag = agent._read_reasoning_echo_from_config() agent.service_tier = service_tier agent.request_overrides = dict(request_overrides or {}) agent.prefill_messages = prefill_messages or [] # Prefilled conversation turns @@ -2951,6 +2955,7 @@ def init_agent( "client_kwargs": dict(agent._client_kwargs), "use_prompt_caching": agent._use_prompt_caching, "use_native_cache_layout": agent._use_native_cache_layout, + "reasoning_echo_flag": getattr(agent, "_reasoning_echo_flag", False), # Context engine state that _try_activate_fallback() overwrites. # Use getattr for model/base_url/api_key/provider since plugin # engines may not have these (they're ContextCompressor-specific). diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 79cfe58135..dbbb0d11e9 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1349,6 +1349,7 @@ def try_recover_primary_transport( if hasattr(agent, "_transport_cache"): agent._transport_cache.clear() agent.api_key = rt["api_key"] + agent._reasoning_echo_flag = rt.get("reasoning_echo_flag", False) if agent.api_mode == "anthropic_messages": from agent.anthropic_adapter import build_anthropic_client @@ -1579,6 +1580,7 @@ def restore_primary_runtime(agent) -> bool: if hasattr(agent, "_transport_cache"): agent._transport_cache.clear() agent.api_key = rt["api_key"] + agent._reasoning_echo_flag = rt.get("reasoning_echo_flag", False) agent._client_kwargs = dict(rt["client_kwargs"]) agent._use_prompt_caching = rt["use_prompt_caching"] # Default to native layout when the restored snapshot predates the @@ -2652,6 +2654,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo "_anthropic_base_url", "_is_anthropic_oauth", "_config_context_length", + "_reasoning_echo_flag", ) } # _client_kwargs is a dict — snapshot a shallow copy so mutating the @@ -2685,6 +2688,9 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo agent.model = new_model agent.provider = new_provider agent.requested_provider = new_provider + # Re-read reasoning_echo from config so the flag reflects the new + # primary model's setting (see _reasoning_echo_opt_in). + agent._reasoning_echo_flag = agent._read_reasoning_echo_from_config() # Use the new base_url when provided. When it's empty AND the # provider is actually changing, do NOT fall back to the current # (old provider's) URL — that silently pairs the new provider label @@ -2970,6 +2976,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo "use_prompt_caching": agent._use_prompt_caching, "use_native_cache_layout": agent._use_native_cache_layout, "reasoning_config": dict(agent.reasoning_config) if getattr(agent, "reasoning_config", None) else None, + "reasoning_echo_flag": getattr(agent, "_reasoning_echo_flag", False), "compressor_model": getattr(_cc, "model", agent.model) if _cc else agent.model, "compressor_base_url": getattr(_cc, "base_url", agent.base_url) if _cc else agent.base_url, "compressor_api_key": getattr(_cc, "api_key", "") if _cc else "", diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 4805362731..7af486dc5b 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -2652,6 +2652,10 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool agent.requested_provider = fb_provider agent.base_url = fb_base_url agent.api_mode = fb_api_mode + # Per-provider reasoning_content echo opt-in (see _reasoning_echo_opt_in). + # Read from the fallback entry so the flag travels with the active + # provider; restore_primary_runtime will revert it from the snapshot. + agent._reasoning_echo_flag = bool(fb.get("reasoning_echo", False)) if hasattr(agent, "_transport_cache"): agent._transport_cache.clear() agent._fallback_activated = True diff --git a/apps/desktop/e2e/batch-clarify.spec.ts b/apps/desktop/e2e/batch-clarify.spec.ts index 8658bd3b1b..acf97ddb2e 100644 --- a/apps/desktop/e2e/batch-clarify.spec.ts +++ b/apps/desktop/e2e/batch-clarify.spec.ts @@ -5,8 +5,8 @@ * model's tool_call_id while `clarify.request` carries a gateway-generated * request_id. A batch payload has no top-level `question`, so the two rows * only merge when the correlation key comes from the question list - * (`batchClarifyMatchValue` in lib/chat-messages.ts). Before that fix this - * exact flow rendered two identical interactive cards. + * (`batchClarifyMatchValue` in lib/chat-messages/tool-parts.ts). Before that + * fix this exact flow rendered two identical interactive cards. * * The flow runs the real chain: composer → gateway → agent → clarify tool → * clarify.request event → renderer, against the mock inference server. diff --git a/apps/desktop/e2e/boot.spec.ts b/apps/desktop/e2e/boot.spec.ts index 3a74fa4cc5..19f2d81fa4 100644 --- a/apps/desktop/e2e/boot.spec.ts +++ b/apps/desktop/e2e/boot.spec.ts @@ -50,6 +50,25 @@ test.describe('dev-mode boot with mock backend', () => { }) }) + // A preload that throws never reaches contextBridge, so the renderer boots + // into "Desktop IPC bridge is unavailable" and every test below it dies on a + // 120s never-became-ready timeout instead. Checking the bridge by name makes + // that failure legible. The sandbox lets preload require only electron, + // events, timers and url — adding any other node builtin lands here. + test('the preload bridge reaches the renderer', async () => { + const bridge = await fixture!.page.evaluate(() => { + const desktop = (window as unknown as { hermesDesktop?: Record }).hermesDesktop + + return { + present: typeof desktop, + glassSupported: typeof desktop?.glassSupported, + translucencySupported: typeof desktop?.translucencySupported + } + }) + + expect(bridge).toEqual({ present: 'object', glassSupported: 'boolean', translucencySupported: 'boolean' }) + }) + test('backend boots and app becomes ready', async () => { // This is the big one — wait for the full boot chain to complete: // electron starts → hermes serve is spawned → WS connects → config diff --git a/apps/desktop/electron/fs-ipc.ts b/apps/desktop/electron/fs-ipc.ts new file mode 100644 index 0000000000..ff25db2013 --- /dev/null +++ b/apps/desktop/electron/fs-ipc.ts @@ -0,0 +1,197 @@ +// IPC surface for local filesystem operations the renderer's project/file +// surfaces use: directory reads, reveal/open in the OS file manager, plugin +// roots + git installs, rename/write/trash. Extracted from main.ts; path +// hardening, HERMES_HOME resolution, and the git binary stay injected. +import fs from 'node:fs' +import path from 'node:path' + +import { ipcMain, shell } from 'electron' + +import { installDesktopPluginFromGit, probePluginRepo } from './desktop-plugin-install' +import { readDirForIpc } from './fs-read-dir' +import { gitRootForIpc } from './git-root' + +export interface FsIpcDeps { + hermesHome: string + readActiveDesktopProfile: () => null | string + expandUserPath: (value: string) => string + resolveRequestedPathForIpc: (value: string, options: { purpose: string }) => string + directoryExists: (value: string) => boolean + resolveGitBinary: () => string +} + +export function registerFsIpc({ + hermesHome, + readActiveDesktopProfile, + expandUserPath, + resolveRequestedPathForIpc, + directoryExists, + resolveGitBinary +}: FsIpcDeps) { + ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath)) + + ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath)) + + // Reveal a path in the OS file manager (Finder / Explorer / Files). + ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => { + const target = String(targetPath || '').trim() + + if (!target) { + return false + } + + try { + shell.showItemInFolder(target) + + return true + } catch { + return false + } + }) + + // Open a DIRECTORY in the OS file manager, creating it first if needed. Unlike + // `reveal` (which selects an existing item and silently no-ops on a missing + // path — the "Open plugins folder" Windows bug), this is for the plugins door, + // which often doesn't exist on first use. `shell.openPath` returns '' on + // success or an error string; both mkdir + openPath failures are surfaced. + ipcMain.handle('hermes:fs:openDir', async (_event, dirPath) => { + const dir = String(dirPath || '').trim() + + if (!dir) { + return { ok: false, error: 'no path' } + } + + try { + await fs.promises.mkdir(dir, { recursive: true }) + const error = await shell.openPath(path.normalize(dir)) + + return error ? { ok: false, error } : { ok: true } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + }) + + // The LOCAL Desktop runtime-plugin root: `/desktop-plugins`, + // resolved from the main-process HERMES_HOME (see resolveHermesHome) — NOT from + // the connected backend. A remote backend reports its own `hermes_home` over + // the gateway, which is a path on the REMOTE box; deriving the plugin dir from + // it yields `undefined/desktop-plugins` (or a non-existent remote path) and the + // on-disk plugin door silently breaks (#66899). Electron owns this resolution + // so it stays valid in every connection mode. Created on demand, like openDir. + async function localPluginsRoot(dirName: string): Promise { + // Profile-aware: a named Desktop profile gets its own plugin root under + // profiles//, matching the profile-scoped hermes_home the backend + // reported before this resolver existed. 'default'/unset pins the global root. + const profile = readActiveDesktopProfile() + const base = profile && profile !== 'default' ? path.join(hermesHome, 'profiles', profile) : hermesHome + const dir = path.join(base, dirName) + + try { + await fs.promises.mkdir(dir, { recursive: true }) + } catch { + // Best-effort create; return the path regardless so the reveal action can + // still surface a real openPath error and the scanner can retry later. + } + + return dir + } + + ipcMain.handle('hermes:fs:desktopPluginsRoot', async () => localPluginsRoot('desktop-plugins')) + + // The LOCAL agent-plugin root (`/plugins`), same Electron-local + // resolution as above. This is the desktop half of a UNIFIED plugin package: + // an agent plugin may ship `desktop/plugin.js` alongside its Python code (the + // same shape as `dashboard/manifest.json`), and the renderer's disk door scans + // this root for it — one installable folder serving both SDKs. + ipcMain.handle('hermes:fs:agentPluginsRoot', async () => localPluginsRoot('plugins')) + + ipcMain.handle('hermes:plugin:probe', async (_event, payload) => { + const identifier = String(payload?.identifier || payload?.repo || '').trim() + + if (!identifier) { + return { ok: false, error: 'identifier is required', agent: false, desktop: false, warnings: [] } + } + + return probePluginRepo(resolveGitBinary(), identifier) + }) + + ipcMain.handle('hermes:plugin:installDesktop', async (_event, payload) => { + const identifier = String(payload?.identifier || payload?.repo || '').trim() + + if (!identifier) { + return { ok: false, error: 'identifier is required' } + } + + const desktopPluginsRoot = await localPluginsRoot('desktop-plugins') + + return installDesktopPluginFromGit(resolveGitBinary(), identifier, desktopPluginsRoot, Boolean(payload?.force)) + }) + + // Rename a file/folder in place. The renderer passes the existing path + a new + // base name; the destination is resolved in the SAME parent dir so a rename can + // never move the item elsewhere or traverse out. Rejects on a name collision. + ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => { + const src = String(targetPath || '').trim() + const name = String(newName || '').trim() + + if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { + throw new Error('Invalid rename') + } + + const dst = path.join(path.dirname(src), name) + + if (dst === src) { + return { path: dst } + } + + if (fs.existsSync(dst)) { + throw new Error(`"${name}" already exists`) + } + + await fs.promises.rename(src, dst) + + return { path: dst } + }) + + // Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path + // is hardened (resolveRequestedPathForIpc) and the parent must already exist — + // this never creates directory trees or escapes the allowed roots, and content + // is size-capped so it can't be abused as a bulk-write primitive. + ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => { + const raw = String(filePath || '').trim() + + if (!raw) { + throw new Error('Invalid path') + } + + const text = String(content ?? '') + + if (text.length > 1_000_000) { + throw new Error('Content too large') + } + + const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' }) + + if (!directoryExists(path.dirname(resolved))) { + throw new Error('Parent directory does not exist') + } + + await fs.promises.writeFile(resolved, text, 'utf8') + + return { path: resolved } + }) + + // Move a file/folder to the OS trash (recoverable) — the VS Code "Delete" + // default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform. + ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => { + const target = String(targetPath || '').trim() + + if (!target) { + throw new Error('Invalid delete') + } + + await shell.trashItem(target) + + return true + }) +} diff --git a/apps/desktop/electron/git-ipc.ts b/apps/desktop/electron/git-ipc.ts new file mode 100644 index 0000000000..105307d03e --- /dev/null +++ b/apps/desktop/electron/git-ipc.ts @@ -0,0 +1,120 @@ +// IPC surface for git-driven features: worktree management ("Start work"), +// the composer coding rail's repo status, the Codex-style review pane, and +// repo-first project discovery. Extracted from main.ts; the git/gh binary +// resolvers stay injected because main.ts also uses them for self-update and +// plugin installs. +import { ipcMain } from 'electron' + +import { scanGitRepos } from './git-repo-scan' +import { + fileDiffVsHead, + repoStatus, + reviewCommit, + reviewCommitContext, + reviewCreatePr, + reviewDiff, + reviewFetchPrComment, + reviewList, + reviewPrList, + reviewPush, + reviewRevert, + reviewRevParse, + reviewShipInfo, + reviewStage, + reviewUnstage +} from './git-review-ops' +import { + addWorktree, + listBaseBranches, + listBranches, + listWorktrees, + removeWorktree, + switchBranch +} from './git-worktree-ops' + +export interface GitIpcDeps { + resolveGitBinary: () => string + resolveGhBinary: () => string +} + +export function registerGitIpc({ resolveGitBinary, resolveGhBinary }: GitIpcDeps) { + // Git-driven worktree management ("Start work" flow). Errors surface to the + // renderer as rejected promises so it can toast a friendly message. + ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary())) + + ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) => + addWorktree(repoPath, options || {}, resolveGitBinary()) + ) + + ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) => + removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary()) + ) + + ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) => + switchBranch(repoPath, branch, resolveGitBinary()) + ) + + ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary())) + + ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) => + listBaseBranches(repoPath, resolveGitBinary()) + ) + + // Compact repo status (branch, ahead/behind, change counts + files) for the + // composer coding rail. Returns null on a non-repo / remote backend so the rail + // hides cleanly rather than erroring. + ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary())) + + // Codex-style review pane: list changed files for a scope, fetch one file's + // unified diff, and stage / unstage / revert. Reads return empty on failure; + // mutations reject so the renderer can toast. + ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) => + reviewList(repoPath, scope, baseRef, resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) => + reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary()) + ) + // Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view). + ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) => + fileDiffVsHead(repoPath, filePath, resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) => + reviewStage(repoPath, filePath ?? null, resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) => + reviewUnstage(repoPath, filePath ?? null, resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) => + reviewRevert(repoPath, filePath ?? null, resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) => + reviewRevParse(repoPath, ref, resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) => + reviewCommit(repoPath, message, Boolean(push), resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) => + reviewCommitContext(repoPath, resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary())) + ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary())) + ipcMain.handle('hermes:git:review:prList', async (_event, repoPath, branches, numbers) => + reviewPrList(repoPath, resolveGhBinary(), branches, numbers) + ) + ipcMain.handle('hermes:git:review:fetchPrComment', async (_event, repoPath, url) => + reviewFetchPrComment(repoPath, resolveGhBinary(), url) + ) + ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) => + reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary()) + ) + + // Repo-first project discovery: scan bounded roots for git repos (pure fs walk, + // no native addon). Never throws to the renderer — failures yield an empty list. + ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => { + try { + return await scanGitRepos(roots || [], options || {}) + } catch { + return [] + } + }) +} diff --git a/apps/desktop/electron/hardening.ts b/apps/desktop/electron/hardening.ts index b885dda1af..f8c77f3186 100644 --- a/apps/desktop/electron/hardening.ts +++ b/apps/desktop/electron/hardening.ts @@ -3,31 +3,23 @@ import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' +// Relative, not `@hermes/shared`: the electron bundle is built by esbuild with +// no tsconfig path resolution (see scripts/bundle-electron-main.mjs), so a bare +// specifier would typecheck and then fail to bundle. +import { + clampDataUrlReadMaxMb, + DATA_URL_READ_DEFAULT_MAX_MB, + DATA_URL_READ_MAX_MAX_MB, + DATA_URL_READ_MIN_MAX_MB +} from '../../shared/src/data-url-read-max' + const DEFAULT_FETCH_TIMEOUT_MS = 15_000 -// Default / floor / ceiling for Desktop's data-URL file load (composer attach, -// image preview, etc.). The whole file is base64-buffered in main, so this is -// a memory guard — not a model limit. Settings → Chat takes a free-form MB -// value; 16 MB ships as default. The ceiling is only a typo guard (very large -// values can OOM / crash the app). -const DATA_URL_READ_DEFAULT_MAX_MB = 16 -const DATA_URL_READ_MIN_MAX_MB = 1 -const DATA_URL_READ_MAX_MAX_MB = 4096 // Remote file.attach sends one base64 JSON-RPC frame. Cap the dedicated attach // reader so the payload still fits uvicorn's raised ws_max_size (384 MiB) // after base64 + framing. Preview stays on the Settings-configurable path. const ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES = 256 * 1024 * 1024 const TEXT_PREVIEW_SOURCE_MAX_BYTES = 64 * 1024 * 1024 -function clampDataUrlReadMaxMb(value) { - const parsed = Number(value) - - if (!Number.isFinite(parsed)) { - return DATA_URL_READ_DEFAULT_MAX_MB - } - - return Math.min(DATA_URL_READ_MAX_MAX_MB, Math.max(DATA_URL_READ_MIN_MAX_MB, Math.round(parsed))) -} - function dataUrlReadMaxBytesFromMb(maxMb) { return clampDataUrlReadMaxMb(maxMb) * 1024 * 1024 } diff --git a/apps/desktop/electron/hud-ipc.ts b/apps/desktop/electron/hud-ipc.ts new file mode 100644 index 0000000000..8a8b7cf000 --- /dev/null +++ b/apps/desktop/electron/hud-ipc.ts @@ -0,0 +1,128 @@ +// IPC surface for HUD mode (the chrome-free floating chat band). Extracted +// from main.ts; the HUD window handle and session-id latch stay injected +// because main.ts owns the window lifecycle and the close broadcast reads the +// latch when handing the session back to the app window. +import { type BrowserWindow, ipcMain } from 'electron' + +export interface HudIpcDeps { + isMac: boolean + getHudWindow: () => BrowserWindow | null + openHudWindow: (sessionId: null | string, profile: null | string) => void + closeHudWindow: () => void + setHudSessionId: (sessionId: null | string) => void +} + +export function registerHudIpc({ isMac, getHudWindow, openHudWindow, closeHudWindow, setHudSessionId }: HudIpcDeps) { + ipcMain.handle('hermes:hud:open', async (_event, request) => { + openHudWindow( + typeof request?.sessionId === 'string' ? request.sessionId : null, + typeof request?.profile === 'string' ? request.profile : null + ) + + return { ok: true } + }) + + // Real frosted glass behind the band — the thing CSS backdrop-filter cannot do, + // because Chromium composites a transparent window's page against nothing and + // the desktop is not in its backdrop root. Vibrancy IS the window's content + // view, so it frosts the whole rectangle; the HUD's layout leaves no dead + // margins for that reason, and the renderer only turns it on while the band is + // showing (idle HUD mode must be the bar and nothing else). + ipcMain.handle('hermes:hud:vibrancy', (_event, on) => { + const hudWindow = getHudWindow() + + if (hudWindow && !hudWindow.isDestroyed() && isMac) { + hudWindow.setVibrancy(on ? 'hud' : null) + } + + return { ok: true } + }) + + // Let clicks fall through the HUD wherever it isn't really there. An + // always-on-top window eats every click inside its rectangle, and most of that + // rectangle is a faded-out band over whatever the user is actually working in. + // `forward` keeps mousemove flowing so the renderer can re-arm when the cursor + // reaches the bar. + ipcMain.on('hermes:hud:ignore-mouse', (_event, ignore) => { + const hudWindow = getHudWindow() + + if (hudWindow && !hudWindow.isDestroyed()) { + hudWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) + } + }) + + ipcMain.on('hermes:hud:move-by', (event, delta) => { + const hudWindow = getHudWindow() + + if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents) { + return + } + + const dx = Number(delta?.x) + const dy = Number(delta?.y) + const width = Number(delta?.width) + const height = Number(delta?.height) + + if (!Number.isFinite(dx) || !Number.isFinite(dy) || !Number.isFinite(width) || !Number.isFinite(height)) { + return + } + + const [x, y] = hudWindow.getPosition() + + // setBounds — NOT setPosition: on Windows, a transparent frameless window + // silently grows ~1px per setPosition call (worse at >100% DPI). The renderer + // snapshots outerWidth/outerHeight when the composer drag arms and re-pins + // to that size on every moveBy (same pattern as the pet overlay drag). + hudWindow.setBounds({ + x: Math.round(x + dx), + y: Math.round(y + dy), + width: Math.round(width), + height: Math.round(height) + }) + }) + + // Resize from the HUD's corner handle. The window is created non-resizable + // (see spawnHudWindow — a transparent frameless window must not expose a + // system resize hot-zone, or dragging grows it), which on Windows/Linux also + // blocks programmatic setBounds sizing — so briefly flip resizable on while + // the size actually changes, exactly like the pet overlay's wheel-scale does. + ipcMain.on('hermes:hud:set-bounds', (event, bounds) => { + const hudWindow = getHudWindow() + + if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents || !bounds) { + return + } + + const win = hudWindow + const width = Math.max(380, Math.round(Number(bounds.width))) + const height = Math.max(160, Math.round(Number(bounds.height))) + const [curW, curH] = win.getSize() + const resizing = width !== curW || height !== curH + + if (resizing && !win.isResizable()) { + win.setResizable(true) + } + + win.setBounds({ x: Math.round(Number(bounds.x)), y: Math.round(Number(bounds.y)), width, height }) + + if (resizing) { + win.setResizable(false) + } + }) + + // The HUD renderer reporting which session it is on, so the close broadcast + // can hand it back to the app window (see hudSessionId). + ipcMain.on('hermes:hud:session', (event, sessionId) => { + const hudWindow = getHudWindow() + + if (hudWindow && !hudWindow.isDestroyed() && event.sender === hudWindow.webContents) { + setHudSessionId(typeof sessionId === 'string' && sessionId ? sessionId : null) + } + }) + + ipcMain.handle('hermes:hud:close', async () => { + closeHudWindow() + + return { ok: true } + }) +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 73177fffd9..0cada4507e 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -29,7 +29,6 @@ import { shell, systemPreferences } from 'electron' -import nodePty from 'node-pty' import { classifyActiveRuntime } from './active-runtime-state' import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child' @@ -60,7 +59,7 @@ import { import { decideBootstrapRepair } from './bootstrap-repair-guard' import { runBootstrap } from './bootstrap-runner' import { detectBundleSkew } from './bundle-skew' -import { applyConnectionChange, resolveTerminalConnection } from './connection-apply' +import { applyConnectionChange } from './connection-apply' import { apiRequestRegistryConnectionId, authModeFromStatus, @@ -120,7 +119,6 @@ import { describeCrashReason, installCrashForensics } from './crash-forensics' import { adoptServedDashboardToken } from './dashboard-token' import { loadOrCreateInstallationId, sshOwnershipId } from './desktop-installation' import { formatDesktopLogLine } from './desktop-log-line' -import { installDesktopPluginFromGit, probePluginRepo } from './desktop-plugin-install' import { resolveDesktopRemoteRoute } from './desktop-remote-route' import { buildPosixCleanupScript, @@ -149,7 +147,7 @@ import { stopFind } from './find-in-page' import { createFirstRunSetupGate } from './first-run-setup-gate' -import { readDirForIpc } from './fs-read-dir' +import { registerFsIpc } from './fs-ipc' import { filenameFromContentDisposition, gatewayFilePath, @@ -158,33 +156,7 @@ import { pumpStreamToFile } from './gateway-file-download' import { probeGatewayWebSocket } from './gateway-ws-probe' -import { scanGitRepos } from './git-repo-scan' -import { - fileDiffVsHead, - repoStatus, - reviewCommit, - reviewCommitContext, - reviewCreatePr, - reviewDiff, - reviewFetchPrComment, - reviewList, - reviewPrList, - reviewPush, - reviewRevert, - reviewRevParse, - reviewShipInfo, - reviewStage, - reviewUnstage -} from './git-review-ops' -import { gitRootForIpc } from './git-root' -import { - addWorktree, - listBaseBranches, - listBranches, - listWorktrees, - removeWorktree, - switchBranch -} from './git-worktree-ops' +import { registerGitIpc } from './git-ipc' import { clearStaleGitLocks } from './gitlock' import { readAndConsumeHandoffResult } from './handoff-result' import { @@ -206,6 +178,7 @@ import { writeSecretFileAtomic } from './hardening' import { cursorPointInWindow } from './hud-cursor' +import { registerHudIpc } from './hud-ipc' import { snapHudBounds } from './hud-snap' import { createHudSnapShortcut } from './hud-snap-shortcut' import { buildHudWindowUrl } from './hud-url' @@ -234,6 +207,7 @@ import { electronProcessStartMarker, parentWatchdogEnv } from './parent-process-identity' +import { registerPetOverlayIpc } from './pet-overlay-ipc' import { buildRegistryProfileRoutes, localRouteFallbackProfiles, @@ -290,21 +264,18 @@ import { SESSION_WINDOW_MIN_WIDTH } from './session-windows' import { ensureLoginShellPath } from './shell-path' -import { ensureSpawnHelperExecutable } from './spawn-helper-perms' import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator' import { collectSshConfigHosts, parseSshGOutput } from './ssh-config' -import { - buildInteractiveSshArgs, - createSshProbeConnection, - pickLocalPort, - redactSecrets, - SshConnection -} from './ssh-connection' +import { createSshProbeConnection, pickLocalPort, redactSecrets, SshConnection } from './ssh-connection' import { createStreamThrottle } from './stream-throttle' +import { registerTerminalIpc } from './terminal-ipc' import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width' import { + backgroundMaterialFor, glassActive, + glassSupportedOn, normalizeState as normalizeTranslucency, + translucencySupportedOn, vibrancyFor as vibrancyForTranslucency, windowBackingOptions, windowOpacityFor @@ -356,12 +327,7 @@ import { getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path' -import { - buildWindowsInteractiveCommand, - connectWindowsRemote, - detectRemotePlatform, - helper -} from './windows-remote-lifecycle' +import { connectWindowsRemote, detectRemotePlatform, helper } from './windows-remote-lifecycle' import { alreadyHasNoSandbox, buildNoSandboxRelaunchArgs, @@ -398,6 +364,12 @@ const IS_WSL = isWslEnvironment() // Truthful macOS kernel major (Tahoe = 25). Product version lies (16 vs 26) per // build SDK, so gate Tahoe workarounds on Darwin instead. const DARWIN_MAJOR = IS_MAC ? Number.parseInt(os.release(), 10) || 0 : 0 +// Glass: macOS vibrancy, or Windows 11 22H2+ system backdrop. Computed once +// so the renderer, the persisted default, and every chat window agree. +const GLASS_SUPPORTED = glassSupportedOn(process.platform, os.release()) +// Clear rides setOpacity, a documented no-op on Linux, so neither mode works +// there and Settings drops the row entirely. +const TRANSLUCENCY_SUPPORTED = translucencySupportedOn(process.platform) const APP_ROOT = app.getAppPath() // Device-local preference: block F12 from opening DevTools. @@ -852,7 +824,6 @@ const APP_ICON_PATHS = [ ] let rendererTitleBarTheme = null -const terminalSessions = new Map() // Force the NATIVE window appearance (vibrancy material, titlebar, the // pre-first-paint window background) to follow the APP theme instead of the @@ -894,18 +865,19 @@ nativeTheme.themeSource = readPersistedThemeSource() // Window translucency (see-through window). One lever, 0–100; 0 = off (the // default). Two modes share the lever (see electron/translucency.ts and // store/translucency): 'clear' maps it to the native window opacity so the -// desktop shows through the whole window; 'glass' (macOS) keeps the window -// opaque and lets the renderer thin its surfaces over the vibrancy material -// instead — a matte blur with full-contrast text. Persisted so a cold launch -// applies it at window creation, before the renderer reports its value. +// desktop shows through the whole window; 'glass' keeps the window opaque +// and lets the renderer thin its surfaces over a platform material instead +// — a matte blur with full-contrast text. macOS uses vibrancy; Windows 11 +// uses DWM acrylic/mica/tabbed. Persisted so a cold launch applies it at +// window creation, before the renderer reports its value. // macOS + Windows only; `setOpacity` is a no-op on Linux. const TRANSLUCENCY_CONFIG_PATH = path.join(app.getPath('userData'), 'translucency.json') function readPersistedTranslucency() { try { - return normalizeTranslucency(JSON.parse(fs.readFileSync(TRANSLUCENCY_CONFIG_PATH, 'utf8')), IS_MAC) + return normalizeTranslucency(JSON.parse(fs.readFileSync(TRANSLUCENCY_CONFIG_PATH, 'utf8')), GLASS_SUPPORTED) } catch { - return normalizeTranslucency(null, IS_MAC) + return normalizeTranslucency(null, GLASS_SUPPORTED) } } @@ -933,17 +905,20 @@ function windowOpacity() { // Re-apply translucency to a live window (runtime toggle, no recreation). // `setOpacity` is a no-op on Linux, which is fine — it just stays opaque there. // The backing swap is the glass half: Chromium composites the page against -// the window backing BEFORE macOS composites the window, so glass needs the -// backing dropped for the vibrancy material to reach a transparent page, and +// the window backing BEFORE the OS composites the window, so glass needs the +// backing dropped for the platform material to reach a transparent page, and // every other state needs the opaque themed backing (anti-flash, and it is // what makes clear mode fade to the desktop instead of to black). // // `changed` says which native properties actually need touching. Dragging the // intensity slider emits ~100 updates, and in glass mode NONE of them change -// anything native — the effect is painted by the renderer and windowOpacityFor -// returns 1 throughout. Re-issuing setVibrancy on every tick restarts its -// 150ms animation before macOS can settle the material, which reads as jank -// and flattens the frost levels into each other. +// anything native — the tint is painted by the renderer and windowOpacityFor +// answers off `fade`, not `intensity`, there. Re-issuing setVibrancy on every +// tick restarts its 150ms animation before macOS can settle the material, +// which reads as jank and flattens the frost levels into each other. Windows +// setBackgroundMaterial is instantaneous but still skipped on tint-only ticks. +// The glass Fade lever is the one glass drag that does reach main, and it +// costs exactly what a Clear drag costs: one setOpacity. // // CAUTION (measured, macOS 26 / Electron 40): a runtime // setBackgroundColor('#00000000') is silently LOST on a window whose @@ -965,11 +940,18 @@ function applyWindowTranslucency(win, changed = { backing: true, material: true, win.setBackgroundColor(glassActive(translucencyState) ? '#00000000' : getWindowBackgroundColor()) } - // Glass frost level = the vibrancy material (macOS has no blur-radius - // knob). Animate the hop so a deliberate frost switch feels continuous — - // which only works if we don't re-issue it on unrelated updates. - if (changed.material && IS_MAC && typeof win.setVibrancy === 'function') { - win.setVibrancy(vibrancyForTranslucency(translucencyState), { animationDuration: 150 }) + if (changed.material) { + // Glass frost level = the platform material. Animate the macOS hop so + // a deliberate frost switch feels continuous — which only works if we + // don't re-issue it on unrelated updates. Windows has no equivalent + // animation option; setBackgroundMaterial is instantaneous. + if (IS_MAC && typeof win.setVibrancy === 'function') { + win.setVibrancy(vibrancyForTranslucency(translucencyState), { animationDuration: 150 }) + } + + if (IS_WINDOWS && GLASS_SUPPORTED && typeof win.setBackgroundMaterial === 'function') { + win.setBackgroundMaterial(backgroundMaterialFor(translucencyState)) + } } } @@ -1002,6 +984,12 @@ function chatWindowSurfaceOptions() { // user's frost choice whenever they click elsewhere. Only observable // under glass — everywhere else the page buries the material. visualEffectState: IS_MAC ? ('active' as const) : undefined, + // Win11 DWM materials only reach the client area on a transparent window + // (electron#49443). Chat windows on glass-capable Windows are born + // transparent so a live Clear→Glass toggle doesn't need a recreate; the + // opaque themed backgroundColor covers it while glass is off. + ...(IS_WINDOWS && GLASS_SUPPORTED ? { transparent: true } : {}), + backgroundMaterial: IS_WINDOWS && GLASS_SUPPORTED ? backgroundMaterialFor(translucencyState) : undefined, opacity: windowOpacity(), ...windowBackingOptions(translucencyState, getWindowBackgroundColor()) } @@ -3792,16 +3780,20 @@ async function handOffWindowsBootstrapRecovery(reason) { const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes') const venvPython = path.join(venvBin, IS_WINDOWS ? 'python.exe' : 'python') - // Choose the gentle in-place --update when ANY real-install signal is present, - // not just the `hermes.exe` console-script shim. That shim is generated at the - // END of venv setup and is absent in exactly the interrupted/quarantined states - // this recovery exists to heal — gating on it alone forced the destructive - // --repair (full venv recreate) and drove reinstall loops. The venv interpreter - // and the bootstrap-complete marker are present earlier and are better signals. - const haveRealInstall = - fileExists(venvPython) || fileExists(venvHermes) || fileExists(path.join(updateRoot, '.hermes-bootstrap-complete')) - - const updaterArgs = chooseUpdaterArgs(haveRealInstall, branch) + // The updater invokes the venv's Hermes launcher, which in turn requires the + // venv interpreter. A bootstrap-complete marker proves only that setup once + // finished; it can outlive a manually removed or quarantined venv. Sending a + // marker-only install through --update dead-ends at "Could not find the hermes + // CLI" instead of rebuilding the runtime, so only a runnable pair gets the + // gentle update path. Partial or missing runtimes go through full repair. + const updaterArgs = chooseUpdaterArgs( + { + hasBootstrapMarker: fileExists(path.join(updateRoot, '.hermes-bootstrap-complete')), + hasVenvHermes: fileExists(venvHermes), + hasVenvPython: fileExists(venvPython) + }, + branch + ) await releaseBackendLockForUpdate(updateRoot) @@ -8863,11 +8855,7 @@ async function teardownSshConnection(profile) { sshConnections.delete(scope) - for (const [id, info] of [...terminalSessions.entries()]) { - if (info.sshScope === scope) { - disposeTerminalSession(id) - } - } + terminalIpc.disposeTerminalSessionsForSshScope(scope) try { if (state.localPort && state.remotePort) { @@ -12140,232 +12128,23 @@ ipcMain.on('hermes:zoom:set-percent', (event, percent) => { setAndPersistZoomLevel(window, percentToZoomLevel(Number(percent))) }) -// --- Pet overlay (pop-out mascot) ----------------------------------------- -// `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space -// bounds (screen=false): convert to screen space by adding the main window's -// content origin so the pet lands where it sat in-window. A remembered/dragged -// spot passes screen-space bounds (screen=true) and is used as-is. We return the -// resolved screen bounds so the renderer can persist exactly where it opened. -ipcMain.handle('hermes:pet-overlay:open', async (_event, request) => { - const bounds = request && request.bounds ? request.bounds : request - const isScreen = Boolean(request && request.screen) - let screenBounds = bounds - - try { - if (bounds && !isScreen && mainWindow && !mainWindow.isDestroyed()) { - const content = mainWindow.getContentBounds() - screenBounds = { - x: content.x + (bounds.x || 0), - y: content.y + (bounds.y || 0), - width: bounds.width, - height: bounds.height - } - } - } catch { - // Fall back to raw bounds if the window geometry is unavailable. - } - - openPetOverlay(screenBounds) - - return { ok: true, bounds: screenBounds } -}) -ipcMain.handle('hermes:pet-overlay:close', async () => { - closePetOverlay() - - return { ok: true } -}) -// Drag/resize: the overlay reports new absolute screen bounds (it already knows -// the pointer's screen coords). Drag keeps the size constant; the wheel-to-scale -// gesture grows/shrinks it so the sprite is never cropped by the window edge. -// The window is created non-resizable (no stray edge-drag on the transparent -// frameless panel), which on Windows/Linux also blocks programmatic setBounds -// sizing — so briefly flip resizable on whenever the size actually changes. -ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => { - if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) { - return - } - - const win = petOverlayWindow - const width = Math.max(80, Math.round(bounds.width)) - const height = Math.max(80, Math.round(bounds.height)) - const [curW, curH] = win.getSize() - const resizing = width !== curW || height !== curH - - if (resizing && !win.isResizable()) { - win.setResizable(true) - } - - win.setBounds({ x: Math.round(bounds.x), y: Math.round(bounds.y), width, height }) - - if (resizing) { - win.setResizable(false) - } -}) -// Click-through: the overlay window is a full rectangle but only the pet pixels -// should be interactive. The renderer toggles this as the cursor enters/leaves -// the sprite so transparent margins pass clicks to whatever is behind. -ipcMain.on('hermes:pet-overlay:ignore-mouse', (_event, ignore) => { - if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { - petOverlayWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) - } -}) -// The overlay is a non-activating panel (focusable:false) so it never steals -// the app's cmd/alt-tab anchor from the main window. But the pop-up composer -// needs the keyboard, so the renderer asks us to flip it focusable + focus it -// while the composer is open, then back to non-activating when it closes. -ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => { - if (!petOverlayWindow || petOverlayWindow.isDestroyed()) { - return - } - - petOverlayWindow.setFocusable(Boolean(focusable)) - - if (focusable) { - petOverlayWindow.focus() - } -}) -// Main renderer → overlay: forward the latest pet state for the overlay to render. -ipcMain.on('hermes:pet-overlay:state', (_event, payload) => { - if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { - petOverlayWindow.webContents.send('hermes:pet-overlay:state', payload) - } -}) -// Overlay → main renderer: control messages (pop back in, composer submit). -ipcMain.on('hermes:pet-overlay:control', (_event, payload) => { - if (!mainWindow || mainWindow.isDestroyed()) { - return - } - - // Double-click toggles the app window: hide it away if it's up front, bring it - // back if it's minimized/buried. Pure window control — nothing for the - // renderer to do, so don't forward it. - if (payload && payload.type === 'toggle-app') { - if (mainWindow.isMinimized() || !mainWindow.isVisible()) { - mainWindow.show() - mainWindow.focus() - } else { - mainWindow.minimize() - } - - return - } - - // The mail icon means "take me to the app": raise the main window (it may be - // minimized or buried) before the renderer navigates to the latest thread. - if (payload && payload.type === 'open-app') { - if (mainWindow.isMinimized()) { - mainWindow.restore() - } - - mainWindow.show() - mainWindow.focus() - } - - mainWindow.webContents.send('hermes:pet-overlay:control', payload) +// --- Pet overlay (pop-out mascot) — see pet-overlay-ipc.ts. --------------- +registerPetOverlayIpc({ + getMainWindow: () => mainWindow, + getPetOverlayWindow: () => petOverlayWindow, + openPetOverlay, + closePetOverlay }) -// --- HUD mode (chrome-free floating chat) ----------------------------------- -ipcMain.handle('hermes:hud:open', async (_event, request) => { - openHudWindow( - typeof request?.sessionId === 'string' ? request.sessionId : null, - typeof request?.profile === 'string' ? request.profile : null - ) - - return { ok: true } -}) - -// Real frosted glass behind the band — the thing CSS backdrop-filter cannot do, -// because Chromium composites a transparent window's page against nothing and -// the desktop is not in its backdrop root. Vibrancy IS the window's content -// view, so it frosts the whole rectangle; the HUD's layout leaves no dead -// margins for that reason, and the renderer only turns it on while the band is -// showing (idle HUD mode must be the bar and nothing else). -ipcMain.handle('hermes:hud:vibrancy', (_event, on) => { - if (hudWindow && !hudWindow.isDestroyed() && IS_MAC) { - hudWindow.setVibrancy(on ? 'hud' : null) +// --- HUD mode (chrome-free floating chat) — see hud-ipc.ts. --------------- +registerHudIpc({ + isMac: IS_MAC, + getHudWindow: () => hudWindow, + openHudWindow, + closeHudWindow, + setHudSessionId: value => { + hudSessionId = value } - - return { ok: true } -}) - -// Let clicks fall through the HUD wherever it isn't really there. An -// always-on-top window eats every click inside its rectangle, and most of that -// rectangle is a faded-out band over whatever the user is actually working in. -// `forward` keeps mousemove flowing so the renderer can re-arm when the cursor -// reaches the bar. -ipcMain.on('hermes:hud:ignore-mouse', (_event, ignore) => { - if (hudWindow && !hudWindow.isDestroyed()) { - hudWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) - } -}) - -ipcMain.on('hermes:hud:move-by', (event, delta) => { - if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents) { - return - } - - const dx = Number(delta?.x) - const dy = Number(delta?.y) - const width = Number(delta?.width) - const height = Number(delta?.height) - - if (!Number.isFinite(dx) || !Number.isFinite(dy) || !Number.isFinite(width) || !Number.isFinite(height)) { - return - } - - const [x, y] = hudWindow.getPosition() - - // setBounds — NOT setPosition: on Windows, a transparent frameless window - // silently grows ~1px per setPosition call (worse at >100% DPI). The renderer - // snapshots outerWidth/outerHeight when the composer drag arms and re-pins - // to that size on every moveBy (same pattern as the pet overlay drag). - hudWindow.setBounds({ - x: Math.round(x + dx), - y: Math.round(y + dy), - width: Math.round(width), - height: Math.round(height) - }) -}) - -// Resize from the HUD's corner handle. The window is created non-resizable -// (see spawnHudWindow — a transparent frameless window must not expose a -// system resize hot-zone, or dragging grows it), which on Windows/Linux also -// blocks programmatic setBounds sizing — so briefly flip resizable on while -// the size actually changes, exactly like the pet overlay's wheel-scale does. -ipcMain.on('hermes:hud:set-bounds', (event, bounds) => { - if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents || !bounds) { - return - } - - const win = hudWindow - const width = Math.max(380, Math.round(Number(bounds.width))) - const height = Math.max(160, Math.round(Number(bounds.height))) - const [curW, curH] = win.getSize() - const resizing = width !== curW || height !== curH - - if (resizing && !win.isResizable()) { - win.setResizable(true) - } - - win.setBounds({ x: Math.round(Number(bounds.x)), y: Math.round(Number(bounds.y)), width, height }) - - if (resizing) { - win.setResizable(false) - } -}) - -// The HUD renderer reporting which session it is on, so the close broadcast -// can hand it back to the app window (see hudSessionId). -ipcMain.on('hermes:hud:session', (event, sessionId) => { - if (hudWindow && !hudWindow.isDestroyed() && event.sender === hudWindow.webContents) { - hudSessionId = typeof sessionId === 'string' && sessionId ? sessionId : null - } -}) - -ipcMain.handle('hermes:hud:close', async () => { - closeHudWindow() - - return { ok: true } }) ipcMain.handle('hermes:bootstrap:reset', async () => { // Renderer's "Reload and retry" path. Clear the latched failure and @@ -14118,12 +13897,20 @@ app.on('before-quit', () => { } }) +// Answered synchronously so preload can publish the verdict before the +// renderer's first script — see the note there on why it cannot decide this +// itself. Registered at module scope, which runs long before any window. +ipcMain.on('hermes:translucency:support', event => { + event.returnValue = { glass: GLASS_SUPPORTED, translucency: TRANSLUCENCY_SUPPORTED } +}) + ipcMain.on('hermes:translucency', (_event, payload) => { - const next = normalizeTranslucency(payload, IS_MAC) + const next = normalizeTranslucency(payload, GLASS_SUPPORTED) const previous = translucencyState if ( next.intensity === previous.intensity && + next.fade === previous.fade && next.mode === previous.mode && next.material === previous.material && next.scope === previous.scope @@ -14417,565 +14204,30 @@ ipcMain.on('hermes:logs:renderer-error', (_event, report) => { flushDesktopLogBufferSync() }) -function isExecutableFile(filePath) { - if (!filePath || !path.isAbsolute(filePath)) { - return false - } - - try { - fs.accessSync(filePath, fs.constants.X_OK) - - return true - } catch { - return false - } -} - -function posixShellSpec(shellPath) { - const shellName = path.basename(shellPath) - const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i'] - - return { args: interactiveArgs, command: shellPath, name: shellName } -} - -// Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box; -// prefer it only after PowerShell 7+ (`pwsh`). -function windowsPowerShellPath() { - const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows' - const builtin = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') - - return isExecutableFile(builtin) ? builtin : findOnPath('powershell.exe') -} - -// Map a resolved shell path to its spawn spec, picking interactive flags by -// family: PowerShell drops its logo banner (so the prompt sits flush like the -// POSIX shells), cmd needs nothing, and everything else (zsh/bash/fish/sh…) -// gets POSIX interactive-login flags. -function shellSpecFor(shellPath) { - const name = path.basename(shellPath).toLowerCase() - - if (name.startsWith('pwsh') || name.startsWith('powershell')) { - return { args: ['-NoLogo'], command: shellPath, name } - } - - if (name.startsWith('cmd')) { - return { args: [], command: shellPath, name } - } - - return posixShellSpec(shellPath) -} - -// Best installed Windows shell: PowerShell 7+ (`pwsh`), then Windows PowerShell -// 5.1, then comspec/cmd.exe as the universal fallback. -function windowsShellSpec() { - const command = - findOnPath('pwsh.exe') || findOnPath('pwsh') || windowsPowerShellPath() || process.env.COMSPEC || 'cmd.exe' - - return shellSpecFor(command) -} - -// Resolve the interactive shell for the embedded terminal: an explicit user -// override wins, otherwise auto-detect the best one installed for the platform. -function terminalShellCommand() { - // HERMES_DESKTOP_SHELL is the cross-platform escape hatch (a path or a bare - // name on PATH); $SHELL is honored on POSIX, where it's the user's canonical - // choice, but ignored on Windows, where it's usually a stray MSYS/Git path - // node-pty can't spawn natively. - const override = (process.env.HERMES_DESKTOP_SHELL || (IS_WINDOWS ? '' : process.env.SHELL) || '').trim() - - if (override) { - const resolved = isExecutableFile(override) ? override : findOnPath(override) - - if (resolved) { - return shellSpecFor(resolved) - } - } - - if (IS_WINDOWS) { - return windowsShellSpec() - } - - const shellPath = ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => isExecutableFile(candidate)) - - return posixShellSpec(shellPath || '/bin/sh') -} - -function safeTerminalCwd(cwd) { - const candidate = path.resolve(String(cwd || app.getPath('home'))) - - try { - const stat = fs.statSync(candidate) - - return stat.isDirectory() ? candidate : path.dirname(candidate) - } catch { - return app.getPath('home') - } -} - -function terminalShellEnv() { - const env = { ...process.env } - - // Electron is commonly launched through `npm run dev`; do not leak npm's - // managed prefix into a user's interactive shell (nvm/proto warn loudly). - for (const key of Object.keys(env)) { - if (key === 'npm_config_prefix' || key.startsWith('npm_config_') || key.startsWith('npm_package_')) { - delete env[key] - } - } - - // Strip color/theme-detection vars that ride along when Electron is launched - // from a non-tty agent shell (Cursor's runner sets NO_COLOR/FORCE_COLOR=0 - // /TERM=dumb; some terminals set COLORFGBG which would flip Hermes' TUI into - // light-mode). Our PTY is a real xterm-compat terminal — force truecolor. - delete env.NO_COLOR - delete env.FORCE_COLOR - delete env.COLORFGBG - - env.COLORTERM = 'truecolor' - env.LC_CTYPE = env.LC_CTYPE || 'UTF-8' - env.TERM = 'xterm-256color' - env.TERM_PROGRAM = 'Hermes' - env.TERM_PROGRAM_VERSION = app.getVersion() - - // Let a hermes/--tui launched in this pane know it's embedded in the desktop - // GUI (build_environment_hints surfaces this). Distinct from HERMES_DESKTOP, - // which marks the agent *backend* and gates cron/gateway behavior. - env.HERMES_DESKTOP_TERMINAL = '1' - - return env -} - -function terminalChannel(id, suffix) { - return `hermes:terminal:${id}:${suffix}` -} - -// Best-effort read of a live PTY child's current working directory so a -// reopened tab can restart the shell where the user last `cd`'d, instead of the -// tab's original launch dir. Shell-agnostic (no prompt/OSC config needed) on -// POSIX; Windows has no cheap per-process cwd query without a native module, so -// it returns null and the caller falls back to the launch cwd. -function readProcessCwd(pid) { - return new Promise(resolve => { - if (!Number.isInteger(pid) || pid <= 0) { - resolve(null) - - return - } - - if (process.platform === 'linux') { - fs.promises - .readlink(`/proc/${pid}/cwd`) - .then(target => resolve(target || null)) - .catch(() => resolve(null)) - - return - } - - if (process.platform === 'darwin') { - // lsof ships with macOS; -Fn emits the cwd fd's path on an `n` line. - execFile('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { timeout: 2000 }, (err, stdout) => { - if (err) { - resolve(null) - - return - } - - const line = String(stdout || '') - .split('\n') - .find(entry => entry.startsWith('n')) - - resolve(line ? line.slice(1) : null) - }) - - return - } - - resolve(null) - }) -} - -function disposeTerminalSession(id) { - const sessionInfo = terminalSessions.get(id) - - if (!sessionInfo) { - return false - } - - terminalSessions.delete(id) - - try { - sessionInfo.pty.kill() - } catch { - // Process may already be gone. - } - - return true -} - -ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath)) - -ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath)) - -// Reveal a path in the OS file manager (Finder / Explorer / Files). -ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => { - const target = String(targetPath || '').trim() - - if (!target) { - return false - } - - try { - shell.showItemInFolder(target) - - return true - } catch { - return false - } +// Local filesystem + plugin-root IPC (readDir/reveal/rename/trash/…) — see fs-ipc.ts. +registerFsIpc({ + hermesHome: HERMES_HOME, + readActiveDesktopProfile, + expandUserPath, + resolveRequestedPathForIpc, + directoryExists, + resolveGitBinary }) -// Open a DIRECTORY in the OS file manager, creating it first if needed. Unlike -// `reveal` (which selects an existing item and silently no-ops on a missing -// path — the "Open plugins folder" Windows bug), this is for the plugins door, -// which often doesn't exist on first use. `shell.openPath` returns '' on -// success or an error string; both mkdir + openPath failures are surfaced. -ipcMain.handle('hermes:fs:openDir', async (_event, dirPath) => { - const dir = String(dirPath || '').trim() +// Git-driven features (worktrees, review pane, repo scan) — see git-ipc.ts. +registerGitIpc({ resolveGitBinary, resolveGhBinary }) - if (!dir) { - return { ok: false, error: 'no path' } - } - - try { - await fs.promises.mkdir(dir, { recursive: true }) - const error = await shell.openPath(path.normalize(dir)) - - return error ? { ok: false, error } : { ok: true } - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : String(error) } - } +// Embedded terminal PTY host (hermes:terminal:*) — see terminal-ipc.ts. +const terminalIpc = registerTerminalIpc({ + isWindows: IS_WINDOWS, + findOnPath, + rememberLog, + activeSshTerminalTarget, + ensureBackend: () => ensureBackend(primaryProfileKey()), + getSshConnectionState: scope => sshConnections.get(scope) }) -// The LOCAL Desktop runtime-plugin root: `/desktop-plugins`, -// resolved from the main-process HERMES_HOME (see resolveHermesHome) — NOT from -// the connected backend. A remote backend reports its own `hermes_home` over -// the gateway, which is a path on the REMOTE box; deriving the plugin dir from -// it yields `undefined/desktop-plugins` (or a non-existent remote path) and the -// on-disk plugin door silently breaks (#66899). Electron owns this resolution -// so it stays valid in every connection mode. Created on demand, like openDir. -async function localPluginsRoot(dirName: string): Promise { - // Profile-aware: a named Desktop profile gets its own plugin root under - // profiles//, matching the profile-scoped hermes_home the backend - // reported before this resolver existed. 'default'/unset pins the global root. - const profile = readActiveDesktopProfile() - const base = profile && profile !== 'default' ? path.join(HERMES_HOME, 'profiles', profile) : HERMES_HOME - const dir = path.join(base, dirName) - - try { - await fs.promises.mkdir(dir, { recursive: true }) - } catch { - // Best-effort create; return the path regardless so the reveal action can - // still surface a real openPath error and the scanner can retry later. - } - - return dir -} - -ipcMain.handle('hermes:fs:desktopPluginsRoot', async () => localPluginsRoot('desktop-plugins')) - -// The LOCAL agent-plugin root (`/plugins`), same Electron-local -// resolution as above. This is the desktop half of a UNIFIED plugin package: -// an agent plugin may ship `desktop/plugin.js` alongside its Python code (the -// same shape as `dashboard/manifest.json`), and the renderer's disk door scans -// this root for it — one installable folder serving both SDKs. -ipcMain.handle('hermes:fs:agentPluginsRoot', async () => localPluginsRoot('plugins')) - -ipcMain.handle('hermes:plugin:probe', async (_event, payload) => { - const identifier = String(payload?.identifier || payload?.repo || '').trim() - - if (!identifier) { - return { ok: false, error: 'identifier is required', agent: false, desktop: false, warnings: [] } - } - - return probePluginRepo(resolveGitBinary(), identifier) -}) - -ipcMain.handle('hermes:plugin:installDesktop', async (_event, payload) => { - const identifier = String(payload?.identifier || payload?.repo || '').trim() - - if (!identifier) { - return { ok: false, error: 'identifier is required' } - } - - const desktopPluginsRoot = await localPluginsRoot('desktop-plugins') - - return installDesktopPluginFromGit(resolveGitBinary(), identifier, desktopPluginsRoot, Boolean(payload?.force)) -}) - -// Rename a file/folder in place. The renderer passes the existing path + a new -// base name; the destination is resolved in the SAME parent dir so a rename can -// never move the item elsewhere or traverse out. Rejects on a name collision. -ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => { - const src = String(targetPath || '').trim() - const name = String(newName || '').trim() - - if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { - throw new Error('Invalid rename') - } - - const dst = path.join(path.dirname(src), name) - - if (dst === src) { - return { path: dst } - } - - if (fs.existsSync(dst)) { - throw new Error(`"${name}" already exists`) - } - - await fs.promises.rename(src, dst) - - return { path: dst } -}) - -// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path -// is hardened (resolveRequestedPathForIpc) and the parent must already exist — -// this never creates directory trees or escapes the allowed roots, and content -// is size-capped so it can't be abused as a bulk-write primitive. -ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => { - const raw = String(filePath || '').trim() - - if (!raw) { - throw new Error('Invalid path') - } - - const text = String(content ?? '') - - if (text.length > 1_000_000) { - throw new Error('Content too large') - } - - const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' }) - - if (!directoryExists(path.dirname(resolved))) { - throw new Error('Parent directory does not exist') - } - - await fs.promises.writeFile(resolved, text, 'utf8') - - return { path: resolved } -}) - -// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete" -// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform. -ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => { - const target = String(targetPath || '').trim() - - if (!target) { - throw new Error('Invalid delete') - } - - await shell.trashItem(target) - - return true -}) - -// Git-driven worktree management ("Start work" flow). Errors surface to the -// renderer as rejected promises so it can toast a friendly message. -ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary())) - -ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) => - addWorktree(repoPath, options || {}, resolveGitBinary()) -) - -ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) => - removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary()) -) - -ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) => - switchBranch(repoPath, branch, resolveGitBinary()) -) - -ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary())) - -ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) => listBaseBranches(repoPath, resolveGitBinary())) - -// Compact repo status (branch, ahead/behind, change counts + files) for the -// composer coding rail. Returns null on a non-repo / remote backend so the rail -// hides cleanly rather than erroring. -ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary())) - -// Codex-style review pane: list changed files for a scope, fetch one file's -// unified diff, and stage / unstage / revert. Reads return empty on failure; -// mutations reject so the renderer can toast. -ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) => - reviewList(repoPath, scope, baseRef, resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) => - reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary()) -) -// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view). -ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) => - fileDiffVsHead(repoPath, filePath, resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) => - reviewStage(repoPath, filePath ?? null, resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) => - reviewUnstage(repoPath, filePath ?? null, resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) => - reviewRevert(repoPath, filePath ?? null, resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) => - reviewRevParse(repoPath, ref, resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) => - reviewCommit(repoPath, message, Boolean(push), resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) => - reviewCommitContext(repoPath, resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary())) -ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary())) -ipcMain.handle('hermes:git:review:prList', async (_event, repoPath, branches, numbers) => - reviewPrList(repoPath, resolveGhBinary(), branches, numbers) -) -ipcMain.handle('hermes:git:review:fetchPrComment', async (_event, repoPath, url) => - reviewFetchPrComment(repoPath, resolveGhBinary(), url) -) -ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) => - reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary()) -) - -// Repo-first project discovery: scan bounded roots for git repos (pure fs walk, -// no native addon). Never throws to the renderer — failures yield an empty list. -ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => { - try { - return await scanGitRepos(roots || [], options || {}) - } catch { - return [] - } -}) - -// node-pty's published tarball ships the POSIX `spawn-helper` without an exec -// bit; the dev flow resolves node-pty straight from node_modules (nothing -// chmods it there), so the first terminal spawn dies with `posix_spawnp -// failed`. Restore the bit once, lazily, right before the first spawn. Packaged -// builds already stage an executable copy, so this is a no-op there. -let _spawnHelperEnsured = false - -function ensureNodePtySpawnHelper() { - if (_spawnHelperEnsured || IS_WINDOWS) { - return - } - - _spawnHelperEnsured = true - - try { - const nodePtyRoot = path.dirname(require.resolve('node-pty/package.json')) - const { fixed, errors } = ensureSpawnHelperExecutable(nodePtyRoot) - - for (const helperPath of fixed) { - rememberLog(`[terminal] restored +x on node-pty spawn-helper: ${helperPath}`) - } - - for (const failure of errors) { - rememberLog(`[terminal] could not chmod spawn-helper ${failure.path}: ${failure.error}`) - } - } catch (error) { - rememberLog(`[terminal] spawn-helper exec check skipped: ${error instanceof Error ? error.message : String(error)}`) - } -} - -ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { - ensureNodePtySpawnHelper() - - const id = crypto.randomUUID() - const { args, command, name } = terminalShellCommand() - const cwd = safeTerminalCwd(payload?.cwd) - const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80) - const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24) - - const sshTarget = await resolveTerminalConnection(activeSshTerminalTarget, () => ensureBackend(primaryProfileKey())) - const remote = Boolean(sshTarget) - const remoteState = remote ? sshConnections.get(sshTarget.scope) : null - - const remoteCommand = - remoteState?.remotePlatform === 'Windows' - ? buildWindowsInteractiveCommand(String(payload?.cwd || '').trim()) - : undefined - - const ptyProcess = remote - ? nodePty.spawn( - process.platform === 'win32' - ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe') - : 'ssh', - buildInteractiveSshArgs(sshTarget.ssh, String(payload?.cwd || '').trim(), undefined, remoteCommand), - { cols, cwd: app.getPath('home'), env: terminalShellEnv(), name: 'xterm-256color', rows } - ) - : nodePty.spawn(command, args, { cols, cwd, env: terminalShellEnv(), name: 'xterm-256color', rows }) - - terminalSessions.set(id, { - pty: ptyProcess, - webContentsId: event.sender.id, - ...(remote ? { sshScope: sshTarget.scope, remoteCwd: String(payload?.cwd || '') } : {}) - }) - - const send = (suffix, payload) => { - if (event.sender.isDestroyed()) { - return - } - - event.sender.send(terminalChannel(id, suffix), payload) - } - - ptyProcess.onData(data => send('data', data)) - ptyProcess.onExit(({ exitCode, signal }) => { - terminalSessions.delete(id) - send('exit', { code: exitCode, signal: signal || null }) - }) - event.sender.once('destroyed', () => disposeTerminalSession(id)) - - return { cwd: remote ? null : cwd, id, shell: remote ? 'ssh' : name } -}) - -ipcMain.handle('hermes:terminal:write', (_event, id, data) => { - const sessionInfo = terminalSessions.get(String(id || '')) - - if (!sessionInfo) { - return false - } - - sessionInfo.pty.write(String(data || '')) - - return true -}) - -ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => { - const sessionInfo = terminalSessions.get(String(id || '')) - - if (!sessionInfo) { - return false - } - - const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80) - const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24) - - sessionInfo.pty.resize(cols, rows) - - return true -}) -ipcMain.handle('hermes:terminal:cwd', async (_event, id) => { - const sessionInfo = terminalSessions.get(String(id || '')) - - if (!sessionInfo) { - return null - } - - return sessionInfo.sshScope !== undefined ? null : readProcessCwd(sessionInfo.pty.pid) -}) - -ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || ''))) +const disposeTerminalSession = terminalIpc.disposeTerminalSession ipcMain.handle('hermes:updates:check', async () => checkUpdates().catch(error => ({ @@ -15678,9 +14930,7 @@ app.on('before-quit', event => { // Kill open PTYs before environment teardown to avoid the node-pty#904 // ThreadSafeFunction SIGABRT race. - for (const id of [...terminalSessions.keys()]) { - disposeTerminalSession(id) - } + terminalIpc.disposeAllTerminalSessions() void backendShutdown.run() }) diff --git a/apps/desktop/electron/pet-overlay-ipc.ts b/apps/desktop/electron/pet-overlay-ipc.ts new file mode 100644 index 0000000000..9738f4e8e0 --- /dev/null +++ b/apps/desktop/electron/pet-overlay-ipc.ts @@ -0,0 +1,151 @@ +// IPC surface for the pop-out pet overlay (mascot window). Extracted from +// main.ts; window handles stay injected because main.ts owns their lifecycle. +import { type BrowserWindow, ipcMain } from 'electron' + +export interface PetOverlayIpcDeps { + getMainWindow: () => BrowserWindow | null + getPetOverlayWindow: () => BrowserWindow | null + openPetOverlay: (bounds: unknown) => void + closePetOverlay: () => void +} + +export function registerPetOverlayIpc({ + getMainWindow, + getPetOverlayWindow, + openPetOverlay, + closePetOverlay +}: PetOverlayIpcDeps) { + // `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space + // bounds (screen=false): convert to screen space by adding the main window's + // content origin so the pet lands where it sat in-window. A remembered/dragged + // spot passes screen-space bounds (screen=true) and is used as-is. We return the + // resolved screen bounds so the renderer can persist exactly where it opened. + ipcMain.handle('hermes:pet-overlay:open', async (_event, request) => { + const bounds = request && request.bounds ? request.bounds : request + const isScreen = Boolean(request && request.screen) + const mainWindow = getMainWindow() + let screenBounds = bounds + + try { + if (bounds && !isScreen && mainWindow && !mainWindow.isDestroyed()) { + const content = mainWindow.getContentBounds() + screenBounds = { + x: content.x + (bounds.x || 0), + y: content.y + (bounds.y || 0), + width: bounds.width, + height: bounds.height + } + } + } catch { + // Fall back to raw bounds if the window geometry is unavailable. + } + + openPetOverlay(screenBounds) + + return { ok: true, bounds: screenBounds } + }) + ipcMain.handle('hermes:pet-overlay:close', async () => { + closePetOverlay() + + return { ok: true } + }) + // Drag/resize: the overlay reports new absolute screen bounds (it already knows + // the pointer's screen coords). Drag keeps the size constant; the wheel-to-scale + // gesture grows/shrinks it so the sprite is never cropped by the window edge. + // The window is created non-resizable (no stray edge-drag on the transparent + // frameless panel), which on Windows/Linux also blocks programmatic setBounds + // sizing — so briefly flip resizable on whenever the size actually changes. + ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => { + const petOverlayWindow = getPetOverlayWindow() + + if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) { + return + } + + const win = petOverlayWindow + const width = Math.max(80, Math.round(bounds.width)) + const height = Math.max(80, Math.round(bounds.height)) + const [curW, curH] = win.getSize() + const resizing = width !== curW || height !== curH + + if (resizing && !win.isResizable()) { + win.setResizable(true) + } + + win.setBounds({ x: Math.round(bounds.x), y: Math.round(bounds.y), width, height }) + + if (resizing) { + win.setResizable(false) + } + }) + // Click-through: the overlay window is a full rectangle but only the pet pixels + // should be interactive. The renderer toggles this as the cursor enters/leaves + // the sprite so transparent margins pass clicks to whatever is behind. + ipcMain.on('hermes:pet-overlay:ignore-mouse', (_event, ignore) => { + const petOverlayWindow = getPetOverlayWindow() + + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) + } + }) + // The overlay is a non-activating panel (focusable:false) so it never steals + // the app's cmd/alt-tab anchor from the main window. But the pop-up composer + // needs the keyboard, so the renderer asks us to flip it focusable + focus it + // while the composer is open, then back to non-activating when it closes. + ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => { + const petOverlayWindow = getPetOverlayWindow() + + if (!petOverlayWindow || petOverlayWindow.isDestroyed()) { + return + } + + petOverlayWindow.setFocusable(Boolean(focusable)) + + if (focusable) { + petOverlayWindow.focus() + } + }) + // Main renderer → overlay: forward the latest pet state for the overlay to render. + ipcMain.on('hermes:pet-overlay:state', (_event, payload) => { + const petOverlayWindow = getPetOverlayWindow() + + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayWindow.webContents.send('hermes:pet-overlay:state', payload) + } + }) + // Overlay → main renderer: control messages (pop back in, composer submit). + ipcMain.on('hermes:pet-overlay:control', (_event, payload) => { + const mainWindow = getMainWindow() + + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + + // Double-click toggles the app window: hide it away if it's up front, bring it + // back if it's minimized/buried. Pure window control — nothing for the + // renderer to do, so don't forward it. + if (payload && payload.type === 'toggle-app') { + if (mainWindow.isMinimized() || !mainWindow.isVisible()) { + mainWindow.show() + mainWindow.focus() + } else { + mainWindow.minimize() + } + + return + } + + // The mail icon means "take me to the app": raise the main window (it may be + // minimized or buried) before the renderer navigates to the latest thread. + if (payload && payload.type === 'open-app') { + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + + mainWindow.show() + mainWindow.focus() + } + + mainWindow.webContents.send('hermes:pet-overlay:control', payload) + }) +} diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 7e2e52a319..2d1ba597ce 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -1,6 +1,17 @@ import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' +// Which translucency the OS can back. Asked synchronously because the renderer +// needs it before its first paint, and answered by main because deciding it +// needs `os.release()` — a sandboxed preload may only require electron, events, +// timers and url, so importing node:os here throws before contextBridge runs +// and takes the ENTIRE bridge down with it (window.hermesDesktop undefined => +// "Desktop IPC bridge is unavailable"). No reply means no glass, which degrades +// to an ordinary opaque window rather than a page thinned over nothing. +const translucencySupport = ipcRenderer.sendSync('hermes:translucency:support') + contextBridge.exposeInMainWorld('hermesDesktop', { + glassSupported: translucencySupport?.glass === true, + translucencySupported: translucencySupport?.translucency === true, getConnection: profile => ipcRenderer.invoke('hermes:connection', profile), // Registry-scoped backend resolution: { connectionId, profile } → descriptor. getConnectionFor: payload => ipcRenderer.invoke('hermes:connection:for', payload), diff --git a/apps/desktop/electron/remote-lifecycle.ts b/apps/desktop/electron/remote-lifecycle.ts index 501786863c..4a672685ec 100644 --- a/apps/desktop/electron/remote-lifecycle.ts +++ b/apps/desktop/electron/remote-lifecycle.ts @@ -28,6 +28,7 @@ import crypto from 'node:crypto' import { parseRemoteProfileListing } from './connection-registry' +import { assertBootstrapNotSuperseded } from './ssh-connection' const LOCKFILE_SCHEMA_VERSION = 2 // Bumped when the desktop<->dashboard reuse contract changes in a way that makes @@ -558,7 +559,7 @@ async function scrapeReadyPort(ssh, logPath, { timeoutMs = DEFAULT_READY_TIMEOUT const remoteLog = expandRemotePath(logPath) while (Date.now() < deadline) { - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) if (isAlive && !(await isAlive())) { const err: any = new Error('Remote dashboard process exited before announcing its port.') @@ -696,14 +697,6 @@ async function cancelForwardSafe(deps, localPort, remotePort) { } } -function assertNotAborted(signal) { - if (signal?.aborted) { - const error: any = new Error('SSH bootstrap was cancelled.') - error.kind = 'superseded' - throw error - } -} - function isForwardBindCollision(error) { return /address already in use|cannot listen to port|bind.*failed/i.test(String(error?.message || error || '')) } @@ -769,7 +762,7 @@ async function connect(deps) { const log = msg => rememberLog(`[ssh-lifecycle] ${msg}`) - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) const platform = await probeRemotePlatform(ssh) log(`remote platform ${platform.os}/${platform.arch}`) const hermesPath = await locateHermes(ssh, remoteHermesPath) @@ -810,7 +803,7 @@ async function connect(deps) { lock.hermesHome === hermesHome if (reusable) { - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) const localPort = await openForward(deps, lock.port) try { @@ -827,7 +820,7 @@ async function connect(deps) { } if (reuseClassification === 'authenticated-stale') { - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) await cancelForwardSafe(deps, localPort, lock.port) await cleanupStale(ssh, ownershipId, lock) } else if (reuseClassification === 'authenticated-ok') { @@ -840,7 +833,7 @@ async function connect(deps) { 'reused remote dashboard' ) - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) log(`reusing remote dashboard pid=${lock.pid} port=${lock.port}`) return { @@ -868,12 +861,12 @@ async function connect(deps) { throw error } } else { - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) await cleanupStale(ssh, ownershipId, lock, pidAlive) } } - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) const spawnToken = mintToken() const { pid, spawnNonce, logPath, tokenFilePath } = await spawnRemoteDashboard(ssh, { @@ -914,21 +907,21 @@ async function connect(deps) { isAlive: () => remotePidAlive(ssh, pid), signal }) - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) log(`remote dashboard bound port ${remotePort}`) localPort = await openForward(deps, remotePort) - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) const baseUrl = `http://127.0.0.1:${localPort}` await waitForHermes(baseUrl, spawnToken) - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) const token = await adoptOwnedServedToken(adoptServedToken, baseUrl, spawnToken, ssh, pid, 'remote dashboard') - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) const tokenFingerprint = fingerprintToken(token) await writeLockfile(ssh, ownershipId, { ...ownedSpawn, port: remotePort, tokenFingerprint }) - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) return { baseUrl, diff --git a/apps/desktop/electron/ssh-connection.ts b/apps/desktop/electron/ssh-connection.ts index 5015f979e3..57264ec639 100644 --- a/apps/desktop/electron/ssh-connection.ts +++ b/apps/desktop/electron/ssh-connection.ts @@ -988,7 +988,19 @@ function createSshProbeConnection(config, options: any = {}) { return new SshConnection(config, { ...options, mux: false }) } +// Bootstrap loops poll a remote for readiness; a newer attempt aborts the +// signal so the stale one stops polling and unwinds. `superseded` tells the +// caller this was replaced, not that it failed. +function assertBootstrapNotSuperseded(signal) { + if (signal?.aborted) { + const error: any = new Error('SSH bootstrap was cancelled.') + error.kind = 'superseded' + throw error + } +} + export { + assertBootstrapNotSuperseded, baseSshOptions, buildControlArgs, buildExecArgs, diff --git a/apps/desktop/electron/terminal-ipc.ts b/apps/desktop/electron/terminal-ipc.ts new file mode 100644 index 0000000000..1b5953b859 --- /dev/null +++ b/apps/desktop/electron/terminal-ipc.ts @@ -0,0 +1,377 @@ +// The embedded terminal's PTY host: shell resolution, env scrubbing, session +// registry, and the hermes:terminal:* IPC surface. Extracted from main.ts; the +// factory owns the session map and returns the dispose helpers main.ts needs +// for SSH teardown. findOnPath / logging / connection routing stay injected. +import { execFile } from 'node:child_process' +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' + +import { app, ipcMain } from 'electron' +import nodePty from 'node-pty' + +import { resolveTerminalConnection } from './connection-apply' +import { ensureSpawnHelperExecutable } from './spawn-helper-perms' +import { buildInteractiveSshArgs } from './ssh-connection' +import { buildWindowsInteractiveCommand } from './windows-remote-lifecycle' + +export interface TerminalIpcDeps { + isWindows: boolean + findOnPath: (command: string) => null | string + rememberLog: (line: string) => void + activeSshTerminalTarget: () => unknown + ensureBackend: () => Promise + getSshConnectionState: (scope: string) => undefined | { remotePlatform?: string } +} + +export interface TerminalIpcApi { + disposeTerminalSession: (id: string) => boolean + disposeTerminalSessionsForSshScope: (scope: string) => void + disposeAllTerminalSessions: () => void +} + +export function registerTerminalIpc({ + isWindows, + findOnPath, + rememberLog, + activeSshTerminalTarget, + ensureBackend, + getSshConnectionState +}: TerminalIpcDeps): TerminalIpcApi { + const terminalSessions = new Map() + + function isExecutableFile(filePath) { + if (!filePath || !path.isAbsolute(filePath)) { + return false + } + + try { + fs.accessSync(filePath, fs.constants.X_OK) + + return true + } catch { + return false + } + } + + function posixShellSpec(shellPath) { + const shellName = path.basename(shellPath) + const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i'] + + return { args: interactiveArgs, command: shellPath, name: shellName } + } + + // Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box; + // prefer it only after PowerShell 7+ (`pwsh`). + function windowsPowerShellPath() { + const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows' + const builtin = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') + + return isExecutableFile(builtin) ? builtin : findOnPath('powershell.exe') + } + + // Map a resolved shell path to its spawn spec, picking interactive flags by + // family: PowerShell drops its logo banner (so the prompt sits flush like the + // POSIX shells), cmd needs nothing, and everything else (zsh/bash/fish/sh…) + // gets POSIX interactive-login flags. + function shellSpecFor(shellPath) { + const name = path.basename(shellPath).toLowerCase() + + if (name.startsWith('pwsh') || name.startsWith('powershell')) { + return { args: ['-NoLogo'], command: shellPath, name } + } + + if (name.startsWith('cmd')) { + return { args: [], command: shellPath, name } + } + + return posixShellSpec(shellPath) + } + + // Best installed Windows shell: PowerShell 7+ (`pwsh`), then Windows PowerShell + // 5.1, then comspec/cmd.exe as the universal fallback. + function windowsShellSpec() { + const command = + findOnPath('pwsh.exe') || findOnPath('pwsh') || windowsPowerShellPath() || process.env.COMSPEC || 'cmd.exe' + + return shellSpecFor(command) + } + + // Resolve the interactive shell for the embedded terminal: an explicit user + // override wins, otherwise auto-detect the best one installed for the platform. + function terminalShellCommand() { + // HERMES_DESKTOP_SHELL is the cross-platform escape hatch (a path or a bare + // name on PATH); $SHELL is honored on POSIX, where it's the user's canonical + // choice, but ignored on Windows, where it's usually a stray MSYS/Git path + // node-pty can't spawn natively. + const override = (process.env.HERMES_DESKTOP_SHELL || (isWindows ? '' : process.env.SHELL) || '').trim() + + if (override) { + const resolved = isExecutableFile(override) ? override : findOnPath(override) + + if (resolved) { + return shellSpecFor(resolved) + } + } + + if (isWindows) { + return windowsShellSpec() + } + + const shellPath = ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => isExecutableFile(candidate)) + + return posixShellSpec(shellPath || '/bin/sh') + } + + function safeTerminalCwd(cwd) { + const candidate = path.resolve(String(cwd || app.getPath('home'))) + + try { + const stat = fs.statSync(candidate) + + return stat.isDirectory() ? candidate : path.dirname(candidate) + } catch { + return app.getPath('home') + } + } + + function terminalShellEnv() { + const env = { ...process.env } + + // Electron is commonly launched through `npm run dev`; do not leak npm's + // managed prefix into a user's interactive shell (nvm/proto warn loudly). + for (const key of Object.keys(env)) { + if (key === 'npm_config_prefix' || key.startsWith('npm_config_') || key.startsWith('npm_package_')) { + delete env[key] + } + } + + // Strip color/theme-detection vars that ride along when Electron is launched + // from a non-tty agent shell (Cursor's runner sets NO_COLOR/FORCE_COLOR=0 + // /TERM=dumb; some terminals set COLORFGBG which would flip Hermes' TUI into + // light-mode). Our PTY is a real xterm-compat terminal — force truecolor. + delete env.NO_COLOR + delete env.FORCE_COLOR + delete env.COLORFGBG + + env.COLORTERM = 'truecolor' + env.LC_CTYPE = env.LC_CTYPE || 'UTF-8' + env.TERM = 'xterm-256color' + env.TERM_PROGRAM = 'Hermes' + env.TERM_PROGRAM_VERSION = app.getVersion() + + // Let a hermes/--tui launched in this pane know it's embedded in the desktop + // GUI (build_environment_hints surfaces this). Distinct from HERMES_DESKTOP, + // which marks the agent *backend* and gates cron/gateway behavior. + env.HERMES_DESKTOP_TERMINAL = '1' + + return env + } + + function terminalChannel(id, suffix) { + return `hermes:terminal:${id}:${suffix}` + } + + // Best-effort read of a live PTY child's current working directory so a + // reopened tab can restart the shell where the user last `cd`'d, instead of the + // tab's original launch dir. Shell-agnostic (no prompt/OSC config needed) on + // POSIX; Windows has no cheap per-process cwd query without a native module, so + // it returns null and the caller falls back to the launch cwd. + function readProcessCwd(pid) { + return new Promise(resolve => { + if (!Number.isInteger(pid) || pid <= 0) { + resolve(null) + + return + } + + if (process.platform === 'linux') { + fs.promises + .readlink(`/proc/${pid}/cwd`) + .then(target => resolve(target || null)) + .catch(() => resolve(null)) + + return + } + + if (process.platform === 'darwin') { + // lsof ships with macOS; -Fn emits the cwd fd's path on an `n` line. + execFile('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { timeout: 2000 }, (err, stdout) => { + if (err) { + resolve(null) + + return + } + + const line = String(stdout || '') + .split('\n') + .find(entry => entry.startsWith('n')) + + resolve(line ? line.slice(1) : null) + }) + + return + } + + resolve(null) + }) + } + + function disposeTerminalSession(id: string) { + const sessionInfo = terminalSessions.get(id) + + if (!sessionInfo) { + return false + } + + terminalSessions.delete(id) + + try { + sessionInfo.pty.kill() + } catch { + // Process may already be gone. + } + + return true + } + + // SSH teardown: close every pane whose PTY rode the disconnected tunnel. + function disposeTerminalSessionsForSshScope(scope: string) { + for (const [id, info] of [...terminalSessions.entries()]) { + if (info.sshScope === scope) { + disposeTerminalSession(id) + } + } + } + + // App shutdown: kill every open PTY before environment teardown. + function disposeAllTerminalSessions() { + for (const id of [...terminalSessions.keys()]) { + disposeTerminalSession(id) + } + } + + // node-pty's published tarball ships the POSIX `spawn-helper` without an exec + // bit; the dev flow resolves node-pty straight from node_modules (nothing + // chmods it there), so the first terminal spawn dies with `posix_spawnp + // failed`. Restore the bit once, lazily, right before the first spawn. Packaged + // builds already stage an executable copy, so this is a no-op there. + let _spawnHelperEnsured = false + + function ensureNodePtySpawnHelper() { + if (_spawnHelperEnsured || isWindows) { + return + } + + _spawnHelperEnsured = true + + try { + const nodePtyRoot = path.dirname(require.resolve('node-pty/package.json')) + const { fixed, errors } = ensureSpawnHelperExecutable(nodePtyRoot) + + for (const helperPath of fixed) { + rememberLog(`[terminal] restored +x on node-pty spawn-helper: ${helperPath}`) + } + + for (const failure of errors) { + rememberLog(`[terminal] could not chmod spawn-helper ${failure.path}: ${failure.error}`) + } + } catch (error) { + rememberLog( + `[terminal] spawn-helper exec check skipped: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { + ensureNodePtySpawnHelper() + + const id = crypto.randomUUID() + const { args, command, name } = terminalShellCommand() + const cwd = safeTerminalCwd(payload?.cwd) + const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80) + const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24) + + const sshTarget = await resolveTerminalConnection(activeSshTerminalTarget, ensureBackend) + const remote = Boolean(sshTarget) + const remoteState = remote ? getSshConnectionState(sshTarget.scope) : null + + const remoteCommand = + remoteState?.remotePlatform === 'Windows' + ? buildWindowsInteractiveCommand(String(payload?.cwd || '').trim()) + : undefined + + const ptyProcess = remote + ? nodePty.spawn( + process.platform === 'win32' + ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe') + : 'ssh', + buildInteractiveSshArgs(sshTarget.ssh, String(payload?.cwd || '').trim(), undefined, remoteCommand), + { cols, cwd: app.getPath('home'), env: terminalShellEnv(), name: 'xterm-256color', rows } + ) + : nodePty.spawn(command, args, { cols, cwd, env: terminalShellEnv(), name: 'xterm-256color', rows }) + + terminalSessions.set(id, { + pty: ptyProcess, + webContentsId: event.sender.id, + ...(remote ? { sshScope: sshTarget.scope, remoteCwd: String(payload?.cwd || '') } : {}) + }) + + const send = (suffix, payload) => { + if (event.sender.isDestroyed()) { + return + } + + event.sender.send(terminalChannel(id, suffix), payload) + } + + ptyProcess.onData(data => send('data', data)) + ptyProcess.onExit(({ exitCode, signal }) => { + terminalSessions.delete(id) + send('exit', { code: exitCode, signal: signal || null }) + }) + event.sender.once('destroyed', () => disposeTerminalSession(id)) + + return { cwd: remote ? null : cwd, id, shell: remote ? 'ssh' : name } + }) + + ipcMain.handle('hermes:terminal:write', (_event, id, data) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo) { + return false + } + + sessionInfo.pty.write(String(data || '')) + + return true + }) + + ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo) { + return false + } + + const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80) + const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24) + + sessionInfo.pty.resize(cols, rows) + + return true + }) + ipcMain.handle('hermes:terminal:cwd', async (_event, id) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo) { + return null + } + + return sessionInfo.sshScope !== undefined ? null : readProcessCwd(sessionInfo.pty.pid) + }) + + ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || ''))) + + return { disposeTerminalSession, disposeTerminalSessionsForSshScope, disposeAllTerminalSessions } +} diff --git a/apps/desktop/electron/translucency.test.ts b/apps/desktop/electron/translucency.test.ts index 3db509f14b..6795fdd5d7 100644 --- a/apps/desktop/electron/translucency.test.ts +++ b/apps/desktop/electron/translucency.test.ts @@ -11,6 +11,7 @@ import { describe, expect, it } from 'vitest' import { + backgroundMaterialFor, clampIntensity, DEFAULT_GLASS_MATERIAL, DEFAULT_GLASS_SCOPE, @@ -18,6 +19,9 @@ import { GLASS_SCOPES, glassActive, type GlassMaterial, + glassMaterialForPicker, + glassMaterialsFor, + glassSupportedOn, glassSurfaceKeep, normalizeMaterial, normalizeMode, @@ -28,9 +32,12 @@ import { TRANSLUCENCY_MIN, TRANSLUCENCY_OPACITY_FLOOR, type TranslucencyState, + translucencySupportedOn, vibrancyFor, windowBackingOptions, - windowOpacityFor + windowOpacityFor, + WINDOWS_BACKGROUND_MATERIALS, + WINDOWS_GLASS_MIN_BUILD } from './translucency' /** The linear ramp the curve replaced. Endpoints must still agree with it. */ @@ -38,13 +45,15 @@ const legacyOpacity = (intensity: number) => 1 - (intensity / 100) * 0.7 const clear = (intensity: number): TranslucencyState => ({ intensity, + fade: 0, mode: 'clear', material: DEFAULT_GLASS_MATERIAL, scope: DEFAULT_GLASS_SCOPE }) -const glass = (intensity: number, material: GlassMaterial = DEFAULT_GLASS_MATERIAL): TranslucencyState => ({ +const glass = (intensity: number, material: GlassMaterial = DEFAULT_GLASS_MATERIAL, fade = 0): TranslucencyState => ({ intensity, + fade, mode: 'glass', material, scope: DEFAULT_GLASS_SCOPE @@ -78,7 +87,7 @@ describe('clampIntensity', () => { }) describe('normalizeMode', () => { - it('accepts glass on macOS only — there is no vibrancy to ride elsewhere', () => { + it('accepts glass only on a platform that has a native material', () => { expect(normalizeMode('glass', true)).toBe('glass') expect(normalizeMode('glass', false)).toBe('clear') }) @@ -90,7 +99,7 @@ describe('normalizeMode', () => { // Glass is pre-selected so the better half of the feature is the one you // find, which is free because the intensity still starts at 0. - it('pre-selects glass on macOS when nothing is recorded', () => { + it('pre-selects glass when the platform supports it and nothing is recorded', () => { expect(normalizeMode(undefined, true)).toBe('glass') expect(normalizeMode('acrylic', true)).toBe('glass') expect(normalizeMode(42, true)).toBe('glass') @@ -163,11 +172,23 @@ describe('windowOpacityFor', () => { expect(windowOpacityFor(clear(240))).toBe(windowOpacityFor(clear(TRANSLUCENCY_MAX))) }) - it('never fades the native window in glass mode — the renderer paints that effect', () => { + // The tint is painted by the renderer, so the intensity lever must never + // reach setOpacity under glass — that separation is what keeps text sharp. + it('ignores the intensity lever entirely in glass mode', () => { expect(windowOpacityFor(glass(0))).toBe(1) expect(windowOpacityFor(glass(60))).toBe(1) expect(windowOpacityFor(glass(100))).toBe(1) }) + + it('fades a glass window only through its own lever, on the ramp clear uses', () => { + expect(windowOpacityFor(glass(60, DEFAULT_GLASS_MATERIAL, 0))).toBe(1) + expect(windowOpacityFor(glass(60, DEFAULT_GLASS_MATERIAL, 40))).toBe(windowOpacityFor(clear(40))) + expect(windowOpacityFor(glass(0, DEFAULT_GLASS_MATERIAL, 100))).toBe(windowOpacityFor(clear(100))) + }) + + it('leaves fade inert under clear, where the intensity lever already is the opacity', () => { + expect(windowOpacityFor({ ...clear(40), fade: 100 })).toBe(windowOpacityFor(clear(40))) + }) }) describe('glassSurfaceKeep', () => { @@ -224,10 +245,125 @@ describe('vibrancyFor', () => { }) }) +describe('glassSupportedOn', () => { + it('is on for macOS regardless of kernel version', () => { + expect(glassSupportedOn('darwin')).toBe(true) + expect(glassSupportedOn('darwin', '24.6.0')).toBe(true) + }) + + it('is on for Windows 11 22H2 and newer, off for everything older', () => { + expect(glassSupportedOn('win32', `10.0.${WINDOWS_GLASS_MIN_BUILD}`)).toBe(true) + expect(glassSupportedOn('win32', `10.0.${WINDOWS_GLASS_MIN_BUILD}.1`)).toBe(true) + expect(glassSupportedOn('win32', `10.0.${WINDOWS_GLASS_MIN_BUILD - 1}`)).toBe(false) + expect(glassSupportedOn('win32', '10.0.19045')).toBe(false) + expect(glassSupportedOn('win32', '10.0')).toBe(false) + expect(glassSupportedOn('win32', '')).toBe(false) + }) + + it('is off on Linux — Electron has no first-party desktop material there', () => { + expect(glassSupportedOn('linux', '6.8.0')).toBe(false) + }) +}) + +describe('backgroundMaterialFor', () => { + it('is none while glass is off so DWM does not keep drawing under the backing', () => { + expect(backgroundMaterialFor(glass(0, 'header'))).toBe('none') + expect(backgroundMaterialFor(clear(60))).toBe('none') + }) + + it('maps the sheer → heavy frost ladder onto acrylic / tabbed / mica', () => { + expect(backgroundMaterialFor(glass(60, 'under-window'))).toBe('acrylic') + expect(backgroundMaterialFor(glass(60, 'popover'))).toBe('tabbed') + expect(backgroundMaterialFor(glass(60, 'titlebar'))).toBe('mica') + }) + + // Windows 11 has three system materials for four rungs, so the two heaviest + // land on mica. The mapping stays total — a saved 'header' still resolves — + // and the picker drops the duplicate instead (see glassMaterialsFor). + it('collapses Glare onto mica with Bright', () => { + expect(backgroundMaterialFor(glass(60, 'header'))).toBe('mica') + expect(backgroundMaterialFor(glass(60, 'header'))).toBe(backgroundMaterialFor(glass(60, 'titlebar'))) + }) + + it('resolves every shipped rung to a real system material', () => { + for (const material of GLASS_MATERIALS) { + expect(WINDOWS_BACKGROUND_MATERIALS, material).toContain(backgroundMaterialFor(glass(60, material))) + } + }) +}) + +describe('translucencySupportedOn', () => { + it('covers the two platforms where setOpacity or a native material exists', () => { + expect(translucencySupportedOn('darwin')).toBe(true) + expect(translucencySupportedOn('win32')).toBe(true) + }) + + // Electron documents setOpacity as doing nothing on Linux, and there is no + // material either — so the setting has no working half to offer there. + it('is off on Linux, where neither mode does anything', () => { + expect(translucencySupportedOn('linux')).toBe(false) + expect(translucencySupportedOn('freebsd')).toBe(false) + }) + + // Win10 loses glass but keeps clear, so the row must survive there. + it('stays on for a Windows build too old for glass', () => { + const oldWindows = `10.0.${WINDOWS_GLASS_MIN_BUILD - 1}` + + expect(glassSupportedOn('win32', oldWindows)).toBe(false) + expect(translucencySupportedOn('win32')).toBe(true) + }) +}) + +describe('the frost rungs a platform offers', () => { + it('offers the whole ladder on macOS', () => { + expect(glassMaterialsFor(false)).toEqual(GLASS_MATERIALS) + }) + + // The census rule, now enforced on Windows too: no two options in the picker + // may composite to the same thing. Bright and Glare are both mica. + it('never offers two rungs that render the same Windows backdrop', () => { + const backdrops = glassMaterialsFor(true).map(material => backgroundMaterialFor(glass(60, material))) + + expect(new Set(backdrops).size).toBe(backdrops.length) + expect(glassMaterialsFor(true).length).toBeLessThan(GLASS_MATERIALS.length) + }) + + it('keeps every distinct Windows backdrop reachable from the picker', () => { + const backdrops = new Set(glassMaterialsFor(true).map(material => backgroundMaterialFor(glass(60, material)))) + const reachable = new Set(GLASS_MATERIALS.map(material => backgroundMaterialFor(glass(60, material)))) + + expect(backdrops).toEqual(reachable) + }) + + // Settings synced from a Mac carry a rung Windows has no button for. The + // picker highlights the button that renders the same backdrop rather than + // showing nothing selected — and does NOT rewrite what the Mac saved. + it('folds a dropped rung onto the button that looks the same', () => { + expect(glassMaterialForPicker('header', true)).toBe('titlebar') + expect(glassMaterialsFor(true)).toContain(glassMaterialForPicker('header', true)) + expect(backgroundMaterialFor(glass(60, glassMaterialForPicker('header', true)))).toBe( + backgroundMaterialFor(glass(60, 'header')) + ) + }) + + it('leaves every rung alone on macOS and every offered rung alone on Windows', () => { + for (const material of GLASS_MATERIALS) { + expect(glassMaterialForPicker(material, false)).toBe(material) + } + + for (const material of glassMaterialsFor(true)) { + expect(glassMaterialForPicker(material, true)).toBe(material) + } + }) +}) + describe('normalizeState', () => { it('parses a modern payload', () => { - expect(normalizeState({ intensity: 40, mode: 'glass', material: 'header', scope: 'sidebar' }, true)).toEqual({ + expect( + normalizeState({ intensity: 40, fade: 15, mode: 'glass', material: 'header', scope: 'sidebar' }, true) + ).toEqual({ intensity: 40, + fade: 15, mode: 'glass', material: 'header', scope: 'sidebar' @@ -239,19 +375,28 @@ describe('normalizeState', () => { it('keeps a legacy intensity-only payload on clear', () => { expect(normalizeState({ intensity: 70 }, true)).toEqual({ intensity: 70, + fade: 0, mode: 'clear', material: DEFAULT_GLASS_MATERIAL, scope: DEFAULT_GLASS_SCOPE }) }) - it('survives junk payloads', () => { - const base = { intensity: 0, material: DEFAULT_GLASS_MATERIAL, scope: DEFAULT_GLASS_SCOPE } + // Fade arrived after glass shipped, so a profile written by the older build + // has no key for it and must come back unfaded rather than undefined. + it('defaults a payload written before fade existed to no fade', () => { + expect(normalizeState({ intensity: 60, mode: 'glass' }, true).fade).toBe(0) + }) - // A fresh macOS profile lands on glass at zero intensity: selected, but off. + it('survives junk payloads', () => { + const base = { intensity: 0, fade: 0, material: DEFAULT_GLASS_MATERIAL, scope: DEFAULT_GLASS_SCOPE } + + // A fresh glass-capable profile lands on glass at zero intensity: selected, but off. expect(normalizeState(null, true)).toEqual({ ...base, mode: 'glass' }) expect(normalizeState('nope', true)).toEqual({ ...base, mode: 'glass' }) - expect(normalizeState({ intensity: 'x', material: 'nope', mode: 'glass', scope: 'nope' }, false)).toEqual({ + expect( + normalizeState({ intensity: 'x', fade: 'x', material: 'nope', mode: 'glass', scope: 'nope' }, false) + ).toEqual({ ...base, mode: 'clear' }) @@ -266,8 +411,8 @@ describe('glassActive', () => { }) }) -// The default must be selected-but-off: a fresh macOS profile shows Glass in -// the picker while the window itself is untouched until the lever moves. +// The default must be selected-but-off: a fresh glass-capable profile shows +// Glass in the picker while the window itself is untouched until the lever moves. describe('a fresh profile', () => { const fresh = normalizeState(null, true) @@ -326,12 +471,22 @@ describe('what an update actually changes natively', () => { expect(nativeDiff(clear(40), clear(41))).toEqual({ backing: false, material: false, opacity: true }) }) + // The one glass drag that reaches main, and it costs what a clear drag costs. + it('is only the opacity while dragging fade under glass', () => { + expect(nativeDiff(glass(60, DEFAULT_GLASS_MATERIAL, 40), glass(60, DEFAULT_GLASS_MATERIAL, 41))).toEqual({ + backing: false, + material: false, + opacity: true + }) + }) + it('is the material alone when the frost level changes', () => { expect(nativeDiff(glass(60, 'under-window'), glass(60, 'header'))).toEqual({ backing: false, material: true, opacity: false }) + expect(backgroundMaterialFor(glass(60, 'under-window'))).not.toBe(backgroundMaterialFor(glass(60, 'header'))) }) // Crossing zero flips glass on/off, which is exactly when the backing has to diff --git a/apps/desktop/electron/translucency.ts b/apps/desktop/electron/translucency.ts index cb9b2f28bf..6e524a8d8f 100644 --- a/apps/desktop/electron/translucency.ts +++ b/apps/desktop/electron/translucency.ts @@ -14,6 +14,7 @@ import { glassActive, type TranslucencyState } from '../../shared/src/translucency' export { + backgroundMaterialFor, clampIntensity, DEFAULT_GLASS_MATERIAL, DEFAULT_GLASS_SCOPE, @@ -21,6 +22,9 @@ export { GLASS_SCOPES, glassActive, type GlassMaterial, + glassMaterialForPicker, + glassMaterialsFor, + glassSupportedOn, glassSurfaceKeep, normalizeMaterial, normalizeMode, @@ -31,8 +35,12 @@ export { TRANSLUCENCY_MIN, TRANSLUCENCY_OPACITY_FLOOR, type TranslucencyState, + translucencySupportedOn, vibrancyFor, - windowOpacityFor + windowOpacityFor, + WINDOWS_BACKGROUND_MATERIALS, + WINDOWS_GLASS_MIN_BUILD, + type WindowsBackgroundMaterial } from '../../shared/src/translucency' /** diff --git a/apps/desktop/electron/windows-hermes-path.test.ts b/apps/desktop/electron/windows-hermes-path.test.ts index 778042b278..0c49b4a2ac 100644 --- a/apps/desktop/electron/windows-hermes-path.test.ts +++ b/apps/desktop/electron/windows-hermes-path.test.ts @@ -5,9 +5,9 @@ // 1. buildPathExtCandidates() — PATHEXT extensions must be tried BEFORE the // empty extension, or an extensionless Git-Bash `hermes` shim shadows // the real hermes.cmd/hermes.exe. -// 2. chooseUpdaterArgs() — must gate on haveRealInstall (any real-install -// signal), not just the hermes.exe console-script shim, or healthy -// installs get forced into a destructive --repair. +// 2. chooseUpdaterArgs() — must distinguish a runnable updater from stale +// install provenance. The bootstrap marker can outlive the venv, and a +// partial venv cannot run the updater; those states require --repair. // 3. resolveVenvHermesCommand() — must probe the venv python via // canImportHermesCli() before trusting it, or a broken venv gets // re-selected forever instead of falling through to bootstrap. @@ -45,17 +45,43 @@ test('buildPathExtCandidates: non-Windows only tries the bare name', () => { assert.deepEqual(buildPathExtCandidates(undefined, false), ['']) }) -test('chooseUpdaterArgs: gentle --update when a real-install signal is present', () => { - assert.deepEqual(chooseUpdaterArgs(true, 'main'), ['--update', '--branch', 'main']) +test('chooseUpdaterArgs: gentle --update when both updater runtime files exist', () => { + assert.deepEqual(chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: true, hasVenvPython: true }, 'main'), [ + '--update', + '--branch', + 'main' + ]) }) -test('chooseUpdaterArgs: destructive --repair only when NO real-install signal is present', () => { - assert.deepEqual(chooseUpdaterArgs(false, 'main'), ['--repair', '--branch', 'main']) +test('chooseUpdaterArgs: marker-only install uses --repair when the venv is gone', () => { + assert.deepEqual( + chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: false, hasVenvPython: false }, 'main'), + ['--repair', '--branch', 'main'] + ) }) -test('chooseUpdaterArgs: passes the branch through unchanged in both cases', () => { - assert.deepEqual(chooseUpdaterArgs(true, 'release/1.2'), ['--update', '--branch', 'release/1.2']) - assert.deepEqual(chooseUpdaterArgs(false, 'release/1.2'), ['--repair', '--branch', 'release/1.2']) +test('chooseUpdaterArgs: partial updater runtimes use --repair', () => { + assert.deepEqual(chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: false, hasVenvPython: true }, 'main'), [ + '--repair', + '--branch', + 'main' + ]) + assert.deepEqual(chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: true, hasVenvPython: false }, 'main'), [ + '--repair', + '--branch', + 'main' + ]) +}) + +test('chooseUpdaterArgs: passes the branch through unchanged in both modes', () => { + assert.deepEqual( + chooseUpdaterArgs({ hasBootstrapMarker: false, hasVenvHermes: true, hasVenvPython: true }, 'release/1.2'), + ['--update', '--branch', 'release/1.2'] + ) + assert.deepEqual( + chooseUpdaterArgs({ hasBootstrapMarker: false, hasVenvHermes: false, hasVenvPython: false }, 'release/1.2'), + ['--repair', '--branch', 'release/1.2'] + ) }) function makeDeps(overrides: Partial[2]> = {}) { diff --git a/apps/desktop/electron/windows-hermes-path.ts b/apps/desktop/electron/windows-hermes-path.ts index 6f3542853e..f94d4b2c39 100644 --- a/apps/desktop/electron/windows-hermes-path.ts +++ b/apps/desktop/electron/windows-hermes-path.ts @@ -11,12 +11,11 @@ * hermes.cmd/hermes.exe; the shim then failed the --version probe and * the desktop fell through to a spurious bootstrap/repair. The fix: * PATHEXT extensions first, empty extension LAST. - * 2. chooseUpdaterArgs() — handOffWindowsBootstrapRecovery() chose - * --update vs the destructive --repair by checking ONLY - * venv\Scripts\hermes.exe (the console-script shim, written at the END - * of venv setup and absent in interrupted states), so it escalated to a - * full venv recreate even on healthy installs. The fix: gate on ANY - * real-install signal, not just the shim. + * 2. chooseUpdaterArgs() — handOffWindowsBootstrapRecovery() must separate + * install provenance from updater viability. A bootstrap-complete marker + * can outlive a deleted venv, while the updater needs BOTH the venv Python + * and Hermes launcher. Marker-only or partial runtimes must use --repair; + * only a runnable pair can use --update. * 3. resolveVenvHermesCommand() — unwrapWindowsVenvHermesCommand() returned * the venv python with NO runtime probe (bypassing the caller's * --version check too), so a venv broken mid-update (e.g. missing @@ -61,23 +60,26 @@ export function buildPathExtCandidates(pathext: string | undefined, isWindows: b } /** - * Choose the Windows bootstrap-recovery updater invocation: the gentle - * in-place --update when ANY real-install signal is present, the - * destructive --repair (full venv recreate) otherwise. + * Choose the Windows bootstrap-recovery invocation. The gentle in-place + * updater can only start when both pieces of its runtime contract exist: the + * venv Python interpreter and the Hermes launcher that drives `hermes update`. + * A bootstrap-complete marker proves install provenance, not current runtime + * usability, and may remain after the venv is removed or quarantined. * - * haveRealInstall must be computed by the caller from ALL real-install - * signals (venv python interpreter, venv hermes shim, bootstrap-complete - * marker) — gating on just the hermes.exe console-script shim alone is the - * regression this function's callers must avoid: that shim is written at - * the END of venv setup and is absent in exactly the interrupted/quarantined - * states this recovery exists to heal. - * - * @param {boolean} haveRealInstall + * @param {BootstrapRecoverySignals} signals * @param {string} branch * @returns {string[]} updater argv, e.g. ['--update', '--branch', 'main']. */ -export function chooseUpdaterArgs(haveRealInstall: boolean, branch: string): string[] { - return haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] +export interface BootstrapRecoverySignals { + hasBootstrapMarker: boolean + hasVenvHermes: boolean + hasVenvPython: boolean +} + +export function chooseUpdaterArgs(signals: BootstrapRecoverySignals, branch: string): string[] { + const canRunUpdater = signals.hasVenvHermes && signals.hasVenvPython + + return canRunUpdater ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] } /** diff --git a/apps/desktop/electron/windows-remote-lifecycle.ts b/apps/desktop/electron/windows-remote-lifecycle.ts index 5d96853a1a..40d89b3ece 100644 --- a/apps/desktop/electron/windows-remote-lifecycle.ts +++ b/apps/desktop/electron/windows-remote-lifecycle.ts @@ -1,6 +1,6 @@ import crypto from 'node:crypto' -import { redactSecrets, SSH_ERROR } from './ssh-connection' +import { assertBootstrapNotSuperseded, redactSecrets, SSH_ERROR } from './ssh-connection' const LOCKFILE_SCHEMA_VERSION = 2 const PROTOCOL_VERSION = 1 @@ -162,14 +162,6 @@ function reusableWindowsLock(lock, state, profile, reuseToken, runtime) { ) } -function assertCurrent(signal) { - if (signal?.aborted) { - const error: any = new Error('SSH bootstrap was cancelled.') - error.kind = 'superseded' - throw error - } -} - async function processState(ssh, runtime, lock) { return helper(ssh, runtime, 'process-state', [ String(lock.pid), @@ -215,7 +207,7 @@ async function waitReady(ssh, runtime, ownershipId, lock, timeoutMs, signal) { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { - assertCurrent(signal) + assertBootstrapNotSuperseded(signal) let state try { @@ -286,7 +278,7 @@ async function connectWindowsRemote(deps) { readyTimeoutMs = 45_000 } = deps - assertCurrent(signal) + assertBootstrapNotSuperseded(signal) const runtime = await probeWindowsRemote(ssh, remoteHermesPath) const inspection = await helper(ssh, runtime, 'inspect', [runtime.hermesPath]) @@ -356,7 +348,7 @@ async function connectWindowsRemote(deps) { await helper(ssh, runtime, 'remove-lock', [ownershipId]) } - assertCurrent(signal) + assertBootstrapNotSuperseded(signal) const token = crypto.randomBytes(32).toString('hex') const spawnNonce = crypto.randomBytes(8).toString('hex') await helper(ssh, runtime, 'upload-token', [ownershipId, spawnNonce], token) @@ -405,7 +397,7 @@ async function connectWindowsRemote(deps) { await forward(localPort, remotePort) const baseUrl = `http://127.0.0.1:${localPort}` await waitForHermes(baseUrl, token) - assertCurrent(signal) + assertBootstrapNotSuperseded(signal) await helper(ssh, runtime, 'write-lock', [ownershipId], JSON.stringify({ ...owned, port: remotePort })) return { diff --git a/apps/desktop/src/api/client.ts b/apps/desktop/src/api/client.ts new file mode 100644 index 0000000000..8a925be2dd --- /dev/null +++ b/apps/desktop/src/api/client.ts @@ -0,0 +1,154 @@ +import { JsonRpcGatewayClient } from '@hermes/shared' + +import type { HermesApiRequest } from '@/global' + +// Desktop startup fires a burst of read-only data calls (config, profiles, +// model info/options, cron) the moment the backend passes readiness. On a +// profile-heavy or remote install these can each take tens of seconds — e.g. +// /api/profiles runs list_profiles(), which does a recursive skill-tree walk +// per profile — so the 15s default (DEFAULT_FETCH_TIMEOUT_MS in hardening.ts) +// times out a backend that is alive-but-busy, surfacing as a spurious +// "Timed out connecting to Hermes backend" that hangs the UI (#48504). +// +// Give the boot burst a generous per-call timeout instead of raising the +// global default: interactive/runtime calls and the liveness poll (/api/status) +// keep the short default so a genuinely-dead backend is still detected fast. +export const STARTUP_REQUEST_TIMEOUT_MS = 60_000 +const DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS = 30_000 +// prompt.submit is effectively fire-and-forget: turn completion is signaled by +// stream / message.complete events, NOT by the RPC return. A long turn (MoA +// presets running references + aggregator in series, deep reasoning, large tool +// chains) can legitimately take minutes to ACK, so bounding the ack by the +// generic 30s default surfaces a false "request timed out" toast while the turn +// is still running and will succeed (issue #55024). Match the backend's +// agent-turn ceiling (agent.gateway_timeout = 1800s) so the ack timeout only +// ever fires when the turn itself would have been abandoned server-side. +export const PROMPT_SUBMIT_REQUEST_TIMEOUT_MS = 1_800_000 + +export class HermesGateway extends JsonRpcGatewayClient { + constructor() { + super({ + closedErrorMessage: 'Hermes gateway connection closed', + connectErrorMessage: 'Could not connect to Hermes gateway', + createRequestId: nextId => nextId, + notConnectedErrorMessage: 'Hermes gateway is not connected', + requestTimeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS + }) + } +} + +// 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 as request scope. Local calls whose +// REST handlers accept profile reuse the primary dashboard via ?profile=; +// unscoped handlers retain a profile backend. Remote overrides still route to +// their owning backend. Null → primary, so single-profile users are unaffected. +let _apiProfile: null | string = null + +export function setApiRequestProfile(profile: null | string): void { + _apiProfile = profile || null +} + +export function profileScoped(profile?: null | string): { profile?: string } { + const selected = profile === undefined ? _apiProfile : profile + + return selected ? { profile: selected } : {} +} + +/** Profile that profile-scoped REST/WS calls should target (null → primary). + * Read-only twin of setApiRequestProfile for modules (e.g. voice playback) + * that build their own connection URLs and must stay on the same backend. */ +export function getApiRequestProfile(): null | string { + return _apiProfile +} + +// Registry connection serving the active gateway (null → the local pool). +// Pushed from store/gateway's setActive — the single seam BOTH +// ensureGatewayProfile and ensureGatewayAgent funnel through — so WS calls +// that dial their own backend (pluginSocket) resolve it through the SAME +// source of truth those paths maintain for $connection. That makes the plugin +// socket follow registry-agent activations too, not just profile switches. +// Same no-store-import contract as _apiProfile (avoids a cycle). +let _apiConnectionId: null | string = null + +export function setApiRequestConnection(connectionId: null | string): void { + _apiConnectionId = connectionId || null +} + +// Registry connection scope for a REST request. A registered remote gateway +// owns its own state.db — cron jobs and their run sessions live THERE — so +// requests for gateway-owned data must carry the connection id for the main +// process to route them to that host (hermes:api's registry branch). Null +// resolves to no tag, keeping single-source users byte-identical; explicit +// 'local' must remain tagged when the legacy primary points elsewhere. +export function connectionScoped(): { connectionId?: string } { + return _apiConnectionId ? { connectionId: _apiConnectionId } : {} +} + +/** Send a REST request to the renderer's active registry source. Request-level + * routing may override the active source for an explicitly-owned resource. + * + * Helpers under `api/` go through here rather than calling the preload bridge + * directly, so the connection tag cannot be forgotten on a new one — with one + * exception. A capabilityScoped() helper must NOT: that scope says "the local + * pool" by omitting `connectionId` entirely, and an absent key cannot override + * the ambient tag spread underneath it, so a 'local' pin would silently route + * to whatever remote gateway happened to be active. Those helpers call the + * bridge directly and own their routing end to end. */ +export function hermesApi(request: HermesApiRequest): Promise { + return window.hermesDesktop.api({ ...connectionScoped(), ...request }) +} + +// ── Capability scope: (connection, profile) routing for the Capabilities +// surface (skills / toolsets / MCP / hub / env / toolset config) ──────────── +// +// A profile is not a machine-global name — it belongs to ONE gateway. The +// Capabilities surface can be pointed at any (connection, profile) pair +// (SkillsView's scope selector, Bot Mode's fixedProfile/fixedConnection), so +// its REST helpers accept either the legacy string form or an explicit scope +// object: +// +// - `undefined` / string → the legacy profile path, PLUS the active registry +// connection tag (connectionScoped, same contract the cron helpers adopted +// in #87882). Without the tag, a window activated onto a registered remote +// gateway read the LOCAL pool's skills/tools/MCP — the wrong machine. +// - `{ connectionId, profile }` → explicit pin. `''`/`'local'` connection +// ids mean the local pool and deliberately DROP the ambient connection +// tag, so a local-profile pick made while a remote gateway is active still +// routes to the local machine. +export type ProfileScope = undefined | null | string | { connectionId?: null | string; profile?: null | string } + +export function capabilityScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } { + if (scope && typeof scope === 'object') { + const profile = (scope.profile ?? '').trim() + const connectionId = (scope.connectionId ?? '').trim() + + return { + ...(profile ? { profile } : {}), + ...(connectionId && connectionId !== 'local' ? { connectionId } : {}) + } + } + + return { ...profileScoped(scope), ...connectionScoped() } +} + +/** Stable cache-key for a capability scope: `profile` for the local/legacy + * path, `connectionId::profile` for an explicit remote pin. Mirrors + * normalizeProfileKey for plain strings so existing keys stay byte-identical. */ +export function profileScopeKey(scope?: ProfileScope): string { + if (scope && typeof scope === 'object') { + const profile = (scope.profile ?? '').trim() || 'default' + const connectionId = (scope.connectionId ?? '').trim() + + return connectionId && connectionId !== 'local' ? `${connectionId}::${profile}` : profile + } + + return (scope ?? '').trim() || 'default' +} + +/** Registry connection id that connection-scoped WS calls should target + * (null → the local pool). Read-only twin of setApiRequestConnection. */ +export function getApiRequestConnection(): null | string { + return _apiConnectionId +} diff --git a/apps/desktop/src/api/config.ts b/apps/desktop/src/api/config.ts new file mode 100644 index 0000000000..38fd4945d6 --- /dev/null +++ b/apps/desktop/src/api/config.ts @@ -0,0 +1,239 @@ +import type { + ConfigSchemaResponse, + CustomEndpointsResponse, + CustomEndpointUpdate, + CustomEndpointValidationResponse, + EnvVarInfo, + HermesConfig, + HermesConfigRecord, + LogsResponse, + OAuthPollResponse, + OAuthProvidersResponse, + OAuthStartResponse, + OAuthSubmitResponse, + StatusResponse +} from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client' + +export function getStatus(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/status' + }) +} + +export function getLogs(params: { + component?: string + file?: string + level?: string + lines?: number + search?: string +}): Promise { + const query = new URLSearchParams() + + if (params.file) { + query.set('file', params.file) + } + + if (typeof params.lines === 'number') { + query.set('lines', String(params.lines)) + } + + if (params.level && params.level !== 'ALL') { + query.set('level', params.level) + } + + if (params.component && params.component !== 'all') { + query.set('component', params.component) + } + + if (params.search) { + query.set('search', params.search) + } + + const suffix = query.toString() + + return hermesApi({ + ...profileScoped(), + path: suffix ? `/api/logs?${suffix}` : '/api/logs' + }) +} + +export function getHermesConfig(profile?: string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/config', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function getHermesConfigRecord(profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/config' + }) +} + +export function getHermesConfigDefaults(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/config/defaults', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function getHermesConfigSchema(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/config/schema' + }) +} + +export function saveHermesConfig(config: HermesConfigRecord, profile?: null | string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(profile), + path: '/api/config', + method: 'PUT', + body: { config } + }) +} + +export function getEnvVars(profile?: null | string): Promise> { + return hermesApi>({ + ...profileScoped(profile), + path: '/api/env' + }) +} + +export function setEnvVar(key: string, value: string, profile?: ProfileScope): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + ...capabilityScoped(profile), + path: '/api/env', + method: 'PUT', + body: { key, value } + }) +} + +export function deleteEnvVar(key: string, profile?: ProfileScope): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + ...capabilityScoped(profile), + path: '/api/env', + method: 'DELETE', + body: { key } + }) +} + +export function revealEnvVar(key: string, profile?: ProfileScope): Promise<{ key: string; value: string }> { + return window.hermesDesktop.api<{ key: string; value: string }>({ + ...capabilityScoped(profile), + path: '/api/env/reveal', + method: 'POST', + body: { key } + }) +} + +export function validateProviderCredential( + key: string, + value: string, + apiKey?: string +): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> { + return hermesApi<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({ + ...profileScoped(), + path: '/api/providers/validate', + method: 'POST', + body: { key, value, api_key: apiKey ?? '' } + }) +} + +export function getCustomEndpoints(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/providers/custom-endpoints' + }) +} + +export function saveCustomEndpoint(endpoint: CustomEndpointUpdate): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/providers/custom-endpoints', + method: 'POST', + body: endpoint + }) +} + +export function validateCustomEndpoint(endpoint: CustomEndpointUpdate): Promise { + return hermesApi({ + path: '/api/providers/custom-endpoints/validate', + method: 'POST', + body: endpoint + }) +} + +export function activateCustomEndpoint(id: string): Promise<{ ok: boolean; provider: string; model: string }> { + return hermesApi<{ ok: boolean; provider: string; model: string }>({ + ...profileScoped(), + path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}/activate`, + method: 'POST' + }) +} + +export function deleteCustomEndpoint(id: string): Promise { + return hermesApi({ + ...profileScoped(), + path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}`, + method: 'DELETE' + }) +} + +export function listOAuthProviders(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/providers/oauth' + }) +} + +export function disconnectOAuthProvider(providerId: string): Promise<{ ok: boolean; provider: string }> { + return hermesApi<{ ok: boolean; provider: string }>({ + ...profileScoped(), + path: `/api/providers/oauth/${encodeURIComponent(providerId)}`, + method: 'DELETE' + }) +} + +export function startOAuthLogin(providerId: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`, + method: 'POST', + body: {} + }) +} + +export function submitOAuthCode(providerId: string, sessionId: string, code: string): Promise { + return hermesApi({ + ...profileScoped(), + path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`, + method: 'POST', + body: { session_id: sessionId, code } + }) +} + +export function pollOAuthSession( + providerId: string, + sessionId: string, + profile?: ProfileScope +): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}` + }) +} + +export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(), + path: `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`, + method: 'DELETE' + }) +} diff --git a/apps/desktop/src/api/cron.ts b/apps/desktop/src/api/cron.ts new file mode 100644 index 0000000000..b790b033d7 --- /dev/null +++ b/apps/desktop/src/api/cron.ts @@ -0,0 +1,153 @@ +import type { + AutomationBlueprint, + CronDeliveryTarget, + CronJob, + CronJobCreatePayload, + CronJobUpdates, + SessionInfo +} from '@/types/hermes' + +import { connectionScoped, hermesApi, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client' + +// The cron trigger endpoint intentionally waits for the whole job so its +// response reflects the persisted execution result. Agent jobs can run far +// longer than the Electron fetch default; keep this override local to the one +// synchronous long-operation endpoint rather than weakening all API timeouts. +const CRON_TRIGGER_REQUEST_TIMEOUT_MS = 24 * 60 * 60 * 1000 + +// Cron jobs are stored per-profile (/cron/jobs.json), and the +// backend's list endpoint defaults to 'all'. Pass a concrete profile key to +// list just that profile's jobs, or 'all' for the unified cross-profile view. +// Omitting the arg keeps the legacy 'all' default for non-profile callers. +// profileScoped() still rides along for backend-process routing. +export function getCronJobs(profile?: string): Promise { + const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : '' + + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs${suffix}`, + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function getCronJob(jobId: string): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}` + }) +} + +export async function getCronJobRuns(jobId: string, limit = 20): Promise { + const { runs } = await hermesApi<{ runs: SessionInfo[] }>({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}` + }) + + return runs ?? [] +} + +// The single source of truth for cron delivery targets (local + configured +// gateways). Both the manual cron editor and the blueprint dialog use this so +// they never offer a platform that isn't connected. Mirrors the dashboard. +export async function getCronDeliveryTargets(): Promise { + const { targets } = await hermesApi<{ targets: CronDeliveryTarget[] }>({ + ...profileScoped(), + ...connectionScoped(), + path: '/api/cron/delivery-targets' + }) + + return targets ?? [] +} + +export function createCronJob(body: CronJobCreatePayload): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: '/api/cron/jobs', + method: 'POST', + body + }) +} + +export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}`, + method: 'PUT', + body: { updates } + }) +} + +export function pauseCronJob(jobId: string): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`, + method: 'POST' + }) +} + +export function resumeCronJob(jobId: string): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`, + method: 'POST' + }) +} + +export function triggerCronJob(jobId: string): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}/trigger`, + method: 'POST', + timeoutMs: CRON_TRIGGER_REQUEST_TIMEOUT_MS + }) +} + +export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}`, + method: 'DELETE' + }) +} + +// Automation Blueprints — parameterized cron templates the backend serves from +// cron/blueprint_catalog.py. getAutomationBlueprints returns the gallery +// (deliver options already rewritten to this machine's configured gateways); +// instantiateAutomationBlueprint fills the slots and creates a real cron job via +// the same create_job path as createCronJob. +// +// Profile-scoping is intentionally asymmetric: the GET catalog is global (the +// list endpoint takes no profile — only deliver options are rewritten from the +// configured gateways), so it carries only the profileScoped() header for +// routing. instantiate creates a real per-profile job, so it names the target +// profile explicitly via ?profile=. This mirrors the dashboard's api.ts. +export function getAutomationBlueprints(): Promise<{ blueprints: AutomationBlueprint[] }> { + return hermesApi<{ blueprints: AutomationBlueprint[] }>({ + ...profileScoped(), + ...connectionScoped(), + path: '/api/cron/blueprints', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function instantiateAutomationBlueprint( + body: { blueprint: string; values: Record }, + profile: string +): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`, + method: 'POST', + body + }) +} diff --git a/apps/desktop/src/api/mcp.ts b/apps/desktop/src/api/mcp.ts new file mode 100644 index 0000000000..f67870e5bf --- /dev/null +++ b/apps/desktop/src/api/mcp.ts @@ -0,0 +1,147 @@ +import type { McpCatalogResponse, McpServerSummary } from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client' + +export interface McpTestResult { + ok: boolean + error?: string + /** `schema_chars` (converted registry-schema size, chars) is additive — + * older backends omit it and the cost overlay shows no token estimate. */ + tools: { name: string; description: string; schema_chars?: number }[] + /** Capability counts (absent on older backends / failed probes). */ + prompts?: number + resources?: number +} + +export interface McpOAuthFlow { + flow_id: string + server_name: string + status: 'starting' | 'authorization_required' | 'approved' | 'error' + authorization_url: string | null + error: string | null + tools?: { name: string; description: string }[] +} + +/** Connect to the server, list its tools, disconnect. Slow (spawns/handshakes + * for real) — well past the 15s default fetch timeout. */ +export function testMcpServer(name: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/mcp/servers/${encodeURIComponent(name)}/test`, + method: 'POST', + timeoutMs: 60_000 + }) +} + +/** Replace the whole `mcp_servers` map (the mcp.json editor's save). Unlike + * `saveHermesConfig`, this REPLACES rather than deep-merges, so deletes, + * re-enables (dropping `enabled: false`), and removed nested fields persist. */ +export function saveMcpServers( + servers: Record>, + profile?: ProfileScope +): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + ...capabilityScoped(profile), + path: '/api/mcp/servers', + method: 'PUT', + body: { servers } + }) +} + +/** Start an MCP OAuth flow and return the authorization URL. */ +export function authMcpServer(name: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/mcp/servers/${encodeURIComponent(name)}/auth`, + method: 'POST', + timeoutMs: 60_000 + }) +} + +export function getMcpOAuthFlow(flowId: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/mcp/oauth/flows/${encodeURIComponent(flowId)}` + }) +} + +/** Cancel an in-flight MCP OAuth flow server-side, freeing the per-server + * "already in progress" slot so a retry doesn't 409. */ +export function cancelMcpOAuthFlow(flowId: string, profile?: null | string): Promise<{ ok: boolean; status: string }> { + return hermesApi<{ ok: boolean; status: string }>({ + ...profileScoped(profile), + path: `/api/mcp/oauth/flows/${encodeURIComponent(flowId)}`, + method: 'DELETE' + }) +} + +// --------------------------------------------------------------------------- +// MCP servers — structured list / test / enable toggle / catalog (parity with +// `hermes mcp` and the dashboard MCP page). Raw JSON editing stays in +// config.yaml via saveHermesConfig. +// --------------------------------------------------------------------------- + +export function listMcpServers(): Promise<{ servers: McpServerSummary[] }> { + return hermesApi<{ servers: McpServerSummary[] }>({ + ...profileScoped(), + path: '/api/mcp/servers' + }) +} + +/** Add one server to `mcp_servers` (validated + name-collision-checked + * server-side — the same endpoint the dashboard's add form uses). */ +export function addMcpServer(body: { + name: string + url?: string + command?: string + args?: string[] + env?: Record + auth?: string +}): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/mcp/servers', + method: 'POST', + body + }) +} + +/** Remove one server from `mcp_servers` (the inline setup card's rollback + * when a directory install is cancelled after the config write). */ +export function removeMcpServer(name: string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(), + path: `/api/mcp/servers/${encodeURIComponent(name)}`, + method: 'DELETE' + }) +} + +export function setMcpServerEnabled(name: string, enabled: boolean): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(), + path: `/api/mcp/servers/${encodeURIComponent(name)}/enabled`, + method: 'PUT', + body: { enabled } + }) +} + +export function getMcpCatalog(profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/mcp/catalog' + }) +} + +export function installMcpCatalogEntry( + name: string, + env: Record = {}, + profile?: ProfileScope +): Promise<{ ok: boolean; name?: string; pid?: number; action?: string; background?: boolean }> { + return window.hermesDesktop.api<{ ok: boolean; name?: string; pid?: number; action?: string; background?: boolean }>({ + ...capabilityScoped(profile), + path: '/api/mcp/catalog/install', + method: 'POST', + body: { name, env, enable: true }, + timeoutMs: 60_000 + }) +} diff --git a/apps/desktop/src/api/messaging.ts b/apps/desktop/src/api/messaging.ts new file mode 100644 index 0000000000..b49bdc93b8 --- /dev/null +++ b/apps/desktop/src/api/messaging.ts @@ -0,0 +1,131 @@ +import type { + MessagingPlatformsResponse, + MessagingPlatformTestResponse, + MessagingPlatformUpdate, + PairingResponse, + PairingUser, + WebhookCreatePayload, + WebhookCreateResponse, + WebhookEnableResponse, + WebhooksResponse +} from '@/types/hermes' + +import { hermesApi, profileScoped } from './client' + +export function getMessagingPlatforms(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/messaging/platforms' + }) +} + +export function updateMessagingPlatform( + platformId: string, + body: MessagingPlatformUpdate, + profile?: null | string +): Promise<{ ok: boolean; platform: string }> { + return hermesApi<{ ok: boolean; platform: string }>({ + ...profileScoped(profile), + path: `/api/messaging/platforms/${encodeURIComponent(platformId)}`, + method: 'PUT', + body + }) +} + +export function testMessagingPlatform( + platformId: string, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/messaging/platforms/${encodeURIComponent(platformId)}/test`, + method: 'POST' + }) +} + +// -- Pairing (who may DM the bot) -------------------------------------------- +// Unknown DMers get a one-time code and land in `pending` until an admin +// approves them. Approval grants on the row's `request_id`, never on the code: +// the code is the requester's proof that the channel is theirs and is never +// returned by the API, while an authenticated admin is only ever identifying +// a row they can already see. + +export function getPairing(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/pairing' + }) +} + +export function approvePairing( + platform: string, + requestId: string, + profile?: null | string +): Promise<{ ok: boolean; user: PairingUser }> { + return hermesApi<{ ok: boolean; user: PairingUser }>({ + ...profileScoped(profile), + path: '/api/pairing/approve', + method: 'POST', + // These endpoints read the profile off the body, not the query string — + // `profileScoped()` alone would approve into the wrong profile's store. + body: { platform, request_id: requestId, ...profileScoped(profile) } + }) +} + +export function revokePairing(platform: string, userId: string, profile?: null | string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(profile), + path: '/api/pairing/revoke', + method: 'POST', + body: { platform, user_id: userId, ...profileScoped(profile) } + }) +} + +// -- Webhooks (subscription CRUD) -------------------------------------------- +// The webhook receiver is its own gateway platform; subscriptions live in a +// shared JSON store the CLI/dashboard also drive. Enable mutates config and +// best-effort restarts the gateway; subscription changes hot-reload. + +export function getWebhooks(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/webhooks' + }) +} + +export function enableWebhooks(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/webhooks/enable', + method: 'POST' + }) +} + +export function createWebhook(body: WebhookCreatePayload): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/webhooks', + method: 'POST', + body + }) +} + +export function deleteWebhook(name: string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(), + path: `/api/webhooks/${encodeURIComponent(name)}`, + method: 'DELETE' + }) +} + +export function setWebhookEnabled( + name: string, + enabled: boolean +): Promise<{ enabled: boolean; name: string; ok: boolean }> { + return hermesApi<{ enabled: boolean; name: string; ok: boolean }>({ + ...profileScoped(), + path: `/api/webhooks/${encodeURIComponent(name)}/enabled`, + method: 'PUT', + body: { enabled } + }) +} diff --git a/apps/desktop/src/api/models.ts b/apps/desktop/src/api/models.ts new file mode 100644 index 0000000000..0aa1ca951f --- /dev/null +++ b/apps/desktop/src/api/models.ts @@ -0,0 +1,129 @@ +import type { + AnalyticsResponse, + AuxiliaryModelsResponse, + MoaConfigResponse, + ModelAssignmentRequest, + ModelAssignmentResponse, + ModelInfoResponse, + ModelOptionsResponse +} from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client' + +export function getGlobalModelInfo(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/model/info', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function getUsageAnalytics(days = 30, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/analytics/usage?days=${Math.max(1, Math.floor(days))}` + }) +} + +export function getGlobalModelOptions( + opts?: { + refresh?: boolean + includeUnconfigured?: boolean + explicitOnly?: boolean + }, + profile?: null | string +): Promise { + const params = new URLSearchParams() + + if (opts?.refresh) { + params.set('refresh', '1') + } + + if (opts?.includeUnconfigured) { + params.set('include_unconfigured', '1') + } + + if (opts?.explicitOnly !== false) { + params.set('explicit_only', '1') + } + + return hermesApi({ + ...profileScoped(profile), + path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export interface RecommendedDefaultModel { + provider: string + model: string + /** True/false for Nous (free vs paid tier); null for other providers. */ + free_tier: boolean | null +} + +// Recommended default model for a freshly-authenticated provider. Mirrors the +// curation `hermes model` does — for Nous it honors the free/paid tier so a +// free user gets a free model instead of a paid default. +export function getRecommendedDefaultModel( + provider: string, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/model/recommended-default?provider=${encodeURIComponent(provider)}` + }) +} + +export function setGlobalModel( + provider: string, + model: string +): Promise<{ ok: boolean; provider: string; model: string }> { + return hermesApi<{ ok: boolean; provider: string; model: string }>({ + ...profileScoped(), + path: '/api/model/set', + method: 'POST', + body: { + scope: 'main', + provider, + model + } + }) +} + +export function getAuxiliaryModels(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/model/auxiliary' + }) +} + +export function getMoaModels(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/model/moa' + }) +} + +export function saveMoaModels( + body: MoaConfigResponse, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/model/moa', + method: 'PUT', + body + }) +} + +export function setModelAssignment( + body: ModelAssignmentRequest, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/model/set', + method: 'POST', + body + }) +} diff --git a/apps/desktop/src/api/plugins.ts b/apps/desktop/src/api/plugins.ts new file mode 100644 index 0000000000..ff8e4a6b0c --- /dev/null +++ b/apps/desktop/src/api/plugins.ts @@ -0,0 +1,128 @@ +import type { HermesConnection } from '@/global' +import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff' + +import { getApiRequestConnection, getApiRequestProfile, hermesApi, profileScoped } from './client' + +/** Resolve the ACTIVE backend's connection descriptor, (connectionId, + * profile)-scoped — mirroring how store/profile resolves $connection: a + * registry agent's descriptor comes from getConnectionFor (its SOURCE + * connection), everything else from the profile-keyed local pool. The + * getConnectionFor bridge is optional (older Desktop mains); without it the + * profile-scoped pool lookup is the best available answer. */ +async function activeConnection(): Promise { + const getConnectionFor = window.hermesDesktop.getConnectionFor + const connectionId = getApiRequestConnection() + + if (connectionId && getConnectionFor) { + return getConnectionFor({ connectionId, profile: getApiRequestProfile() }) + } + + return window.hermesDesktop.getConnection(getApiRequestProfile()) +} + +/** Options for a plugin REST call — mirrors the app's own `hermesDesktop.api` + * shape, minus the path (which is namespace-derived). */ +export interface PluginRestOptions { + method?: string + body?: unknown + /** Single-file multipart upload (see HermesApiRequest.upload). */ + upload?: { filename: string; contentType?: string; bytes: ArrayBuffer } + timeoutMs?: number +} + +// Normalize `path` to a leading-slash suffix relative to `/api/plugins/`. +// The namespace is the boundary — reject `..` so a relative segment can't +// normalize out into another plugin's API or a core route. Check the path +// portion only (before any query/hash). +function pluginPathSuffix(caller: string, path: string): string { + const suffix = path.startsWith('/') ? path : `/${path}` + + if (suffix.split(/[?#]/, 1)[0].split('/').includes('..')) { + throw new Error(`${caller}: illegal path traversal in "${path}"`) + } + + return suffix +} + +/** The plugin REST door. Every call is scoped BY CONSTRUCTION to the plugin's + * own backend namespace — `path` is relative to `/api/plugins/` + * ('/board' → `/api/plugins/kanban/board`), so a plugin can't address another + * plugin's API or a core route through it. Profile-aware like every desktop + * REST call. Broader reach (core endpoints, another namespace) is the future + * declared-capability seam; today the namespace IS the boundary. */ +export async function pluginRest(pluginId: string, path: string, opts: PluginRestOptions = {}): Promise { + if (!window.hermesDesktop?.api) { + throw new Error('Hermes desktop bridge unavailable') + } + + const suffix = pluginPathSuffix('pluginRest', path) + + return hermesApi({ + path: `/api/plugins/${pluginId}${suffix}`, + method: opts.method, + body: opts.body, + upload: opts.upload, + timeoutMs: opts.timeoutMs, + ...profileScoped() + }) +} + +/** The plugin WebSocket door — the live twin of `pluginRest`, scoped the same + * way: `path` is relative to `/api/plugins/` ('/events' → the + * plugin's own event stream). Token-mode backends auth via the same query + * credential the app's own sockets use; OAuth remotes resolve null (callers + * keep their polling fallback — every consumer must have one anyway, since a + * socket can drop). Auto-reconnects with backoff until disposed. */ +export function pluginSocket(pluginId: string, path: string, onMessage: (data: unknown) => void): () => void { + const suffix = pluginPathSuffix('pluginSocket', path) + + let socket: null | WebSocket = null + let disposed = false + let attempt = 0 + + const connect = async () => { + const connection = await activeConnection().catch(() => null) + + // No bridge / OAuth cookie auth (WS tickets are single-use, core-managed): + // stay on the polling fallback rather than half-working. + if (disposed || !connection || connection.authMode === 'oauth') { + return + } + + const base = connection.baseUrl.replace(/^http/, 'ws') + const join = suffix.includes('?') ? '&' : '?' + socket = new WebSocket( + `${base}/api/plugins/${pluginId}${suffix}${join}token=${encodeURIComponent(connection.token)}` + ) + + socket.onmessage = event => { + attempt = 0 + + try { + onMessage(JSON.parse(String(event.data))) + } catch { + // Non-JSON frame — plugin streams are JSON by contract; skip it. + } + } + + socket.onclose = () => { + socket = null + + if (!disposed) { + // Full-jitter exponential backoff: same rationale as the gateway + // socket reconnect loops — an immediate-retry loop across many + // desktop clients floods the gateway with connection attempts + // during a restart. + window.setTimeout(() => void connect(), reconnectBackoffDelayMs(attempt, { baseDelayMs: 500, capMs: 30_000 })) + attempt += 1 + } + } + } + + void connect() + + return () => { + disposed = true + socket?.close() + } +} diff --git a/apps/desktop/src/api/profiles.ts b/apps/desktop/src/api/profiles.ts new file mode 100644 index 0000000000..514e367da5 --- /dev/null +++ b/apps/desktop/src/api/profiles.ts @@ -0,0 +1,104 @@ +import type { + ProfileCreatePayload, + ProfileDesktopOverlay, + ProfileSetupCommand, + ProfileSoul, + ProfilesResponse +} from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, STARTUP_REQUEST_TIMEOUT_MS } from './client' + +export function getProfiles(): Promise { + return hermesApi({ + path: '/api/profiles', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function createProfile(body: ProfileCreatePayload): Promise<{ name: string; ok: boolean; path: string }> { + return hermesApi<{ name: string; ok: boolean; path: string }>({ + path: '/api/profiles', + method: 'POST', + body + }) +} + +export function renameProfile(name: string, newName: string): Promise<{ name: string; ok: boolean; path: string }> { + return hermesApi<{ name: string; ok: boolean; path: string }>({ + path: `/api/profiles/${encodeURIComponent(name)}`, + method: 'PATCH', + body: { new_name: newName } + }) +} + +export function deleteProfile(name: string, scope?: ProfileScope): Promise<{ ok: boolean; path: string }> { + const normalized = name.trim() + const scopedProfile = scope && typeof scope === 'object' ? scope.profile?.trim() : undefined + + if (!normalized) { + return Promise.reject(new Error('Profile name required')) + } + + if (normalized.toLowerCase() === 'default' || scopedProfile?.toLowerCase() === 'default') { + return Promise.reject(new Error('The default profile cannot be deleted.')) + } + + return hermesApi<{ ok: boolean; path: string }>({ + ...capabilityScoped(scope), + ...(scope && typeof scope === 'object' && scope.connectionId?.trim() === 'local' + ? { connectionId: 'local' } + : {}), + path: `/api/profiles/${encodeURIComponent(normalized)}`, + method: 'DELETE' + }) +} + +export function getProfileSoul(name: string): Promise { + return hermesApi({ + path: `/api/profiles/${encodeURIComponent(name)}/soul` + }) +} + +export function updateProfileSoul(name: string, content: string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + path: `/api/profiles/${encodeURIComponent(name)}/soul`, + method: 'PUT', + body: { content } + }) +} + +export function getProfileSetupCommand(name: string): Promise { + return hermesApi({ + path: `/api/profiles/${encodeURIComponent(name)}/setup-command` + }) +} + +/** Export a profile to a shareable .tar.gz on the backend's filesystem. + * `extraFiles` stages extra root-level files (desktop.json — the appearance/ + * interface overlay) into the archive alongside the profile's own artifacts. */ +export function exportProfileArchive( + name: string, + opts: { extraFiles?: Record; output?: string } = {} +): Promise<{ archive: string; ok: boolean }> { + return hermesApi<{ archive: string; ok: boolean }>({ + path: `/api/profiles/${encodeURIComponent(name)}/export`, + method: 'POST', + body: { extra_files: opts.extraFiles ?? {}, output: opts.output ?? '' }, + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +/** Import a profile .tar.gz as a new profile. Returns the bundled desktop + * appearance overlay too (when the archive carried one) so the caller can + * apply theme/layout without another round-trip. */ +export function importProfileArchive( + archive: string, + name?: string +): Promise<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }> { + return hermesApi<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }>({ + path: '/api/profiles/import', + method: 'POST', + body: { archive, name: name || null }, + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} diff --git a/apps/desktop/src/api/sessions.ts b/apps/desktop/src/api/sessions.ts new file mode 100644 index 0000000000..9bd5adfba1 --- /dev/null +++ b/apps/desktop/src/api/sessions.ts @@ -0,0 +1,487 @@ +import { isMissingRestEndpoint } from '@/lib/gateway-rpc' +import { recordTranscriptTail } from '@/store/transcript-tail' +import type { + PaginatedSessions, + SessionInfo, + SessionMessage, + SessionMessagesResponse, + SessionSearchResponse +} from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope } from './client' + +const SESSION_LIST_REQUEST_TIMEOUT_MS = 60_000 + +function sessionScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } { + if (scope === undefined || scope === null) { + return {} + } + + const scoped = capabilityScoped(scope) + + if (typeof scope === 'object' && scope.connectionId?.trim() === 'local') { + return { ...scoped, connectionId: 'local' } + } + + return scoped +} + +function sessionScopeQuery(scope?: ProfileScope): string { + const profile = sessionScoped(scope).profile + + return profile ? `?profile=${encodeURIComponent(profile)}` : '' +} + +/** + * Trim a page to its window WITHOUT discarding pinned rows. + * + * The list endpoints deliberately back-fill pinned conversations past their + * LIMIT — a pin means "always reachable", so an aged-out pinned chat is + * appended after the recency window. A plain `slice(0, limit)` throws exactly + * those rows away again, which is why pins silently stopped rendering past + * some count: the sidebar could only ever show the pins that happened to fall + * inside the most-recent page. + */ +function pageWindow(sessions: SessionInfo[], limit: number): SessionInfo[] { + if (sessions.length <= limit) { + return sessions + } + + const recent = sessions.slice(0, limit) + + return [...recent, ...sessions.slice(limit).filter(session => session.pinned)] +} + +export async function listSessions( + limit = 40, + minMessages = 0, + archived: 'exclude' | 'include' | 'only' = 'exclude', + order: 'created' | 'recent' = 'recent' +): Promise { + const result = await hermesApi({ + path: + `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` + + `&archived=${archived}&order=${order}`, + timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS + }) + + return { + ...result, + sessions: pageWindow(result.sessions, limit), + offset: 0 + } +} + +// 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". +// Source scoping lets callers split the unified list into independent slices: +// recents pass `excludeSources: ['cron']`, the cron-jobs section passes +// `source: 'cron'`. Without this a burst of (always-newest) cron sessions +// consumes the whole recents page and starves real conversations. +export interface SessionSourceFilter { + source?: string + excludeSources?: string[] +} + +export async function listAllProfileSessions( + limit = 40, + minMessages = 0, + archived: 'exclude' | 'include' | 'only' = 'exclude', + order: 'created' | 'recent' = 'recent', + profile: 'all' | (string & {}) = 'all', + filter: SessionSourceFilter = {} +): Promise { + const sourceParam = filter.source ? `&source=${encodeURIComponent(filter.source)}` : '' + + const excludeParam = filter.excludeSources?.length + ? `&exclude_sources=${encodeURIComponent(filter.excludeSources.join(','))}` + : '' + + const result = await hermesApi({ + path: + `/api/profiles/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` + + `&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}${sourceParam}${excludeParam}`, + timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS + }) + + return { + ...result, + sessions: pageWindow(result.sessions, limit), + offset: 0 + } +} + +// Batched sidebar slices in one request: recents (scoped to the active profile), +// cron, and messaging. The backend opens each profile's state.db once and runs +// all three filtered queries, replacing three separate listAllProfileSessions +// calls that each reopened + re-counted every profile DB per refresh. Electron +// splices remote profiles per slice (see interceptSessionRequestForRemote). +export interface SidebarSessionSlice { + sessions: SessionInfo[] + /** Per-profile "the window came back full, more rows exist on disk" flags — + * what pagination needs, without a COUNT(*) per profile DB per refresh. */ + profiles_truncated?: Record + /** Per-profile tokens and spend over every session, not just this window. + * Absent from the legacy per-slice endpoint, which has no aggregate. */ + profiles_usage?: Record +} + +/** Which profiles filled their per-profile window in a returned page. The + * legacy per-slice endpoint doesn't report this, so derive it from the rows: + * a profile at (or over) the cap still has more on disk. Pinned rows are + * discounted — they're back-filled past the LIMIT, so counting them fakes a + * full page and leaves a "Load more" that can never resolve. */ +function profilesTruncatedFrom(sessions: SessionInfo[], cap: number): Record { + const counts = new Map() + + for (const session of sessions) { + const key = session.profile || 'default' + + counts.set(key, (counts.get(key) ?? 0) + (session.pinned ? 0 : 1)) + } + + return Object.fromEntries([...counts].map(([name, count]) => [name, count >= cap])) +} + +export interface SidebarSessionsResponse { + recents: SidebarSessionSlice + cron: SidebarSessionSlice + messaging: SidebarSessionSlice + errors?: Array<{ profile: string; error: string }> +} + +export interface SidebarSessionsRequest { + recentsProfile: 'all' | (string & {}) + recentsLimit: number + recentsExclude: string[] + cronLimit: number + messagingLimit: number + messagingExclude: string[] +} + +// The batched /sidebar endpoint shipped later than the per-slice route, so a +// newer desktop can meet an older backend that 404s it ("No such API +// endpoint"). Endpoint-missing is a capability signal, not a transient +// failure: remember it (per renderer lifetime — a runtime home change reloads +// the window and re-probes) and serve every subsequent refresh straight from +// the three proven per-slice calls instead of re-probing a known-dead route +// once per turn/broadcast. +let sidebarBatchEndpointMissing = false + +// Capability flags are per-backend facts. A hard re-home reloads the window +// (module state resets naturally), but a soft gateway switch re-dials in +// place — the next backend may well have the batched route, so the switch +// paths call this to re-probe rather than leak the old backend's capability. +export function resetSidebarBatchCapability() { + sidebarBatchEndpointMissing = false +} + +// Compatibility fallback: reassemble the three sidebar slices from the +// per-slice endpoint, mirroring the batched route's semantics (min_messages=1, +// archived excluded, recency order; every slice scoped to the caller's profile). +// Rides the same Electron remote-splice +// interception as the pre-batching desktop, so remote profiles stay correct. +async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise { + const [recents, cron, messaging] = await Promise.all([ + listAllProfileSessions(req.recentsLimit, 1, 'exclude', 'recent', req.recentsProfile, { + excludeSources: req.recentsExclude + }), + listAllProfileSessions(req.cronLimit, 1, 'exclude', 'recent', req.recentsProfile, { source: 'cron' }), + listAllProfileSessions(req.messagingLimit, 1, 'exclude', 'recent', req.recentsProfile, { + excludeSources: req.messagingExclude + }) + ]) + + const errors = [...(recents.errors ?? []), ...(cron.errors ?? []), ...(messaging.errors ?? [])] + + return { + recents: { + profiles_truncated: profilesTruncatedFrom(recents.sessions, req.recentsLimit), + sessions: recents.sessions + }, + cron: { sessions: cron.sessions }, + messaging: { sessions: messaging.sessions }, + ...(errors.length ? { errors } : {}) + } +} + +/** The PR each of these sessions opened, recovered from its own transcript — + * for sessions whose recorded branch can't answer (they started on trunk and + * did the work in a worktree). Also returns every id it looked at, so the + * caller can remember a miss and never ask again. */ +export function scanSessionPullRequests( + ids: string[] +): Promise<{ pull_requests: Record; scanned: string[] }> { + return hermesApi<{ + pull_requests: Record + scanned: string[] + }>({ + path: '/api/profiles/sessions/pull-requests', + method: 'POST', + body: { ids } + }) +} + +export async function listSidebarSessions(req: SidebarSessionsRequest): Promise { + if (sidebarBatchEndpointMissing) { + return listSidebarSessionsLegacy(req) + } + + const params = new URLSearchParams({ + recents_profile: req.recentsProfile, + recents_limit: String(Math.max(1, req.recentsLimit)), + cron_limit: String(Math.max(1, req.cronLimit)), + messaging_limit: String(Math.max(1, req.messagingLimit)) + }) + + if (req.recentsExclude.length) { + params.set('recents_exclude', req.recentsExclude.join(',')) + } + + if (req.messagingExclude.length) { + params.set('messaging_exclude', req.messagingExclude.join(',')) + } + + let result: SidebarSessionsResponse + + try { + result = await hermesApi({ + path: `/api/profiles/sessions/sidebar?${params.toString()}`, + timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS + }) + } catch (err) { + // Safe to read a 404 as route-missing here: this GET has no path params, + // so it cannot 404 on a bad id. + if (!isMissingRestEndpoint(err)) { + throw err + } + + // Older backend without the batched route (desktop/runtime version skew). + sidebarBatchEndpointMissing = true + + return listSidebarSessionsLegacy(req) + } + + return { + recents: { ...result.recents, sessions: result.recents?.sessions ?? [] }, + cron: { ...result.cron, sessions: result.cron?.sessions ?? [] }, + messaging: { ...result.messaging, sessions: result.messaging?.sessions ?? [] }, + errors: result.errors + } +} + +// Mutations take the owning `profile` so Electron can route them to the correct +// remote backend or local profile scope. Omit for the current/default profile. +export function setSessionArchived(id: string, archived: boolean, profile?: string | null): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...(profile ? { profile } : {}), + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'PATCH', + body: { archived } + }) +} + +// Mirror a sidebar pin to the backend "keep" flag so the sessions.auto_archive +// sweep (which runs backend-side, blind to Desktop localStorage) never hides a +// pinned chat. Best-effort: the sidebar stays localStorage-driven for its own +// display; this only feeds the backend policy. +export function setSessionPinnedRemote(id: string, pinned: boolean, profile?: string | null): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...(profile ? { profile } : {}), + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'PATCH', + body: { pinned } + }) +} + +// Mirror a sidebar unread toggle to the backend read-state watermark +// (sessions.last_read_at via SessionDB.set_session_read). Same profile +// routing as the other session mutations: a remote session's row lives only +// on its remote host, so the owning profile must travel with the request. +export function setSessionUnreadRemote(id: string, unread: boolean, profile?: string | null): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...(profile ? { profile } : {}), + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'PATCH', + body: { unread } + }) +} + +export function searchSessions(query: string): Promise { + return hermesApi({ + path: `/api/sessions/search?q=${encodeURIComponent(query)}` + }) +} + +// Resolves a single session row by id on one backend (the active profile, or +// the given `profile`). The backend resolves exact ids and unique prefixes and +// 404s when the id isn't on that profile — so a cheap by-id lookup replaces the +// cross-profile list scan when locating an unknown id's owner. +export function getSession(id: string, profile?: ProfileScope): Promise { + const suffix = sessionScopeQuery(profile) + + return hermesApi({ + ...sessionScoped(profile), + path: `/api/sessions/${encodeURIComponent(id)}${suffix}` + }) +} + +// Reads another profile's transcript. For a remote profile Electron reroutes +// this GET to the remote backend (which serves its own state.db); for a local +// profile the primary opens that profile's state.db via ?profile=. Omit for +// the current/default profile. +export function getSessionMessages( + id: string, + profile?: ProfileScope, + page: { limit?: number; offset?: number; order?: 'latest' | 'oldest'; includeCompacted?: boolean } = {} +): Promise { + const query = new URLSearchParams() + + const sessionScope = sessionScoped(profile) + + if (sessionScope.profile) { + query.set('profile', sessionScope.profile) + } + + if (page.limit !== undefined) { + query.set('limit', String(page.limit)) + } + + if (page.offset !== undefined) { + query.set('offset', String(page.offset)) + } + + if (page.order) { + query.set('order', page.order) + } + + if (page.includeCompacted !== undefined) { + query.set('include_compacted', String(page.includeCompacted)) + } + + const suffix = query.size ? `?${query.toString()}` : '' + + return hermesApi({ + ...sessionScope, + path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}` + }) +} + +/** + * The initial hydration page: enough tail to fill the transcript window a few + * times over, small enough that opening a long session doesn't ship (and + * convert) hundreds of rows nobody has scrolled to. Older rows load on demand + * via `getOlderSessionMessages` when "Show earlier" exhausts the in-memory + * store (see app/chat/transcript-backfill). + */ +export const LATEST_SESSION_MESSAGES_LIMIT = 120 + +export function getLatestSessionMessages(id: string, profile?: ProfileScope): Promise { + // includeCompacted: durable display history must include rows preserved by + // in-place compaction (active=0, compacted=1); without them the transcript + // silently ends at the compaction boundary and earlier turns are unreachable. + return getSessionMessages(id, profile, { + limit: LATEST_SESSION_MESSAGES_LIMIT, + order: 'latest', + includeCompacted: true + }).then(page => { + // Record whether the tail was truncated (page came back full) and where + // the next older page starts, so "Show earlier" can backfill over REST + // (app/chat/transcript-backfill). Keyed under both the requested id and + // the resolved id — callers hold either. + recordTranscriptTail(id, page, profile) + + if (page.session_id && page.session_id !== id) { + recordTranscriptTail(page.session_id, page, profile) + } + + return page + }) +} + +/** + * One page of messages OLDER than the `offset` newest rows. + * + * Backend semantics (`_handle_session_messages` → `SessionDB.get_messages` + * with `latest=True`): the offset is measured back from the NEWEST message + * and the selected page is returned in chronological order. So after a tail + * hydration of N rows, `getOlderSessionMessages(id, profile, N)` returns the + * page immediately preceding it, ready to prepend. + * + * Legacy backends without pagination support return the full transcript and + * no `pagination` metadata — callers detect that via the missing field and + * treat the response as the complete history (see transcript-backfill). + */ +export function getOlderSessionMessages( + id: string, + profile: ProfileScope, + offset: number, + limit: number = LATEST_SESSION_MESSAGES_LIMIT +): Promise { + return getSessionMessages(id, profile, { includeCompacted: true, limit, offset, order: 'latest' }) +} + +export async function getAllSessionMessages( + id: string, + profile?: ProfileScope, + options: { maxJsonChars?: number } = {} +): Promise { + const messages: SessionMessage[] = [] + const pageSize = 500 + const maxJsonChars = options.maxJsonChars ?? 32_000_000 + let jsonChars = 0 + let offset = 0 + let resolvedSessionId = id + + while (true) { + const page = await getSessionMessages(id, profile, { + limit: pageSize, + offset, + order: 'oldest', + includeCompacted: true + }) + + resolvedSessionId = page.session_id + jsonChars += (JSON.stringify(page.messages) ?? '').length + + if (jsonChars > maxJsonChars) { + throw new Error( + 'Session transcript exceeds the Desktop safe-load limit; use the Web Dashboard export for this session.' + ) + } + + messages.push(...page.messages) + + // Legacy backends ignore pagination and return the full transcript. + if (!page.pagination || page.messages.length === 0 || page.messages.length < page.pagination.limit) { + break + } + + offset += page.messages.length + } + + return { session_id: resolvedSessionId, messages } +} + +export function deleteSession(id: string, profile?: ProfileScope): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...sessionScoped(profile), + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'DELETE' + }) +} + +export function renameSession( + id: string, + title: string, + profile?: string | null +): Promise<{ ok: boolean; title: string }> { + return hermesApi<{ ok: boolean; title: string }>({ + ...(profile ? { profile } : {}), + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'PATCH', + body: { title, ...(profile ? { profile } : {}) } + }) +} diff --git a/apps/desktop/src/api/skills.ts b/apps/desktop/src/api/skills.ts new file mode 100644 index 0000000000..cf29d2daf2 --- /dev/null +++ b/apps/desktop/src/api/skills.ts @@ -0,0 +1,162 @@ +import type { + SkillHubPreview, + SkillHubScanResult, + SkillHubSearchResponse, + SkillHubSourcesResponse, + SkillInfo, + StarmapGraph +} from '@/types/hermes' +import type { ActionResponse } from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client' + +export function getSkills(profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/skills' + }) +} + +/** Raw SKILL.md text (frontmatter included) for ANY skill — bundled, hub, or + * learned — backing the Capabilities detail pane's full-skill view. */ +export function getSkillContent( + name: string, + profile?: ProfileScope +): Promise<{ content: string; name: string; path: string }> { + return window.hermesDesktop.api<{ content: string; name: string; path: string }>({ + ...capabilityScoped(profile), + path: `/api/skills/content?name=${encodeURIComponent(name)}` + }) +} + +export function setSkillEnabled( + name: string, + enabled: boolean, + profile?: ProfileScope +): Promise<{ ok: boolean; name: string; enabled: boolean }> { + return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({ + ...capabilityScoped(profile), + path: '/api/skills/toggle', + method: 'PUT', + body: { name, enabled } + }) +} + +export function getStarmapGraph(): Promise { + return hermesApi({ + ...profileScoped(), + // Backend REST contract — stays /api/learning even though the UI feature is + // now "star map". Renaming this would break against an un-upgraded backend. + path: '/api/learning/graph' + }) +} + +export interface LearningNodeDetail { + content: string + kind: 'memory' | 'skill' + label: string + ok: boolean +} + +export function getLearningNode(id: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/learning/node?id=${encodeURIComponent(id)}` + }) +} + +export function deleteLearningNode(id: string, profile?: ProfileScope): Promise<{ message: string; ok: boolean }> { + return window.hermesDesktop.api<{ message: string; ok: boolean }>({ + ...capabilityScoped(profile), + path: '/api/learning/node', + method: 'DELETE', + body: { id } + }) +} + +export function editLearningNode( + id: string, + content: string, + profile?: ProfileScope +): Promise<{ message: string; ok: boolean }> { + return window.hermesDesktop.api<{ message: string; ok: boolean }>({ + ...capabilityScoped(profile), + path: '/api/learning/node', + method: 'PUT', + body: { content, id } + }) +} + +// --------------------------------------------------------------------------- +// Skills hub — search / preview / scan / install (parity with `hermes skills` +// and the dashboard's Browse-hub tab). Installs spawn background actions whose +// logs are tailed via getActionStatus(). +// --------------------------------------------------------------------------- + +const HUB_REQUEST_TIMEOUT_MS = 45_000 + +export function getSkillHubSources(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/skills/hub/sources', + timeoutMs: HUB_REQUEST_TIMEOUT_MS + }) +} + +export function searchSkillsHub( + query: string, + source = 'all', + limit = 20, + profile?: null | string +): Promise { + const params = new URLSearchParams({ q: query, source, limit: String(limit) }) + + return hermesApi({ + ...profileScoped(profile), + path: `/api/skills/hub/search?${params.toString()}`, + timeoutMs: HUB_REQUEST_TIMEOUT_MS + }) +} + +export function previewSkillHub(identifier: string, profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`, + timeoutMs: HUB_REQUEST_TIMEOUT_MS + }) +} + +export function scanSkillHub(identifier: string, profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/skills/hub/scan?identifier=${encodeURIComponent(identifier)}`, + timeoutMs: HUB_REQUEST_TIMEOUT_MS + }) +} + +export function installSkillFromHub(identifier: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/skills/hub/install', + method: 'POST', + body: { identifier } + }) +} + +export function uninstallSkillFromHub(name: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/skills/hub/uninstall', + method: 'POST', + body: { name } + }) +} + +export function updateSkillsFromHub(profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/skills/hub/update', + method: 'POST', + body: {} + }) +} diff --git a/apps/desktop/src/api/system.ts b/apps/desktop/src/api/system.ts new file mode 100644 index 0000000000..678d09e549 --- /dev/null +++ b/apps/desktop/src/api/system.ts @@ -0,0 +1,247 @@ +import type { + ActionResponse, + ActionStatusResponse, + AudioSpeakResponse, + AudioTranscriptionResponse, + BackendUpdateCheckResponse, + CuratorStatusResponse, + DebugShareResponse, + ElevenLabsVoicesResponse, + MemoryProviderConfig, + MemoryProviderOAuthStatus, + MemoryStatusResponse +} from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client' + +export const AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS = 180_000 +export const AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS = 600_000 +const AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR = 35 + +export function audioSpeakRequestTimeoutMs(text: string): number { + const estimated = Math.max( + AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS, + Math.ceil(String(text || '').length * AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR) + ) + + return Math.min(AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS, estimated) +} + +export const AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS = 180_000 +export const AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS = 600_000 +// The transcribe payload is the base64 audio data URL itself, so its string +// length tracks clip size. ~0.1ms/char keeps short clips at the floor while +// letting multi-minute recordings scale toward the cap (a base64 char is +// ~0.75 bytes, so at 128kbps ≈ 21k chars/s of audio this budgets ~2s of +// timeout per 1s of audio before the cap clamps it). +const AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR = 0.1 + +export function audioTranscribeRequestTimeoutMs(dataUrl: string): number { + const estimated = Math.max( + AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS, + Math.ceil(String(dataUrl || '').length * AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR) + ) + + return Math.min(AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS, estimated) +} + +// surface=declared serves the curated desktop schema; the dashboard consumes the raw plugin schema. +export function getMemoryProviderConfig(provider: string, profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared` + }) +} + +export function saveMemoryProviderConfig( + provider: string, + values: Record, + profile?: null | string +): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(profile), + path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`, + method: 'PUT', + body: { values } + }) +} + +// Memory-provider OAuth connect (provider-keyed; 404s for providers without an +// OAuth flow). Profile-scoped: the grant lands in the active profile's config. +export function startMemoryProviderOAuth( + provider: string, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/start`, + method: 'POST' + }) +} + +export function getMemoryProviderOAuthStatus( + provider: string, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/status` + }) +} + +// --------------------------------------------------------------------------- +// Memory data + curator (parity with `hermes memory` / `hermes curator`). +// --------------------------------------------------------------------------- + +export function getMemoryStatus(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/memory' + }) +} + +export function resetMemory(target: 'all' | 'memory' | 'user'): Promise<{ ok: boolean; deleted: string[] }> { + return hermesApi<{ ok: boolean; deleted: string[] }>({ + ...profileScoped(), + path: '/api/memory/reset', + method: 'POST', + body: { target } + }) +} + +export function getCuratorStatus(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/curator' + }) +} + +export function setCuratorPaused(paused: boolean): Promise<{ ok: boolean; paused: boolean }> { + return hermesApi<{ ok: boolean; paused: boolean }>({ + ...profileScoped(), + path: '/api/curator/paused', + method: 'PUT', + body: { paused } + }) +} + +export function runCurator(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/curator/run', + method: 'POST', + body: {} + }) +} + +export function restartGateway(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/gateway/restart', + method: 'POST' + }) +} + +export function updateHermes(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/hermes/update', + method: 'POST' + }) +} + +/** Query the connected backend's own update state. In remote mode this is the + * authoritative source for the backend's behind-count + "what's changed", + * distinct from the Electron client clone's git state. */ +export function checkHermesUpdate(force = false): Promise { + return hermesApi({ + ...profileScoped(), + path: `/api/hermes/update/check${force ? '?force=true' : ''}` + }) +} + +export function getActionStatus(name: string, lines = 200, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}` + }) +} + +export function transcribeAudio(dataUrl: string, mimeType?: string): Promise { + return hermesApi({ + path: '/api/audio/transcribe', + method: 'POST', + ...profileScoped(), + body: { + data_url: dataUrl, + mime_type: mimeType + }, + // Transcription blocks until provider STT, file handling, and response + // encoding finish. Remote providers and long clips regularly exceed the + // default 15s Electron backend timeout. + timeoutMs: audioTranscribeRequestTimeoutMs(dataUrl) + }) +} + +export function speakText(text: string): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/audio/speak', + method: 'POST', + body: { text }, + // TTS blocks until provider synthesis, file read, and base64 encoding + // finish. Remote providers and large messages regularly exceed the + // default 15s Electron backend timeout. + timeoutMs: audioSpeakRequestTimeoutMs(text) + }) +} + +export function getElevenLabsVoices(profile?: null | string): Promise { + return hermesApi({ + path: '/api/audio/elevenlabs/voices', + ...profileScoped(profile) + }) +} + +/** `gh` CLI presence + auth state, for the composer's GitHub skill pill + * (GitHub is deliberately not an MCP — the github/* skills are the + * integration). Backend caches for 5 minutes; `refresh` bypasses. */ +export function getGhAuthStatus(refresh = false): Promise<{ available: boolean; authenticated: boolean }> { + return hermesApi<{ available: boolean; authenticated: boolean }>({ + ...profileScoped(), + path: `/api/git/gh-auth${refresh ? '?refresh=true' : ''}` + }) +} + +// --------------------------------------------------------------------------- +// Maintenance operations (parity with `hermes doctor` / `hermes security +// audit` / `hermes backup` / `hermes debug share` and the dashboard System +// page). All except debug share are spawn-based background actions tailed via +// getActionStatus(). +// --------------------------------------------------------------------------- + +export function runDoctor(): Promise { + return hermesApi({ path: '/api/ops/doctor', method: 'POST', body: {} }) +} + +export function runSecurityAudit(): Promise { + return hermesApi({ path: '/api/ops/security-audit', method: 'POST', body: {} }) +} + +export function runBackup(): Promise { + return hermesApi({ + path: '/api/ops/backup', + method: 'POST', + body: {} + }) +} + +export function runDebugShare(): Promise { + return hermesApi({ + path: '/api/ops/debug-share', + method: 'POST', + body: {}, + // Synchronous upload of report + logs to the paste service. + timeoutMs: 120_000 + }) +} diff --git a/apps/desktop/src/api/toolsets.ts b/apps/desktop/src/api/toolsets.ts new file mode 100644 index 0000000000..862c0fc521 --- /dev/null +++ b/apps/desktop/src/api/toolsets.ts @@ -0,0 +1,141 @@ +import type { + ActionResponse, + ComputerUseStatus, + TerminalBackendsResponse, + ToolsetConfig, + ToolsetInfo, + ToolsetModelsResponse +} from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client' + +// The optional trailing `profile` on every capability fetcher below is the +// Capabilities view's profile-scope override: it lets the Skills/Tools/MCP +// panels configure ANY profile without swapping the app-wide active profile. +// Omitting it (every pre-existing caller) means `profileScoped(undefined)` +// falls back to the app-wide `_apiProfile`, so behavior is byte-identical. +export function getToolsets(profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/tools/toolsets' + }) +} + +export function setToolsetEnabled( + name: string, + enabled: boolean, + profile?: ProfileScope +): Promise<{ ok: boolean; name: string; enabled: boolean }> { + return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}`, + method: 'PUT', + body: { enabled } + }) +} + +export function getToolsetConfig(name: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}/config` + }) +} + +export function getToolsetModels( + name: string, + provider?: string, + profile?: ProfileScope +): Promise { + const suffix = provider ? `?provider=${encodeURIComponent(provider)}` : '' + + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}/models${suffix}` + }) +} + +export function selectToolsetModel( + name: string, + model: string, + provider?: string, + profile?: ProfileScope +): Promise<{ ok: boolean; name: string; model: string }> { + return window.hermesDesktop.api<{ ok: boolean; name: string; model: string }>({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}/model`, + method: 'PUT', + body: { model, provider } + }) +} + +export interface SelectToolsetProviderResponse { + ok: boolean + name: string + provider: string + /** Present when the selection was scoped to one web capability. */ + capability?: string + /** Present (true) when a managed Nous row was selected but the Portal + * entitlement is missing — the row won't activate until the user signs + * in to Nous Portal. */ + needs_nous_auth?: boolean + /** The managed feature key (e.g. "browser") when needs_nous_auth is set. */ + feature?: string +} + +export function selectToolsetProvider( + name: string, + provider: string, + capability?: 'search' | 'extract', + profile?: ProfileScope +): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`, + method: 'PUT', + body: capability ? { provider, capability } : { provider } + }) +} + +export function runToolsetPostSetup( + name: string, + key: string, + profile?: ProfileScope +): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`, + method: 'POST', + body: { key } + }) +} + +export function getTerminalBackends(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/tools/terminal/backends' + }) +} + +export function selectTerminalBackend(backend: string): Promise<{ ok: boolean; backend: string }> { + return hermesApi<{ ok: boolean; backend: string }>({ + ...profileScoped(), + path: '/api/tools/terminal/backend', + method: 'PUT', + body: { backend } + }) +} + +export function getComputerUseStatus(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/tools/computer-use/status' + }) +} + +export function grantComputerUsePermissions(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/tools/computer-use/permissions/grant', + method: 'POST' + }) +} diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index 4ceefb5d3a..023afe92dd 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -67,6 +67,8 @@ const cssEscape = (value: string): string => { } interface SubmitDetail { + /** Unique mounted composer surface captured at click time. */ + surfaceId: string target: ComposerTarget text: string /** `hidden` types the persisted user row so no bubble renders — the @@ -155,6 +157,38 @@ const dispatch = (name: string, detail: T) => { window.setTimeout(() => window.dispatchEvent(new CustomEvent(name, { detail })), 0) } +/** Submit is the one bus mutation that must preserve the chat visible at click + * time. Deferring it lets a parent click handler/tab reveal switch the active + * keep-alive pane before subscribers run, so the task is dropped or claimed by + * another composer. Other bus events intentionally defer for focus restoration. + */ +const dispatchNow = (name: string, detail: T) => { + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(name, { detail })) + } +} + +/** Unique identity for the visible composer surface addressed by a submit. */ +const getVisibleComposerSurfaceId = (target: ComposerTarget): string | null => { + if (typeof document === 'undefined') { + return null + } + + const surface = queryVisible(`[data-composer-target="${cssEscape(target)}"]`) + + return surface?.dataset.composerSurfaceId || null +} + +const composerSurfaceIsVisible = (target: ComposerTarget, surfaceId: string): boolean => { + if (typeof document === 'undefined') { + return false + } + + return queryAllVisible(`[data-composer-target="${cssEscape(target)}"]`).some( + surface => surface.dataset.composerSurfaceId === surfaceId + ) +} + const subscribe = (name: string, handler: (detail: T) => void) => { if (typeof window === 'undefined') { return () => undefined @@ -264,17 +298,36 @@ export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) * the agent a task without the user round-tripping through the input. */ export const requestComposerSubmit = ( text: string, - { target = 'active', displayKind }: { target?: ComposerTarget | 'active'; displayKind?: 'hidden' } = {} -) => { + { + displayKind, + surfaceId: requestedSurfaceId, + target = 'active' + }: { displayKind?: 'hidden'; surfaceId?: null | string; target?: ComposerTarget | 'active' } = {} +): boolean => { const trimmed = text.trim() - if (trimmed) { - dispatch(SUBMIT_EVENT, { - target: resolve(target), - text: trimmed, - ...(displayKind ? { displayKind } : {}) - }) + if (!trimmed) { + return false } + + const resolvedTarget = resolve(target) + + const surfaceId = requestedSurfaceId === undefined ? getVisibleComposerSurfaceId(resolvedTarget) : requestedSurfaceId + + // Fail closed: without an exact visible surface identity, broadcasting a + // submit could make more than one keep-alive/new-chat composer claim it. + if (!surfaceId || (requestedSurfaceId !== undefined && !composerSurfaceIsVisible(resolvedTarget, surfaceId))) { + return false + } + + dispatchNow(SUBMIT_EVENT, { + surfaceId, + target: resolvedTarget, + text: trimmed, + ...(displayKind ? { displayKind } : {}) + }) + + return true } export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) => diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx index e2eb17419d..b05a47a581 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx @@ -1,6 +1,8 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { type Dispatch, type PropsWithChildren, type SetStateAction, useLayoutEffect, useState } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { PaneVisibleContext } from '@/components/pane-shell/pane-visibility' import { $clarifyRequests } from '@/store/clarify' import type { ComposerAttachment } from '@/store/composer' import { $gateway } from '@/store/gateway' @@ -12,23 +14,41 @@ import { setSudoRequest } from '@/store/prompts' +import { type ComposerTarget, requestComposerSubmit } from '../focus' +import { ComposerScopeProvider, ComposerSurfaceProvider, MAIN_COMPOSER_SCOPE } from '../scope' + import { useComposerSubmit } from './use-composer-submit' interface SubmitHarnessOptions { attachments?: ComposerAttachment[] busy?: boolean compacting?: boolean + inputDisabled?: boolean + scopeTarget?: ComposerTarget + sessionKey?: string | null + submitOnHide?: boolean + surfaceId?: string | null text?: string + visible?: boolean } +let surfaceSequence = 0 + function renderSubmitHook({ attachments = [], busy = false, compacting = false, - text = '' + inputDisabled = false, + scopeTarget = 'main', + sessionKey = 'stored-session', + submitOnHide = false, + surfaceId, + text = '', + visible = true }: SubmitHarnessOptions = {}) { + const resolvedSurfaceId = surfaceId === undefined ? `test-surface-${++surfaceSequence}` : surfaceId const draftRef = { current: text } - const editor = document.createElement('div') + const editor = window.document.createElement('div') editor.dataset.slot = 'composer-rich-input' editor.textContent = text const editorRef = { current: editor } @@ -36,43 +56,214 @@ function renderSubmitHook({ const onSteer = vi.fn(async () => true) const onSubmit = vi.fn(async () => true) const queueCurrentDraft = vi.fn(() => true) + let updatePaneVisible: Dispatch> | undefined const clearDraft = vi.fn(() => { draftRef.current = '' editorRef.current!.textContent = '' }) - const hook = renderHook(() => - useComposerSubmit({ - activeQueueSessionKey: 'stored-session', - activeQueueSessionKeyRef: { current: 'stored-session' }, - attachments, - busy, - compacting, - clearDraft, - disabled: false, - draftRef, - drainNextQueued: vi.fn(async () => false), - editorRef, - exitQueuedEdit: vi.fn(() => false), - focusInput: vi.fn(), - inputDisabled: false, - loadIntoComposer: vi.fn(), - onCancel, - onSteer, - onSubmit, - queueCurrentDraft, - queueEdit: null, - queuedPrompts: [], - sessionId: 'runtime-session', - setComposerText: vi.fn(), - stashAt: vi.fn() - }) + const Wrapper = ({ children }: PropsWithChildren) => { + const [paneVisible, setPaneVisible] = useState(visible) + updatePaneVisible = setPaneVisible + + useLayoutEffect(() => { + if (submitOnHide && !paneVisible) { + requestComposerSubmit('ship while hiding', { target: scopeTarget }) + } + }, [paneVisible]) + + return ( + + + +
+ {children} +
+
+
+
+ ) + } + + const hook = renderHook( + () => + useComposerSubmit({ + activeQueueSessionKey: sessionKey, + activeQueueSessionKeyRef: { current: sessionKey }, + attachments, + busy, + compacting, + clearDraft, + disabled: false, + draftRef, + drainNextQueued: vi.fn(async () => false), + editorRef, + exitQueuedEdit: vi.fn(() => false), + focusInput: vi.fn(), + inputDisabled, + loadIntoComposer: vi.fn(), + onCancel, + onSteer, + onSubmit, + queueCurrentDraft, + queueEdit: null, + queuedPrompts: [], + sessionId: 'runtime-session', + setComposerText: vi.fn(), + stashAt: vi.fn() + }), + { wrapper: Wrapper } ) - return { clearDraft, hook, onCancel, onSteer, onSubmit, queueCurrentDraft } + return { + clearDraft, + hook, + onCancel, + onSteer, + onSubmit, + queueCurrentDraft, + composerSurfaceId: resolvedSurfaceId, + setPaneVisible(nextVisible: boolean) { + if (!updatePaneVisible) { + throw new Error('Pane visibility setter was not initialized') + } + + updatePaneVisible(nextVisible) + } + } } +describe('useComposerSubmit external request routing', () => { + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('does not fan out a main ship across keep-alives or other projects', async () => { + const visibleMain = renderSubmitHook({ sessionKey: 'session-a' }) + const hiddenMain = renderSubmitHook({ sessionKey: 'session-b', visible: false }) + const visibleTile = renderSubmitHook({ scopeTarget: 'tile:project-b', sessionKey: 'tile-session' }) + + const hiddenTile = renderSubmitHook({ + scopeTarget: 'tile:project-c', + sessionKey: 'other-tile', + visible: false + }) + + expect(requestComposerSubmit('ship this branch', { target: 'main' })).toBe(true) + + await waitFor(() => + expect(visibleMain.onSubmit).toHaveBeenCalledWith('ship this branch', { + composerScope: 'session-a' + }) + ) + expect(visibleMain.onSubmit).toHaveBeenCalledTimes(1) + expect(hiddenMain.onSubmit).not.toHaveBeenCalled() + expect(visibleTile.onSubmit).not.toHaveBeenCalled() + expect(hiddenTile.onSubmit).not.toHaveBeenCalled() + }) + + it('routes a tile-targeted submit to that tile only', async () => { + const main = renderSubmitHook({ sessionKey: 'main-session' }) + const tile = renderSubmitHook({ scopeTarget: 'tile:project-b', sessionKey: 'tile-session' }) + + expect(requestComposerSubmit('ship project B', { target: 'tile:project-b' })).toBe(true) + + await waitFor(() => + expect(tile.onSubmit).toHaveBeenCalledWith('ship project B', { + composerScope: 'tile-session' + }) + ) + expect(main.onSubmit).not.toHaveBeenCalled() + }) + + it('uses the captured surface id when two visible composers share a target', async () => { + const first = renderSubmitHook({ sessionKey: 'session-first' }) + const second = renderSubmitHook({ sessionKey: 'session-second' }) + + requestComposerSubmit('ship exactly one session', { surfaceId: second.composerSurfaceId, target: 'main' }) + + await waitFor(() => + expect(second.onSubmit).toHaveBeenCalledWith('ship exactly one session', { + composerScope: 'session-second' + }) + ) + expect(first.onSubmit).not.toHaveBeenCalled() + }) + + it('submits to the session visible at click time even when the same click switches tabs', async () => { + const hiddenA = renderSubmitHook({ sessionKey: 'session-a', visible: false }) + const visibleB = renderSubmitHook({ sessionKey: 'session-b' }) + + act(() => { + requestComposerSubmit('ship session B', { target: 'main' }) + visibleB.setPaneVisible(false) + hiddenA.setPaneVisible(true) + }) + + await waitFor(() => + expect(visibleB.onSubmit).toHaveBeenCalledWith('ship session B', { + composerScope: 'session-b' + }) + ) + expect(hiddenA.onSubmit).not.toHaveBeenCalled() + }) + + it('does not fan out when visible composers do not have queue session keys yet', async () => { + const firstNewSession = renderSubmitHook({ sessionKey: null }) + const secondNewSession = renderSubmitHook({ sessionKey: null }) + + act(() => { + requestComposerSubmit('ship the visible new session', { target: 'main' }) + }) + + await waitFor(() => expect(firstNewSession.onSubmit).toHaveBeenCalledTimes(1)) + expect(secondNewSession.onSubmit).not.toHaveBeenCalled() + }) + + it('fails closed when the visible composer has no surface identity', () => { + const unidentified = renderSubmitHook({ surfaceId: null }) + + expect(requestComposerSubmit('do not broadcast this', { target: 'main' })).toBe(false) + expect(unidentified.onSubmit).not.toHaveBeenCalled() + }) + + it('fails closed when a pinned origin surface is no longer visible', () => { + const hidden = renderSubmitHook({ sessionKey: 'hidden-origin', visible: false }) + const visible = renderSubmitHook({ sessionKey: 'other-visible' }) + + expect( + requestComposerSubmit('do not send to a stale origin', { + surfaceId: hidden.composerSurfaceId, + target: 'main' + }) + ).toBe(false) + expect(hidden.onSubmit).not.toHaveBeenCalled() + expect(visible.onSubmit).not.toHaveBeenCalled() + }) + + it('does not submit through a composer whose pane is hidden during the request', () => { + const main = renderSubmitHook({ submitOnHide: true }) + + act(() => main.setPaneVisible(false)) + + expect(main.onSubmit).not.toHaveBeenCalled() + }) + + it('does not submit through a disabled composer', () => { + const disabled = renderSubmitHook({ inputDisabled: true }) + + requestComposerSubmit('do not send this', { target: 'main' }) + + expect(disabled.onSubmit).not.toHaveBeenCalled() + }) +}) + describe('useComposerSubmit busy-turn routing', () => { afterEach(() => { cleanup() diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts index 7cfa31a13e..9435881b43 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts @@ -1,5 +1,6 @@ -import { type RefObject, useEffect, useRef } from 'react' +import { type RefObject, useLayoutEffect, useRef } from 'react' +import { usePaneVisible } from '@/components/pane-shell/pane-visibility' import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { triggerHaptic } from '@/lib/haptics' import { hasClarifyRequest, skipClarifyRequest } from '@/store/clarify' @@ -13,7 +14,7 @@ import { cloneAttachments, type QueueEditState } from '../composer-utils' import { onComposerSubmitRequest } from '../focus' import { pathifyRefs } from '../path-refs' import { composerPlainText } from '../rich-editor' -import { useComposerScope } from '../scope' +import { useComposerScope, useComposerSurfaceId } from '../scope' import type { ChatBarProps } from '../types' interface UseComposerSubmitArgs { @@ -76,7 +77,9 @@ export function useComposerSubmit({ setComposerText, stashAt }: UseComposerSubmitArgs) { + const paneVisible = usePaneVisible() const scope = useComposerScope() + const surfaceId = useComposerSurfaceId() // Shared send primitive: fire onSubmit, and if the gateway rejects (accepted // === false) or throws, re-load + re-stash the draft so the words survive. @@ -103,19 +106,26 @@ export function useComposerSubmit({ } // External "submit this prompt" requests (e.g. the review pane's agent-ship - // button) route through the same send path. A ref keeps the listener stable - // while always calling the latest dispatchSubmit closure. + // button) route through the same send path. Match both the composer target + // and the exact visible surface captured at click time — every tile stays + // mounted, and a session can be rendered in more than one pane. const dispatchSubmitRef = useRef(dispatchSubmit) dispatchSubmitRef.current = dispatchSubmit - useEffect( + useLayoutEffect( () => - onComposerSubmitRequest(({ target, text, displayKind }) => { - if (target === 'main' && !inputDisabled) { + onComposerSubmitRequest(({ surfaceId: requestedSurfaceId, target, text, displayKind }) => { + if ( + target === scope.target && + surfaceId !== null && + requestedSurfaceId === surfaceId && + paneVisible && + !inputDisabled + ) { dispatchSubmitRef.current(text, undefined, displayKind) } }), - [inputDisabled] + [inputDisabled, paneVisible, scope.target, surfaceId] ) const submitDraft = () => { diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts index fae1d03a31..a6c5c898af 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts @@ -36,6 +36,26 @@ import { detectTrigger, textBeforeCaret, type TriggerState } from '../text-utils * prose off and stranded a partial `folder:` in front of the chip, because the * window it removed wasn't the token the user was typing. */ +/** The keyup half of trigger detection, shared by both composers. + * + * If the open popover already consumed this key in keydown (Arrow/Enter/Tab/ + * Escape), skip the refresh: those keys never edit text, and for Escape the + * keydown already closed the menu — refreshing here would re-detect the + * still-present `/` and instantly reopen it. It reads a ref set during keydown + * rather than `trigger`, because by keyup time React has re-rendered and + * `trigger` may already be null. */ +export function triggerKeyUpHandler(consumedRef: MutableRefObject, refreshTrigger: () => void) { + return () => { + if (consumedRef.current) { + consumedRef.current = false + + return + } + + window.setTimeout(refreshTrigger, 0) + } +} + export function rebuildAroundCaret(editor: HTMLDivElement, tokenLength: number, insert: DocumentFragment | string) { const current = composerPlainText(editor) const caret = caretOffsetInEditor(editor) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.test.tsx index bbffc396ec..c687d310e4 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-undo.test.tsx @@ -2,6 +2,8 @@ import { render } from '@testing-library/react' import { createRef, type RefObject } from 'react' import { describe, expect, it, vi } from 'vitest' +import { placeCaretAtEnd } from '../test-utils' + import { useComposerUndo } from './use-composer-undo' /** Mount the hook against a real contentEditable, exposing its API. */ @@ -36,19 +38,10 @@ function makeEditor(text: string) { return { editor, ref } } -const caretAtEnd = (editor: HTMLElement) => { - const range = document.createRange() - const selection = window.getSelection()! - range.selectNodeContents(editor) - range.collapse(false) - selection.removeAllRanges() - selection.addRange(range) -} - describe('useComposerUndo', () => { it('restores the pre-edit text, which is what a paste destroyed', () => { const { editor, ref } = makeEditor('before') - caretAtEnd(editor) + placeCaretAtEnd(editor) const { api, view } = mountUndo(ref, () => editor.textContent || '') @@ -69,7 +62,7 @@ describe('useComposerUndo', () => { it('withUndoPoint banks only when the edit actually ran', () => { const { editor, ref } = makeEditor('text') - caretAtEnd(editor) + placeCaretAtEnd(editor) const { api, view } = mountUndo(ref, () => editor.textContent || '') @@ -95,7 +88,7 @@ describe('useComposerUndo', () => { it('claims a native historyUndo aimed at the focused editor', () => { const { editor, ref } = makeEditor('kept') editor.focus() - caretAtEnd(editor) + placeCaretAtEnd(editor) const { api, view } = mountUndo(ref, () => editor.textContent || '') @@ -162,7 +155,7 @@ describe('useComposerUndo', () => { it('reset drops history so undo cannot cross a draft swap', () => { const { editor, ref } = makeEditor('session A') - caretAtEnd(editor) + placeCaretAtEnd(editor) const { api, view } = mountUndo(ref, () => editor.textContent || '') diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index c61f81f1fc..fc4b05937f 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -50,7 +50,7 @@ import { useComposerPlaceholder } from './hooks/use-composer-placeholder' import { useComposerPopout } from './hooks/use-composer-popout' import { useComposerQueue } from './hooks/use-composer-queue' import { useComposerSubmit } from './hooks/use-composer-submit' -import { useComposerTrigger } from './hooks/use-composer-trigger' +import { triggerKeyUpHandler, useComposerTrigger } from './hooks/use-composer-trigger' import { useComposerUndo } from './hooks/use-composer-undo' import { useComposerUrlDialog } from './hooks/use-composer-url-dialog' import { useComposerVoice } from './hooks/use-composer-voice' @@ -904,21 +904,7 @@ export function ChatBar({ } } - const handleEditorKeyUp = () => { - // If this keyup belongs to a key the open trigger popover already consumed - // in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never - // edit text, and for Escape the keydown already closed the menu — a refresh - // here would re-detect the still-present `/` and instantly reopen it. We - // read a ref set during keydown rather than `trigger`, because by keyup - // time React has re-rendered and `trigger` may already be null. - if (triggerKeyConsumedRef.current) { - triggerKeyConsumedRef.current = false - - return - } - - window.setTimeout(refreshTrigger, 0) - } + const handleEditorKeyUp = triggerKeyUpHandler(triggerKeyConsumedRef, refreshTrigger) const { dragActive, @@ -1278,7 +1264,7 @@ export function ChatBar({ // A tile's rail reviews ITS worktree: pin the pane's scope to // this surface's cwd. Main keeps the classic follow-the- // active-session scope (null). - onOpen={() => toggleReview(scope.target === 'main' ? null : (cwd ?? null))} + onOpen={() => toggleReview(scope.target === 'main' ? null : (cwd ?? null), scope.target)} onOpenWorktree={openInWorktree} onSwitchBranch={handleSwitchBranch} repoPath={cwd} diff --git a/apps/desktop/src/app/chat/composer/rich-editor.test.ts b/apps/desktop/src/app/chat/composer/rich-editor.test.ts index 6c3d2f8733..d3bfd60eed 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.test.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.test.ts @@ -11,16 +11,7 @@ import { replaceBeforeCaret, RICH_INPUT_SLOT } from './rich-editor' - -const caretIn = (editor: HTMLElement) => { - const range = document.createRange() - const selection = window.getSelection()! - - range.selectNodeContents(editor) - range.collapse(false) - selection.removeAllRanges() - selection.addRange(range) -} +import { placeCaretAtEnd } from './test-utils' describe('renderComposerContents', () => { it('renders refs and raw text without interpreting user text as HTML', () => { @@ -160,7 +151,7 @@ describe('insertInlineRefsIntoEditor', () => { editor.dataset.slot = RICH_INPUT_SLOT editor.append(document.createTextNode('review')) document.body.append(editor) - caretIn(editor) + placeCaretAtEnd(editor) expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ') @@ -172,7 +163,7 @@ describe('insertInlineRefsIntoEditor', () => { editor.dataset.slot = RICH_INPUT_SLOT editor.append(document.createTextNode('review ')) document.body.append(editor) - caretIn(editor) + placeCaretAtEnd(editor) expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ') @@ -185,7 +176,7 @@ describe('insertComposerContentsAtCaret', () => { const editor = document.createElement('div') editor.dataset.slot = RICH_INPUT_SLOT document.body.append(editor) - caretIn(editor) + placeCaretAtEnd(editor) insertComposerContentsAtCaret(editor, 'one\ntwo\nthree') @@ -221,7 +212,7 @@ describe('insertComposerContentsAtCaret', () => { const editor = document.createElement('div') editor.dataset.slot = RICH_INPUT_SLOT document.body.append(editor) - caretIn(editor) + placeCaretAtEnd(editor) insertComposerContentsAtCaret(editor, 'read @url:`https://example.dev/a` now') @@ -237,7 +228,7 @@ describe('insertComposerContentsAtCaret', () => { const editor = document.createElement('div') editor.dataset.slot = RICH_INPUT_SLOT document.body.append(editor) - caretIn(editor) + placeCaretAtEnd(editor) insertComposerContentsAtCaret(editor, '/some-skill') @@ -253,7 +244,7 @@ describe('insertComposerContentsAtCaret', () => { const editor = document.createElement('div') editor.dataset.slot = RICH_INPUT_SLOT document.body.append(editor) - caretIn(editor) + placeCaretAtEnd(editor) insertComposerContentsAtCaret(editor, 'clean @file:`a.ts` with /some-skill then ship') @@ -268,7 +259,7 @@ describe('insertComposerContentsAtCaret', () => { const editor = document.createElement('div') editor.dataset.slot = RICH_INPUT_SLOT document.body.append(editor) - caretIn(editor) + placeCaretAtEnd(editor) insertComposerContentsAtCaret(editor, 'see /usr/local/bin and /goal ship it') @@ -283,7 +274,7 @@ describe('insertComposerContentsAtCaret', () => { editor.dataset.slot = RICH_INPUT_SLOT editor.textContent = 'foo' document.body.append(editor) - caretIn(editor) + placeCaretAtEnd(editor) insertComposerContentsAtCaret(editor, '/some-skill') @@ -298,7 +289,7 @@ describe('insertComposerContentsAtCaret', () => { editor.dataset.slot = RICH_INPUT_SLOT editor.append(refChipElement('file', '`a.ts`')) document.body.append(editor) - caretIn(editor) + placeCaretAtEnd(editor) insertComposerContentsAtCaret(editor, '/some-skill') diff --git a/apps/desktop/src/app/chat/composer/scope.tsx b/apps/desktop/src/app/chat/composer/scope.tsx index e7e7aff880..a14b53ca42 100644 --- a/apps/desktop/src/app/chat/composer/scope.tsx +++ b/apps/desktop/src/app/chat/composer/scope.tsx @@ -43,3 +43,15 @@ const ComposerScopeContext = createContext(MAIN_COMPOSER_SCOPE) export const ComposerScopeProvider = ComposerScopeContext.Provider export const useComposerScope = (): ComposerScope => useContext(ComposerScopeContext) + +/** + * Unique identity for one mounted ChatView/composer pair. Session ids cannot + * fill this role: a fresh chat has no id yet, and the same stored session can + * be rendered in more than one layout pane. External submit requests pin this + * surface id at click time so exactly one composer can claim the task. + */ +const ComposerSurfaceContext = createContext(null) + +export const ComposerSurfaceProvider = ComposerSurfaceContext.Provider + +export const useComposerSurfaceId = (): string | null => useContext(ComposerSurfaceContext) diff --git a/apps/desktop/src/app/chat/composer/test-utils.ts b/apps/desktop/src/app/chat/composer/test-utils.ts new file mode 100644 index 0000000000..23c71f74d4 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/test-utils.ts @@ -0,0 +1,14 @@ +/** Collapse the selection to the end of the editor's contents. + * + * The composer's undo stack, ref rendering, and trigger detection all read the + * live selection, and jsdom starts with none — without this they see a caret + * at offset 0 and take the wrong branch. */ +export function placeCaretAtEnd(editor: HTMLElement) { + const range = document.createRange() + const selection = window.getSelection()! + + range.selectNodeContents(editor) + range.collapse(false) + selection.removeAllRanges() + selection.addRange(range) +} diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 6e8f149c00..c5ca7dd084 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -3,7 +3,7 @@ import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import type { ReadableAtom } from 'nanostores' import type * as React from 'react' -import { memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, Suspense, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { useLocation } from 'react-router' import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils' @@ -58,7 +58,7 @@ import { ChatSwapOverlay } from './chat-swap-overlay' import { ChatBar, ChatBarFallback } from './composer' import { requestComposerInsert } from './composer/focus' import { droppedFileInlineRefs } from './composer/inline-refs' -import { useComposerScope } from './composer/scope' +import { ComposerSurfaceProvider, useComposerScope, useComposerSurfaceId } from './composer/scope' import type { ChatBarState } from './composer/types' import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions' import { type DragKind, useFileDropZone } from './hooks/use-file-drop-zone' @@ -338,7 +338,17 @@ function ChatRuntimeBoundary({ // Memoized: the tile caller (session-tile.tsx) and the contrib surface re-render // on idle ticks unrelated to the chat; with stable callback props (hoisted to // useCallback at the call sites) memo() lets the whole chat shell skip those. -export const ChatView = memo(function ChatView({ +export const ChatView = memo(function ChatView(props: ChatViewProps) { + const composerSurfaceId = useId() + + return ( + + + + ) +}) + +const ChatViewContent = memo(function ChatViewContent({ className, gateway, modelMenuContent, @@ -373,6 +383,7 @@ export const ChatView = memo(function ChatView({ // atoms) or a tile's session slice — same component either way. const view = useSessionView() const composerScope = useComposerScope() + const composerSurfaceId = useComposerSurfaceId() const isPrimary = view.kind === 'primary' const activeSessionId = useStore(view.$runtimeId) const storedId = useStore(view.$storedId) @@ -576,6 +587,7 @@ export const ChatView = memo(function ChatView({ className )} data-chat-surface="" + data-composer-surface-id={composerSurfaceId} data-composer-target={composerScope.target} data-session-anchor={sessionAnchor} > diff --git a/apps/desktop/src/app/chat/session-tile-attachments.test.tsx b/apps/desktop/src/app/chat/session-tile-attachments.test.tsx index b9a97a8611..b756f821e4 100644 --- a/apps/desktop/src/app/chat/session-tile-attachments.test.tsx +++ b/apps/desktop/src/app/chat/session-tile-attachments.test.tsx @@ -13,6 +13,8 @@ import { import { $connection, $sessions } from '@/store/session' import { $sessionStates, type SessionTileDelegate, setSessionTileDelegate } from '@/store/session-states' +import { deferred } from '../../test/deferred' + const requestGateway = vi.fn() vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({ @@ -44,16 +46,6 @@ const STAGED_PATH = '/root/.hermes/attachments/photo.png' const THUMBNAIL = 'data:image/png;base64,dGh1bWJuYWls' const FULL_SOURCE = 'data:image/png;base64,b3JpZ2luYWw=' -function deferred() { - let resolve!: (value: T) => void - - const promise = new Promise(res => { - resolve = res - }) - - return { promise, resolve } -} - function makeAttachment(occurrenceId = createComposerAttachmentOccurrenceId()): ComposerAttachment { return { detail: HOST_PATH, diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 6930c5343d..d36903a5ee 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -467,16 +467,19 @@ export function ChatSidebar({ // 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. + // profile in. Grouped rendering stays gated on `showAllProfiles` (multi-profile + // + ALL) so a single-profile user is never stranded in a grouped view with no + // rail — but the *data* still has to fan in when the persisted scope is ALL + // (Grouping → Profile). Filtering that pool against the `__all__` sentinel + // matches nothing and empties recents + pins. // Archived rows are excluded from the sessions query, so Archived is a view of // its own set rather than a filter over this one — a flat list of archived // rows, no project tree, no date or status dividers. const scopedSessions = useMemo(() => { const pool = showArchived ? archivedSessions : sessions - return showAllProfiles ? pool : pool.filter(s => normalizeProfileKey(s.profile) === profileScope) - }, [sessions, archivedSessions, showArchived, showAllProfiles, profileScope]) + return filterSessionsByProfileScope(pool, profileScope) + }, [sessions, archivedSessions, showArchived, profileScope]) // One predicate for the status/project filters, so the flat list and the // project lanes narrow by the same rule. A project lane holds rows the loaded diff --git a/apps/desktop/src/app/chat/sidebar/profile-scope.test.ts b/apps/desktop/src/app/chat/sidebar/profile-scope.test.ts index 14e73edb61..68585af986 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-scope.test.ts +++ b/apps/desktop/src/app/chat/sidebar/profile-scope.test.ts @@ -27,4 +27,12 @@ describe('filterSessionsByProfileScope', () => { expect(filterSessionsByProfileScope(rows, ALL_PROFILES)).toBe(rows) }) + + it('does not empty ALL scope when every row is one profile', () => { + // Grouping → Profile persists ALL even with one profile. Filtering + // against the `__all__` sentinel would empty recents and pins. + const rows = [row('a', 'default'), row('b', 'default'), row('c', 'default')] + + expect(filterSessionsByProfileScope(rows, ALL_PROFILES)).toBe(rows) + }) }) diff --git a/apps/desktop/src/app/chat/sidebar/profile-scope.ts b/apps/desktop/src/app/chat/sidebar/profile-scope.ts index 45686d8252..d585b14c9b 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-scope.ts +++ b/apps/desktop/src/app/chat/sidebar/profile-scope.ts @@ -1,7 +1,13 @@ import { ALL_PROFILES, normalizeProfileKey } from '@/store/profile' import type { SessionInfo } from '@/types/hermes' -/** Return the sessions visible in one sidebar profile scope, or the original unified list for All profiles. */ +/** + * Sessions visible in one sidebar profile scope. + * + * ALL (`__all__`) returns the caller's list unchanged — including the + * single-profile case, where Grouping → Profile persists that sentinel + * while grouped rendering stays off. Never filter against `__all__`. + */ export function filterSessionsByProfileScope(sessions: SessionInfo[], profileScope: string): SessionInfo[] { if (profileScope === ALL_PROFILES) { return sessions diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts index bd32d4fd4a..c81f1d72b1 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { HermesGitWorktree } from '@/global' +import { makeCwdSession } from '@/test/session-info' import type { ProjectInfo, SessionInfo } from '@/types/hermes' import { @@ -22,29 +23,6 @@ import { // covered by tests/tui_gateway/test_project_tree.py). This file only covers the // thin render helpers the desktop still owns + the VISUAL worktree enhancer. -let nextId = 0 - -function makeSession(cwd: null | string, overrides: Partial = {}): SessionInfo { - return { - archived: false, - cwd, - ended_at: null, - id: `s${nextId++}`, - input_tokens: 0, - is_active: false, - last_active: 1_000, - message_count: 1, - model: 'claude', - output_tokens: 0, - preview: null, - source: 'cli', - started_at: 1_000, - title: null, - tool_call_count: 0, - ...overrides - } -} - const lane = (over: Partial & Pick): SidebarSessionGroup => ({ path: null, sessions: [], @@ -76,7 +54,7 @@ describe('kanbanWorktreeDir', () => { describe('sortWorktreeGroups', () => { it('pins trunk to the top, sinks kanban to the bottom, and orders the rest by recency', () => { - const at = (t: number) => [makeSession('/x', { last_active: t })] + const at = (t: number) => [makeCwdSession('/x', { last_active: t })] const groups = [ lane({ id: 'k', label: 'kanban', isKanban: true, sessions: at(999) }), @@ -92,7 +70,7 @@ describe('sortWorktreeGroups', () => { it('pins the live home checkout above trunk, even when it has no sessions yet', () => { const groups = [ - lane({ id: 'main', label: 'main', isMain: true, sessions: [makeSession('/x', { last_active: 999 })] }), + lane({ id: 'main', label: 'main', isMain: true, sessions: [makeCwdSession('/x', { last_active: 999 })] }), lane({ id: 'home', label: 'bb/projects-paradigm', isMain: true, isHome: true }) ] @@ -150,7 +128,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'main', isMain: true, path: '/repo', - sessions: [makeSession('/repo')] + sessions: [makeCwdSession('/repo')] }) ] } @@ -176,7 +154,13 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { it('does not add a second "main" for a linked worktree checked out on main', () => { const groups = [ - lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] }) + lane({ + id: '/repo::branch::main', + label: 'main', + isMain: true, + path: '/repo', + sessions: [makeCwdSession('/repo')] + }) ] const discovered: HermesGitWorktree[] = [ @@ -216,14 +200,14 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'main', isMain: true, path: '/repo', - sessions: [makeSession('/repo')] + sessions: [makeCwdSession('/repo')] }), lane({ id: '/repo-ci', label: 'hermes-agent-ci', isMain: false, path: '/repo-ci', - sessions: [makeSession('/repo-ci')] + sessions: [makeCwdSession('/repo-ci')] }) ] } @@ -256,7 +240,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'bb/attempts', isMain: false, path: '/repo/.worktrees/attempts', - sessions: [makeSession('/repo/.worktrees/attempts')] + sessions: [makeCwdSession('/repo/.worktrees/attempts')] }) ] } @@ -291,7 +275,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'bb/feature', isMain: false, path: '/repo-feature', - sessions: [makeSession('/repo-feature'), makeSession('/repo-feature')] + sessions: [makeCwdSession('/repo-feature'), makeCwdSession('/repo-feature')] }) ] } @@ -313,7 +297,13 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { id: '/repo', path: '/repo', groups: [ - lane({ id: '/repo-ci', label: 'repo-ci', isMain: false, path: '/repo-ci', sessions: [makeSession('/repo-ci')] }) + lane({ + id: '/repo-ci', + label: 'repo-ci', + isMain: false, + path: '/repo-ci', + sessions: [makeCwdSession('/repo-ci')] + }) ] } @@ -334,7 +324,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'main', isMain: true, path: '/repo', - sessions: [makeSession('/repo')] + sessions: [makeCwdSession('/repo')] }) ] } @@ -366,7 +356,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'main', isMain: true, path: '/repo', - sessions: [makeSession('/repo')] + sessions: [makeCwdSession('/repo')] }) ] } @@ -391,14 +381,14 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'main', isMain: true, path: '/repo', - sessions: [makeSession('/repo', { id: 'a' })] + sessions: [makeCwdSession('/repo', { id: 'a' })] }), lane({ id: '/repo::branch::old', label: 'old-feature', isMain: true, path: '/repo', - sessions: [makeSession('/repo', { id: 'b' })] + sessions: [makeCwdSession('/repo', { id: 'b' })] }) ] } @@ -425,7 +415,7 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => { label: 'main', isMain: true, path: '/repo', - sessions: [makeSession('/repo')] + sessions: [makeCwdSession('/repo')] }) ] } @@ -481,11 +471,11 @@ const homeNode = (sessions: SessionInfo[]): SidebarProjectTree => describe('liveSessionProjectId', () => { it('maps a brand-new (unpersisted) session to its auto project (the repo root)', () => { - expect(liveSessionProjectId(makeSession('/www/app'), [])).toBe('/www/app') + expect(liveSessionProjectId(makeCwdSession('/www/app'), [])).toBe('/www/app') }) it('routes a session under an explicit project folder to that project', () => { - const id = liveSessionProjectId(makeSession('/www/app/src', { git_repo_root: '/www/app', git_branch: 'feat' }), [ + const id = liveSessionProjectId(makeCwdSession('/www/app/src', { git_repo_root: '/www/app', git_branch: 'feat' }), [ makeProject('p_app', ['/www/app']) ]) @@ -495,31 +485,31 @@ describe('liveSessionProjectId', () => { it('anchors a cwd-less session on its git_repo_root (backend groups it there too)', () => { // Older/imported rows carry only a repo root; the sidebar files them under // the repo's project, so membership (and color) must resolve from the root. - expect(liveSessionProjectId(makeSession(null, { git_repo_root: '/www/app' }), [])).toBe('/www/app') + expect(liveSessionProjectId(makeCwdSession(null, { git_repo_root: '/www/app' }), [])).toBe('/www/app') expect( - liveSessionProjectId(makeSession(null, { git_repo_root: '/www/app' }), [makeProject('p_app', ['/www/app'])]) + liveSessionProjectId(makeCwdSession(null, { git_repo_root: '/www/app' }), [makeProject('p_app', ['/www/app'])]) ).toBe('p_app') }) it('skips cwd-less, kanban-task, and out-of-tree (sibling) worktree sessions', () => { - expect(liveSessionProjectId(makeSession(null), [])).toBeNull() + expect(liveSessionProjectId(makeCwdSession(null), [])).toBeNull() // Kanban task worktree → folds into the kanban bucket, not a project preview. - expect(liveSessionProjectId(makeSession('/repo/.worktrees/t_aaaaaaaa'), [])).toBeNull() + expect(liveSessionProjectId(makeCwdSession('/repo/.worktrees/t_aaaaaaaa'), [])).toBeNull() // Sibling worktree OUTSIDE the repo root → project can't be derived from the row. - expect(liveSessionProjectId(makeSession('/elsewhere/wt', { git_repo_root: '/repo' }), [])).toBeNull() + expect(liveSessionProjectId(makeCwdSession('/elsewhere/wt', { git_repo_root: '/repo' }), [])).toBeNull() }) it('places an in-tree worktree session under its repo project (the root is in the path)', () => { // "Convert a branch" / "new worktree" land at `/.worktrees/`, // so they belong to the same auto project as the repo root and must show in // the overview at once, not wait for the next backend refresh. - expect(liveSessionProjectId(makeSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [])).toBe( + expect(liveSessionProjectId(makeCwdSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [])).toBe( '/www/app' ) }) it('routes an in-tree worktree session to the owning explicit project', () => { - const id = liveSessionProjectId(makeSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [ + const id = liveSessionProjectId(makeCwdSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [ makeProject('p_app', ['/www/app']) ]) @@ -532,13 +522,13 @@ describe('liveSessionProjectId', () => { // only the auto-project (repo root) fallback needs cwd-under-root // confidence. Match via the repo root... expect( - liveSessionProjectId(makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }), [ + liveSessionProjectId(makeCwdSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }), [ makeProject('p_proj', ['/home/u/proj']) ]) ).toBe('p_proj') // ...and via the cwd. expect( - liveSessionProjectId(makeSession('/www/elsewhere/sub', { git_repo_root: '/home/u/proj' }), [ + liveSessionProjectId(makeCwdSession('/www/elsewhere/sub', { git_repo_root: '/home/u/proj' }), [ makeProject('p_www', ['/www/elsewhere']) ]) ).toBe('p_www') @@ -547,13 +537,13 @@ describe('liveSessionProjectId', () => { it('matches a mixed-case/separator Windows cwd to its explicit project in the live overlay', () => { // The bug: a fresh Windows session drops into the overlay before the next // backend refresh; case-sensitive matching missed its project until then. - const id = liveSessionProjectId(makeSession('c:/work/notes/SUB'), [makeProject('p_notes', ['C:\\Work\\Notes'])]) + const id = liveSessionProjectId(makeCwdSession('c:/work/notes/SUB'), [makeProject('p_notes', ['C:\\Work\\Notes'])]) expect(id).toBe('p_notes') }) it('matches a root-relative WSL cwd (single backslash) case-insensitively', () => { - const id = liveSessionProjectId(makeSession('//wsl.localhost/Ubuntu/home/alice/PROJ'), [ + const id = liveSessionProjectId(makeCwdSession('//wsl.localhost/Ubuntu/home/alice/PROJ'), [ makeProject('p_proj', ['\\wsl.localhost\\Ubuntu\\home\\alice\\proj']) ]) @@ -562,7 +552,7 @@ describe('liveSessionProjectId', () => { it('keeps POSIX cwd matching case-sensitive (no false project match)', () => { // Distinct case on POSIX is a distinct path → falls back to its own auto id. - expect(liveSessionProjectId(makeSession('/work/notes'), [makeProject('p_notes', ['/Work/Notes'])])).toBe( + expect(liveSessionProjectId(makeCwdSession('/work/notes'), [makeProject('p_notes', ['/Work/Notes'])])).toBe( '/work/notes' ) }) @@ -575,19 +565,19 @@ describe('sessionProjectColor', () => { }) it('inherits the color of the explicit project the session belongs to', () => { - const session = makeSession('/www/app/src', { git_repo_root: '/www/app' }) + const session = makeCwdSession('/www/app/src', { git_repo_root: '/www/app' }) expect(sessionProjectColor(session, [colored('p_app', ['/www/app'], '#4a9eff')])).toBe('#4a9eff') }) it('returns null when the owning project has no color set', () => { - const session = makeSession('/www/app/src', { git_repo_root: '/www/app' }) + const session = makeCwdSession('/www/app/src', { git_repo_root: '/www/app' }) expect(sessionProjectColor(session, [makeProject('p_app', ['/www/app'])])).toBeNull() }) it('colors a cwd-less session by its git_repo_root project (the grouped-but-grey fix)', () => { - const session = makeSession(null, { git_repo_root: '/www/app' }) + const session = makeCwdSession(null, { git_repo_root: '/www/app' }) expect(sessionProjectColor(session, [colored('p_app', ['/www/app'], '#4a9eff')])).toBe('#4a9eff') }) @@ -595,7 +585,7 @@ describe('sessionProjectColor', () => { it('colors a cwd-outside-root session when an explicit project folder matches', () => { // The backend tree groups such a row under the project; the client color // derivation must agree instead of leaving the row (and its tab) grey. - const session = makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }) + const session = makeCwdSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }) expect(sessionProjectColor(session, [colored('p_proj', ['/home/u/proj'], '#4a9eff')])).toBe('#4a9eff') }) @@ -603,15 +593,15 @@ describe('sessionProjectColor', () => { it('returns null for a session that only maps to an auto repo root (no explicit project)', () => { // liveSessionProjectId falls back to the repo root id, which is not a // project row and therefore carries no color. - expect(sessionProjectColor(makeSession('/www/app'), [])).toBeNull() + expect(sessionProjectColor(makeCwdSession('/www/app'), [])).toBeNull() }) it('returns null for an unplaceable (cwd-less) session', () => { - expect(sessionProjectColor(makeSession(null), [colored('p_app', ['/www/app'], '#4a9eff')])).toBeNull() + expect(sessionProjectColor(makeCwdSession(null), [colored('p_app', ['/www/app'], '#4a9eff')])).toBeNull() }) it('uses the longest-prefix project when nested projects both match', () => { - const session = makeSession('/www/app/packages/api/src', { git_repo_root: '/www/app' }) + const session = makeCwdSession('/www/app/packages/api/src', { git_repo_root: '/www/app' }) const projects = [ colored('p_root', ['/www/app'], '#111111'), @@ -630,7 +620,7 @@ describe('overlayLiveLanes', () => { repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }] }) - const live = [makeSession('/www/app', { id: 'fresh', git_branch: 'main' })] + const live = [makeCwdSession('/www/app', { id: 'fresh', git_branch: 'main' })] const overlaid = overlayLiveLanes(project, live) const lane = overlaid.repos[0].groups.find(g => g.label === 'main') @@ -649,7 +639,7 @@ describe('overlayLiveLanes', () => { repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }] }) - const live = [makeSession('/www/app/.worktrees/baby', { id: 'fresh' })] + const live = [makeCwdSession('/www/app/.worktrees/baby', { id: 'fresh' })] const overlaid = overlayLiveLanes(project, live) const lane = overlaid.repos[0].groups.find(g => g.id === '/www/app/.worktrees/baby') @@ -665,7 +655,7 @@ describe('overlayLiveLanes', () => { repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }] }) - const live = [makeSession('/www/app/.worktrees/t_abc12345', { id: 'k' })] + const live = [makeCwdSession('/www/app/.worktrees/t_abc12345', { id: 'k' })] const overlaid = overlayLiveLanes(project, live) const lane = overlaid.repos[0].groups.find(g => g.isKanban) @@ -675,7 +665,7 @@ describe('overlayLiveLanes', () => { }) it('does not duplicate a session already present in a backend lane', () => { - const existing = makeSession('/www/app', { id: 'dup', git_branch: 'main' }) + const existing = makeCwdSession('/www/app', { id: 'dup', git_branch: 'main' }) const project = projectNode({ id: '/www/app', @@ -704,8 +694,8 @@ describe('overlayLiveLanes', () => { // and CREATE a second main lane with the same sessions — dual lanes in the // project drill-in (e.g. main + codex-research-guardian). const root = '/home/hermes/hermes-workspace/codex-research-guardian' - const a = makeSession(root, { id: 's1' }) // empty git_branch / git_repo_root - const b = makeSession(root, { id: 's2' }) + const a = makeCwdSession(root, { id: 's1' }) // empty git_branch / git_repo_root + const b = makeCwdSession(root, { id: 's2' }) const project = projectNode({ id: root, @@ -742,7 +732,7 @@ describe('overlayLiveLanes', () => { it('joins a fresh live session into an existing non-git workspace lane (no branch id)', () => { const root = '/work/notes' - const existing = makeSession(root, { id: 'old' }) + const existing = makeCwdSession(root, { id: 'old' }) const project = projectNode({ id: root, @@ -759,7 +749,7 @@ describe('overlayLiveLanes', () => { ] }) - const fresh = makeSession(root, { id: 'fresh' }) + const fresh = makeCwdSession(root, { id: 'fresh' }) const overlaid = overlayLiveLanes(project, [existing, fresh]) const groups = overlaid.repos[0].groups @@ -769,14 +759,14 @@ describe('overlayLiveLanes', () => { }) it('preserves backend recency order when live sessions overlay a lane', () => { - const recentlyActive = makeSession('/www/app', { + const recentlyActive = makeCwdSession('/www/app', { id: 'recently-active', git_branch: 'main', started_at: 1, last_active: 3 }) - const newlyCreated = makeSession('/www/app', { + const newlyCreated = makeCwdSession('/www/app', { id: 'newly-created', git_branch: 'main', started_at: 2, @@ -816,7 +806,7 @@ describe('overlayLiveLanes', () => { // Backend keyed the worktree lane off a branch-style id (no live git probe), // but the lane PATH is the worktree dir. A new session under that worktree // must join the existing lane, not spawn a twin. - const existing = makeSession('/www/app/.worktrees/baby', { id: 'old' }) + const existing = makeCwdSession('/www/app/.worktrees/baby', { id: 'old' }) const project = projectNode({ id: '/www/app', @@ -838,7 +828,7 @@ describe('overlayLiveLanes', () => { ] }) - const fresh = makeSession('/www/app/.worktrees/baby', { id: 'fresh' }) + const fresh = makeCwdSession('/www/app/.worktrees/baby', { id: 'fresh' }) const overlaid = overlayLiveLanes(project, [existing, fresh]) const lanes = overlaid.repos[0].groups.filter(g => g.path === '/www/app/.worktrees/baby') @@ -850,7 +840,7 @@ describe('overlayLiveLanes', () => { it('places a session into an out-of-tree (sibling) worktree lane by its path', () => { // `hermes-agent-ci` is a linked worktree living BESIDE the repo, not under // it — repo-root nesting fails, but the existing lane carries its real path. - const existing = makeSession('/www/app-ci', { id: 'old' }) + const existing = makeCwdSession('/www/app-ci', { id: 'old' }) const project = projectNode({ id: '/www/app', @@ -868,7 +858,7 @@ describe('overlayLiveLanes', () => { ] }) - const fresh = makeSession('/www/app-ci', { id: 'fresh' }) + const fresh = makeCwdSession('/www/app-ci', { id: 'fresh' }) const overlaid = overlayLiveLanes(project, [existing, fresh]) const ci = overlaid.repos[0].groups.find(g => g.path === '/www/app-ci') @@ -890,7 +880,7 @@ describe('overlayLiveLanes', () => { repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups }] }) - const fresh = makeSession('/www/app-retry', { id: 'fresh' }) + const fresh = makeCwdSession('/www/app-retry', { id: 'fresh' }) const overlaid = overlayLiveLanes(project, [fresh]) const lane = overlaid.repos[0].groups.find(g => g.path === '/www/app-retry') @@ -899,8 +889,8 @@ describe('overlayLiveLanes', () => { }) it('evicts a deleted/archived snapshot row (and drops the lane once empty)', () => { - const a = makeSession('/www/app', { id: 'keep', git_branch: 'main' }) - const b = makeSession('/www/app/.worktrees/baby', { id: 'gone' }) + const a = makeCwdSession('/www/app', { id: 'keep', git_branch: 'main' }) + const b = makeCwdSession('/www/app/.worktrees/baby', { id: 'gone' }) const project = projectNode({ id: '/www/app', @@ -927,11 +917,11 @@ describe('overlayLiveLanes', () => { }) it('adds a brand-new detached chat to Home, and evicts a deleted one', () => { - const existing = makeSession(null, { id: 'old', started_at: 1 }) - const doomed = makeSession(null, { id: 'gone', started_at: 2 }) + const existing = makeCwdSession(null, { id: 'old', started_at: 1 }) + const doomed = makeCwdSession(null, { id: 'gone', started_at: 2 }) const home = homeNode([existing, doomed]) - const overlaid = overlayLiveLanes(home, [makeSession(null, { id: 'fresh', started_at: 9 })], new Set(['gone'])) + const overlaid = overlayLiveLanes(home, [makeCwdSession(null, { id: 'fresh', started_at: 9 })], new Set(['gone'])) expect(overlaid.repos[0].groups[0].sessions.map(s => s.id)).toEqual(['fresh', 'old']) expect(overlaid.sessionCount).toBe(2) @@ -943,14 +933,14 @@ describe('overlayLiveLanes', () => { // and back out on the next snapshot. const home = homeNode([]) - expect(overlayLiveLanes(home, [makeSession('/www/app', { id: 'fresh' })])).toBe(home) + expect(overlayLiveLanes(home, [makeCwdSession('/www/app', { id: 'fresh' })])).toBe(home) }) it('evicts a session from the main lane when the live overlay places it into a worktree lane', () => { // Session was in main when the backend tree was captured, but the live // $sessions cache now has it under a worktree cwd. The overlay must place // it ONLY in the worktree lane — not both. - const session = makeSession('/www/app/.worktrees/feature', { id: 'moved', git_branch: 'feature' }) + const session = makeCwdSession('/www/app/.worktrees/feature', { id: 'moved', git_branch: 'feature' }) const project = projectNode({ id: '/www/app', @@ -989,10 +979,10 @@ describe('overlayLivePreviews', () => { it('merges live sessions into a project preview, live first, capped to the limit', () => { const project = projectNode({ id: '/www/app', - previewSessions: [makeSession('/www/app', { id: 'old', started_at: 1, last_active: 1 })] + previewSessions: [makeCwdSession('/www/app', { id: 'old', started_at: 1, last_active: 1 })] }) - const live = [makeSession('/www/app', { id: 'fresh', started_at: 99, last_active: 99 })] + const live = [makeCwdSession('/www/app', { id: 'fresh', started_at: 99, last_active: 99 })] const previews = overlayLivePreviews([project], live, [], 3) @@ -1003,8 +993,8 @@ describe('overlayLivePreviews', () => { const project = projectNode({ id: '/www/app', previewSessions: [ - makeSession('/www/app', { id: 'gone', started_at: 5, last_active: 5 }), - makeSession('/www/app', { id: 'old', started_at: 1, last_active: 1 }) + makeCwdSession('/www/app', { id: 'gone', started_at: 5, last_active: 5 }), + makeCwdSession('/www/app', { id: 'old', started_at: 1, last_active: 1 }) ] }) @@ -1017,9 +1007,9 @@ describe('overlayLivePreviews', () => { const project = projectNode({ id: '/www/app', previewSessions: [ - makeSession('/www/app', { id: 'newest', last_active: 9, started_at: 9 }), - makeSession('/www/app', { id: 'cheap', last_active: 8, started_at: 8 }), - makeSession('/www/app', { id: 'priciest', last_active: 1, started_at: 1 }) + makeCwdSession('/www/app', { id: 'newest', last_active: 9, started_at: 9 }), + makeCwdSession('/www/app', { id: 'cheap', last_active: 8, started_at: 8 }), + makeCwdSession('/www/app', { id: 'priciest', last_active: 1, started_at: 1 }) ] }) @@ -1029,7 +1019,7 @@ describe('overlayLivePreviews', () => { }) it('previews a detached session under Home, which no cwd could place', () => { - const previews = overlayLivePreviews([homeNode([])], [makeSession(null, { id: 'fresh' })], [], 3) + const previews = overlayLivePreviews([homeNode([])], [makeCwdSession(null, { id: 'fresh' })], [], 3) expect(previews[NO_PROJECT_ID].map(s => s.id)).toEqual(['fresh']) }) @@ -1037,8 +1027,8 @@ describe('overlayLivePreviews', () => { describe('excludeProjectSessions', () => { it('drops matching rows from every lane and recounts the subtree', () => { - const keep = makeSession('/www/app', { id: 'keep' }) - const pinnedRow = makeSession('/www/app', { id: 'pinned' }) + const keep = makeCwdSession('/www/app', { id: 'keep' }) + const pinnedRow = makeCwdSession('/www/app', { id: 'pinned' }) const project = projectNode({ id: '/www/app', @@ -1062,7 +1052,7 @@ describe('excludeProjectSessions', () => { }) it('keeps a lane the filter emptied — a worktree is structure, not a row', () => { - const pinnedRow = makeSession('/www/app/wt', { id: 'pinned' }) + const pinnedRow = makeCwdSession('/www/app/wt', { id: 'pinned' }) const project = projectNode({ id: '/www/app', @@ -1090,7 +1080,7 @@ describe('excludeProjectSessions', () => { it('returns the same node when nothing matches (memo-stable)', () => { const project = projectNode({ id: '/www/app', - previewSessions: [makeSession('/www/app', { id: 'keep' })], + previewSessions: [makeCwdSession('/www/app', { id: 'keep' })], repos: [ { id: '/www/app', @@ -1098,7 +1088,7 @@ describe('excludeProjectSessions', () => { path: '/www/app', sessionCount: 1, groups: [ - lane({ id: 'main', isMain: true, label: 'main', sessions: [makeSession('/www/app', { id: 'keep' })] }) + lane({ id: 'main', isMain: true, label: 'main', sessions: [makeCwdSession('/www/app', { id: 'keep' })] }) ] } ], @@ -1111,7 +1101,7 @@ describe('excludeProjectSessions', () => { it('survives the live overlay: a lane left empty by the filter is not pruned', () => { // The two run in sequence on an entered project (filter, then overlay), and // the overlay drops lanes it empties — it must not take the filter's with it. - const pinnedRow = makeSession('/www/app/wt', { id: 'pinned' }) + const pinnedRow = makeCwdSession('/www/app/wt', { id: 'pinned' }) const project = projectNode({ id: '/www/app', diff --git a/apps/desktop/src/app/command-palette/highlight-watcher.test.tsx b/apps/desktop/src/app/command-palette/highlight-watcher.test.tsx index d2929fcc1e..2e92a24678 100644 --- a/apps/desktop/src/app/command-palette/highlight-watcher.test.tsx +++ b/apps/desktop/src/app/command-palette/highlight-watcher.test.tsx @@ -9,18 +9,12 @@ import { render } from '@testing-library/react' import { Command } from 'cmdk' import { describe, expect, it, vi } from 'vitest' +import { stubMenuDomApis, stubResizeObserver } from '@/test/jsdom' + import { HighlightWatcher } from './highlight-watcher' -// cmdk observes group headings with a ResizeObserver, which jsdom lacks. -class TestResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -} - -vi.stubGlobal('ResizeObserver', TestResizeObserver) - -Element.prototype.scrollIntoView = function scrollIntoView() {} +stubResizeObserver() +stubMenuDomApis() const palette = (onValue: (value: string) => void, onRootValueChange?: (value: string) => void) => ( diff --git a/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx b/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx index 6766b2dae3..1adab907f3 100644 --- a/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx +++ b/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx @@ -10,9 +10,11 @@ import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' -import { useEffect, useState } from 'react' +import { useState } from 'react' +import { StatusRow } from '@/app/command-palette/status-row' import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud' +import { useDebounced } from '@/app/hooks/use-debounced' import type { DesktopMarketplaceSearchItem } from '@/global' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' @@ -23,18 +25,6 @@ import { $marketplaceInstalls } from '@/themes/user-themes' const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }) -function useDebounced(value: T, delayMs: number): T { - const [debounced, setDebounced] = useState(value) - - useEffect(() => { - const handle = setTimeout(() => setDebounced(value), delayMs) - - return () => clearTimeout(handle) - }, [value, delayMs]) - - return debounced -} - interface MarketplaceThemePageProps { search: string /** Activate a freshly installed theme by slug. */ @@ -90,17 +80,17 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa } if (query.isLoading) { - return } text={copy.loading} /> + return } text={copy.loading} /> } if (query.isError) { - return + return } const results = query.data ?? [] if (results.length === 0) { - return + return } return ( @@ -156,17 +146,3 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa ) } - -function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) { - return ( -
- {icon} - {text} -
- ) -} diff --git a/apps/desktop/src/app/command-palette/pet-palette-page.tsx b/apps/desktop/src/app/command-palette/pet-palette-page.tsx index 241bb43312..b23157f5d4 100644 --- a/apps/desktop/src/app/command-palette/pet-palette-page.tsx +++ b/apps/desktop/src/app/command-palette/pet-palette-page.tsx @@ -10,6 +10,7 @@ import { useStore } from '@nanostores/react' import { useEffect, useMemo } from 'react' +import { StatusRow } from '@/app/command-palette/status-row' import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud' import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' import { PetThumb } from '@/components/pet/pet-thumb' @@ -59,15 +60,15 @@ export function PetPalettePage({ search, onGenerate }: PetPalettePageProps) { } if (status === 'loading' && !gallery) { - return } text={copy.loading} /> + return } text={copy.loading} /> } if (status === 'stale') { - return + return } if (!gallery?.pets.length && error) { - return + return } const mutating = Boolean(busy) @@ -95,7 +96,7 @@ export function PetPalettePage({ search, onGenerate }: PetPalettePageProps) { {error &&

{error}

} {shown.length === 0 ? ( - + ) : ( shown.map(pet => { const isActive = enabled && pet.slug === active @@ -198,17 +199,3 @@ export function PetInlineToggle() { ) } - -function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) { - return ( -
- {icon} - {text} -
- ) -} diff --git a/apps/desktop/src/app/command-palette/status-row.tsx b/apps/desktop/src/app/command-palette/status-row.tsx new file mode 100644 index 0000000000..2fad2de52f --- /dev/null +++ b/apps/desktop/src/app/command-palette/status-row.tsx @@ -0,0 +1,18 @@ +import type React from 'react' + +import { cn } from '@/lib/utils' + +/** Centered loading / empty / error row for a Cmd-K page's result list. */ +export function StatusRow({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) { + return ( +
+ {icon} + {text} +
+ ) +} diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index 5c65fd3f19..f5d400a204 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -59,7 +59,14 @@ import { SIDEBAR_MAX_WIDTH } from '@/store/layout' import { runExportProfileFlow, runImportProfileFlow } from '@/store/profile-share' -import { $reviewOpen, closeReview, openReview, REVIEW_PANE_ID } from '@/store/review' +import { + $reviewOpen, + $reviewScopeCwd, + $reviewScopeTarget, + closeReview, + openReview, + REVIEW_PANE_ID +} from '@/store/review' import { $currentCwd, $selectedStoredSessionId, $sessions, $yoloActive, sessionMatchesStoredId } from '@/store/session' import { watchSessionPins } from '@/store/session-pin-sync' import { watchUnreadWriteGuard } from '@/store/session-unread-remote' @@ -580,7 +587,7 @@ bindPaneVisibility( 'review', computed([$reviewOpen, $hasWorkspace], (open, workspace) => open && workspace), closeReview, - openReview + () => openReview($reviewScopeCwd.get(), $reviewScopeTarget.get()) ) // ⌃` / statusbar toggle — the terminal COLLAPSES to a rail (tab stays), not // hides; PTYs stay alive while collapsed (see PersistentTerminal). diff --git a/apps/desktop/src/app/contrib/dev/credits-notice-demo.ts b/apps/desktop/src/app/contrib/dev/credits-notice-demo.ts index 1766489818..4f934b8e53 100644 --- a/apps/desktop/src/app/contrib/dev/credits-notice-demo.ts +++ b/apps/desktop/src/app/contrib/dev/credits-notice-demo.ts @@ -2,7 +2,7 @@ // usage bands. Each trigger emits ONE synthetic `notification.show` / // `notification.clear` gateway event through the real fan-out // (`emitLocalGatewayEvent`), so it exercises the actual dispatcher branch in -// `use-message-stream/gateway-event.ts` — toast render, key-replacement +// `use-message-stream/gateway-event/status.ts` — toast render, key-replacement // escalation, TTL self-dismiss, native OS notification, and billing re-poll. // // Installed only under `import.meta.env.DEV` (see contrib/wiring.tsx), so none diff --git a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx index 6a6e47397a..5dd54083cf 100644 --- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx @@ -6,6 +6,8 @@ import { _resetLegacyDiscardForTests } from '@/store/session' import type * as WindowsStore from '@/store/windows' import type { SessionInfo } from '@/types/hermes' +import { makeSessionInfo } from '../../../test/session-info' + import { useDesktopIntegrations } from './use-desktop-integrations' // Mutable HUD-window flag so the restore tests can flip the window kind the @@ -36,24 +38,7 @@ vi.mock('@/store/windows', async importOriginal => { const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } const initialHermesDesktop = desktopWindow.hermesDesktop -const session = (over: Partial = {}): SessionInfo => ({ - archived: false, - cwd: null, - ended_at: null, - id: 'live', - input_tokens: 0, - is_active: false, - last_active: 0, - message_count: 0, - model: null, - output_tokens: 0, - preview: null, - source: null, - started_at: 0, - title: null, - tool_call_count: 0, - ...over -}) +const session = (over: Partial = {}): SessionInfo => makeSessionInfo({ id: 'live', ...over }) describe('useDesktopIntegrations', () => { let navigate: ReturnType void>> diff --git a/apps/desktop/src/app/contrib/mcp-install-deeplink-dialog.tsx b/apps/desktop/src/app/contrib/mcp-install-deeplink-dialog.tsx index aa4b1b3cab..a657c98687 100644 --- a/apps/desktop/src/app/contrib/mcp-install-deeplink-dialog.tsx +++ b/apps/desktop/src/app/contrib/mcp-install-deeplink-dialog.tsx @@ -16,19 +16,12 @@ import { getHermesConfigRecord, saveMcpServers } from '@/hermes' import { useI18n } from '@/i18n' import { AlertTriangle } from '@/lib/icons' import { MCP_DEEPLINK_NAME_RE } from '@/lib/mcp-deeplink' +import { getServers } from '@/lib/mcp-servers' import { $mcpInstallRequest } from '@/store/mcp-deeplink-install' import { notify, readableError } from '@/store/notifications' import { setHermesConfigCache } from '../hooks/use-config-record' -type McpServers = Record> - -const getServers = (config: { mcp_servers?: unknown } | null): McpServers => { - const raw = config?.mcp_servers - - return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as McpServers) : {} -} - /** * Explicit-confirm gate for `hermes://mcp/install` deep links. The payload is * arbitrary attacker-controllable input (any web page can open the link), so diff --git a/apps/desktop/src/app/cron/cron-actions.test.ts b/apps/desktop/src/app/cron/cron-actions.test.ts index d79246a69a..f7cbe89755 100644 --- a/apps/desktop/src/app/cron/cron-actions.test.ts +++ b/apps/desktop/src/app/cron/cron-actions.test.ts @@ -12,18 +12,10 @@ vi.mock('@/hermes', () => ({ import { beginCronJobsRequest } from '@/store/cron' +import { deferred } from '../../test/deferred' + import { mutateAndRefreshCronJobs, refreshCronJobs, triggerAndRefreshCronJobs } from './cron-actions' -function deferred() { - let resolve!: (value: T) => void - - const promise = new Promise(res => { - resolve = res - }) - - return { promise, resolve } -} - describe('triggerAndRefreshCronJobs', () => { beforeEach(() => { getApiRequestConnection.mockReset() diff --git a/apps/desktop/src/app/right-sidebar/review/ship-bar.tsx b/apps/desktop/src/app/right-sidebar/review/ship-bar.tsx index 2f85a9ade4..3d0e03325f 100644 --- a/apps/desktop/src/app/right-sidebar/review/ship-bar.tsx +++ b/apps/desktop/src/app/right-sidebar/review/ship-bar.tsx @@ -15,6 +15,7 @@ import { $reviewCommitDefault, $reviewCommitMsgBusy, $reviewFiles, + $reviewScopeTarget, $reviewShipBusy, $reviewShipInfo, cancelCommitMessage, @@ -35,6 +36,7 @@ export function ReviewShipBar() { const c = t.statusStack.coding const files = useStore($reviewFiles) const ship = useStore($reviewShipInfo) + const scopeTarget = useStore($reviewScopeTarget) const busy = useStore($reviewShipBusy) const generating = useStore($reviewCommitMsgBusy) const commitDefault = useStore($reviewCommitDefault) @@ -129,7 +131,11 @@ export function ReviewShipBar() {