Merge current upstream into fix/remote-bot-routing
Preserve the connection-bound Bot and session routing implementation while
adopting upstream's modular Desktop API split and all changes through
b2057c168.
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, unknown> }).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
|
||||
|
||||
@@ -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: `<HERMES_HOME>/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<string> {
|
||||
// Profile-aware: a named Desktop profile gets its own plugin root under
|
||||
// profiles/<name>/, 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 (`<HERMES_HOME>/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
|
||||
})
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
+109
-859
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<unknown>
|
||||
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<path>` 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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Parameters<typeof resolveVenvHermesCommand>[2]> = {}) {
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<T>(request: HermesApiRequest): Promise<T> {
|
||||
return window.hermesDesktop.api<T>({ ...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
|
||||
}
|
||||
@@ -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<StatusResponse> {
|
||||
return hermesApi<StatusResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/status'
|
||||
})
|
||||
}
|
||||
|
||||
export function getLogs(params: {
|
||||
component?: string
|
||||
file?: string
|
||||
level?: string
|
||||
lines?: number
|
||||
search?: string
|
||||
}): Promise<LogsResponse> {
|
||||
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<LogsResponse>({
|
||||
...profileScoped(),
|
||||
path: suffix ? `/api/logs?${suffix}` : '/api/logs'
|
||||
})
|
||||
}
|
||||
|
||||
export function getHermesConfig(profile?: string): Promise<HermesConfig> {
|
||||
return hermesApi<HermesConfig>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/config',
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function getHermesConfigRecord(profile?: ProfileScope): Promise<HermesConfigRecord> {
|
||||
return window.hermesDesktop.api<HermesConfigRecord>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/config'
|
||||
})
|
||||
}
|
||||
|
||||
export function getHermesConfigDefaults(): Promise<HermesConfigRecord> {
|
||||
return hermesApi<HermesConfigRecord>({
|
||||
...profileScoped(),
|
||||
path: '/api/config/defaults',
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function getHermesConfigSchema(profile?: null | string): Promise<ConfigSchemaResponse> {
|
||||
return hermesApi<ConfigSchemaResponse>({
|
||||
...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<Record<string, EnvVarInfo>> {
|
||||
return hermesApi<Record<string, EnvVarInfo>>({
|
||||
...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<CustomEndpointsResponse> {
|
||||
return hermesApi<CustomEndpointsResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/providers/custom-endpoints'
|
||||
})
|
||||
}
|
||||
|
||||
export function saveCustomEndpoint(endpoint: CustomEndpointUpdate): Promise<CustomEndpointsResponse> {
|
||||
return hermesApi<CustomEndpointsResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/providers/custom-endpoints',
|
||||
method: 'POST',
|
||||
body: endpoint
|
||||
})
|
||||
}
|
||||
|
||||
export function validateCustomEndpoint(endpoint: CustomEndpointUpdate): Promise<CustomEndpointValidationResponse> {
|
||||
return hermesApi<CustomEndpointValidationResponse>({
|
||||
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<CustomEndpointsResponse> {
|
||||
return hermesApi<CustomEndpointsResponse>({
|
||||
...profileScoped(),
|
||||
path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
export function listOAuthProviders(): Promise<OAuthProvidersResponse> {
|
||||
return hermesApi<OAuthProvidersResponse>({
|
||||
...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<OAuthStartResponse> {
|
||||
return window.hermesDesktop.api<OAuthStartResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
|
||||
method: 'POST',
|
||||
body: {}
|
||||
})
|
||||
}
|
||||
|
||||
export function submitOAuthCode(providerId: string, sessionId: string, code: string): Promise<OAuthSubmitResponse> {
|
||||
return hermesApi<OAuthSubmitResponse>({
|
||||
...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<OAuthPollResponse> {
|
||||
return window.hermesDesktop.api<OAuthPollResponse>({
|
||||
...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'
|
||||
})
|
||||
}
|
||||
@@ -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 (<HERMES_HOME>/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<CronJob[]> {
|
||||
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
|
||||
|
||||
return hermesApi<CronJob[]>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs${suffix}`,
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function getCronJob(jobId: string): Promise<CronJob> {
|
||||
return hermesApi<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCronJobRuns(jobId: string, limit = 20): Promise<SessionInfo[]> {
|
||||
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<CronDeliveryTarget[]> {
|
||||
const { targets } = await hermesApi<{ targets: CronDeliveryTarget[] }>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: '/api/cron/delivery-targets'
|
||||
})
|
||||
|
||||
return targets ?? []
|
||||
}
|
||||
|
||||
export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
|
||||
return hermesApi<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: '/api/cron/jobs',
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise<CronJob> {
|
||||
return hermesApi<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
|
||||
method: 'PUT',
|
||||
body: { updates }
|
||||
})
|
||||
}
|
||||
|
||||
export function pauseCronJob(jobId: string): Promise<CronJob> {
|
||||
return hermesApi<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`,
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function resumeCronJob(jobId: string): Promise<CronJob> {
|
||||
return hermesApi<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`,
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function triggerCronJob(jobId: string): Promise<CronJob> {
|
||||
return hermesApi<CronJob>({
|
||||
...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<string, string> },
|
||||
profile: string
|
||||
): Promise<CronJob> {
|
||||
return hermesApi<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`,
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
}
|
||||
@@ -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<McpTestResult> {
|
||||
return window.hermesDesktop.api<McpTestResult>({
|
||||
...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<string, Record<string, unknown>>,
|
||||
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<McpOAuthFlow> {
|
||||
return window.hermesDesktop.api<McpOAuthFlow>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/mcp/servers/${encodeURIComponent(name)}/auth`,
|
||||
method: 'POST',
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
}
|
||||
|
||||
export function getMcpOAuthFlow(flowId: string, profile?: ProfileScope): Promise<McpOAuthFlow> {
|
||||
return window.hermesDesktop.api<McpOAuthFlow>({
|
||||
...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<string, string>
|
||||
auth?: string
|
||||
}): Promise<McpServerSummary> {
|
||||
return hermesApi<McpServerSummary>({
|
||||
...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<McpCatalogResponse> {
|
||||
return window.hermesDesktop.api<McpCatalogResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/mcp/catalog'
|
||||
})
|
||||
}
|
||||
|
||||
export function installMcpCatalogEntry(
|
||||
name: string,
|
||||
env: Record<string, string> = {},
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -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<MessagingPlatformsResponse> {
|
||||
return hermesApi<MessagingPlatformsResponse>({
|
||||
...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<MessagingPlatformTestResponse> {
|
||||
return hermesApi<MessagingPlatformTestResponse>({
|
||||
...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<PairingResponse> {
|
||||
return hermesApi<PairingResponse>({
|
||||
...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<WebhooksResponse> {
|
||||
return hermesApi<WebhooksResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/webhooks'
|
||||
})
|
||||
}
|
||||
|
||||
export function enableWebhooks(): Promise<WebhookEnableResponse> {
|
||||
return hermesApi<WebhookEnableResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/webhooks/enable',
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function createWebhook(body: WebhookCreatePayload): Promise<WebhookCreateResponse> {
|
||||
return hermesApi<WebhookCreateResponse>({
|
||||
...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 }
|
||||
})
|
||||
}
|
||||
@@ -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<ModelInfoResponse> {
|
||||
return hermesApi<ModelInfoResponse>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/model/info',
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function getUsageAnalytics(days = 30, profile?: ProfileScope): Promise<AnalyticsResponse> {
|
||||
return window.hermesDesktop.api<AnalyticsResponse>({
|
||||
...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<ModelOptionsResponse> {
|
||||
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<ModelOptionsResponse>({
|
||||
...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<RecommendedDefaultModel> {
|
||||
return hermesApi<RecommendedDefaultModel>({
|
||||
...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<AuxiliaryModelsResponse> {
|
||||
return hermesApi<AuxiliaryModelsResponse>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/model/auxiliary'
|
||||
})
|
||||
}
|
||||
|
||||
export function getMoaModels(profile?: null | string): Promise<MoaConfigResponse> {
|
||||
return hermesApi<MoaConfigResponse>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/model/moa'
|
||||
})
|
||||
}
|
||||
|
||||
export function saveMoaModels(
|
||||
body: MoaConfigResponse,
|
||||
profile?: null | string
|
||||
): Promise<MoaConfigResponse & { ok: boolean }> {
|
||||
return hermesApi<MoaConfigResponse & { ok: boolean }>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/model/moa',
|
||||
method: 'PUT',
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
export function setModelAssignment(
|
||||
body: ModelAssignmentRequest,
|
||||
profile?: null | string
|
||||
): Promise<ModelAssignmentResponse> {
|
||||
return hermesApi<ModelAssignmentResponse>({
|
||||
...profileScoped(profile),
|
||||
path: '/api/model/set',
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
}
|
||||
@@ -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<HermesConnection> {
|
||||
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/<id>`.
|
||||
// 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/<pluginId>`
|
||||
* ('/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<T>(pluginId: string, path: string, opts: PluginRestOptions = {}): Promise<T> {
|
||||
if (!window.hermesDesktop?.api) {
|
||||
throw new Error('Hermes desktop bridge unavailable')
|
||||
}
|
||||
|
||||
const suffix = pluginPathSuffix('pluginRest', path)
|
||||
|
||||
return hermesApi<T>({
|
||||
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/<pluginId>` ('/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()
|
||||
}
|
||||
}
|
||||
@@ -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<ProfilesResponse> {
|
||||
return hermesApi<ProfilesResponse>({
|
||||
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<ProfileSoul> {
|
||||
return hermesApi<ProfileSoul>({
|
||||
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<ProfileSetupCommand> {
|
||||
return hermesApi<ProfileSetupCommand>({
|
||||
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<string, string>; 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
|
||||
})
|
||||
}
|
||||
@@ -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<PaginatedSessions> {
|
||||
const result = await hermesApi<PaginatedSessions>({
|
||||
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<PaginatedSessions> {
|
||||
const sourceParam = filter.source ? `&source=${encodeURIComponent(filter.source)}` : ''
|
||||
|
||||
const excludeParam = filter.excludeSources?.length
|
||||
? `&exclude_sources=${encodeURIComponent(filter.excludeSources.join(','))}`
|
||||
: ''
|
||||
|
||||
const result = await hermesApi<PaginatedSessions>({
|
||||
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<string, boolean>
|
||||
/** 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<string, { cost_usd: number; tokens: number }>
|
||||
}
|
||||
|
||||
/** 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<string, boolean> {
|
||||
const counts = new Map<string, number>()
|
||||
|
||||
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<SidebarSessionsResponse> {
|
||||
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<string, { number: number; url: string }>; scanned: string[] }> {
|
||||
return hermesApi<{
|
||||
pull_requests: Record<string, { number: number; url: string }>
|
||||
scanned: string[]
|
||||
}>({
|
||||
path: '/api/profiles/sessions/pull-requests',
|
||||
method: 'POST',
|
||||
body: { ids }
|
||||
})
|
||||
}
|
||||
|
||||
export async function listSidebarSessions(req: SidebarSessionsRequest): Promise<SidebarSessionsResponse> {
|
||||
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<SidebarSessionsResponse>({
|
||||
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<SessionSearchResponse> {
|
||||
return hermesApi<SessionSearchResponse>({
|
||||
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<SessionInfo> {
|
||||
const suffix = sessionScopeQuery(profile)
|
||||
|
||||
return hermesApi<SessionInfo>({
|
||||
...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<SessionMessagesResponse> {
|
||||
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<SessionMessagesResponse>({
|
||||
...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<SessionMessagesResponse> {
|
||||
// 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<SessionMessagesResponse> {
|
||||
return getSessionMessages(id, profile, { includeCompacted: true, limit, offset, order: 'latest' })
|
||||
}
|
||||
|
||||
export async function getAllSessionMessages(
|
||||
id: string,
|
||||
profile?: ProfileScope,
|
||||
options: { maxJsonChars?: number } = {}
|
||||
): Promise<SessionMessagesResponse> {
|
||||
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 } : {}) }
|
||||
})
|
||||
}
|
||||
@@ -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<SkillInfo[]> {
|
||||
return window.hermesDesktop.api<SkillInfo[]>({
|
||||
...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<StarmapGraph> {
|
||||
return hermesApi<StarmapGraph>({
|
||||
...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<LearningNodeDetail> {
|
||||
return window.hermesDesktop.api<LearningNodeDetail>({
|
||||
...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<SkillHubSourcesResponse> {
|
||||
return hermesApi<SkillHubSourcesResponse>({
|
||||
...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<SkillHubSearchResponse> {
|
||||
const params = new URLSearchParams({ q: query, source, limit: String(limit) })
|
||||
|
||||
return hermesApi<SkillHubSearchResponse>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/skills/hub/search?${params.toString()}`,
|
||||
timeoutMs: HUB_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function previewSkillHub(identifier: string, profile?: null | string): Promise<SkillHubPreview> {
|
||||
return hermesApi<SkillHubPreview>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`,
|
||||
timeoutMs: HUB_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function scanSkillHub(identifier: string, profile?: null | string): Promise<SkillHubScanResult> {
|
||||
return hermesApi<SkillHubScanResult>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/skills/hub/scan?identifier=${encodeURIComponent(identifier)}`,
|
||||
timeoutMs: HUB_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function installSkillFromHub(identifier: string, profile?: ProfileScope): Promise<ActionResponse> {
|
||||
return window.hermesDesktop.api<ActionResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/skills/hub/install',
|
||||
method: 'POST',
|
||||
body: { identifier }
|
||||
})
|
||||
}
|
||||
|
||||
export function uninstallSkillFromHub(name: string, profile?: ProfileScope): Promise<ActionResponse> {
|
||||
return window.hermesDesktop.api<ActionResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/skills/hub/uninstall',
|
||||
method: 'POST',
|
||||
body: { name }
|
||||
})
|
||||
}
|
||||
|
||||
export function updateSkillsFromHub(profile?: ProfileScope): Promise<ActionResponse> {
|
||||
return window.hermesDesktop.api<ActionResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: '/api/skills/hub/update',
|
||||
method: 'POST',
|
||||
body: {}
|
||||
})
|
||||
}
|
||||
@@ -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<MemoryProviderConfig> {
|
||||
return hermesApi<MemoryProviderConfig>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`
|
||||
})
|
||||
}
|
||||
|
||||
export function saveMemoryProviderConfig(
|
||||
provider: string,
|
||||
values: Record<string, string>,
|
||||
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<MemoryProviderOAuthStatus> {
|
||||
return hermesApi<MemoryProviderOAuthStatus>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/start`,
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function getMemoryProviderOAuthStatus(
|
||||
provider: string,
|
||||
profile?: null | string
|
||||
): Promise<MemoryProviderOAuthStatus> {
|
||||
return hermesApi<MemoryProviderOAuthStatus>({
|
||||
...profileScoped(profile),
|
||||
path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/status`
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memory data + curator (parity with `hermes memory` / `hermes curator`).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getMemoryStatus(): Promise<MemoryStatusResponse> {
|
||||
return hermesApi<MemoryStatusResponse>({
|
||||
...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<CuratorStatusResponse> {
|
||||
return hermesApi<CuratorStatusResponse>({
|
||||
...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<ActionResponse> {
|
||||
return hermesApi<ActionResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/curator/run',
|
||||
method: 'POST',
|
||||
body: {}
|
||||
})
|
||||
}
|
||||
|
||||
export function restartGateway(): Promise<ActionResponse> {
|
||||
return hermesApi<ActionResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/gateway/restart',
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function updateHermes(): Promise<ActionResponse> {
|
||||
return hermesApi<ActionResponse>({
|
||||
...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<BackendUpdateCheckResponse> {
|
||||
return hermesApi<BackendUpdateCheckResponse>({
|
||||
...profileScoped(),
|
||||
path: `/api/hermes/update/check${force ? '?force=true' : ''}`
|
||||
})
|
||||
}
|
||||
|
||||
export function getActionStatus(name: string, lines = 200, profile?: ProfileScope): Promise<ActionStatusResponse> {
|
||||
return window.hermesDesktop.api<ActionStatusResponse>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}`
|
||||
})
|
||||
}
|
||||
|
||||
export function transcribeAudio(dataUrl: string, mimeType?: string): Promise<AudioTranscriptionResponse> {
|
||||
return hermesApi<AudioTranscriptionResponse>({
|
||||
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<AudioSpeakResponse> {
|
||||
return hermesApi<AudioSpeakResponse>({
|
||||
...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<ElevenLabsVoicesResponse> {
|
||||
return hermesApi<ElevenLabsVoicesResponse>({
|
||||
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<ActionResponse> {
|
||||
return hermesApi<ActionResponse>({ path: '/api/ops/doctor', method: 'POST', body: {} })
|
||||
}
|
||||
|
||||
export function runSecurityAudit(): Promise<ActionResponse> {
|
||||
return hermesApi<ActionResponse>({ path: '/api/ops/security-audit', method: 'POST', body: {} })
|
||||
}
|
||||
|
||||
export function runBackup(): Promise<ActionResponse & { archive?: string }> {
|
||||
return hermesApi<ActionResponse & { archive?: string }>({
|
||||
path: '/api/ops/backup',
|
||||
method: 'POST',
|
||||
body: {}
|
||||
})
|
||||
}
|
||||
|
||||
export function runDebugShare(): Promise<DebugShareResponse> {
|
||||
return hermesApi<DebugShareResponse>({
|
||||
path: '/api/ops/debug-share',
|
||||
method: 'POST',
|
||||
body: {},
|
||||
// Synchronous upload of report + logs to the paste service.
|
||||
timeoutMs: 120_000
|
||||
})
|
||||
}
|
||||
@@ -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<ToolsetInfo[]> {
|
||||
return window.hermesDesktop.api<ToolsetInfo[]>({
|
||||
...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<ToolsetConfig> {
|
||||
return window.hermesDesktop.api<ToolsetConfig>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}/config`
|
||||
})
|
||||
}
|
||||
|
||||
export function getToolsetModels(
|
||||
name: string,
|
||||
provider?: string,
|
||||
profile?: ProfileScope
|
||||
): Promise<ToolsetModelsResponse> {
|
||||
const suffix = provider ? `?provider=${encodeURIComponent(provider)}` : ''
|
||||
|
||||
return window.hermesDesktop.api<ToolsetModelsResponse>({
|
||||
...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<SelectToolsetProviderResponse> {
|
||||
return window.hermesDesktop.api<SelectToolsetProviderResponse>({
|
||||
...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<ActionResponse & { key: string }> {
|
||||
return window.hermesDesktop.api<ActionResponse & { key: string }>({
|
||||
...capabilityScoped(profile),
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`,
|
||||
method: 'POST',
|
||||
body: { key }
|
||||
})
|
||||
}
|
||||
|
||||
export function getTerminalBackends(): Promise<TerminalBackendsResponse> {
|
||||
return hermesApi<TerminalBackendsResponse>({
|
||||
...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<ComputerUseStatus> {
|
||||
return hermesApi<ComputerUseStatus>({
|
||||
...profileScoped(),
|
||||
path: '/api/tools/computer-use/status'
|
||||
})
|
||||
}
|
||||
|
||||
export function grantComputerUsePermissions(): Promise<ActionResponse> {
|
||||
return hermesApi<ActionResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/tools/computer-use/permissions/grant',
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
@@ -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 = <T>(name: string, detail: T) => {
|
||||
window.setTimeout(() => window.dispatchEvent(new CustomEvent<T>(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 = <T>(name: string, detail: T) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent<T>(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<HTMLElement>(`[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<HTMLElement>(`[data-composer-target="${cssEscape(target)}"]`).some(
|
||||
surface => surface.dataset.composerSurfaceId === surfaceId
|
||||
)
|
||||
}
|
||||
|
||||
const subscribe = <T>(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<SubmitDetail>(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<SubmitDetail>(SUBMIT_EVENT, {
|
||||
surfaceId,
|
||||
target: resolvedTarget,
|
||||
text: trimmed,
|
||||
...(displayKind ? { displayKind } : {})
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) =>
|
||||
|
||||
@@ -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<SetStateAction<boolean>> | 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 (
|
||||
<ComposerScopeProvider value={{ ...MAIN_COMPOSER_SCOPE, target: scopeTarget }}>
|
||||
<ComposerSurfaceProvider value={resolvedSurfaceId}>
|
||||
<PaneVisibleContext.Provider value={paneVisible}>
|
||||
<div
|
||||
data-composer-surface-id={resolvedSurfaceId ?? undefined}
|
||||
data-composer-target={scopeTarget}
|
||||
data-pane-hidden={paneVisible ? undefined : ''}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</PaneVisibleContext.Provider>
|
||||
</ComposerSurfaceProvider>
|
||||
</ComposerScopeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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<boolean>, 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)
|
||||
|
||||
@@ -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 || '')
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -43,3 +43,15 @@ const ComposerScopeContext = createContext<ComposerScope>(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<string | null>(null)
|
||||
|
||||
export const ComposerSurfaceProvider = ComposerSurfaceContext.Provider
|
||||
|
||||
export const useComposerSurfaceId = (): string | null => useContext(ComposerSurfaceContext)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 (
|
||||
<ComposerSurfaceProvider value={composerSurfaceId}>
|
||||
<ChatViewContent {...props} />
|
||||
</ComposerSurfaceProvider>
|
||||
)
|
||||
})
|
||||
|
||||
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}
|
||||
>
|
||||
|
||||
@@ -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<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
|
||||
const promise = new Promise<T>(res => {
|
||||
resolve = res
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function makeAttachment(occurrenceId = createComposerAttachmentOccurrenceId()): ComposerAttachment {
|
||||
return {
|
||||
detail: HOST_PATH,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> = {}): 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<SidebarSessionGroup> & Pick<SidebarSessionGroup, 'id' | 'label'>): 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 `<repoRoot>/.worktrees/<slug>`,
|
||||
// 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',
|
||||
|
||||
@@ -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) => (
|
||||
<Command onValueChange={onRootValueChange}>
|
||||
|
||||
@@ -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<T>(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 <Status icon={<Loader2 className="size-3.5 animate-spin" />} text={copy.loading} />
|
||||
return <StatusRow icon={<Loader2 className="size-3.5 animate-spin" />} text={copy.loading} />
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return <Status text={copy.error} tone="error" />
|
||||
return <StatusRow text={copy.error} tone="error" />
|
||||
}
|
||||
|
||||
const results = query.data ?? []
|
||||
|
||||
if (results.length === 0) {
|
||||
return <Status text={copy.empty} />
|
||||
return <StatusRow text={copy.empty} />
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -156,17 +146,3 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 px-2 py-6 text-xs',
|
||||
tone === 'error' ? 'text-(--ui-red)' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 <Status icon={<Loader2 className="size-3.5 animate-spin" />} text={copy.loading} />
|
||||
return <StatusRow icon={<Loader2 className="size-3.5 animate-spin" />} text={copy.loading} />
|
||||
}
|
||||
|
||||
if (status === 'stale') {
|
||||
return <Status text={copy.staleBackend} tone="error" />
|
||||
return <StatusRow text={copy.staleBackend} tone="error" />
|
||||
}
|
||||
|
||||
if (!gallery?.pets.length && error) {
|
||||
return <Status text={error} tone="error" />
|
||||
return <StatusRow text={error} tone="error" />
|
||||
}
|
||||
|
||||
const mutating = Boolean(busy)
|
||||
@@ -95,7 +96,7 @@ export function PetPalettePage({ search, onGenerate }: PetPalettePageProps) {
|
||||
{error && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{error}</p>}
|
||||
|
||||
{shown.length === 0 ? (
|
||||
<Status text={copy.empty} />
|
||||
<StatusRow text={copy.empty} />
|
||||
) : (
|
||||
shown.map(pet => {
|
||||
const isActive = enabled && pet.slug === active
|
||||
@@ -198,17 +199,3 @@ export function PetInlineToggle() {
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 px-2 py-6 text-xs',
|
||||
tone === 'error' ? 'text-(--ui-red)' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 px-2 py-6 text-xs',
|
||||
tone === 'error' ? 'text-(--ui-red)' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> = {}): 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> = {}): SessionInfo => makeSessionInfo({ id: 'live', ...over })
|
||||
|
||||
describe('useDesktopIntegrations', () => {
|
||||
let navigate: ReturnType<typeof vi.fn<(...args: unknown[]) => void>>
|
||||
|
||||
@@ -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<string, Record<string, unknown>>
|
||||
|
||||
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
|
||||
|
||||
@@ -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<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
|
||||
const promise = new Promise<T>(res => {
|
||||
resolve = res
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('triggerAndRefreshCronJobs', () => {
|
||||
beforeEach(() => {
|
||||
getApiRequestConnection.mockReset()
|
||||
|
||||
@@ -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() {
|
||||
<Button
|
||||
className="min-w-0 flex-1 justify-center px-7 text-[0.7rem] text-muted-foreground/85 hover:text-foreground"
|
||||
disabled={!hasFiles}
|
||||
onClick={() => requestComposerSubmit(c.agentShipPrompt, { target: 'main' })}
|
||||
onClick={() => {
|
||||
if (!requestComposerSubmit(c.agentShipPrompt, { target: scopeTarget })) {
|
||||
notifyError(new Error(c.agentShipUnavailable), c.agentShip)
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { hiddenPaneProps, PANE_HIDDEN_ATTR } from '@/components/pane-shell/pane-visibility'
|
||||
import { $paneStates } from '@/store/panes'
|
||||
import { reactRoot } from '@/test/react-root'
|
||||
|
||||
import { installWindowStateBridge, setDocumentHidden, type WindowStateBridge } from '../../../test/window-state'
|
||||
import { $terminalTakeover } from '../store'
|
||||
|
||||
import { PersistentTerminal, TerminalSlot } from './persistent'
|
||||
@@ -24,54 +25,8 @@ vi.mock('./workspace', () => ({
|
||||
let resizeObserverCallback: ResizeObserverCallback | null = null
|
||||
let mutationObserverCallback: MutationCallback | null = null
|
||||
let mutationObserveCalls: Array<{ options?: MutationObserverInit; target: Node }> = []
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null
|
||||
|
||||
function render(ui: ReactNode) {
|
||||
container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
root = createRoot(container)
|
||||
|
||||
act(() => {
|
||||
root!.render(ui)
|
||||
})
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root!.unmount()
|
||||
})
|
||||
}
|
||||
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
}
|
||||
|
||||
function setVisibility(hidden: boolean) {
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, value: hidden })
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: hidden ? 'hidden' : 'visible' })
|
||||
}
|
||||
|
||||
function installWindowStateBridge() {
|
||||
windowStateCallback = null
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
onWindowStateChanged: vi.fn((callback: typeof windowStateCallback) => {
|
||||
windowStateCallback = callback
|
||||
|
||||
return () => {
|
||||
if (windowStateCallback === callback) {
|
||||
windowStateCallback = null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
const mount = reactRoot()
|
||||
let windowState: WindowStateBridge
|
||||
|
||||
function rect(top: number, left: number, width: number, height: number): DOMRect {
|
||||
return {
|
||||
@@ -146,9 +101,9 @@ function HiddenPaneHarness({ hidden }: { hidden: boolean }) {
|
||||
describe('PersistentTerminal rect tracking', () => {
|
||||
beforeEach(() => {
|
||||
;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
setVisibility(false)
|
||||
setDocumentHidden(false)
|
||||
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
|
||||
installWindowStateBridge()
|
||||
windowState = installWindowStateBridge()
|
||||
resizeObserverCallback = null
|
||||
mutationObserverCallback = null
|
||||
mutationObserveCalls = []
|
||||
@@ -181,11 +136,11 @@ describe('PersistentTerminal rect tracking', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
mount.unmount()
|
||||
$terminalTakeover.set(false)
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
setVisibility(false)
|
||||
setDocumentHidden(false)
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
@@ -194,7 +149,7 @@ describe('PersistentTerminal rect tracking', () => {
|
||||
let currentRect = rect(10, 20, 200, 100)
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => currentRect)
|
||||
|
||||
render(<Harness />)
|
||||
mount.render(<Harness />)
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -231,7 +186,7 @@ describe('PersistentTerminal rect tracking', () => {
|
||||
let currentRect = rect(10, 20, 200, 100)
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => currentRect)
|
||||
|
||||
render(<Harness />)
|
||||
mount.render(<Harness />)
|
||||
|
||||
expect(mutationObserveCalls.length).toBeGreaterThan(0)
|
||||
expect(mutationObserveCalls.every(call => call.options?.subtree === false)).toBe(true)
|
||||
@@ -240,7 +195,7 @@ describe('PersistentTerminal rect tracking', () => {
|
||||
raf.runNext()
|
||||
})
|
||||
|
||||
const overlay = container!.lastElementChild as HTMLElement
|
||||
const overlay = mount.container!.lastElementChild as HTMLElement
|
||||
expect(overlay.style.top).toBe('10px')
|
||||
expect(overlay.style.left).toBe('20px')
|
||||
expect(raf.pending()).toBe(0)
|
||||
@@ -271,7 +226,7 @@ describe('PersistentTerminal rect tracking', () => {
|
||||
const before = $paneStates.get()
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
|
||||
|
||||
render(<Harness />)
|
||||
mount.render(<Harness />)
|
||||
raf.runNext()
|
||||
expect(raf.pending()).toBe(0)
|
||||
|
||||
@@ -291,12 +246,12 @@ describe('PersistentTerminal rect tracking', () => {
|
||||
const raf = installRaf()
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
|
||||
|
||||
render(<Harness />)
|
||||
mount.render(<Harness />)
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
windowStateCallback?.({ isMinimized: true, isVisible: false })
|
||||
windowState.emit({ isMinimized: true, isVisible: false })
|
||||
})
|
||||
|
||||
expect(raf.cancel).toHaveBeenCalledTimes(1)
|
||||
@@ -309,7 +264,7 @@ describe('PersistentTerminal rect tracking', () => {
|
||||
expect(raf.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
windowStateCallback?.({ isMinimized: false, isVisible: true })
|
||||
windowState.emit({ isMinimized: false, isVisible: true })
|
||||
})
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(2)
|
||||
@@ -319,7 +274,7 @@ describe('PersistentTerminal rect tracking', () => {
|
||||
const raf = installRaf()
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
|
||||
|
||||
render(<Harness />)
|
||||
mount.render(<Harness />)
|
||||
expect(raf.pending()).toBe(1)
|
||||
|
||||
act(() => window.dispatchEvent(new Event('blur')))
|
||||
@@ -328,7 +283,7 @@ describe('PersistentTerminal rect tracking', () => {
|
||||
act(() => window.dispatchEvent(new Event('focus')))
|
||||
expect(raf.pending()).toBe(1)
|
||||
|
||||
cleanup()
|
||||
mount.unmount()
|
||||
expect(raf.pending()).toBe(0)
|
||||
|
||||
act(() => {
|
||||
@@ -344,7 +299,7 @@ describe('PersistentTerminal rect tracking', () => {
|
||||
vi.mocked(document.hasFocus).mockReturnValue(false)
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
|
||||
|
||||
render(<Harness />)
|
||||
mount.render(<Harness />)
|
||||
|
||||
expect(raf.request).not.toHaveBeenCalled()
|
||||
|
||||
@@ -358,10 +313,10 @@ describe('PersistentTerminal rect tracking', () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
|
||||
$terminalTakeover.set(true)
|
||||
|
||||
render(<HiddenPaneHarness hidden={false} />)
|
||||
mount.render(<HiddenPaneHarness hidden={false} />)
|
||||
|
||||
const overlay = container!.lastElementChild as HTMLElement
|
||||
const workspace = container!.querySelector('[data-testid="terminal-workspace"]')
|
||||
const overlay = mount.container!.lastElementChild as HTMLElement
|
||||
const workspace = mount.container!.querySelector('[data-testid="terminal-workspace"]')
|
||||
|
||||
expect(overlay.style.visibility).toBe('visible')
|
||||
expect(overlay.style.opacity).toBe('1')
|
||||
@@ -377,7 +332,7 @@ describe('PersistentTerminal rect tracking', () => {
|
||||
expect(raf.pending()).toBe(0)
|
||||
|
||||
act(() => {
|
||||
root!.render(<HiddenPaneHarness hidden />)
|
||||
mount.root!.render(<HiddenPaneHarness hidden />)
|
||||
mutationObserverCallback?.([], {} as MutationObserver)
|
||||
})
|
||||
expect(raf.pending()).toBe(1)
|
||||
@@ -389,7 +344,7 @@ describe('PersistentTerminal rect tracking', () => {
|
||||
expect(overlay.style.visibility).toBe('hidden')
|
||||
expect(overlay.style.opacity).toBe('0')
|
||||
expect(overlay.style.pointerEvents).toBe('none')
|
||||
expect(container!.querySelector('[data-testid="terminal-workspace"]')).toBe(workspace)
|
||||
expect(mount.container!.querySelector('[data-testid="terminal-workspace"]')).toBe(workspace)
|
||||
|
||||
act(() => {
|
||||
raf.runNext()
|
||||
@@ -397,7 +352,7 @@ describe('PersistentTerminal rect tracking', () => {
|
||||
expect(raf.pending()).toBe(0)
|
||||
|
||||
act(() => {
|
||||
root!.render(<HiddenPaneHarness hidden={false} />)
|
||||
mount.root!.render(<HiddenPaneHarness hidden={false} />)
|
||||
mutationObserverCallback?.([], {} as MutationObserver)
|
||||
})
|
||||
|
||||
@@ -408,6 +363,6 @@ describe('PersistentTerminal rect tracking', () => {
|
||||
expect(overlay.style.visibility).toBe('visible')
|
||||
expect(overlay.style.opacity).toBe('1')
|
||||
expect(overlay.style.pointerEvents).toBe('auto')
|
||||
expect(container!.querySelector('[data-testid="terminal-workspace"]')).toBe(workspace)
|
||||
expect(mount.container!.querySelector('[data-testid="terminal-workspace"]')).toBe(workspace)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,20 +13,12 @@ import {
|
||||
setNewChatWorkspaceTarget
|
||||
} from '@/store/session'
|
||||
|
||||
import { deferred } from '../../../test/deferred'
|
||||
|
||||
import { useCwdActions } from './use-cwd-actions'
|
||||
|
||||
type CwdActionsHandle = ReturnType<typeof useCwdActions>
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
|
||||
const promise = new Promise<T>(done => {
|
||||
resolve = done
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function Harness({
|
||||
activeSessionIdRef,
|
||||
onReady,
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
setDefaultReasoningEffort
|
||||
} from '@/store/session'
|
||||
|
||||
import { deferred } from '../../../test/deferred'
|
||||
|
||||
import { useHermesConfig } from './use-hermes-config'
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
@@ -27,16 +29,6 @@ vi.mock('@/hermes', () => ({
|
||||
|
||||
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
|
||||
const promise = new Promise<T>(done => {
|
||||
resolve = done
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
const mockConfig = (config: Record<string, unknown>) =>
|
||||
vi.mocked(getHermesConfig).mockResolvedValue(config as Awaited<ReturnType<typeof getHermesConfig>>)
|
||||
|
||||
|
||||
@@ -1,59 +1,24 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { textPart } from '@/lib/chat-messages'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $notifications, clearNotifications } from '@/store/notifications'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
|
||||
|
||||
const SID = 'rt-new-session'
|
||||
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
let sessionStates: Map<string, ClientSessionState> | null = null
|
||||
let stream: MessageStreamHarness
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
sessionStates = sessionStateByRuntimeIdRef.current
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function mountStream() {
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
function mountStream() {
|
||||
stream = renderMessageStream(SID)
|
||||
}
|
||||
|
||||
/** Seed the session as it looks right after a first-message submit: the
|
||||
* optimistic user row is present, the turn is awaiting its response. */
|
||||
function seedOptimisticFirstMessage() {
|
||||
sessionStates!.set(SID, {
|
||||
stream.states.set(SID, {
|
||||
...createClientSessionState('stored-new-session', [
|
||||
{ id: 'user-123-abc', role: 'user', parts: [textPart('first message of a new chat')] }
|
||||
]),
|
||||
@@ -64,8 +29,6 @@ function seedOptimisticFirstMessage() {
|
||||
|
||||
describe('useMessageStream agent-init error surfacing (#63078)', () => {
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
sessionStates = null
|
||||
clearNotifications()
|
||||
})
|
||||
|
||||
@@ -76,11 +39,11 @@ describe('useMessageStream agent-init error surfacing (#63078)', () => {
|
||||
})
|
||||
|
||||
it('renders an agent-init failure as a visible in-transcript error and keeps the optimistic first message', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
seedOptimisticFirstMessage()
|
||||
|
||||
act(() =>
|
||||
handleEvent!({
|
||||
stream.handleEvent({
|
||||
payload: {
|
||||
message:
|
||||
'agent initialization timed out after 601s — your message was not sent; retry once the session is ready'
|
||||
@@ -90,7 +53,7 @@ describe('useMessageStream agent-init error surfacing (#63078)', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const state = sessionStates!.get(SID)!
|
||||
const state = stream.state()
|
||||
|
||||
// The user's optimistic first message must survive — the failure mode of
|
||||
// #63078 was the message silently vanishing into a blank session.
|
||||
@@ -111,19 +74,19 @@ describe('useMessageStream agent-init error surfacing (#63078)', () => {
|
||||
expect($notifications.get().some(n => n.kind === 'error' && n.message?.includes('was not sent'))).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the pre-ready cancel error event (#65567 server emit) visibly', async () => {
|
||||
await mountStream()
|
||||
it('renders the pre-ready cancel error event (#65567 server emit) visibly', () => {
|
||||
mountStream()
|
||||
seedOptimisticFirstMessage()
|
||||
|
||||
act(() =>
|
||||
handleEvent!({
|
||||
stream.handleEvent({
|
||||
payload: { message: 'Turn cancelled before the agent was ready' },
|
||||
session_id: SID,
|
||||
type: 'error'
|
||||
})
|
||||
)
|
||||
|
||||
const state = sessionStates!.get(SID)!
|
||||
const state = stream.state()
|
||||
expect(state.messages.some(m => m.role === 'assistant' && m.error?.includes('cancelled'))).toBe(true)
|
||||
expect(state.messages.some(m => m.id === 'user-123-abc')).toBe(true)
|
||||
expect(state.busy).toBe(false)
|
||||
|
||||
+14
-49
@@ -1,55 +1,20 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $approvalModes, approvalModeForProfile } from '@/store/approval-mode'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
|
||||
|
||||
const ACTIVE_SID = 'session-active'
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
let stream: MessageStreamHarness
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(ACTIVE_SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function mountStream() {
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
function mountStream() {
|
||||
stream = renderMessageStream(ACTIVE_SID)
|
||||
}
|
||||
|
||||
describe('live session.info approval mode reconciliation', () => {
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
$approvalModes.set({})
|
||||
$activeGatewayProfile.set('work')
|
||||
})
|
||||
@@ -59,11 +24,11 @@ describe('live session.info approval mode reconciliation', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reconciles an active-session event under its source gateway profile', async () => {
|
||||
await mountStream()
|
||||
it('reconciles an active-session event under its source gateway profile', () => {
|
||||
mountStream()
|
||||
|
||||
act(() =>
|
||||
handleEvent!({
|
||||
stream.handleEvent({
|
||||
payload: { approval_mode: 'off' },
|
||||
profile: 'work',
|
||||
session_id: ACTIVE_SID,
|
||||
@@ -75,11 +40,11 @@ describe('live session.info approval mode reconciliation', () => {
|
||||
expect(approvalModeForProfile('default')).toBe('smart')
|
||||
})
|
||||
|
||||
it('ignores stale session.info from a non-active session on the active gateway', async () => {
|
||||
await mountStream()
|
||||
it('ignores stale session.info from a non-active session on the active gateway', () => {
|
||||
mountStream()
|
||||
|
||||
act(() =>
|
||||
handleEvent!({
|
||||
stream.handleEvent({
|
||||
payload: { approval_mode: 'off' },
|
||||
profile: 'work',
|
||||
session_id: 'session-stale',
|
||||
@@ -90,11 +55,11 @@ describe('live session.info approval mode reconciliation', () => {
|
||||
expect(approvalModeForProfile('work')).toBe('smart')
|
||||
})
|
||||
|
||||
it('does not cache an event under a different active profile when its source profile is absent', async () => {
|
||||
await mountStream()
|
||||
it('does not cache an event under a different active profile when its source profile is absent', () => {
|
||||
mountStream()
|
||||
$activeGatewayProfile.set('personal')
|
||||
|
||||
act(() => handleEvent!({ payload: { approval_mode: 'off' }, session_id: ACTIVE_SID, type: 'session.info' }))
|
||||
act(() => stream.handleEvent({ payload: { approval_mode: 'off' }, session_id: ACTIVE_SID, type: 'session.info' }))
|
||||
|
||||
expect(approvalModeForProfile('personal')).toBe('smart')
|
||||
expect(approvalModeForProfile('work')).toBe('smart')
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { type MutableRefObject, useEffect, useRef } from 'react'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $clarifyRequests, clearClarifyRequest } from '@/store/clarify'
|
||||
import { onScrollToBottomRequest } from '@/store/thread-scroll'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
|
||||
|
||||
// A `clarify.request` must leave an answerable inline row even when the
|
||||
// `tool.start` that normally mounts it was missed (stream reconnect /
|
||||
@@ -18,62 +13,29 @@ import { useMessageStream } from './index'
|
||||
|
||||
const SID = 'session-1'
|
||||
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
let stateRef: MutableRefObject<Map<string, ClientSessionState>> | null = null
|
||||
let stream: MessageStreamHarness
|
||||
let stopScrollListener: (() => void) | null = null
|
||||
|
||||
const scrollToBottom = vi.fn()
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
stateRef = sessionStateByRuntimeIdRef
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function mountStream() {
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
function mountStream() {
|
||||
stream = renderMessageStream(SID)
|
||||
}
|
||||
|
||||
const clarifyRequest = (payload: Record<string, unknown>) =>
|
||||
act(() => handleEvent!({ payload, session_id: SID, type: 'clarify.request' }))
|
||||
act(() => stream.handleEvent({ payload, session_id: SID, type: 'clarify.request' }))
|
||||
|
||||
const toolStart = (payload: Record<string, unknown>) =>
|
||||
act(() => handleEvent!({ payload, session_id: SID, type: 'tool.start' }))
|
||||
act(() => stream.handleEvent({ payload, session_id: SID, type: 'tool.start' }))
|
||||
|
||||
function clarifyParts() {
|
||||
const messages = stateRef?.current.get(SID)?.messages ?? []
|
||||
const messages = stream.state().messages ?? []
|
||||
|
||||
return messages.flatMap(m => m.parts).filter(p => p.type === 'tool-call' && p.toolName === 'clarify')
|
||||
}
|
||||
|
||||
describe('clarify.request stream hydration', () => {
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
stateRef = null
|
||||
clearClarifyRequest()
|
||||
scrollToBottom.mockClear()
|
||||
stopScrollListener = onScrollToBottomRequest(scrollToBottom)
|
||||
@@ -87,8 +49,8 @@ describe('clarify.request stream hydration', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('mounts an answerable clarify row when the tool.start row was missed', async () => {
|
||||
await mountStream()
|
||||
it('mounts an answerable clarify row when the tool.start row was missed', () => {
|
||||
mountStream()
|
||||
|
||||
clarifyRequest({ choices: ['yes', 'no'], question: 'Ship it?', request_id: 'req-1' })
|
||||
|
||||
@@ -101,19 +63,19 @@ describe('clarify.request stream hydration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reveals a clarify prompt raised by the active session', async () => {
|
||||
await mountStream()
|
||||
it('reveals a clarify prompt raised by the active session', () => {
|
||||
mountStream()
|
||||
|
||||
clarifyRequest({ choices: ['yes', 'no'], question: 'Ship it?', request_id: 'req-reveal' })
|
||||
|
||||
expect(scrollToBottom).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not move the active thread for a background session clarify', async () => {
|
||||
await mountStream()
|
||||
it('does not move the active thread for a background session clarify', () => {
|
||||
mountStream()
|
||||
|
||||
act(() =>
|
||||
handleEvent!({
|
||||
stream.handleEvent({
|
||||
payload: { choices: ['yes', 'no'], question: 'Ship it?', request_id: 'req-background' },
|
||||
session_id: 'session-background',
|
||||
type: 'clarify.request'
|
||||
@@ -123,8 +85,8 @@ describe('clarify.request stream hydration', () => {
|
||||
expect(scrollToBottom).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves multi-select through the store and hydrated tool row', async () => {
|
||||
await mountStream()
|
||||
it('preserves multi-select through the store and hydrated tool row', () => {
|
||||
mountStream()
|
||||
|
||||
clarifyRequest({
|
||||
choices: ['read', 'write'],
|
||||
@@ -149,8 +111,8 @@ describe('clarify.request stream hydration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('merges with the real tool.start row even though its id differs from the request id', async () => {
|
||||
await mountStream()
|
||||
it('merges with the real tool.start row even though its id differs from the request id', () => {
|
||||
mountStream()
|
||||
|
||||
// Reality: tool.start carries the model's tool_call_id, clarify.request a
|
||||
// separately-generated request_id. They must still collapse to ONE card
|
||||
@@ -161,8 +123,8 @@ describe('clarify.request stream hydration', () => {
|
||||
expect(clarifyParts()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not duplicate when clarify.request arrives before the tool.start row', async () => {
|
||||
await mountStream()
|
||||
it('does not duplicate when clarify.request arrives before the tool.start row', () => {
|
||||
mountStream()
|
||||
|
||||
clarifyRequest({ choices: ['a'], question: 'Pick', request_id: 'req-3' })
|
||||
toolStart({ args: { choices: ['a'], question: 'Pick' }, name: 'clarify', tool_id: 'call-xyz' })
|
||||
@@ -170,8 +132,8 @@ describe('clarify.request stream hydration', () => {
|
||||
expect(clarifyParts()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('merges a BATCH tool.start row with its clarify.request (no top-level question)', async () => {
|
||||
await mountStream()
|
||||
it('merges a BATCH tool.start row with its clarify.request (no top-level question)', () => {
|
||||
mountStream()
|
||||
|
||||
// The batch shape: tool args carry `questions`, no top-level `question`.
|
||||
// The correlation key must come from the question list, or the two ids
|
||||
@@ -190,10 +152,11 @@ describe('clarify.request stream hydration', () => {
|
||||
})
|
||||
|
||||
expect(clarifyParts()).toHaveLength(1)
|
||||
expect($clarifyRequests.get()[SID]?.questions).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not duplicate when the batch clarify.request arrives before tool.start', async () => {
|
||||
await mountStream()
|
||||
it('does not duplicate when the batch clarify.request arrives before tool.start', () => {
|
||||
mountStream()
|
||||
|
||||
clarifyRequest({
|
||||
questions: [
|
||||
@@ -209,5 +172,6 @@ describe('clarify.request stream hydration', () => {
|
||||
})
|
||||
|
||||
expect(clarifyParts()).toHaveLength(1)
|
||||
expect($clarifyRequests.get()[SID]?.questions).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,59 +1,25 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $compactingSessions, setSessionCompacting } from '@/store/compaction'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
|
||||
|
||||
const SID = 'session-1'
|
||||
const OTHER_SID = 'session-2'
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
let stream: MessageStreamHarness
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function mountStream() {
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
function mountStream() {
|
||||
stream = renderMessageStream(SID)
|
||||
}
|
||||
|
||||
function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}) {
|
||||
act(() => handleEvent!({ payload, session_id: SID, type }))
|
||||
act(() => stream.handleEvent({ payload, session_id: SID, type }))
|
||||
}
|
||||
|
||||
describe('useMessageStream compaction lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
$compactingSessions.set({})
|
||||
})
|
||||
|
||||
@@ -68,8 +34,8 @@ describe('useMessageStream compaction lifecycle', () => {
|
||||
['thinking.delta', { text: 'still working' }],
|
||||
['reasoning.delta', { text: 'thinking again' }],
|
||||
['tool.start', { name: 'terminal', tool_id: 'tool-1' }]
|
||||
] as const)('clears the stale compaction phase when %s resumes the turn', async (type, payload) => {
|
||||
await mountStream()
|
||||
] as const)('clears the stale compaction phase when %s resumes the turn', (type, payload) => {
|
||||
mountStream()
|
||||
setSessionCompacting(OTHER_SID, true)
|
||||
|
||||
emit('status.update', { kind: 'compacting' })
|
||||
@@ -80,8 +46,8 @@ describe('useMessageStream compaction lifecycle', () => {
|
||||
expect($compactingSessions.get()).toEqual({ [OTHER_SID]: true })
|
||||
})
|
||||
|
||||
it('clears the compaction phase on the structured completion edge', async () => {
|
||||
await mountStream()
|
||||
it('clears the compaction phase on the structured completion edge', () => {
|
||||
mountStream()
|
||||
setSessionCompacting(OTHER_SID, true)
|
||||
|
||||
emit('status.update', { kind: 'compacting' })
|
||||
|
||||
+11
-48
@@ -1,10 +1,6 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import {
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
@@ -12,50 +8,17 @@ import {
|
||||
setCurrentModelSource,
|
||||
setCurrentProvider
|
||||
} from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
|
||||
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
let stream: MessageStreamHarness
|
||||
|
||||
function Harness({ activeSessionId }: { activeSessionId: string | null }) {
|
||||
const sessionIdRef = useRef<string | null>(activeSessionId)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
sessionIdRef.current = activeSessionId
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef: sessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function mountStream(activeSessionId: string | null) {
|
||||
render(<Harness activeSessionId={activeSessionId} />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
function mountStream(activeSessionId: string | null) {
|
||||
stream = renderMessageStream(activeSessionId)
|
||||
}
|
||||
|
||||
describe('session.info does not clobber composer model selection', () => {
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
setCurrentModel('deepseek-v4-flash')
|
||||
setCurrentProvider('deepseek')
|
||||
setCurrentModelSource('manual')
|
||||
@@ -69,11 +32,11 @@ describe('session.info does not clobber composer model selection', () => {
|
||||
setCurrentModelSource('')
|
||||
})
|
||||
|
||||
it('keeps a sticky manual pick when a global session.info carries the profile default', async () => {
|
||||
await mountStream(null)
|
||||
it('keeps a sticky manual pick when a global session.info carries the profile default', () => {
|
||||
mountStream(null)
|
||||
|
||||
act(() =>
|
||||
handleEvent!({
|
||||
stream.handleEvent({
|
||||
payload: { model: 'deepseek-chat', provider: 'deepseek' },
|
||||
type: 'session.info'
|
||||
})
|
||||
@@ -83,11 +46,11 @@ describe('session.info does not clobber composer model selection', () => {
|
||||
expect($currentProvider.get()).toBe('deepseek')
|
||||
})
|
||||
|
||||
it('keeps the composer pick when an unscoped session.info arrives with no live session', async () => {
|
||||
await mountStream(null)
|
||||
it('keeps the composer pick when an unscoped session.info arrives with no live session', () => {
|
||||
mountStream(null)
|
||||
|
||||
act(() =>
|
||||
handleEvent!({
|
||||
stream.handleEvent({
|
||||
payload: { cwd: '/tmp/project', model: 'deepseek-chat', provider: 'deepseek' },
|
||||
type: 'session.info'
|
||||
})
|
||||
|
||||
@@ -9,10 +9,12 @@ import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
|
||||
import { useSessionStateCache } from '../use-session-state-cache'
|
||||
|
||||
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
|
||||
const SID = 'session-1'
|
||||
let appendAssistantDelta: ((sessionId: string, delta: string) => void) | null = null
|
||||
let stream: MessageStreamHarness
|
||||
let states: Map<string, ClientSessionState>
|
||||
type UpdateSessionState = (
|
||||
sessionId: string,
|
||||
@@ -21,44 +23,17 @@ type UpdateSessionState = (
|
||||
) => ClientSessionState
|
||||
let updateSessionState: ReturnType<typeof vi.fn<UpdateSessionState>>
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(states)
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
appendAssistantDelta = stream.appendAssistantDelta
|
||||
}, [stream.appendAssistantDelta])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** The shared harness, but writing through a spy so the unmount test can count
|
||||
* the writes that happen after teardown. */
|
||||
function mountStream() {
|
||||
render(<Harness />)
|
||||
expect(appendAssistantDelta).not.toBeNull()
|
||||
stream = renderMessageStream(SID, { states, updateSessionState })
|
||||
}
|
||||
|
||||
function assistantText() {
|
||||
const message = states.get(SID)?.messages.at(-1)
|
||||
const part = message?.parts.at(-1)
|
||||
|
||||
return part?.type === 'text' ? part.text : ''
|
||||
}
|
||||
const assistantText = () => stream.text()
|
||||
|
||||
describe('useMessageStream delta flush scheduling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
appendAssistantDelta = null
|
||||
states = new Map()
|
||||
updateSessionState = vi.fn((sessionId: string, updater: (state: ClientSessionState) => ClientSessionState) => {
|
||||
const next = updater(states.get(sessionId) ?? createClientSessionState())
|
||||
@@ -81,7 +56,7 @@ describe('useMessageStream delta flush scheduling', () => {
|
||||
it('flushes streaming text on a bounded timer while the window is unfocused', async () => {
|
||||
mountStream()
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'still streaming'))
|
||||
act(() => stream.appendDelta(SID, 'still streaming'))
|
||||
|
||||
expect(window.requestAnimationFrame).not.toHaveBeenCalled()
|
||||
expect(assistantText()).toBe('')
|
||||
@@ -97,7 +72,7 @@ describe('useMessageStream delta flush scheduling', () => {
|
||||
vi.mocked(performance.now).mockReturnValue(0)
|
||||
mountStream()
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'caught up on focus'))
|
||||
act(() => stream.appendDelta(SID, 'caught up on focus'))
|
||||
expect(assistantText()).toBe('')
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
@@ -120,7 +95,7 @@ describe('useMessageStream delta flush scheduling', () => {
|
||||
})
|
||||
mountStream()
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'focused without visibility change'))
|
||||
act(() => stream.appendDelta(SID, 'focused without visibility change'))
|
||||
expect(assistantText()).toBe('')
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
@@ -133,7 +108,7 @@ describe('useMessageStream delta flush scheduling', () => {
|
||||
vi.mocked(performance.now).mockReturnValue(0)
|
||||
mountStream()
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'final delta'))
|
||||
act(() => stream.appendDelta(SID, 'final delta'))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
cleanup()
|
||||
@@ -164,7 +139,7 @@ describe('useMessageStream delta flush scheduling', () => {
|
||||
|
||||
mountStream()
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'first'))
|
||||
act(() => stream.appendDelta(SID, 'first'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
@@ -177,7 +152,7 @@ describe('useMessageStream delta flush scheduling', () => {
|
||||
now = 1100
|
||||
act(() => rafCallbacks[0](1040))
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'second'))
|
||||
act(() => stream.appendDelta(SID, 'second'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(79)
|
||||
})
|
||||
@@ -201,7 +176,7 @@ describe('useMessageStream delta flush scheduling', () => {
|
||||
|
||||
mountStream()
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'first'))
|
||||
act(() => stream.appendDelta(SID, 'first'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
@@ -210,7 +185,7 @@ describe('useMessageStream delta flush scheduling', () => {
|
||||
|
||||
// 100ms later (well past the 33ms floor): the next flush is immediate.
|
||||
now = 1100
|
||||
act(() => appendAssistantDelta!(SID, 'second'))
|
||||
act(() => stream.appendDelta(SID, 'second'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
@@ -230,14 +205,14 @@ describe('useMessageStream delta flush scheduling', () => {
|
||||
|
||||
mountStream()
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'a'))
|
||||
act(() => stream.appendDelta(SID, 'a'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
// A second flush starts before the first flush's frame lands.
|
||||
now = 1010
|
||||
act(() => appendAssistantDelta!(SID, 'b'))
|
||||
act(() => stream.appendDelta(SID, 'b'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(23)
|
||||
})
|
||||
@@ -250,7 +225,7 @@ describe('useMessageStream delta flush scheduling', () => {
|
||||
now = 1030
|
||||
act(() => rafCallbacks[0](1000))
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'c'))
|
||||
act(() => stream.appendDelta(SID, 'c'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(13)
|
||||
})
|
||||
@@ -267,6 +242,7 @@ describe('useMessageStream composed with the real useSessionStateCache', () => {
|
||||
// measured frame cost includes the deferred $messages commit it adapts to.
|
||||
let cache: ReturnType<typeof useSessionStateCache> | null = null
|
||||
let published: ChatMessage[]
|
||||
let appendAssistantDelta: ((sessionId: string, delta: string) => void) | null = null
|
||||
|
||||
function ComposedHarness() {
|
||||
const busyRef: MutableRefObject<boolean> = { current: false }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+217
@@ -0,0 +1,217 @@
|
||||
import { readActivePreview } from '@/app/chat/right-rail/preview-reader'
|
||||
import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream'
|
||||
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
|
||||
import { closeAgentTerminalByProc } from '@/app/right-sidebar/terminal/terminals'
|
||||
import type { TourAction, TourStep } from '@/lib/tour'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { applyDesktopLayoutPreset, revealDesktopPane } from '@/store/pane-focus'
|
||||
import { recordAgentReaction } from '@/store/reactions-local'
|
||||
import { setMessages } from '@/store/session'
|
||||
|
||||
import type { GatewayEventContext } from './types'
|
||||
|
||||
/** Desktop-surface bridge events: read-back requests the agent blocks on
|
||||
* (terminal/preview/window), agent terminal streaming, pane reveal, and
|
||||
* message reactions. */
|
||||
export function handleDesktopBridgeEvent(ctx: GatewayEventContext): boolean {
|
||||
const { event, payload, isActiveEvent } = ctx
|
||||
|
||||
if (event.type === 'terminal.read.request') {
|
||||
// read_terminal tool: serialize the renderer's xterm buffer and answer
|
||||
// immediately (Python blocks on the respond). Empty text = no live pane.
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
|
||||
if (requestId) {
|
||||
const start = typeof payload?.start === 'number' ? payload.start : undefined
|
||||
const count = typeof payload?.count === 'number' ? payload.count : undefined
|
||||
const result = readActiveTerminal({ start, count })
|
||||
|
||||
void $gateway.get()?.request('terminal.read.respond', {
|
||||
request_id: requestId,
|
||||
text: result ? JSON.stringify(result) : ''
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'preview.read.request') {
|
||||
// read_preview tool: serialize the active preview tab (a Browser
|
||||
// webview's page text is async) and answer. Empty text = nothing open.
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
|
||||
if (requestId) {
|
||||
const start = typeof payload?.start === 'number' ? payload.start : undefined
|
||||
const count = typeof payload?.count === 'number' ? payload.count : undefined
|
||||
|
||||
void readActivePreview({ count, start }).then(result => {
|
||||
void $gateway.get()?.request('preview.read.respond', {
|
||||
request_id: requestId,
|
||||
text: result ? JSON.stringify(result) : ''
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'window.read.request') {
|
||||
// read_window_below tool: main owns native window enumeration, so ask
|
||||
// it over IPC and answer. Empty text = unavailable (no bridge, or
|
||||
// enumeration unsupported on this system e.g. Wayland).
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
|
||||
if (requestId) {
|
||||
const read = window.hermesDesktop?.readWindowBelow
|
||||
|
||||
const answer = (result: unknown) =>
|
||||
$gateway.get()?.request('window.read.respond', {
|
||||
request_id: requestId,
|
||||
text: result ? JSON.stringify(result) : ''
|
||||
})
|
||||
|
||||
// .catch: ipcRenderer.invoke rejects on an older shell without the
|
||||
// handler or a main-side throw — without an empty answer the tool
|
||||
// would stall its full 30s timeout.
|
||||
void Promise.resolve(read ? read() : null).then(answer, () => answer(null))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'agent.terminal.output') {
|
||||
// Live chunk from a background process → its read-only agent terminal tab.
|
||||
writeAgentTerminalChunk(payload?.process_id ?? '', payload?.chunk ?? '')
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'terminal.close') {
|
||||
// Agent closed its own read-only tab via the desktop-gated close_terminal tool.
|
||||
// The process is untouched — this only drops the view.
|
||||
closeAgentTerminalByProc(payload?.process_id ?? '')
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'tour.request') {
|
||||
// tour tool: run one guided-tour action (highlight/step/discover) via
|
||||
// driver.js — on the app's own DOM or inside the preview pane's guest
|
||||
// page — and answer with the outcome. Dynamic import keeps driver.js
|
||||
// and the preview injection payload off the boot path. Active session
|
||||
// only: a background turn must never paint overlays on the user's
|
||||
// screen (desktop AGENTS.md: offer, don't hijack).
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
|
||||
if (requestId) {
|
||||
const answer = (result: unknown) =>
|
||||
$gateway.get()?.request('tour.respond', {
|
||||
request_id: requestId,
|
||||
text: result ? JSON.stringify(result) : ''
|
||||
})
|
||||
|
||||
if (isActiveEvent) {
|
||||
void import('@/lib/tour')
|
||||
.then(({ runTour }) =>
|
||||
runTour(
|
||||
{
|
||||
kind: (payload?.action ?? 'stop') as TourAction['kind'],
|
||||
selector: payload?.selector,
|
||||
side: payload?.side as TourStep['side'],
|
||||
startAt: payload?.step_index,
|
||||
steps: payload?.steps as TourStep[] | undefined,
|
||||
text: payload?.text,
|
||||
title: payload?.title
|
||||
},
|
||||
payload?.surface === 'preview' ? 'preview' : 'app'
|
||||
)
|
||||
)
|
||||
.then(answer, error =>
|
||||
answer({ error: error instanceof Error ? error.message : String(error), success: false })
|
||||
)
|
||||
} else {
|
||||
void answer({
|
||||
error: 'Tours only run in the session the user is looking at.',
|
||||
success: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'pane.reveal') {
|
||||
// Agent revealed a pane via the desktop-gated focus_pane tool, in
|
||||
// response to an explicit user request. Active session only — a
|
||||
// background turn must never move the user's focus (desktop AGENTS.md:
|
||||
// offer, don't hijack).
|
||||
if (isActiveEvent) {
|
||||
revealDesktopPane(payload?.pane ?? '')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'layout.apply') {
|
||||
// Agent applied a layout preset via the desktop-gated apply_layout
|
||||
// tool. Same contract as pane.reveal: active session only, and the
|
||||
// preset resolves against the SAME layouts registry the picker reads,
|
||||
// so core, plugin, and user presets are all addressable.
|
||||
if (isActiveEvent) {
|
||||
applyDesktopLayoutPreset(typeof payload?.preset === 'string' ? payload.preset : '')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'message.reaction') {
|
||||
// The agent reacted to a message via the desktop-gated
|
||||
// react_to_message tool. Already persisted — this only paints it now
|
||||
// instead of at the next resume. Fresh ChatMessage object per change:
|
||||
// the runtime repository caches normalized ThreadMessages in a WeakMap
|
||||
// keyed by ChatMessage identity.
|
||||
const reactedRowId = payload?.row_id
|
||||
|
||||
if (typeof reactedRowId === 'number') {
|
||||
const nextReactions = Array.isArray(payload?.reactions) ? payload.reactions : []
|
||||
const reactedRole = payload?.role === 'assistant' ? 'assistant' : 'user'
|
||||
|
||||
setMessages(messages => {
|
||||
// Preferred leg: the message already knows its durable row id
|
||||
// (rehydrated transcript, or a live row that has round-tripped).
|
||||
const byRowId = messages.find(message => message.rowId === reactedRowId)
|
||||
|
||||
if (byRowId) {
|
||||
// Overlay survives the end-of-turn resume, which rebuilds from
|
||||
// in-memory history that doesn't carry this mid-turn DB write.
|
||||
recordAgentReaction(reactedRowId, nextReactions)
|
||||
|
||||
return messages.map(message =>
|
||||
message.rowId === reactedRowId ? { ...message, reactions: nextReactions } : message
|
||||
)
|
||||
}
|
||||
|
||||
// Live leg: the targeted message is still optimistic (no rowId —
|
||||
// it hasn't round-tripped through a resume). The agent's default
|
||||
// target is the newest message of that role, so stamp the reaction
|
||||
// AND the now-known row id onto it. Without this the event matches
|
||||
// nothing and the reaction only appears after a reload.
|
||||
const lastIndex = messages.findLastIndex(message => message.role === reactedRole && message.rowId === undefined)
|
||||
|
||||
if (lastIndex === -1) {
|
||||
return messages
|
||||
}
|
||||
|
||||
recordAgentReaction(reactedRowId, nextReactions)
|
||||
|
||||
return messages.map((message, index) =>
|
||||
index === lastIndex ? { ...message, rowId: reactedRowId, reactions: nextReactions } : message
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { registryBackendScopeKey } from '@hermes/shared'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import type { GatewayEventPayload } from '@/lib/chat-messages'
|
||||
import {
|
||||
approvalReplaySessionId,
|
||||
resolveGatewayEventSessionId,
|
||||
UNSCOPED_STREAM_EVENT_TYPES
|
||||
} from '@/lib/gateway-events'
|
||||
import { setSessionCompacting } from '@/store/compaction'
|
||||
import { $gateway, activeGatewayConnectionId } from '@/store/gateway'
|
||||
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import { replayPendingApproval } from '@/store/prompts'
|
||||
import { setSessionProviderWait } from '@/store/provider-wait'
|
||||
import { setSessionDraftingTool } from '@/store/tool-drafting'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { handleDesktopBridgeEvent } from './desktop-bridge'
|
||||
import { handleInputRequestEvent } from './input-requests'
|
||||
import { handleLifecycleEvent } from './lifecycle'
|
||||
import { handleMessageStreamEvent } from './message-stream'
|
||||
import { handleSessionInfoEvent } from './session-info'
|
||||
import { handleStatusEvent } from './status'
|
||||
import { handleToolEvent } from './tools'
|
||||
import type { GatewayEventContext, GatewayEventDeps, GatewayEventHandler } from './types'
|
||||
|
||||
export type { GatewayEventDeps } from './types'
|
||||
|
||||
/**
|
||||
* Events that retire a "drafting a tool call" claim.
|
||||
*
|
||||
* `tool.generating` opens the claim and nothing closes it — a draft can be
|
||||
* abandoned without ever reaching `tool.start`, so enumerating the ways one
|
||||
* *ends* left the label on screen for the rest of the turn. Inverted: the
|
||||
* claim only covers what the model is emitting right now, and any other output
|
||||
* from the session proves it moved on. Same rule the TUI applies to its
|
||||
* transient trail lines (`turnController.pruneTransient`).
|
||||
*/
|
||||
const DRAFT_SUPERSEDING_EVENT_TYPES = new Set([
|
||||
'error',
|
||||
'message.complete',
|
||||
'message.delta',
|
||||
'message.start',
|
||||
'reasoning.delta',
|
||||
'thinking.delta',
|
||||
'tool.complete',
|
||||
'tool.progress',
|
||||
'tool.start'
|
||||
])
|
||||
|
||||
const COMPACTION_RESUME_EVENT_TYPES = new Set([
|
||||
'message.delta',
|
||||
'message.interim',
|
||||
'thinking.delta',
|
||||
'reasoning.delta',
|
||||
'reasoning.available',
|
||||
'moa.reference',
|
||||
'moa.aggregating',
|
||||
'moa.progress',
|
||||
'moa.phase',
|
||||
'tool.start',
|
||||
'tool.progress',
|
||||
'tool.generating',
|
||||
'tool.complete'
|
||||
])
|
||||
|
||||
const PROVIDER_WAIT_SUPERSEDING_EVENT_TYPES = new Set([
|
||||
'error',
|
||||
'message.complete',
|
||||
'message.delta',
|
||||
'message.interim',
|
||||
'message.start',
|
||||
'reasoning.available',
|
||||
'reasoning.delta',
|
||||
'tool.complete',
|
||||
'tool.generating',
|
||||
'tool.progress',
|
||||
'tool.start'
|
||||
])
|
||||
|
||||
// Ordered family handlers; each consumes its own event types and reports
|
||||
// whether it did, so dispatch stops at the first taker.
|
||||
const HANDLERS: GatewayEventHandler[] = [
|
||||
handleLifecycleEvent,
|
||||
handleSessionInfoEvent,
|
||||
handleMessageStreamEvent,
|
||||
handleToolEvent,
|
||||
handleInputRequestEvent,
|
||||
handleDesktopBridgeEvent,
|
||||
handleStatusEvent
|
||||
]
|
||||
|
||||
/** The gateway-event dispatcher, extracted from useMessageStream. */
|
||||
export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||
const { activeSessionIdRef, compactedTurnRef, refreshHermesConfig, sessionStateByRuntimeIdRef } = deps
|
||||
|
||||
const unscopedStreamSessionIdRef = useRef<string | null>(null)
|
||||
|
||||
// session.info arrives in bursts (agent build ready + turn end + title /
|
||||
// MCP / compress edges within the same second). Each used to fire its own
|
||||
// refreshHermesConfig — two REST calls (config + defaults) per event, per
|
||||
// turn, including for BACKGROUND sessions whose values the fetch can't even
|
||||
// apply. Coalesce to one trailing fetch per burst; the caller gates on
|
||||
// `apply` so background traffic doesn't schedule anything.
|
||||
const configRefreshTimerRef = useRef<null | number>(null)
|
||||
|
||||
const scheduleConfigRefresh = useCallback(() => {
|
||||
if (configRefreshTimerRef.current !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
void refreshHermesConfig()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
configRefreshTimerRef.current = window.setTimeout(() => {
|
||||
configRefreshTimerRef.current = null
|
||||
void refreshHermesConfig()
|
||||
}, 300)
|
||||
}, [refreshHermesConfig])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (configRefreshTimerRef.current !== null && typeof window !== 'undefined') {
|
||||
window.clearTimeout(configRefreshTimerRef.current)
|
||||
configRefreshTimerRef.current = null
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return useCallback(
|
||||
(event: RpcEvent) => {
|
||||
const payload = event.payload as GatewayEventPayload | undefined
|
||||
|
||||
// "From the active profile" must mean "from the active SOURCE": every
|
||||
// registered connection exposes a 'default' profile, so a bare profile
|
||||
// comparison attributes gateway B's 'default' events to gateway A's
|
||||
// 'default'. Compare the composite (connectionId, profile) scope with
|
||||
// registryBackendScopeKey — untagged primary events keep the legacy
|
||||
// bare-profile behavior byte-identical.
|
||||
const fromActiveSource = (): boolean =>
|
||||
(!event.profile || normalizeProfileKey(event.profile) === normalizeProfileKey($activeGatewayProfile.get())) &&
|
||||
registryBackendScopeKey(event.connectionId ?? null, event.profile ?? null) ===
|
||||
registryBackendScopeKey(activeGatewayConnectionId(), event.profile ?? null)
|
||||
|
||||
const occurredAt =
|
||||
typeof payload?.timestamp === 'number' && Number.isFinite(payload.timestamp)
|
||||
? payload.timestamp
|
||||
: Date.now() / 1000
|
||||
|
||||
const explicitSid = event.session_id || ''
|
||||
|
||||
const route = resolveGatewayEventSessionId({
|
||||
activeSessionId: activeSessionIdRef.current,
|
||||
eventType: event.type,
|
||||
explicitSessionId: explicitSid,
|
||||
unscopedStreamSessionId: unscopedStreamSessionIdRef.current
|
||||
})
|
||||
|
||||
unscopedStreamSessionIdRef.current = route.nextUnscopedStreamSessionId
|
||||
|
||||
if (route.drop) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = route.sessionId
|
||||
|
||||
// Late stragglers: an unscoped stream event attributed via the
|
||||
// active-session fallback (no pin) to a session that has no live turn
|
||||
// belongs to a turn that already ended elsewhere. Dropping it keeps the
|
||||
// previous session's tail events (a delayed `thinking.delta` or
|
||||
// `status.update`) from landing in a freshly opened chat (#43142 family:
|
||||
// busy/streaming UI inherited when switching sessions).
|
||||
if (
|
||||
sessionId &&
|
||||
!explicitSid &&
|
||||
!route.pinned &&
|
||||
event.type &&
|
||||
event.type !== 'message.start' &&
|
||||
UNSCOPED_STREAM_EVENT_TYPES.has(event.type)
|
||||
) {
|
||||
const state = sessionStateByRuntimeIdRef.current.get(sessionId)
|
||||
|
||||
const hasLiveTurn = Boolean(
|
||||
state && (state.awaitingResponse || state.busy || state.streamId || state.sawAssistantPayload)
|
||||
)
|
||||
|
||||
if (!hasLiveTurn) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current
|
||||
|
||||
const replaySessionId = approvalReplaySessionId(event.type, activeSessionIdRef.current, sessionId)
|
||||
|
||||
if (replaySessionId) {
|
||||
void replayPendingApproval($gateway.get(), replaySessionId).catch(() => undefined)
|
||||
}
|
||||
|
||||
// Mid-turn compaction does not emit another message.start. The first
|
||||
// model output or tool event proves summarization has finished and the
|
||||
// turn has resumed, so retire the phase label without waiting for the
|
||||
// whole turn to complete.
|
||||
if (sessionId && COMPACTION_RESUME_EVENT_TYPES.has(event.type) && compactedTurnRef.current.has(sessionId)) {
|
||||
setSessionCompacting(sessionId, false)
|
||||
}
|
||||
|
||||
if (sessionId && DRAFT_SUPERSEDING_EVENT_TYPES.has(event.type)) {
|
||||
setSessionDraftingTool(sessionId, '')
|
||||
}
|
||||
|
||||
if (sessionId && PROVIDER_WAIT_SUPERSEDING_EVENT_TYPES.has(event.type)) {
|
||||
setSessionProviderWait(sessionId, '')
|
||||
}
|
||||
|
||||
const ctx: GatewayEventContext = {
|
||||
deps,
|
||||
event,
|
||||
payload,
|
||||
sessionId,
|
||||
explicitSid,
|
||||
isActiveEvent,
|
||||
occurredAt,
|
||||
fromActiveSource,
|
||||
scheduleConfigRefresh
|
||||
}
|
||||
|
||||
for (const handler of HANDLERS) {
|
||||
if (handler(ctx)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
// The deps object is rebuilt by the caller each render, but every field it
|
||||
// carries is stable (refs, useCallback-wrapped fns, queryClient), so
|
||||
// depending on the individual fields keeps the handler identity stable.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
deps.appendAssistantDelta,
|
||||
deps.appendReasoningDelta,
|
||||
deps.activeSessionIdRef,
|
||||
deps.activeGatewayProfile,
|
||||
deps.compactedTurnRef,
|
||||
deps.completeAssistantMessage,
|
||||
deps.failAssistantMessage,
|
||||
deps.finalizeInterimAssistantMessage,
|
||||
deps.flushQueuedDeltas,
|
||||
deps.hydrateFromStoredSession,
|
||||
deps.lastCwdInfoSessionRef,
|
||||
deps.nativeSubagentSessionsRef,
|
||||
deps.queryClient,
|
||||
scheduleConfigRefresh,
|
||||
deps.scheduleSessionsRefresh,
|
||||
deps.sessionInterrupted,
|
||||
deps.sessionStateByRuntimeIdRef,
|
||||
deps.updateSessionState,
|
||||
deps.upsertToolCall
|
||||
]
|
||||
)
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
import { translateNow } from '@/i18n'
|
||||
import { normalizeChoices, normalizeQuestions, setClarifyRequest, warnDroppedChoices } from '@/store/clarify'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { setMcpSetupRequest } from '@/store/mcp-setup'
|
||||
import { dispatchNativeNotification } from '@/store/native-notifications'
|
||||
import { receiveApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
||||
import { requestScrollToBottom } from '@/store/thread-scroll'
|
||||
|
||||
import type { GatewayEventContext } from './types'
|
||||
|
||||
/** The blocking-input family: clarify / MCP setup consent / approval / sudo /
|
||||
* secret requests. The Python side is blocked on the matching *.respond, so
|
||||
* each of these must be parked per-session and surfaced. */
|
||||
export function handleInputRequestEvent(ctx: GatewayEventContext): boolean {
|
||||
const { deps, event, payload, sessionId, occurredAt } = ctx
|
||||
const { activeSessionIdRef, updateSessionState, upsertToolCall } = deps
|
||||
|
||||
if (event.type === 'clarify.request') {
|
||||
// Surface the clarify tool's overlay. The Python side is blocked on
|
||||
// `clarify.respond`, so without this handler the agent would hang
|
||||
// forever (see tools/clarify_tool.py + tui_gateway/server.py:_block).
|
||||
//
|
||||
// Store the request for whichever session raised it — even a background
|
||||
// one. clarify.request is a one-shot event; if we dropped it for an
|
||||
// unfocused session, that session would block on `clarify.respond`
|
||||
// indefinitely and re-focusing it could never recover (the event is
|
||||
// gone). Parking it per-session lets the user answer once they switch
|
||||
// over; the inline ClarifyTool reads the active session's entry.
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
const question = typeof payload?.question === 'string' ? payload.question : ''
|
||||
const rawChoices = payload?.choices
|
||||
const choices = normalizeChoices(rawChoices)
|
||||
const multiSelect = payload?.multi_select === true
|
||||
// Batch (multi-question) clarify: `questions` replaces question/choices
|
||||
// on the wire. `answers` rides along only on reconnect replay, carrying
|
||||
// the per-question locks the server already accepted.
|
||||
const questions = normalizeQuestions(payload?.questions)
|
||||
|
||||
const lockedAnswers =
|
||||
typeof payload?.answers === 'object' && payload?.answers !== null
|
||||
? Object.fromEntries(
|
||||
Object.entries(payload.answers as Record<string, unknown>).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string'
|
||||
)
|
||||
)
|
||||
: undefined
|
||||
|
||||
if (requestId && questions.length > 0) {
|
||||
setClarifyRequest({
|
||||
choices: null,
|
||||
lockedAnswers,
|
||||
multiSelect: false,
|
||||
question: '',
|
||||
questions,
|
||||
requestId,
|
||||
sessionId: sessionId ?? null
|
||||
})
|
||||
|
||||
if (sessionId) {
|
||||
// Same hydration-race guard as the single-question path below: the
|
||||
// form mounts from the tool row, so upsert a stable one keyed by
|
||||
// the request id in case tool.start was missed.
|
||||
upsertToolCall(
|
||||
sessionId,
|
||||
{
|
||||
args: {
|
||||
questions: questions.map(q => ({
|
||||
choices: q.choices ?? undefined,
|
||||
multi_select: q.multiSelect || undefined,
|
||||
question: q.question
|
||||
}))
|
||||
},
|
||||
name: 'clarify',
|
||||
tool_id: requestId
|
||||
},
|
||||
'running',
|
||||
event.type,
|
||||
occurredAt
|
||||
)
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
|
||||
if (sessionId === activeSessionIdRef.current) {
|
||||
requestScrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: questions.map(q => q.question).join(' · '),
|
||||
kind: 'input',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.inputTitle')
|
||||
})
|
||||
} else if (requestId && question) {
|
||||
if (rawChoices != null && choices.length === 0) {
|
||||
warnDroppedChoices('gateway', question, rawChoices)
|
||||
}
|
||||
|
||||
setClarifyRequest({
|
||||
requestId,
|
||||
question,
|
||||
choices: choices.length > 0 ? choices : null,
|
||||
multiSelect,
|
||||
sessionId: sessionId ?? null
|
||||
})
|
||||
|
||||
if (sessionId) {
|
||||
// `clarify.request` is the blocking event the Python side waits on,
|
||||
// while the inline UI normally mounts from the earlier `tool.start`
|
||||
// row. If that row was missed (stream reconnect / hydration race) the
|
||||
// sidebar still says "needs input" but there is nowhere to render the
|
||||
// choices. Upsert a stable pending clarify tool row from the request
|
||||
// itself so the prompt stays answerable; a real tool.start/complete
|
||||
// with the same request id merges rather than duplicates.
|
||||
upsertToolCall(
|
||||
sessionId,
|
||||
{
|
||||
args: { choices, ...(multiSelect ? { multi_select: true } : {}), question },
|
||||
name: 'clarify',
|
||||
tool_id: requestId
|
||||
},
|
||||
'running',
|
||||
event.type,
|
||||
occurredAt
|
||||
)
|
||||
|
||||
// The transcript only renders the active session, so a background
|
||||
// clarify is otherwise invisible (the row just keeps spinning like
|
||||
// it's working). Flag the session so the sidebar shows a persistent
|
||||
// "needs input" indicator on its row — works for the active session
|
||||
// too, and survives alt-tab / window blur (unlike a toast).
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
|
||||
if (sessionId === activeSessionIdRef.current) {
|
||||
requestScrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: question,
|
||||
kind: 'input',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.inputTitle')
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'mcp.setup.request') {
|
||||
// setup_mcp tool (desktop GUI): the agent proposed an MCP server and
|
||||
// the Python side is blocked on mcp.setup.respond. Park the request
|
||||
// per-session (like clarify) and upsert a stable pending tool row so
|
||||
// the inline consent card has somewhere to render even when the
|
||||
// tool.start event was missed (stream reconnect / hydration race).
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
const server = typeof payload?.server === 'string' ? payload.server : ''
|
||||
const rawAction = typeof payload?.action === 'string' ? payload.action : 'install'
|
||||
const action = rawAction === 'enable' || rawAction === 'authorize' ? rawAction : 'install'
|
||||
const reason = typeof payload?.reason === 'string' ? payload.reason : ''
|
||||
|
||||
if (requestId && server) {
|
||||
setMcpSetupRequest({ action, reason, requestId, server, sessionId: sessionId ?? null })
|
||||
|
||||
if (sessionId) {
|
||||
upsertToolCall(
|
||||
sessionId,
|
||||
{ args: { action, reason, server }, name: 'setup_mcp', tool_id: requestId },
|
||||
'running'
|
||||
)
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: reason || server,
|
||||
kind: 'input',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.inputTitle')
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'approval.request') {
|
||||
// Dangerous-command / execute_code approval. The Python side is blocked
|
||||
// in _await_gateway_decision() until approval.respond lands; without
|
||||
// this the agent stalls until its 5-min timeout and the tool is BLOCKED.
|
||||
// Park it per-session (like clarify) so a *background* profile's turn can
|
||||
// raise it and wait — the sidebar flags "needs input" and the inline bar
|
||||
// surfaces once the user focuses that chat.
|
||||
const command = typeof payload?.command === 'string' ? payload.command : ''
|
||||
const description = typeof payload?.description === 'string' ? payload.description : 'dangerous command'
|
||||
|
||||
void receiveApprovalRequest($gateway.get(), {
|
||||
// false only when a tirith warning forbids it; backend omits the field otherwise.
|
||||
allowPermanent: payload?.allow_permanent !== false,
|
||||
choices: Array.isArray(payload?.choices)
|
||||
? payload.choices.filter(choice => typeof choice === 'string')
|
||||
: undefined,
|
||||
command,
|
||||
description,
|
||||
requestId: typeof payload?.request_id === 'string' ? payload.request_id : undefined,
|
||||
sessionId: sessionId ?? null,
|
||||
smartDenied: payload?.smart_denied === true
|
||||
}).catch(() => undefined)
|
||||
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
actions: [
|
||||
{ id: 'approve', text: translateNow('notifications.native.approveAction') },
|
||||
{ id: 'reject', text: translateNow('notifications.native.rejectAction') }
|
||||
],
|
||||
body: command || description,
|
||||
kind: 'approval',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.approvalTitle')
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'sudo.request') {
|
||||
// Sudo password capture (tools/terminal_tool.py). Blocked on
|
||||
// sudo.respond {request_id, password}.
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
|
||||
if (requestId) {
|
||||
setSudoRequest({ requestId, sessionId: sessionId ?? null })
|
||||
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: translateNow('notifications.native.inputBody'),
|
||||
kind: 'input',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.inputTitle')
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'secret.request') {
|
||||
// Skill credential capture (tools/skills_tool.py). Blocked on
|
||||
// secret.respond {request_id, value}.
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
|
||||
if (requestId) {
|
||||
const envVar = typeof payload?.env_var === 'string' ? payload.env_var : ''
|
||||
const promptText = typeof payload?.prompt === 'string' ? payload.prompt : ''
|
||||
|
||||
setSecretRequest({
|
||||
requestId,
|
||||
envVar,
|
||||
prompt: promptText,
|
||||
sessionId: sessionId ?? null
|
||||
})
|
||||
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: promptText || envVar || translateNow('notifications.native.inputBody'),
|
||||
kind: 'input',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.inputTitle')
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { HermesSkin } from '@hermes/shared/skin'
|
||||
|
||||
import {
|
||||
notifyCronChanged,
|
||||
notifyPairingChanged,
|
||||
notifyPetChanged,
|
||||
notifyPlatformsChanged,
|
||||
notifySessionsChanged,
|
||||
type PetChangeMeta,
|
||||
setChangeEventsAvailable
|
||||
} from '@/store/live-sync'
|
||||
import { dropSessionState, unbindTileRuntime } from '@/store/session-states'
|
||||
// Leaf import (not the `@/themes` barrel) to avoid pulling the ThemeProvider
|
||||
// module graph into the gateway event hot path.
|
||||
import { ingestBackendSkin } from '@/themes/backend-sync'
|
||||
|
||||
import type { GatewayEventContext } from './types'
|
||||
|
||||
/** gateway.ready / skin.changed / change-watcher broadcasts / session.reclaimed. */
|
||||
export function handleLifecycleEvent(ctx: GatewayEventContext): boolean {
|
||||
const { deps, event, payload, fromActiveSource } = ctx
|
||||
|
||||
if (event.type === 'gateway.ready') {
|
||||
// Seed the active skin into the desktop theme registry without applying,
|
||||
// so a fresh connect never overrides the user's persisted desktop theme.
|
||||
ingestBackendSkin((payload as { skin?: HermesSkin } | undefined)?.skin, { apply: false })
|
||||
// Backends with the change watcher broadcast pet/cron/sessions change
|
||||
// events; consumers demote their legacy polls to slow backstops.
|
||||
setChangeEventsAvailable(Boolean((payload as { change_events?: boolean } | undefined)?.change_events))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'skin.changed') {
|
||||
// A runtime skin switch (Hermes activating an authored skin, or `/skin`
|
||||
// on another surface). Only the active source+profile's change repaints.
|
||||
if (fromActiveSource()) {
|
||||
ingestBackendSkin(payload as HermesSkin | undefined, { apply: true })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'pet.changed' ||
|
||||
event.type === 'cron.changed' ||
|
||||
event.type === 'sessions.changed' ||
|
||||
event.type === 'platforms.changed' ||
|
||||
event.type === 'pairing.changed'
|
||||
) {
|
||||
// Change-watcher broadcasts (server._broadcast_watched_changes): the
|
||||
// backend's on-disk signature moved. Route to the live-sync ticks the
|
||||
// former pollers now subscribe to. Only the active source+profile's
|
||||
// changes apply — background profile sockets (and other connections'
|
||||
// gateways) watch their own homes.
|
||||
if (fromActiveSource()) {
|
||||
if (event.type === 'pet.changed') {
|
||||
notifyPetChanged(payload as PetChangeMeta | undefined)
|
||||
} else if (event.type === 'cron.changed') {
|
||||
notifyCronChanged()
|
||||
} else if (event.type === 'platforms.changed') {
|
||||
notifyPlatformsChanged()
|
||||
} else if (event.type === 'pairing.changed') {
|
||||
notifyPairingChanged()
|
||||
} else {
|
||||
notifySessionsChanged()
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'session.reclaimed') {
|
||||
// The backend reclaimed a live session we may still be holding (idle
|
||||
// TTL, LRU cap, or the WS-orphan reap). Without this the runtime id
|
||||
// stays cached until something fails against it, which reads as the
|
||||
// session vanishing rather than being reclaimed. Drop the cached state
|
||||
// now — the stored row is untouched, so the sidebar keeps the
|
||||
// conversation and reopening it resumes from the DB.
|
||||
const reclaimedRuntimeId = String((payload as { session_id?: string } | undefined)?.session_id ?? '')
|
||||
|
||||
if (reclaimedRuntimeId) {
|
||||
dropSessionState(reclaimedRuntimeId)
|
||||
// A tile bound to the reclaimed runtime would otherwise render an
|
||||
// empty transcript forever: its view reads $sessionStates[runtime]
|
||||
// (just dropped) and its resume effect is gated on !runtimeId, so a
|
||||
// bound tile never re-resumes (#82620). Unbind it so the effect
|
||||
// refires against the intact stored session — and purge the wiring
|
||||
// cache's entry, or resumeTile's warm path would hand the dead
|
||||
// runtime straight back instead of cold-resuming a live one.
|
||||
unbindTileRuntime(reclaimedRuntimeId)
|
||||
deps.sessionStateByRuntimeIdRef.current.delete(reclaimedRuntimeId)
|
||||
}
|
||||
|
||||
// The row's ended_at moved, so refresh the lists that render it.
|
||||
notifySessionsChanged()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
import type { BillingBlock } from '@hermes/shared'
|
||||
|
||||
import { burstVibeHearts } from '@/components/chat/vibe-hearts'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { coerceGatewayText, coerceThinkingText } from '@/lib/chat-runtime'
|
||||
import { playCompletionSound } from '@/lib/completion-sound'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block'
|
||||
import { clearClarifyRequest } from '@/store/clarify'
|
||||
import { setSessionCompacting } from '@/store/compaction'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
|
||||
import { clearAllPrompts } from '@/store/prompts'
|
||||
import { providerWaitText, setSessionProviderWait } from '@/store/provider-wait'
|
||||
import { setCurrentUsage, setTurnStartedAt } from '@/store/session'
|
||||
import { pruneFinishedSessionSubagents } from '@/store/subagents'
|
||||
import { clearActiveSessionTodos } from '@/store/todos'
|
||||
|
||||
import type { GatewayEventContext } from './types'
|
||||
|
||||
function firstBillingLine(text: string): string {
|
||||
return (text || '').split('\n')[0]?.trim() ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* A turn failed on a billing wall (out of credits / payment required). The
|
||||
* gateway forwards the structured descriptor built by `agent/billing_links.py`;
|
||||
* we cache it per-session (drives the in-chat banner) AND raise one sticky,
|
||||
* billing-specific toast — never the generic "Hermes error" — with a smart CTA
|
||||
* (Nous → in-app Settings → Billing, other providers → their billing page).
|
||||
*/
|
||||
function surfaceBillingBlock(sessionId: string, raw: unknown): void {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const block = raw as BillingBlock
|
||||
|
||||
if (typeof block.provider !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
setBillingBlock(sessionId, block)
|
||||
|
||||
const ctaCopy = {
|
||||
addCredits: translateNow('billingBlock.addCredits'),
|
||||
openBilling: translateNow('billingBlock.openBilling')
|
||||
}
|
||||
|
||||
notify({
|
||||
// Collapse repeat walls from the same provider into one toast.
|
||||
id: `billing-block:${block.provider}`,
|
||||
kind: 'warning',
|
||||
icon: 'credit-card',
|
||||
title: block.is_nous
|
||||
? translateNow('billingBlock.titleNous')
|
||||
: translateNow('billingBlock.titleProvider', block.provider_label),
|
||||
message: firstBillingLine(block.message) || translateNow('billingBlock.fallbackMessage'),
|
||||
// Sticky: a credit wall blocks every turn until resolved.
|
||||
durationMs: 0,
|
||||
action: { label: billingCtaLabel(block, ctaCopy), onClick: () => runBillingRecovery(block) }
|
||||
})
|
||||
}
|
||||
|
||||
/** The message/reasoning/MoA streaming family: message.start → deltas →
|
||||
* interim → complete, thinking/reasoning deltas, moa.* progress, reaction. */
|
||||
export function handleMessageStreamEvent(ctx: GatewayEventContext): boolean {
|
||||
const { deps, event, payload, sessionId, isActiveEvent, occurredAt } = ctx
|
||||
|
||||
const {
|
||||
appendAssistantDelta,
|
||||
appendReasoningDelta,
|
||||
compactedTurnRef,
|
||||
completeAssistantMessage,
|
||||
finalizeInterimAssistantMessage,
|
||||
flushQueuedDeltas,
|
||||
nativeSubagentSessionsRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState
|
||||
} = deps
|
||||
|
||||
if (event.type === 'message.start') {
|
||||
if (!sessionId) {
|
||||
return true
|
||||
}
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
pruneFinishedSessionSubagents(sessionId)
|
||||
setSessionCompacting(sessionId, false)
|
||||
compactedTurnRef.current.delete(sessionId)
|
||||
nativeSubagentSessionsRef.current.delete(sessionId)
|
||||
// A fresh turn on this session optimistically clears its billing wall;
|
||||
// if credits are still exhausted the next failure re-raises it.
|
||||
clearBillingBlock(sessionId)
|
||||
|
||||
if (isActiveEvent) {
|
||||
triggerHaptic('streamStart')
|
||||
}
|
||||
|
||||
// Submit→accept latency: seedOptimistic armed the clock at Enter; this
|
||||
// event is the backend accepting the turn. Debug-only visibility into
|
||||
// how long the arm actually took (the "no progress box for seconds"
|
||||
// complaint) — reads the pre-update cache, costs nothing when clean.
|
||||
const seededAt = sessionStateByRuntimeIdRef.current.get(sessionId)?.turnStartedAt
|
||||
|
||||
if (typeof seededAt === 'number') {
|
||||
console.debug('[turn-accept-latency]', { sessionId, ms: Date.now() - seededAt })
|
||||
}
|
||||
|
||||
updateSessionState(sessionId, state => {
|
||||
// If the user clicked Stop (cancelRun set interrupted=true), don't
|
||||
// let a stale message.start from a chained turn (goal follow-up,
|
||||
// completion drain) or an in-flight LLM response re-arm busy.
|
||||
// The interrupt is user intent — the backend's cooperative cancel
|
||||
// may not have propagated yet, so its events are stale. The turn's
|
||||
// finally block will emit session.info with running=false to clear
|
||||
// busy for real once the agent loop actually exits.
|
||||
if (state.interrupted) {
|
||||
return state
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
busy: true,
|
||||
awaitingResponse: true,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: false,
|
||||
interimBoundaryPending: false,
|
||||
// Backend accepted the turn — the no-payload settle gate below may
|
||||
// now treat a running=false heartbeat as a real turn end.
|
||||
turnLive: true,
|
||||
// Keep the submit-time seed (submit.ts seedOptimistic) — resetting
|
||||
// here would hide the submit→accept round trip from the timer.
|
||||
// Backend-originated turns (queue drain elsewhere, goal follow-up)
|
||||
// have no seed and arm here.
|
||||
turnStartedAt: state.turnStartedAt ?? Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
if (isActiveEvent) {
|
||||
// Belt-and-suspenders mirror of the ACTIVE session's per-session
|
||||
// clock (the load-bearing mirror is the view-sync flush in
|
||||
// use-session-state-cache). Mirror the seeded value, not Date.now():
|
||||
// resetting to accept-time here would visibly snap the timer back
|
||||
// after the submit-time seed above already started it.
|
||||
setTurnStartedAt(sessionStateByRuntimeIdRef.current.get(sessionId)?.turnStartedAt ?? Date.now())
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'message.delta') {
|
||||
if (sessionId) {
|
||||
appendAssistantDelta(sessionId, coerceGatewayText(payload?.text), occurredAt)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'message.interim') {
|
||||
// The agent emitted interim assistant commentary (text alongside tool
|
||||
// calls, or the attempted final answer before a verify-on-stop nudge).
|
||||
// Finalize it as its own sealed bubble so message.complete doesn't wipe
|
||||
// it — the text was already streamed via message.delta and is visible.
|
||||
if (sessionId) {
|
||||
flushQueuedDeltas(sessionId)
|
||||
const text = coerceGatewayText(payload?.text)
|
||||
|
||||
if (text) {
|
||||
finalizeInterimAssistantMessage(sessionId, text, occurredAt)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'thinking.delta') {
|
||||
// Most thinking.delta frames are kawaii spinner rewrites and stay out
|
||||
// of the transcript. Explained provider waits are different: the core
|
||||
// emits them after prolonged silence, so name that wait in the existing
|
||||
// bottom-of-thread status row instead of leaving only an unlabeled timer.
|
||||
if (sessionId) {
|
||||
setSessionProviderWait(sessionId, providerWaitText(coerceGatewayText(payload?.text)))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'reaction') {
|
||||
// Core-detected affection (ily / <3 / good bot) on the user's message.
|
||||
// Play hearts only for the visible session so background turns stay quiet.
|
||||
if (isActiveEvent && (payload?.kind ?? 'vibe') === 'vibe') {
|
||||
burstVibeHearts()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'reasoning.delta') {
|
||||
if (sessionId) {
|
||||
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text), false, occurredAt)
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: true })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'reasoning.available') {
|
||||
if (sessionId) {
|
||||
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text), true, occurredAt)
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: true })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'moa.reference') {
|
||||
// MoA reference-model output — surface as a labelled thinking chunk
|
||||
// (tagged with the source model) before the aggregator's response, so
|
||||
// the mixture-of-agents process is visible. Reuses the reasoning
|
||||
// disclosure rather than introducing a parallel surface.
|
||||
if (sessionId) {
|
||||
const label = coerceGatewayText(payload?.label) || 'reference'
|
||||
const idx = typeof payload?.index === 'number' ? payload.index : undefined
|
||||
const cnt = typeof payload?.count === 'number' ? payload.count : undefined
|
||||
const header = idx && cnt ? `◇ Reference ${idx}/${cnt} — ${label}` : `◇ Reference — ${label}`
|
||||
const body = coerceThinkingText(payload?.text)
|
||||
const text = `${header}\n${body}\n\n`
|
||||
|
||||
if (idx === undefined || idx <= 1) {
|
||||
// First reference: clear any stale reasoning left over from
|
||||
// before this turn's references start, same as before.
|
||||
appendReasoningDelta(sessionId, text, true, occurredAt)
|
||||
} else {
|
||||
// Later references must accumulate, not replace — otherwise
|
||||
// each new reference wipes out the ones already shown (#64658).
|
||||
// Queue-then-flush (rather than the streamed/batched queue path)
|
||||
// applies it immediately, since each reference arrives as one
|
||||
// complete block rather than incremental tokens. reasoning.delta
|
||||
// cannot be mid-flight here: MoAChatCompletions.reference_callback
|
||||
// (agent/moa_loop.py) fires "moa.reference" once per reference's
|
||||
// already-complete text, with no concurrent token stream for the
|
||||
// reference-gathering phase, so there is no in-flight delta to
|
||||
// collide with in the shared queue bucket.
|
||||
appendReasoningDelta(sessionId, text, false, occurredAt)
|
||||
flushQueuedDeltas(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: true })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'moa.aggregating') {
|
||||
// Status transition only; the aggregator's reply arrives via the normal
|
||||
// message stream. No reasoning/transcript mutation here.
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: true })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'moa.progress') {
|
||||
// Live reference fan-out progress ("refs k/n") — surfaced in the same
|
||||
// reasoning disclosure the references land in. These lines arrive
|
||||
// BEFORE any moa.reference event (references are only emitted once the
|
||||
// whole fan-out completes), and the first moa.reference replaces the
|
||||
// block, so the progress trail is self-cleaning.
|
||||
if (sessionId && typeof payload?.refs_done === 'number' && typeof payload?.refs_total === 'number') {
|
||||
const label = coerceGatewayText(payload?.label)
|
||||
|
||||
const line = label
|
||||
? `◇ MoA refs ${payload.refs_done}/${payload.refs_total} — ${label}\n`
|
||||
: `◇ MoA refs ${payload.refs_done}/${payload.refs_total}\n`
|
||||
|
||||
appendReasoningDelta(sessionId, line, payload.refs_done <= 1, occurredAt)
|
||||
flushQueuedDeltas(sessionId)
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: true })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'moa.phase') {
|
||||
// Phase transition — currently only phase="aggregator" (fan-out done,
|
||||
// aggregator acting). Append a one-line marker; the first
|
||||
// moa.reference that follows replaces the whole block.
|
||||
if (sessionId && payload?.phase === 'aggregator') {
|
||||
appendReasoningDelta(sessionId, '◇ MoA aggregating…\n', false, occurredAt)
|
||||
flushQueuedDeltas(sessionId)
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: true })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'message.complete') {
|
||||
if (!sessionId) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Turn ended — drop any blocking prompt still open for THIS session
|
||||
// (e.g. interrupted, or the approval already resolved). Scoped to the
|
||||
// session so a background turn finishing can't wipe the active chat's
|
||||
// prompt, and vice versa.
|
||||
clearAllPrompts(sessionId)
|
||||
clearClarifyRequest(undefined, sessionId)
|
||||
// Turn ended without a final `todo` update — drop a still-unfinished
|
||||
// list so "Tasks N/M" doesn't stay pinned above the composer with the
|
||||
// last item stuck pending/in_progress. Finished lists keep their linger.
|
||||
clearActiveSessionTodos(sessionId)
|
||||
setSessionCompacting(sessionId, false)
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
// Keyed by session so only one window beeps when several are open.
|
||||
playCompletionSound(sessionId)
|
||||
|
||||
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
|
||||
|
||||
// Terminal error frames (status "error") carry the failure in
|
||||
// structured fields: `error` is the message, and `partial` marks
|
||||
// `text` as streamed output to keep rather than the error string.
|
||||
const failure =
|
||||
payload?.status === 'error'
|
||||
? {
|
||||
error: coerceGatewayText(payload.error).trim() || finalText || 'Hermes reported an error',
|
||||
partial: Boolean(payload.partial)
|
||||
}
|
||||
: undefined
|
||||
|
||||
completeAssistantMessage(sessionId, finalText, payload?.response_previewed, failure, occurredAt)
|
||||
|
||||
// Structured billing wall forwarded by the gateway (out of credits /
|
||||
// payment required) — cache it + raise a billing-specific toast.
|
||||
if (payload?.billing) {
|
||||
surfaceBillingBlock(sessionId, payload.billing)
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
setTurnStartedAt(null)
|
||||
|
||||
// Pet beat: a finished turn always celebrates — go straight to the
|
||||
// jump, never linger on the run/reason pose. One atom update (clears
|
||||
// toolRunning/reasoning AND sets celebrate together) so no stray "run"
|
||||
// frame leaks to the sprite — including the popped-out overlay, which
|
||||
// mirrors each activity change. The jump runs ~2 loops, then settles.
|
||||
flashPetActivity({ celebrate: true, reasoning: false, toolRunning: false }, 2200)
|
||||
|
||||
// Light up the pet's mail icon if the user wasn't looking when the turn
|
||||
// finished — a glanceable "new message" hint on the popped-out overlay.
|
||||
// Cleared when they open the app via the mail icon or refocus the window.
|
||||
if (typeof document !== 'undefined' && !document.hasFocus()) {
|
||||
markPetUnread()
|
||||
}
|
||||
}
|
||||
|
||||
if (payload?.usage) {
|
||||
// Per-session twin FIRST (the statusbar reads it for focused tiles);
|
||||
// the primary-only global mirrors the ACTIVE session — ungated it
|
||||
// let a background tile's turn overwrite the primary's count.
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
usage: { calls: 0, input: 0, output: 0, total: 0, ...state.usage, ...payload.usage }
|
||||
}))
|
||||
|
||||
if (isActiveEvent) {
|
||||
setCurrentUsage(current => ({ ...current, ...payload.usage }))
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { modelOptionsQueryKey } from '@/lib/model-options'
|
||||
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
|
||||
import { requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
|
||||
import { followActiveSessionCwd } from '@/store/projects'
|
||||
import {
|
||||
$currentCwd,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
sessionMatchesStoredId,
|
||||
setCurrentBranch,
|
||||
setCurrentCwdTransient,
|
||||
setCurrentFastMode,
|
||||
setCurrentPersonality,
|
||||
setCurrentReasoningEffort,
|
||||
setCurrentServiceTier,
|
||||
setCurrentUsage,
|
||||
setSessions,
|
||||
setTerminalBackend,
|
||||
setWorkspaceCwdOwner,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { reportInstallMethodWarning } from '@/store/updates'
|
||||
|
||||
import { finalizeInterruptedMessages } from '../../use-prompt-actions/rewind'
|
||||
import { hasSessionInfoStatePatch, PRE_TURN_LIVE_SETTLE_GRACE_MS, sessionInfoStatePatch } from '../utils'
|
||||
|
||||
import type { GatewayEventContext } from './types'
|
||||
|
||||
/**
|
||||
* Whether a `session.info` payload's `stored_session_id` may be treated as the
|
||||
* selected conversation's, so its cwd can be claimed for it (#71254).
|
||||
*
|
||||
* Absent is not the same as different: the backend omits the id on a
|
||||
* not-yet-built (`lazy`) session, and refusing there would leave the workspace
|
||||
* marked un-owned for the rest of the conversation. Matching goes through the
|
||||
* lineage (`sessionMatchesStoredId`) so a compression-rotated tip and the root
|
||||
* a pinned-row selection may hold still read as one conversation.
|
||||
*/
|
||||
function sessionInfoDescribesSelectedSession(storedSessionId: string | undefined): boolean {
|
||||
const infoStoredSessionId = storedSessionId?.trim() || null
|
||||
const selected = $selectedStoredSessionId.get() ?? null
|
||||
|
||||
if (!infoStoredSessionId) {
|
||||
return true
|
||||
}
|
||||
|
||||
// A named session cannot describe a fresh draft. Treating a null selection as
|
||||
// a wildcard let a background tile's `session.info` rehome the draft to the
|
||||
// tile's workspace.
|
||||
if (!selected) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (infoStoredSessionId === selected) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Either id may be the live tip or the lineage root, so ask whether ONE row
|
||||
// answers to both rather than assuming which side rotated.
|
||||
return $sessions
|
||||
.get()
|
||||
.some(session => sessionMatchesStoredId(session, infoStoredSessionId) && sessionMatchesStoredId(session, selected))
|
||||
}
|
||||
|
||||
/** session.info / session.usage / session.title. */
|
||||
export function handleSessionInfoEvent(ctx: GatewayEventContext): boolean {
|
||||
const { deps, event, payload, sessionId, explicitSid, isActiveEvent, occurredAt, fromActiveSource } = ctx
|
||||
|
||||
const {
|
||||
activeGatewayProfile,
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession,
|
||||
lastCwdInfoSessionRef,
|
||||
queryClient,
|
||||
scheduleSessionsRefresh,
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState
|
||||
} = deps
|
||||
|
||||
if (event.type === 'session.info') {
|
||||
// Apply session-scoped fields when the event targets the active
|
||||
// session, OR when it's a global broadcast and we have no session.
|
||||
const apply = explicitSid ? isActiveEvent : !activeSessionIdRef.current
|
||||
const statePatch = sessionInfoStatePatch(payload)
|
||||
const hasStatePatch = hasSessionInfoStatePatch(statePatch)
|
||||
const modelChanged = typeof payload?.model === 'string'
|
||||
const providerChanged = typeof payload?.provider === 'string'
|
||||
const runningChanged = typeof payload?.running === 'boolean'
|
||||
// The backend stamps model/provider (as strings) on EVERY session.info,
|
||||
// so the presence flags above are true on every heartbeat/turn edge —
|
||||
// fine for the cheap atom writes below (nanostores skips identical
|
||||
// values), but they also drove queryClient.invalidateQueries, refetching
|
||||
// the model-options provider catalog once or twice per turn for a model
|
||||
// that never changed. Only a genuine VALUE change (vs the session's own
|
||||
// cached runtime state, captured before the state patch below applies;
|
||||
// composer atoms as the fallback for an uncached session) invalidates.
|
||||
const knownState = sessionId ? sessionStateByRuntimeIdRef.current.get(sessionId) : undefined
|
||||
const modelValueChanged = modelChanged && payload!.model !== (knownState?.model ?? $currentModel.get())
|
||||
|
||||
const providerValueChanged =
|
||||
providerChanged && payload!.provider !== (knownState?.provider ?? $currentProvider.get())
|
||||
|
||||
// Config is profile-scoped, but session.info also arrives for background
|
||||
// sessions. Only an active-session event from the currently active
|
||||
// gateway may reconcile the foreground cache. Requiring the renderer's
|
||||
// source tag prevents an event queued before a profile swap from being
|
||||
// attributed to the newly active profile.
|
||||
if (isActiveEvent && typeof payload?.approval_mode === 'string' && event.profile && fromActiveSource()) {
|
||||
reconcileApprovalModeForProfile(event.profile, payload.approval_mode)
|
||||
}
|
||||
|
||||
if (apply) {
|
||||
// Do not call setCurrentModel / setCurrentProvider here. Composer
|
||||
// model/provider is sticky UI state (localStorage + manual picks).
|
||||
// Periodic session.info heartbeats often carry the profile default
|
||||
// (or a stale session model) and would silently revert the dropdown.
|
||||
// Active-session model/provider still flows through the session state
|
||||
// cache via updateSessionState → syncRuntimeMetadataToView below.
|
||||
|
||||
if (typeof payload?.cwd === 'string' && sessionInfoDescribesSelectedSession(payload.stored_session_id)) {
|
||||
// The active session's agent can relocate itself (new repo/worktree
|
||||
// via the terminal). When the SAME active session's cwd actually
|
||||
// moves, follow it — refresh the project tree + scope so the sidebar
|
||||
// tracks the live thread. A fresh selection (different session id)
|
||||
// is a switch, not a move, so it refreshes data without yanking scope.
|
||||
const cwdMoved = payload.cwd !== $currentCwd.get()
|
||||
const sameSession = !!sessionId && sessionId === lastCwdInfoSessionRef.current
|
||||
|
||||
lastCwdInfoSessionRef.current = sessionId
|
||||
setCurrentCwdTransient(payload.cwd)
|
||||
|
||||
// The backend just confirmed the selected conversation's real
|
||||
// workspace, so it owns the path we wrote. Without the claim the
|
||||
// marker keeps naming whoever held it before — including the
|
||||
// released state a detached resume leaves behind — and the primary
|
||||
// workspace-derived surfaces stay hidden against a folder the
|
||||
// backend has confirmed (#71254).
|
||||
setWorkspaceCwdOwner($selectedStoredSessionId.get())
|
||||
|
||||
if (cwdMoved && sameSession) {
|
||||
void followActiveSessionCwd(payload.cwd)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof payload?.branch === 'string') {
|
||||
setCurrentBranch(payload.branch)
|
||||
}
|
||||
|
||||
if (typeof payload?.terminal_backend === 'string') {
|
||||
setTerminalBackend(payload.terminal_backend)
|
||||
}
|
||||
|
||||
if (typeof payload?.personality === 'string') {
|
||||
setCurrentPersonality(normalizePersonalityValue(payload.personality))
|
||||
}
|
||||
|
||||
if (typeof payload?.reasoning_effort === 'string') {
|
||||
setCurrentReasoningEffort(payload.reasoning_effort)
|
||||
}
|
||||
|
||||
if (typeof payload?.service_tier === 'string') {
|
||||
setCurrentServiceTier(payload.service_tier)
|
||||
}
|
||||
|
||||
if (typeof payload?.fast === 'boolean') {
|
||||
setCurrentFastMode(payload.fast)
|
||||
}
|
||||
|
||||
if (typeof payload?.yolo === 'boolean') {
|
||||
setYoloActive(payload.yolo)
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionId && hasStatePatch) {
|
||||
updateSessionState(
|
||||
sessionId,
|
||||
state => ({
|
||||
...state,
|
||||
...statePatch,
|
||||
branch: statePatch.branch ?? state.branch,
|
||||
cwd: statePatch.cwd ?? state.cwd
|
||||
}),
|
||||
payload?.stored_session_id || undefined
|
||||
)
|
||||
}
|
||||
|
||||
// The running→busy transition must reach EVERY session, not just the
|
||||
// active one. The `apply` gate above correctly scopes view-only side
|
||||
// effects (setCurrentCwd, etc.) to the focused chat,
|
||||
// but the per-session busy state is what drives the sidebar working
|
||||
// indicator — a background session's turn start/finish must update
|
||||
// its dot without the user opening it. updateSessionState only
|
||||
// mutates the per-runtime cache entry, and syncSessionStateToView
|
||||
// guards the view publish to the active session, so this is safe.
|
||||
if (runningChanged && sessionId) {
|
||||
// Set when THIS event released a turn that ended without ever
|
||||
// producing an assistant payload, so the catch-up side effects below
|
||||
// run on that edge only. The updater is invoked exactly once,
|
||||
// synchronously, by updateSessionState.
|
||||
let recoveredWithoutPayload = false
|
||||
|
||||
const nextState = updateSessionState(
|
||||
sessionId,
|
||||
state => {
|
||||
const busy = Boolean(payload!.running)
|
||||
|
||||
if (state.busy === busy && (busy || !state.awaitingResponse)) {
|
||||
return state
|
||||
}
|
||||
|
||||
if (busy) {
|
||||
// Don't re-arm busy from a stale session.info if the user
|
||||
// just clicked Stop (interrupted=true). The backend's
|
||||
// cooperative interrupt may not have propagated yet, so
|
||||
// running is still true in the heartbeat. The turn's
|
||||
// finally block will emit running=false to clear busy.
|
||||
if (state.interrupted) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Prefer the gateway-reported turn_started_at so the timer
|
||||
// survives session switches and session.info heartbeats.
|
||||
const gatewayTurnStartedAt =
|
||||
typeof payload!.turn_started_at === 'number' && payload!.turn_started_at > 0
|
||||
? payload!.turn_started_at * 1000
|
||||
: null
|
||||
|
||||
return {
|
||||
...state,
|
||||
busy,
|
||||
// running=true from the backend is turn-live proof, same as
|
||||
// message.start (e.g. resuming an already-running session
|
||||
// that never replays its start event).
|
||||
turnLive: true,
|
||||
turnStartedAt: state.turnStartedAt ?? gatewayTurnStartedAt ?? Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
// The turn has not started backend-side yet. submit arms
|
||||
// busy/awaitingResponse optimistically, so a running=false
|
||||
// heartbeat that lands in the gap before the turn spins up is a
|
||||
// pre-start report, not a finished turn — settling on it would
|
||||
// drop the spinner and re-open the send guard mid-flight.
|
||||
// turnLive is stamped only once the backend reports the turn
|
||||
// live (message.start, the running=true edge, or a resumed
|
||||
// in-flight turn) and is cleared by every settle, so false
|
||||
// here is exactly "no turn has been reported running yet".
|
||||
// (turnStartedAt can't discriminate — it is optimistically
|
||||
// seeded at submit so the visible timer starts at Enter.)
|
||||
//
|
||||
// BOUNDED (#86795): an armed turn that never goes live — a
|
||||
// restore/edit whose rewind was refused after the optimistic
|
||||
// arm, a submit response lost to a gateway bounce, a terminal
|
||||
// error event that never arrived — would otherwise hold this
|
||||
// gate forever. busy then latches until app restart:
|
||||
// isTargetSessionBusy refuses every send, the composer queues
|
||||
// each message, and the queue drain (gated on busy→false) never
|
||||
// fires. turnStartedAt is seeded at the optimistic arm, so its
|
||||
// age bounds the hold; past the grace window (or with no clock
|
||||
// at all) the gateway's running=false is authoritative and the
|
||||
// settle below releases the session.
|
||||
const armedAt = state.turnStartedAt
|
||||
|
||||
const withinPreStartGrace =
|
||||
typeof armedAt === 'number' && Date.now() - armedAt < PRE_TURN_LIVE_SETTLE_GRACE_MS
|
||||
|
||||
if (state.awaitingResponse && !state.sawAssistantPayload && !state.turnLive && withinPreStartGrace) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Past that gate the turn DID start and the backend now reports it
|
||||
// finished. When no assistant payload ever arrived (gateway crash
|
||||
// mid-stream, provider error before the first delta, agent-build
|
||||
// failure) message.complete never fires, so this is the only event
|
||||
// that can release the session. Bailing here instead left
|
||||
// awaitingResponse/busy latched until app restart (#46517): the
|
||||
// per-session busy flag is authoritative for isTargetSessionBusy,
|
||||
// so submitPrompt and the slash dispatcher silently returned false
|
||||
// and the session accepted no further input.
|
||||
recoveredWithoutPayload = state.awaitingResponse && !state.sawAssistantPayload
|
||||
|
||||
return {
|
||||
...state,
|
||||
awaitingResponse: false,
|
||||
busy,
|
||||
// The turn is over but its streaming bubble may still say
|
||||
// pending — running=false from the agent loop's finally block
|
||||
// is the ONLY settle signal when message.complete never
|
||||
// arrives (turn crash, reconnect gap). Left pending, that
|
||||
// bubble shows a thinking indicator forever, stranded
|
||||
// mid-transcript once the next user message lands after it.
|
||||
// finalizeInterruptedMessages un-pends kept text and drops
|
||||
// empty placeholders; on the normal path message.complete
|
||||
// already settled everything and this is a no-op.
|
||||
messages: finalizeInterruptedMessages(state.messages, state.streamId, occurredAt),
|
||||
pendingBranchGroup: null,
|
||||
streamId: null,
|
||||
turnStartedAt: null,
|
||||
turnLive: false
|
||||
}
|
||||
},
|
||||
payload?.stored_session_id || undefined
|
||||
)
|
||||
|
||||
if (recoveredWithoutPayload) {
|
||||
// Stays unscoped, like the settle above: a background session's
|
||||
// sidebar row has to drop its working dot without the user opening
|
||||
// it. This fires on the recovery edge only — once awaitingResponse
|
||||
// is false the `state.busy === busy` guard above short-circuits
|
||||
// every later heartbeat — so it costs one coalesced refresh per
|
||||
// broken turn, not one per tick.
|
||||
scheduleSessionsRefresh()
|
||||
|
||||
// The transcript catch-up IS scoped. The stream died, but the turn
|
||||
// itself may have completed and been persisted, so refetch stored
|
||||
// history for the session actually on screen; a background session
|
||||
// reads its history when the user opens it, and hydrating every one
|
||||
// of them here would fan a REST call out per idle session.
|
||||
if (isActiveEvent) {
|
||||
void hydrateFromStoredSession(3, nextState.storedSessionId, sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (payload?.usage && (!explicitSid || isActiveEvent)) {
|
||||
setCurrentUsage(current => ({ ...current, ...payload.usage }))
|
||||
}
|
||||
|
||||
requestDesktopOnboardingForCredentialWarning(payload?.credential_warning)
|
||||
|
||||
if (apply) {
|
||||
reportInstallMethodWarning(payload?.install_warning)
|
||||
// Config refetch is only meaningful for the foreground context —
|
||||
// everything refreshHermesConfig applies is either active-session
|
||||
// guarded or a composer/global pref. Background sessions' heartbeats
|
||||
// used to trigger it too (two REST calls each, every turn).
|
||||
ctx.scheduleConfigRefresh()
|
||||
}
|
||||
|
||||
if (modelValueChanged || providerValueChanged) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: explicitSid && sessionId ? modelOptionsQueryKey(activeGatewayProfile, sessionId) : ['model-options']
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'session.usage') {
|
||||
// Live usage tick emitted while a turn is mid-flight (see tui_gateway
|
||||
// _start_usage_ticker) so the status-bar context window tracks growth
|
||||
// during the turn instead of only jumping at message.complete.
|
||||
if (payload?.usage && sessionId) {
|
||||
// Per-session twin first: a focused secondary tile reads this cache,
|
||||
// while the primary-only global mirrors the active session.
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
usage: { calls: 0, input: 0, output: 0, total: 0, ...state.usage, ...payload.usage }
|
||||
}))
|
||||
|
||||
if (isActiveEvent) {
|
||||
setCurrentUsage(current => ({ ...current, ...payload.usage }))
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'session.title') {
|
||||
// Live auto-title push (titler runs async, after the turn's refresh).
|
||||
const storedId = typeof payload?.session_id === 'string' ? payload.session_id : ''
|
||||
const nextTitle = typeof payload?.title === 'string' ? payload.title.trim() : ''
|
||||
|
||||
if (storedId && nextTitle) {
|
||||
setSessions(prev => prev.map(s => (sessionMatchesStoredId(s, storedId) ? { ...s, title: nextTitle } : s)))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { translateNow } from '@/i18n'
|
||||
import { textPart } from '@/lib/chat-messages'
|
||||
import { coerceGatewayText } from '@/lib/chat-runtime'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { type AgentNoticePayload, clearAgentNotice, nativeNoticeInput, showAgentNotice } from '@/store/agent-notices'
|
||||
import { clearClarifyRequest } from '@/store/clarify'
|
||||
import { setSessionCompacting } from '@/store/compaction'
|
||||
import { refreshBackgroundProcesses } from '@/store/composer-status'
|
||||
import { applyGoalStatusText } from '@/store/goals'
|
||||
import { dispatchNativeNotification } from '@/store/native-notifications'
|
||||
import { isDiskFullErrorMessage, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { flashPetActivity, setPetActivity } from '@/store/pet'
|
||||
import { clearAllPrompts } from '@/store/prompts'
|
||||
import { setTurnStartedAt } from '@/store/session'
|
||||
import { clearActiveSessionTodos } from '@/store/todos'
|
||||
|
||||
import type { GatewayEventContext } from './types'
|
||||
|
||||
/** status.update / review.summary / notification.show / notification.clear /
|
||||
* error — the status-and-notice tail of the dispatcher. */
|
||||
export function handleStatusEvent(ctx: GatewayEventContext): boolean {
|
||||
const { deps, event, payload, sessionId, isActiveEvent, occurredAt } = ctx
|
||||
const { compactedTurnRef, failAssistantMessage, flushQueuedDeltas, queryClient, updateSessionState } = deps
|
||||
|
||||
if (event.type === 'status.update') {
|
||||
if (sessionId && payload?.kind === 'compacting') {
|
||||
setSessionCompacting(sessionId, true)
|
||||
compactedTurnRef.current.add(sessionId)
|
||||
} else if (sessionId && payload?.kind === 'compacted') {
|
||||
setSessionCompacting(sessionId, false)
|
||||
compactedTurnRef.current.delete(sessionId)
|
||||
} else if (sessionId && payload?.kind === 'process') {
|
||||
// The gateway's notification poller announces background process
|
||||
// completions / watch matches here — re-sync the status stack.
|
||||
void refreshBackgroundProcesses(sessionId)
|
||||
} else if (sessionId && payload?.kind === 'goal') {
|
||||
applyGoalStatusText(sessionId, coerceGatewayText(payload?.text))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'review.summary') {
|
||||
// Self-improvement background review saved something to memory/skills
|
||||
// and emitted a persistent summary (Python formats it as
|
||||
// "💾 Self-improvement review: …"). The CLI prints this via
|
||||
// prompt_toolkit and the Ink TUI renders it as a system line; the
|
||||
// desktop has neither, so without this handler the skill/memory
|
||||
// change happens silently. Surface it as a persistent system message
|
||||
// in the transcript so the user is always informed — it must not be a
|
||||
// transient toast that can be missed.
|
||||
//
|
||||
// Typed here with the `review:` marker (same convention as `steer:` /
|
||||
// `slash:`) so SystemMessage can paint it as the memory-write row it
|
||||
// is instead of sniffing the backend's prose. The leading 💾 goes with
|
||||
// it — the row draws its own glyph.
|
||||
const text = coerceGatewayText(payload?.text)
|
||||
.trim()
|
||||
.replace(/^[^\p{L}\p{N}]+/u, '')
|
||||
|
||||
if (text && sessionId) {
|
||||
flushQueuedDeltas(sessionId)
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
id: `review-summary-${Date.now()}`,
|
||||
role: 'system',
|
||||
parts: [textPart(`review:${text}`, occurredAt)],
|
||||
timestamp: occurredAt
|
||||
}
|
||||
]
|
||||
}))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'notification.show') {
|
||||
// Driver-agnostic agent notice (credits usage/grant/depleted/restored
|
||||
// from `agent/credits_tracker.py`). The Ink TUI renders these in its
|
||||
// status bar; the desktop renders them as toasts. The notice key doubles
|
||||
// as the toast id, so the escalating 50→75→90 credits line replaces in
|
||||
// place instead of stacking. Account-wide signal — shown regardless of
|
||||
// which session is focused.
|
||||
const notice = event.payload as AgentNoticePayload | undefined
|
||||
|
||||
showAgentNotice(notice)
|
||||
|
||||
// The urgent pair (access paused / restored) also breaks through as a
|
||||
// native OS notification when Hermes is backgrounded; dispatch is gated
|
||||
// by the user's notification prefs + backgrounded check.
|
||||
const native = nativeNoticeInput(notice, translateNow('notifications.native.creditsTitle'))
|
||||
|
||||
if (native) {
|
||||
dispatchNativeNotification(native)
|
||||
}
|
||||
|
||||
// A credits crossing moves the account balance. Settings → Billing polls
|
||||
// `billing.state` every 30s; nudge it so the page reflects the crossing
|
||||
// immediately instead of up to 30s late.
|
||||
if (notice?.key?.startsWith('credits.')) {
|
||||
void queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'notification.clear') {
|
||||
// Key-matched dismissal (e.g. credits restored clears the depleted
|
||||
// notice). notify() keys the toast by the notice key, so this maps
|
||||
// straight to dismissNotification(key).
|
||||
clearAgentNotice((event.payload as AgentNoticePayload | undefined)?.key)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'error') {
|
||||
const errorMessage = payload?.message || 'Hermes reported an error'
|
||||
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
|
||||
|
||||
// A turn that errors out has also ended — drop any open blocking prompt
|
||||
// for this session so an approval/sudo/secret overlay can't linger past
|
||||
// the failed turn (same intent as the message.complete clear).
|
||||
if (sessionId) {
|
||||
clearAllPrompts(sessionId)
|
||||
clearClarifyRequest(undefined, sessionId)
|
||||
clearActiveSessionTodos(sessionId)
|
||||
setSessionCompacting(sessionId, false)
|
||||
compactedTurnRef.current.delete(sessionId)
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: false, toolRunning: false })
|
||||
flashPetActivity({ error: true })
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: errorMessage,
|
||||
kind: 'turnError',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.turnErrorTitle')
|
||||
})
|
||||
|
||||
if (looksLikeProviderSetup) {
|
||||
requestDesktopOnboarding(errorMessage)
|
||||
} else if (isDiskFullErrorMessage(errorMessage)) {
|
||||
notifyError(new Error(errorMessage), translateNow('notifications.errors.diskFull'))
|
||||
} else {
|
||||
// Toast globally, not just when the failing thread is focused: a
|
||||
// turn-ending error (e.g. out of funds) blocks every thread, so the
|
||||
// inline error alone is too easy to miss. The stable id collapses the
|
||||
// same error from multiple blocked threads into one toast.
|
||||
notify({
|
||||
id: `gateway-error:${errorMessage}`,
|
||||
kind: 'error',
|
||||
title: 'Hermes error',
|
||||
message: errorMessage
|
||||
})
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
flushQueuedDeltas(sessionId)
|
||||
failAssistantMessage(sessionId, errorMessage, occurredAt)
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
setTurnStartedAt(null)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
|
||||
import { refreshBackgroundProcesses } from '@/store/composer-status'
|
||||
import { flashPetActivity, setPetActivity } from '@/store/pet'
|
||||
import { pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
|
||||
import { reportMcpToolResult } from '@/store/suggestion-providers/repair'
|
||||
import { invalidateSkillSuggestionIndex } from '@/store/suggestion-providers/skill'
|
||||
import { recordToolDiff } from '@/store/tool-diffs'
|
||||
import { setSessionDraftingTool } from '@/store/tool-drafting'
|
||||
import { notifyWorkspaceChanged, toolChangedPath, toolMayMutateFiles } from '@/store/workspace-events'
|
||||
|
||||
import { SUBAGENT_EVENT_TYPES, toTodoPayload } from '../utils'
|
||||
|
||||
import type { GatewayEventContext } from './types'
|
||||
|
||||
/** tool.generating / tool.start / tool.progress / tool.complete / subagent.*. */
|
||||
export function handleToolEvent(ctx: GatewayEventContext): boolean {
|
||||
const { deps, event, payload, sessionId, isActiveEvent, occurredAt } = ctx
|
||||
const { flushQueuedDeltas, nativeSubagentSessionsRef, sessionInterrupted, updateSessionState, upsertToolCall } = deps
|
||||
|
||||
if (event.type === 'tool.generating') {
|
||||
// Announced while the model is still emitting the call's JSON, so it
|
||||
// carries a name and nothing else — no id, no args. Materializing a row
|
||||
// from it strands an argless placeholder whenever the bubble is sealed
|
||||
// before the real `tool.start` arrives, because the two can no longer be
|
||||
// reconciled across the boundary. It's a status, so say it as one.
|
||||
// A stopped turn can still emit a frame or two before the backend
|
||||
// notices, and naming a tool we will never run leaves the label up
|
||||
// until something else retires it. `mutateStream` drops late tool rows
|
||||
// on the same condition; the status line has to agree with it.
|
||||
if (!sessionId || sessionInterrupted(sessionId)) {
|
||||
return true
|
||||
}
|
||||
|
||||
setSessionDraftingTool(sessionId, typeof payload?.name === 'string' ? payload.name : '')
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: false, toolRunning: true })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'tool.start' || event.type === 'tool.progress') {
|
||||
if (!sessionId) {
|
||||
return true
|
||||
}
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running', event.type, occurredAt)
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: false, toolRunning: true })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.type === 'tool.complete') {
|
||||
if (sessionId) {
|
||||
flushQueuedDeltas(sessionId)
|
||||
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type, occurredAt)
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ toolRunning: false })
|
||||
|
||||
// A tool can fail without ending the turn when the agent recovers
|
||||
// and continues. Surface that failure as a short pet beat too;
|
||||
// otherwise only turn-level errors ever reach the failed state.
|
||||
if (payload?.error) {
|
||||
flashPetActivity({ error: true })
|
||||
}
|
||||
}
|
||||
|
||||
// A pending clarify blocks the turn, so the first tool.complete after
|
||||
// one is the clarify resolving — drop the "needs input" flag here so
|
||||
// the sidebar indicator clears as soon as it's answered, not only at
|
||||
// message.complete.
|
||||
updateSessionState(sessionId, state => (state.needsInput ? { ...state, needsInput: false } : state))
|
||||
|
||||
// terminal/process tool calls are the only things that spawn or reap
|
||||
// background processes — sync the composer status stack right after.
|
||||
if (!sessionInterrupted(sessionId) && (payload?.name === 'terminal' || payload?.name === 'process')) {
|
||||
void refreshBackgroundProcesses(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
// The agent just created/deleted/renamed a skill, which adds or removes
|
||||
// its `/name` command. Drop the composer's cached `/` list so the new
|
||||
// skill is offerable now rather than after the hour-long TTL — and the
|
||||
// skill-suggestion provider's index with it.
|
||||
if (payload?.name === 'skill_manage') {
|
||||
invalidateSlashCompletions()
|
||||
invalidateSkillSuggestionIndex()
|
||||
}
|
||||
|
||||
// MCP tool outcomes feed the connection-repair suggestion provider:
|
||||
// an auth/connection-shaped failure offers a reconnect pill; a later
|
||||
// success against the same server withdraws it.
|
||||
if (sessionId && typeof payload?.name === 'string' && payload.name.startsWith('mcp__')) {
|
||||
reportMcpToolResult(
|
||||
sessionId,
|
||||
payload.name,
|
||||
Boolean(payload.error),
|
||||
[payload.error, payload.result].filter(part => typeof part === 'string').join(' ')
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
|
||||
recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff)
|
||||
}
|
||||
|
||||
// A file-mutating tool just finished — nudge the git-mirroring surfaces
|
||||
// (coding rail, review pane, file tree) to refresh. Event-driven, not
|
||||
// polled: fires exactly when the agent touches the tree.
|
||||
if (payload && toolMayMutateFiles(payload)) {
|
||||
notifyWorkspaceChanged(toolChangedPath(payload))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (SUBAGENT_EVENT_TYPES.has(event.type)) {
|
||||
if (sessionId && payload && !sessionInterrupted(sessionId)) {
|
||||
if (!nativeSubagentSessionsRef.current.has(sessionId)) {
|
||||
pruneDelegateFallbackSubagents(sessionId)
|
||||
}
|
||||
|
||||
nativeSubagentSessionsRef.current.add(sessionId)
|
||||
upsertSubagent(
|
||||
sessionId,
|
||||
payload as Record<string, unknown>,
|
||||
event.type === 'subagent.spawn_requested' || event.type === 'subagent.start',
|
||||
event.type
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import type { MutableRefObject } from 'react'
|
||||
|
||||
import type { GatewayEventPayload } from '@/lib/chat-messages'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import type { ClientSessionState } from '../../../../types'
|
||||
|
||||
export interface GatewayEventDeps {
|
||||
activeGatewayProfile: string
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
compactedTurnRef: MutableRefObject<Set<string>>
|
||||
lastCwdInfoSessionRef: MutableRefObject<string | null>
|
||||
nativeSubagentSessionsRef: MutableRefObject<Set<string>>
|
||||
appendAssistantDelta: (sessionId: string, delta: string, occurredAt?: number) => void
|
||||
appendReasoningDelta: (sessionId: string, delta: string, replace?: boolean, occurredAt?: number) => void
|
||||
completeAssistantMessage: (
|
||||
sessionId: string,
|
||||
text: string,
|
||||
responsePreviewed?: boolean,
|
||||
failure?: { error: string; partial: boolean },
|
||||
occurredAt?: number
|
||||
) => void
|
||||
failAssistantMessage: (sessionId: string, errorMessage: string, occurredAt?: number) => void
|
||||
flushQueuedDeltas: (sessionId?: string) => void
|
||||
finalizeInterimAssistantMessage: (sessionId: string, text: string, occurredAt?: number) => void
|
||||
hydrateFromStoredSession: (
|
||||
attempts?: number,
|
||||
storedSessionId?: string | null,
|
||||
runtimeSessionId?: string | null
|
||||
) => Promise<void>
|
||||
queryClient: QueryClient
|
||||
refreshHermesConfig: () => Promise<void>
|
||||
scheduleSessionsRefresh: () => void
|
||||
sessionInterrupted: (sessionId: string) => boolean
|
||||
sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>>
|
||||
updateSessionState: (
|
||||
sessionId: string,
|
||||
updater: (state: ClientSessionState) => ClientSessionState,
|
||||
storedSessionId?: string | null
|
||||
) => ClientSessionState
|
||||
upsertToolCall: (
|
||||
sessionId: string,
|
||||
payload: GatewayEventPayload | undefined,
|
||||
phase: 'running' | 'complete',
|
||||
sourceEventType?: string,
|
||||
occurredAt?: number
|
||||
) => void
|
||||
}
|
||||
|
||||
/** Everything a per-family handler needs about the event being dispatched —
|
||||
* the routing preamble in index.ts computes it once per event. */
|
||||
export interface GatewayEventContext {
|
||||
deps: GatewayEventDeps
|
||||
event: RpcEvent
|
||||
payload: GatewayEventPayload | undefined
|
||||
/** Routed session id (explicit, pinned unscoped stream, or active fallback). */
|
||||
sessionId: null | string
|
||||
/** The raw `session_id` on the event ('' when unscoped). */
|
||||
explicitSid: string
|
||||
/** The routed session is the one on screen. */
|
||||
isActiveEvent: boolean
|
||||
/** Event timestamp in epoch seconds (payload timestamp or receipt time). */
|
||||
occurredAt: number
|
||||
/** The event came from the active (connection, profile) source. */
|
||||
fromActiveSource: () => boolean
|
||||
/** Coalesced trailing refreshHermesConfig (one per session.info burst). */
|
||||
scheduleConfigRefresh: () => void
|
||||
}
|
||||
|
||||
/** A family handler consumes matching event types and reports whether it did. */
|
||||
export type GatewayEventHandler = (ctx: GatewayEventContext) => boolean
|
||||
@@ -1,72 +1,41 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { clearSessionTodos } from '@/store/todos'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
|
||||
|
||||
const SID = 'session-1'
|
||||
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
let sessionStates: Map<string, ClientSessionState>
|
||||
let stream: MessageStreamHarness
|
||||
let mockCompleteSound: ReturnType<typeof vi.fn>
|
||||
let mockHaptic: ReturnType<typeof vi.fn>
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
sessionStates.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
function mountStream() {
|
||||
stream = renderMessageStream(SID)
|
||||
}
|
||||
|
||||
async function mountStream() {
|
||||
sessionStates = new Map()
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
}
|
||||
const start = () => act(() => stream.handleEvent({ payload: {}, session_id: SID, type: 'message.start' }))
|
||||
|
||||
const start = () => act(() => handleEvent!({ payload: {}, session_id: SID, type: 'message.start' }))
|
||||
const delta = (text: string) => act(() => handleEvent!({ payload: { text }, session_id: SID, type: 'message.delta' }))
|
||||
const delta = (text: string) =>
|
||||
act(() => stream.handleEvent({ payload: { text }, session_id: SID, type: 'message.delta' }))
|
||||
|
||||
const interim = (text: string) =>
|
||||
act(() => handleEvent!({ payload: { text, already_streamed: true }, session_id: SID, type: 'message.interim' }))
|
||||
act(() => stream.handleEvent({ payload: { text, already_streamed: true }, session_id: SID, type: 'message.interim' }))
|
||||
|
||||
const complete = (text: string) =>
|
||||
act(() => handleEvent!({ payload: { text }, session_id: SID, type: 'message.complete' }))
|
||||
act(() => stream.handleEvent({ payload: { text }, session_id: SID, type: 'message.complete' }))
|
||||
|
||||
const completePreviewed = (text: string) =>
|
||||
act(() => handleEvent!({ payload: { text, response_previewed: true }, session_id: SID, type: 'message.complete' }))
|
||||
act(() =>
|
||||
stream.handleEvent({ payload: { text, response_previewed: true }, session_id: SID, type: 'message.complete' })
|
||||
)
|
||||
|
||||
function getState(): ClientSessionState {
|
||||
return sessionStates.get(SID) ?? createClientSessionState()
|
||||
return stream.state()
|
||||
}
|
||||
|
||||
function assistantText(): string {
|
||||
@@ -87,7 +56,6 @@ function assistantMessages(): string[] {
|
||||
|
||||
describe('useMessageStream interim text sealing', () => {
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
clearSessionTodos(SID)
|
||||
})
|
||||
|
||||
@@ -98,7 +66,7 @@ describe('useMessageStream interim text sealing', () => {
|
||||
})
|
||||
|
||||
it('preserves interim text that the final response does not include', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
await start()
|
||||
|
||||
await delta('awaaaaa clean!! tsc zero errors')
|
||||
@@ -112,7 +80,7 @@ describe('useMessageStream interim text sealing', () => {
|
||||
})
|
||||
|
||||
it('marks sealed interim bubbles interim and leaves the final reply unmarked', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
await start()
|
||||
|
||||
await delta('Let me check the files.')
|
||||
@@ -130,7 +98,7 @@ describe('useMessageStream interim text sealing', () => {
|
||||
})
|
||||
|
||||
it('clears the interim mark when a previewed final settles onto the interim bubble', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
await start()
|
||||
|
||||
await interim('same reply')
|
||||
@@ -142,7 +110,7 @@ describe('useMessageStream interim text sealing', () => {
|
||||
})
|
||||
|
||||
it('dedupes interim text when the final response includes it', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
await start()
|
||||
|
||||
await delta('Let me check the files.')
|
||||
@@ -156,7 +124,7 @@ describe('useMessageStream interim text sealing', () => {
|
||||
})
|
||||
|
||||
it('clears interimBoundaryPending at turn end so the next turn starts clean', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
await start()
|
||||
|
||||
await delta('interim text')
|
||||
@@ -175,7 +143,7 @@ describe('useMessageStream interim text sealing', () => {
|
||||
})
|
||||
|
||||
it('finalizes an interim segment without settling the turn', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
await start()
|
||||
|
||||
await delta('streaming text')
|
||||
@@ -187,7 +155,7 @@ describe('useMessageStream interim text sealing', () => {
|
||||
})
|
||||
|
||||
it('settles an identical final onto a non-previewed interim (tool-call turn) instead of duplicating (#63679)', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
await start()
|
||||
|
||||
// A plain tool-call turn: the streamed text is sealed as an interim at the
|
||||
@@ -203,7 +171,7 @@ describe('useMessageStream interim text sealing', () => {
|
||||
})
|
||||
|
||||
it('settles a prefix-extended final onto a non-previewed interim (streamed + trailing delta)', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
await start()
|
||||
|
||||
// The stream dropped/settled early at the tool boundary; the final adds a
|
||||
@@ -218,7 +186,7 @@ describe('useMessageStream interim text sealing', () => {
|
||||
})
|
||||
|
||||
it('settles final onto interim even after message.start reset the boundary flag (#74560)', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
await start()
|
||||
await delta('partial')
|
||||
await interim('partial')
|
||||
@@ -235,12 +203,12 @@ describe('useMessageStream interim text sealing', () => {
|
||||
})
|
||||
|
||||
it('appends a distinct previewed final after a message.start reset instead of overwriting the interim', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
await start()
|
||||
await interim('old interim text')
|
||||
// A genuinely new turn begins — message.start resets interimBoundaryPending.
|
||||
// Production ordering: mid-turn compaction-resume events do NOT include
|
||||
// message.start (COMPACTION_RESUME_EVENT_TYPES in gateway-event.ts), and
|
||||
// message.start (COMPACTION_RESUME_EVENT_TYPES in gateway-event/index.ts), and
|
||||
// the TUI gateway emits message.complete BEFORE goal-followup starts
|
||||
// (tui_gateway/server.py), so a previewed final arriving after the reset
|
||||
// is a DISTINCT reply, not a rewrite of the interim. It must append its
|
||||
@@ -255,7 +223,7 @@ describe('useMessageStream interim text sealing', () => {
|
||||
})
|
||||
|
||||
it('appends a genuinely different final as its own bubble (two real assistant segments)', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
await start()
|
||||
|
||||
// The interim is one segment (pre-tool commentary); the final is different
|
||||
@@ -271,7 +239,7 @@ describe('useMessageStream interim text sealing', () => {
|
||||
})
|
||||
|
||||
it('settles an identical final completion onto the interim when response_previewed', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
await start()
|
||||
|
||||
await interim('same reply')
|
||||
@@ -285,7 +253,7 @@ describe('useMessageStream interim text sealing', () => {
|
||||
})
|
||||
|
||||
it('settles a prefix-matched final onto the interim when response_previewed', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
await start()
|
||||
|
||||
// Interim text is a PREFIX of the final — the model streamed part of
|
||||
@@ -302,7 +270,7 @@ describe('useMessageStream interim text sealing', () => {
|
||||
})
|
||||
|
||||
it('dedupes partial-stream-then-nudge: streamed prefix + interim + previewed final settles to one bubble', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
await start()
|
||||
|
||||
// The model streamed part of its answer via message.delta, then the
|
||||
@@ -319,16 +287,16 @@ describe('useMessageStream interim text sealing', () => {
|
||||
})
|
||||
|
||||
it('ignores malformed message.interim payload', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
await start()
|
||||
|
||||
// No payload at all
|
||||
await act(() => handleEvent!({ type: 'message.interim' } as RpcEvent))
|
||||
await act(() => stream.handleEvent({ type: 'message.interim' } as RpcEvent))
|
||||
// Empty text
|
||||
await act(() => handleEvent!({ payload: { text: '' }, session_id: SID, type: 'message.interim' } as RpcEvent))
|
||||
await act(() => stream.handleEvent({ payload: { text: '' }, session_id: SID, type: 'message.interim' } as RpcEvent))
|
||||
// Undefined text
|
||||
await act(() =>
|
||||
handleEvent!({ payload: { text: undefined }, session_id: SID, type: 'message.interim' } as RpcEvent)
|
||||
stream.handleEvent({ payload: { text: undefined }, session_id: SID, type: 'message.interim' } as RpcEvent)
|
||||
)
|
||||
|
||||
// Turn continues without finalizing or throwing
|
||||
@@ -337,7 +305,7 @@ describe('useMessageStream interim text sealing', () => {
|
||||
})
|
||||
|
||||
it('clears interimBoundaryPending on message.start', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
await start()
|
||||
|
||||
await delta('interim text')
|
||||
|
||||
@@ -1,88 +1,41 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
|
||||
|
||||
const SID = 'session-1'
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
let latestState: ClientSessionState | null = null
|
||||
let stream: MessageStreamHarness
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
latestState = next
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function mountStream() {
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
function mountStream() {
|
||||
stream = renderMessageStream(SID)
|
||||
}
|
||||
|
||||
function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}) {
|
||||
act(() => handleEvent!({ payload, session_id: SID, type }))
|
||||
act(() => stream.handleEvent({ payload, session_id: SID, type }))
|
||||
}
|
||||
|
||||
function reasoningText(): string {
|
||||
const message = latestState?.messages.at(-1)
|
||||
const part = message?.parts.find(p => p.type === 'reasoning')
|
||||
|
||||
return part?.type === 'reasoning' ? part.text : ''
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
latestState = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('useMessageStream moa.progress / moa.phase surfacing', () => {
|
||||
it('shows refs k/n lines in the reasoning block as references complete', async () => {
|
||||
await mountStream()
|
||||
it('shows refs k/n lines in the reasoning block as references complete', () => {
|
||||
mountStream()
|
||||
|
||||
emit('message.start')
|
||||
emit('moa.progress', { label: 'model-a', refs_done: 1, refs_total: 3 })
|
||||
emit('moa.progress', { label: 'model-b', refs_done: 2, refs_total: 3 })
|
||||
|
||||
const text = reasoningText()
|
||||
const text = stream.reasoningText()
|
||||
expect(text).toContain('MoA refs 1/3 — model-a')
|
||||
expect(text).toContain('MoA refs 2/3 — model-b')
|
||||
})
|
||||
|
||||
it('restarts the progress block on the first ref of a new fan-out', async () => {
|
||||
await mountStream()
|
||||
it('restarts the progress block on the first ref of a new fan-out', () => {
|
||||
mountStream()
|
||||
|
||||
emit('message.start')
|
||||
emit('moa.progress', { label: 'stale', refs_done: 1, refs_total: 2 })
|
||||
@@ -90,32 +43,32 @@ describe('useMessageStream moa.progress / moa.phase surfacing', () => {
|
||||
// A later turn's fan-out starts over at 1/N — old lines must not linger.
|
||||
emit('moa.progress', { label: 'fresh', refs_done: 1, refs_total: 2 })
|
||||
|
||||
const text = reasoningText()
|
||||
const text = stream.reasoningText()
|
||||
expect(text).toContain('MoA refs 1/2 — fresh')
|
||||
expect(text).not.toContain('stale')
|
||||
})
|
||||
|
||||
it('appends the aggregating marker on moa.phase and ignores unknown phases', async () => {
|
||||
await mountStream()
|
||||
it('appends the aggregating marker on moa.phase and ignores unknown phases', () => {
|
||||
mountStream()
|
||||
|
||||
emit('message.start')
|
||||
emit('moa.progress', { label: 'model-a', refs_done: 1, refs_total: 1 })
|
||||
emit('moa.phase', { phase: 'reference', refs_done: 1, refs_total: 1 })
|
||||
expect(reasoningText()).not.toContain('aggregating')
|
||||
expect(stream.reasoningText()).not.toContain('aggregating')
|
||||
|
||||
emit('moa.phase', { aggregator: 'agg-model', phase: 'aggregator', refs_done: 1, refs_total: 1 })
|
||||
expect(reasoningText()).toContain('MoA aggregating…')
|
||||
expect(stream.reasoningText()).toContain('MoA aggregating…')
|
||||
})
|
||||
|
||||
it('a following moa.reference replaces the progress trail (self-cleaning)', async () => {
|
||||
await mountStream()
|
||||
it('a following moa.reference replaces the progress trail (self-cleaning)', () => {
|
||||
mountStream()
|
||||
|
||||
emit('message.start')
|
||||
emit('moa.progress', { label: 'model-a', refs_done: 1, refs_total: 1 })
|
||||
emit('moa.phase', { phase: 'aggregator', refs_done: 1, refs_total: 1 })
|
||||
emit('moa.reference', { count: 1, index: 1, label: 'model-a', text: 'advice-a' })
|
||||
|
||||
const text = reasoningText()
|
||||
const text = stream.reasoningText()
|
||||
expect(text).toContain('Reference 1/1 — model-a')
|
||||
expect(text).not.toContain('MoA refs')
|
||||
expect(text).not.toContain('aggregating')
|
||||
|
||||
+16
-63
@@ -1,81 +1,34 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
|
||||
|
||||
const SID = 'session-1'
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
let latestState: ClientSessionState | null = null
|
||||
let stream: MessageStreamHarness
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
latestState = next
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function mountStream() {
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
function mountStream() {
|
||||
stream = renderMessageStream(SID)
|
||||
}
|
||||
|
||||
function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}) {
|
||||
act(() => handleEvent!({ payload, session_id: SID, type }))
|
||||
}
|
||||
|
||||
function reasoningText(): string {
|
||||
const message = latestState?.messages.at(-1)
|
||||
const part = message?.parts.find(p => p.type === 'reasoning')
|
||||
|
||||
return part?.type === 'reasoning' ? part.text : ''
|
||||
act(() => stream.handleEvent({ payload, session_id: SID, type }))
|
||||
}
|
||||
|
||||
describe('useMessageStream moa.reference accumulation (#64658)', () => {
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
latestState = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('keeps every reference model labelled block instead of only the latest one', async () => {
|
||||
await mountStream()
|
||||
it('keeps every reference model labelled block instead of only the latest one', () => {
|
||||
mountStream()
|
||||
|
||||
emit('moa.reference', { count: 2, index: 1, label: 'model-a', text: 'advice-a' })
|
||||
emit('moa.reference', { count: 2, index: 2, label: 'model-b', text: 'advice-b' })
|
||||
|
||||
const text = reasoningText()
|
||||
const text = stream.reasoningText()
|
||||
|
||||
expect(text).toContain('model-a')
|
||||
expect(text).toContain('advice-a')
|
||||
@@ -83,25 +36,25 @@ describe('useMessageStream moa.reference accumulation (#64658)', () => {
|
||||
expect(text).toContain('advice-b')
|
||||
})
|
||||
|
||||
it('handles a single-reference MoA turn (count=1) without regression', async () => {
|
||||
await mountStream()
|
||||
it('handles a single-reference MoA turn (count=1) without regression', () => {
|
||||
mountStream()
|
||||
|
||||
emit('moa.reference', { count: 1, index: 1, label: 'model-a', text: 'only-advice' })
|
||||
|
||||
const text = reasoningText()
|
||||
const text = stream.reasoningText()
|
||||
|
||||
expect(text).toContain('model-a')
|
||||
expect(text).toContain('only-advice')
|
||||
})
|
||||
|
||||
it('accumulates three or more references in order', async () => {
|
||||
await mountStream()
|
||||
it('accumulates three or more references in order', () => {
|
||||
mountStream()
|
||||
|
||||
emit('moa.reference', { count: 3, index: 1, label: 'model-a', text: 'advice-a' })
|
||||
emit('moa.reference', { count: 3, index: 2, label: 'model-b', text: 'advice-b' })
|
||||
emit('moa.reference', { count: 3, index: 3, label: 'model-c', text: 'advice-c' })
|
||||
|
||||
const text = reasoningText()
|
||||
const text = stream.reasoningText()
|
||||
|
||||
const orderOk =
|
||||
text.indexOf('advice-a') < text.indexOf('advice-b') && text.indexOf('advice-b') < text.indexOf('advice-c')
|
||||
|
||||
+10
-44
@@ -1,60 +1,26 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $petActivity, $petState, setPetActivity } from '@/store/pet'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
|
||||
|
||||
const SID = 'session-1'
|
||||
const OTHER_SID = 'session-2'
|
||||
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
let stream: MessageStreamHarness
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function mountStream() {
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
function mountStream() {
|
||||
stream = renderMessageStream(SID)
|
||||
}
|
||||
|
||||
function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}, sessionId = SID) {
|
||||
act(() => handleEvent!({ payload, session_id: sessionId, type }))
|
||||
act(() => stream.handleEvent({ payload, session_id: sessionId, type }))
|
||||
}
|
||||
|
||||
describe('pet tool-failure reaction', () => {
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
setPetActivity({
|
||||
busy: false,
|
||||
awaitingInput: false,
|
||||
@@ -72,8 +38,8 @@ describe('pet tool-failure reaction', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('briefly shows failed when the active session has an isolated tool error', async () => {
|
||||
await mountStream()
|
||||
it('briefly shows failed when the active session has an isolated tool error', () => {
|
||||
mountStream()
|
||||
|
||||
emit('tool.start', { name: 'terminal', tool_id: 'tool-1' })
|
||||
emit('tool.complete', { name: 'terminal', tool_id: 'tool-1', error: 'exit code 1' })
|
||||
@@ -82,8 +48,8 @@ describe('pet tool-failure reaction', () => {
|
||||
expect($petState.get()).toBe('failed')
|
||||
})
|
||||
|
||||
it('does not show failed for a successful tool or a background-session failure', async () => {
|
||||
await mountStream()
|
||||
it('does not show failed for a successful tool or a background-session failure', () => {
|
||||
mountStream()
|
||||
|
||||
emit('tool.complete', { name: 'terminal', tool_id: 'tool-1' })
|
||||
expect($petActivity.get().error).toBe(false)
|
||||
|
||||
@@ -1,57 +1,23 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $providerWaitSessions } from '@/store/provider-wait'
|
||||
import { clearAllSessionStates, dropSessionState } from '@/store/session-states'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
|
||||
|
||||
const SID = 'session-1'
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
let stream: MessageStreamHarness
|
||||
|
||||
function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}) {
|
||||
act(() => handleEvent!({ payload, session_id: SID, type }))
|
||||
act(() => stream.handleEvent({ payload, session_id: SID, type }))
|
||||
}
|
||||
|
||||
describe('provider wait visibility', () => {
|
||||
beforeEach(async () => {
|
||||
handleEvent = null
|
||||
$providerWaitSessions.set({})
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
stream = renderMessageStream(SID)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
+25
-53
@@ -1,6 +1,5 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isTargetSessionBusy } from '@/app/session/hooks/use-prompt-actions/utils'
|
||||
@@ -8,12 +7,10 @@ import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { modelOptionsQueryKey } from '@/lib/model-options'
|
||||
import { setCurrentModel, setCurrentProvider } from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
|
||||
import { PRE_TURN_LIVE_SETTLE_GRACE_MS } from './utils'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
|
||||
// Per-turn REST amplification guards: session.info must not refetch config for
|
||||
// background sessions nor invalidate the model-options catalog when the model
|
||||
// string is merely PRESENT (the backend stamps it on every event) rather than
|
||||
@@ -21,53 +18,28 @@ import { useMessageStream } from './index'
|
||||
|
||||
const ACTIVE_SID = 'session-active'
|
||||
const ACTIVE_PROFILE = 'compass'
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
let stream: MessageStreamHarness
|
||||
let refreshHermesConfig: ReturnType<typeof vi.fn<() => Promise<void>>>
|
||||
let refreshSessions: ReturnType<typeof vi.fn<() => Promise<void>>>
|
||||
let hydrateFromStoredSession: ReturnType<typeof vi.fn<() => Promise<void>>>
|
||||
let queryClient: QueryClient
|
||||
let sessionStates: Map<string, ClientSessionState> | null = null
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(ACTIVE_SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
|
||||
sessionStates = sessionStateByRuntimeIdRef.current
|
||||
|
||||
const stream = useMessageStream({
|
||||
function mountStream() {
|
||||
stream = renderMessageStream(ACTIVE_SID, {
|
||||
activeGatewayProfile: ACTIVE_PROFILE,
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession,
|
||||
queryClient,
|
||||
refreshHermesConfig,
|
||||
refreshSessions,
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
refreshSessions
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function mountStream() {
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
sessionStates = stream.states
|
||||
}
|
||||
|
||||
const sessionInfo = (sessionId: string, payload: Record<string, unknown>) =>
|
||||
act(() => handleEvent!({ payload, session_id: sessionId, type: 'session.info' }))
|
||||
act(() => stream.handleEvent({ payload, session_id: sessionId, type: 'session.info' }))
|
||||
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
sessionStates = null
|
||||
refreshHermesConfig = vi.fn<() => Promise<void>>(async () => undefined)
|
||||
refreshSessions = vi.fn<() => Promise<void>>(async () => undefined)
|
||||
@@ -88,7 +60,7 @@ afterEach(() => {
|
||||
describe('session.info config refetch gating', () => {
|
||||
it('coalesces active-session bursts into one trailing config fetch', async () => {
|
||||
// Mount under real timers (waitFor), then freeze time for the debounce.
|
||||
await mountStream()
|
||||
mountStream()
|
||||
vi.useFakeTimers()
|
||||
|
||||
sessionInfo(ACTIVE_SID, { model: 'm1', running: true })
|
||||
@@ -105,7 +77,7 @@ describe('session.info config refetch gating', () => {
|
||||
})
|
||||
|
||||
it('never fetches config for a background session heartbeat', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
vi.useFakeTimers()
|
||||
|
||||
sessionInfo('session-background', { model: 'm1', running: true })
|
||||
@@ -120,8 +92,8 @@ describe('session.info config refetch gating', () => {
|
||||
})
|
||||
|
||||
describe('session.info model-options invalidation gating', () => {
|
||||
it('skips invalidation when model/provider merely restate the known values', async () => {
|
||||
await mountStream()
|
||||
it('skips invalidation when model/provider merely restate the known values', () => {
|
||||
mountStream()
|
||||
const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
|
||||
// Seed the session's cached runtime state.
|
||||
@@ -135,8 +107,8 @@ describe('session.info model-options invalidation gating', () => {
|
||||
expect(invalidate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invalidates when the session model actually changes', async () => {
|
||||
await mountStream()
|
||||
it('invalidates when the session model actually changes', () => {
|
||||
mountStream()
|
||||
const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
|
||||
sessionInfo(ACTIVE_SID, { model: 'm1', provider: 'p1', running: true })
|
||||
@@ -159,10 +131,10 @@ describe('session.info settles a turn that produced no assistant payload', () =>
|
||||
const busyFor = (sessionId: string) => isTargetSessionBusy(Object.fromEntries(sessionStates!), sessionId, false)
|
||||
|
||||
const startTurn = (sessionId: string) =>
|
||||
act(() => handleEvent!({ payload: {}, session_id: sessionId, type: 'message.start' }))
|
||||
act(() => stream.handleEvent({ payload: {}, session_id: sessionId, type: 'message.start' }))
|
||||
|
||||
it('leaves the session sendable after a started turn ends with no payload', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
|
||||
startTurn(ACTIVE_SID)
|
||||
expect(busyFor(ACTIVE_SID)).toBe(true)
|
||||
@@ -179,7 +151,7 @@ describe('session.info settles a turn that produced no assistant payload', () =>
|
||||
})
|
||||
|
||||
it('keeps waiting when running=false lands before the turn ever started', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
|
||||
// submit arms busy/awaitingResponse optimistically — and seeds the visible
|
||||
// turn clock (turnStartedAt) at Enter — so this heartbeat is the pre-start
|
||||
@@ -213,7 +185,7 @@ describe('session.info settles a turn that produced no assistant payload', () =>
|
||||
// never fires. Only an app restart cleared it. Past the grace window the
|
||||
// gateway's running=false is authoritative and must settle the session.
|
||||
it('settles an armed-but-never-live turn once the pre-start grace expires (#86795)', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
|
||||
act(() => {
|
||||
sessionStates!.set(ACTIVE_SID, {
|
||||
@@ -240,7 +212,7 @@ describe('session.info settles a turn that produced no assistant payload', () =>
|
||||
// one — submit and the rewind optimistic transforms both seed the clock —
|
||||
// but a regression that forgets the seed must fail open, not latch).
|
||||
it('settles an armed turn with no clock instead of latching busy (#86795)', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
|
||||
act(() => {
|
||||
sessionStates!.set(ACTIVE_SID, {
|
||||
@@ -258,8 +230,8 @@ describe('session.info settles a turn that produced no assistant payload', () =>
|
||||
expect(busyFor(ACTIVE_SID)).toBe(false)
|
||||
})
|
||||
|
||||
it('un-latches a background session but does not hydrate its transcript', async () => {
|
||||
await mountStream()
|
||||
it('un-latches a background session but does not hydrate its transcript', () => {
|
||||
mountStream()
|
||||
|
||||
startTurn('session-background')
|
||||
sessionInfo('session-background', { running: false })
|
||||
@@ -273,7 +245,7 @@ describe('session.info settles a turn that produced no assistant payload', () =>
|
||||
})
|
||||
|
||||
it('hydrates the foreground transcript once and coalesces the sidebar refresh', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
|
||||
startTurn(ACTIVE_SID)
|
||||
vi.useFakeTimers()
|
||||
@@ -294,11 +266,11 @@ describe('session.info settles a turn that produced no assistant payload', () =>
|
||||
|
||||
describe('message.complete sidebar refresh coalescing', () => {
|
||||
it('collapses near-simultaneous completions into one refresh', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
vi.useFakeTimers()
|
||||
|
||||
act(() => handleEvent!({ payload: { text: 'a' }, session_id: 's1', type: 'message.complete' }))
|
||||
act(() => handleEvent!({ payload: { text: 'b' }, session_id: 's2', type: 'message.complete' }))
|
||||
act(() => stream.handleEvent({ payload: { text: 'a' }, session_id: 's1', type: 'message.complete' }))
|
||||
act(() => stream.handleEvent({ payload: { text: 'b' }, session_id: 's2', type: 'message.complete' }))
|
||||
|
||||
expect(refreshSessions).not.toHaveBeenCalled()
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
@@ -8,7 +7,7 @@ import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $sessionStates, $sessionTiles, publishSessionState } from '@/store/session-states'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
|
||||
|
||||
// `session.reclaimed`: the backend tore down a live session we're still
|
||||
// holding (idle TTL, LRU cap, WS-orphan reap). Before this event the runtime id
|
||||
@@ -17,48 +16,18 @@ import { useMessageStream } from './index'
|
||||
|
||||
const ACTIVE_SID = 'session-active'
|
||||
const ACTIVE_PROFILE = 'compass'
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
let stream: MessageStreamHarness
|
||||
let queryClient: QueryClient
|
||||
let wiringCache: Map<string, ClientSessionState>
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(ACTIVE_SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
|
||||
wiringCache = sessionStateByRuntimeIdRef.current
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeGatewayProfile: ACTIVE_PROFILE,
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient,
|
||||
refreshHermesConfig: vi.fn<() => Promise<void>>(async () => undefined),
|
||||
refreshSessions: vi.fn<() => Promise<void>>(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function mountStream() {
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
function mountStream() {
|
||||
stream = renderMessageStream(ACTIVE_SID, { activeGatewayProfile: ACTIVE_PROFILE, queryClient })
|
||||
wiringCache = stream.states
|
||||
}
|
||||
|
||||
const reclaim = (sessionId: string, reason = 'ws_orphan_reap') =>
|
||||
act(() =>
|
||||
handleEvent!({
|
||||
stream.handleEvent({
|
||||
payload: { reason, session_id: sessionId, stored_session_id: 'stored-1' },
|
||||
session_id: '',
|
||||
type: 'session.reclaimed'
|
||||
@@ -66,7 +35,6 @@ const reclaim = (sessionId: string, reason = 'ws_orphan_reap') =>
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
queryClient = new QueryClient()
|
||||
$sessionStates.set({})
|
||||
$sessionTiles.set([])
|
||||
@@ -80,8 +48,8 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('session.reclaimed', () => {
|
||||
it('drops the cached state for the reclaimed runtime', async () => {
|
||||
await mountStream()
|
||||
it('drops the cached state for the reclaimed runtime', () => {
|
||||
mountStream()
|
||||
publishSessionState('live-gone', createClientSessionState())
|
||||
expect($sessionStates.get()['live-gone']).toBeDefined()
|
||||
|
||||
@@ -90,8 +58,8 @@ describe('session.reclaimed', () => {
|
||||
expect($sessionStates.get()['live-gone']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves every other live session alone', async () => {
|
||||
await mountStream()
|
||||
it('leaves every other live session alone', () => {
|
||||
mountStream()
|
||||
publishSessionState('live-gone', createClientSessionState())
|
||||
publishSessionState('live-kept', createClientSessionState())
|
||||
|
||||
@@ -103,8 +71,8 @@ describe('session.reclaimed', () => {
|
||||
expect($sessionStates.get()['live-kept']).toBeDefined()
|
||||
})
|
||||
|
||||
it('ignores a payload with no runtime id instead of clearing everything', async () => {
|
||||
await mountStream()
|
||||
it('ignores a payload with no runtime id instead of clearing everything', () => {
|
||||
mountStream()
|
||||
publishSessionState('live-a', createClientSessionState())
|
||||
publishSessionState('live-b', createClientSessionState())
|
||||
|
||||
@@ -114,12 +82,11 @@ describe('session.reclaimed', () => {
|
||||
expect(Object.keys($sessionStates.get()).sort()).toEqual(['live-a', 'live-b'])
|
||||
})
|
||||
|
||||
it('drops the runtime regardless of which reclaim reason fired', async () => {
|
||||
it('drops the runtime regardless of which reclaim reason fired', () => {
|
||||
for (const reason of ['idle_timeout', 'lru_evict', 'ws_orphan_reap']) {
|
||||
$sessionStates.set({})
|
||||
cleanup()
|
||||
handleEvent = null
|
||||
await mountStream()
|
||||
mountStream()
|
||||
publishSessionState('live-gone', createClientSessionState())
|
||||
|
||||
reclaim('live-gone', reason)
|
||||
@@ -132,8 +99,8 @@ describe('session.reclaimed', () => {
|
||||
// state drop above empties the tile's view, but with `runtimeId` still set
|
||||
// the tile's resume effect (gated on !runtimeId) never refires — an empty
|
||||
// transcript under live chrome, unrecoverable without closing the tab.
|
||||
it('unbinds a tile holding the reclaimed runtime so its resume can refire', async () => {
|
||||
await mountStream()
|
||||
it('unbinds a tile holding the reclaimed runtime so its resume can refire', () => {
|
||||
mountStream()
|
||||
publishSessionState('live-gone', createClientSessionState('stored-1'))
|
||||
$sessionTiles.set([
|
||||
{ runtimeId: 'live-gone', storedSessionId: 'stored-1' },
|
||||
@@ -152,8 +119,8 @@ describe('session.reclaimed', () => {
|
||||
// The wiring cache is resumeTile's warm path: a leftover entry for the dead
|
||||
// runtime would be handed straight back on the re-resume, rebinding the tile
|
||||
// to the same reclaimed id the backend just forgot.
|
||||
it('purges the wiring cache entry for the reclaimed runtime', async () => {
|
||||
await mountStream()
|
||||
it('purges the wiring cache entry for the reclaimed runtime', () => {
|
||||
mountStream()
|
||||
wiringCache.set('live-gone', createClientSessionState('stored-1'))
|
||||
wiringCache.set('live-kept', createClientSessionState('stored-2'))
|
||||
|
||||
|
||||
+9
-49
@@ -4,56 +4,21 @@
|
||||
// mid-transcript — the dither block anywhere but the tail. session.info
|
||||
// running=false is the turn's finally-block signal and the only settle edge
|
||||
// those paths still emit, so it must finalize the bubble.
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
|
||||
import { STREAM_DELTA_FLUSH_MS } from './utils'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
|
||||
const SID = 'stale-pending-session'
|
||||
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
let states: Map<string, ClientSessionState>
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
states = sessionStateByRuntimeIdRef.current
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
let stream: MessageStreamHarness
|
||||
|
||||
async function mountHarness() {
|
||||
vi.useFakeTimers()
|
||||
render(<Harness />)
|
||||
stream = renderMessageStream(SID)
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
@@ -65,14 +30,9 @@ const flushDeltas = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
const emit = (event: RpcEvent) => act(() => handleEvent?.(event))
|
||||
const emit = (event: RpcEvent) => act(() => stream.handleEvent(event))
|
||||
|
||||
describe('turn end without message.complete (session.info running=false)', () => {
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
states = new Map()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
@@ -86,11 +46,11 @@ describe('turn end without message.complete (session.info running=false)', () =>
|
||||
emit({ payload: { text: 'partial answer' }, session_id: SID, type: 'message.delta' })
|
||||
await flushDeltas()
|
||||
|
||||
expect(states.get(SID)?.messages.at(-1)?.pending).toBe(true)
|
||||
expect(stream.state()?.messages.at(-1)?.pending).toBe(true)
|
||||
|
||||
emit({ payload: { running: false }, session_id: SID, type: 'session.info' })
|
||||
|
||||
const state = states.get(SID)
|
||||
const state = stream.state()
|
||||
const tail = state?.messages.at(-1)
|
||||
|
||||
expect(tail?.role).toBe('assistant')
|
||||
@@ -118,7 +78,7 @@ describe('turn end without message.complete (session.info running=false)', () =>
|
||||
|
||||
emit({ payload: { running: false }, session_id: SID, type: 'session.info' })
|
||||
|
||||
const state = states.get(SID)
|
||||
const state = stream.state()
|
||||
|
||||
// Same math as Stop: an empty-text placeholder is dropped, nothing stays
|
||||
// pending, and the stream binding is released.
|
||||
|
||||
@@ -1,56 +1,14 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
|
||||
import { STREAM_DELTA_FLUSH_MS } from './utils'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
|
||||
const SID = 'stream-session'
|
||||
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
let states: Map<string, ClientSessionState>
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
states = sessionStateByRuntimeIdRef.current
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
let stream: MessageStreamHarness
|
||||
|
||||
describe('stream delta delivery', () => {
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
states = new Map()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
@@ -64,7 +22,7 @@ describe('stream delta delivery', () => {
|
||||
const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
|
||||
vi.useFakeTimers()
|
||||
|
||||
render(<Harness />)
|
||||
stream = renderMessageStream(SID)
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
@@ -72,7 +30,7 @@ describe('stream delta delivery', () => {
|
||||
// Reach the frame-gated branch: it is only taken once the coalescing floor
|
||||
// has already elapsed since the previous flush. Send one delta, let it
|
||||
// flush, then idle past the floor so the NEXT delta schedules immediately.
|
||||
act(() => handleEvent?.({ payload: { text: 'first ' }, session_id: SID, type: 'message.delta' }))
|
||||
act(() => stream.handleEvent({ payload: { text: 'first ' }, session_id: SID, type: 'message.delta' }))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(STREAM_DELTA_FLUSH_MS)
|
||||
})
|
||||
@@ -80,13 +38,13 @@ describe('stream delta delivery', () => {
|
||||
await vi.advanceTimersByTimeAsync(STREAM_DELTA_FLUSH_MS * 2)
|
||||
})
|
||||
|
||||
act(() => handleEvent?.({ payload: { text: 'and the rest' }, session_id: SID, type: 'message.delta' }))
|
||||
act(() => stream.handleEvent({ payload: { text: 'and the rest' }, session_id: SID, type: 'message.delta' }))
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(STREAM_DELTA_FLUSH_MS)
|
||||
})
|
||||
|
||||
expect(states.get(SID)?.messages.at(-1)?.parts).toMatchObject([{ type: 'text', text: 'first and the rest' }])
|
||||
expect(stream.state()?.messages.at(-1)?.parts).toMatchObject([{ type: 'text', text: 'first and the rest' }])
|
||||
// The flush must not have depended on a frame: this mock parks every rAF
|
||||
// callback, yet the text arrived. runFlush still registers its
|
||||
// adaptive-floor measurement callback here; that one is allowed to wait
|
||||
|
||||
+14
-52
@@ -1,63 +1,29 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
|
||||
|
||||
const SID = 'session-1'
|
||||
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
let sessionStates: Map<string, ClientSessionState>
|
||||
let stream: MessageStreamHarness
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
sessionStates.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
function mountStream() {
|
||||
stream = renderMessageStream(SID)
|
||||
}
|
||||
|
||||
async function mountStream() {
|
||||
sessionStates = new Map()
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
}
|
||||
const start = () => act(() => stream.handleEvent({ payload: {}, session_id: SID, type: 'message.start' }))
|
||||
|
||||
const start = () => act(() => handleEvent!({ payload: {}, session_id: SID, type: 'message.start' }))
|
||||
const delta = (text: string) => act(() => handleEvent!({ payload: { text }, session_id: SID, type: 'message.delta' }))
|
||||
const delta = (text: string) =>
|
||||
act(() => stream.handleEvent({ payload: { text }, session_id: SID, type: 'message.delta' }))
|
||||
|
||||
const completeWithError = (payload: Record<string, unknown>) =>
|
||||
act(() => handleEvent!({ payload: { status: 'error', ...payload }, session_id: SID, type: 'message.complete' }))
|
||||
act(() => stream.handleEvent({ payload: { status: 'error', ...payload }, session_id: SID, type: 'message.complete' }))
|
||||
|
||||
function getState(): ClientSessionState {
|
||||
return sessionStates.get(SID) ?? createClientSessionState()
|
||||
return stream.state()
|
||||
}
|
||||
|
||||
function lastAssistant() {
|
||||
@@ -65,17 +31,13 @@ function lastAssistant() {
|
||||
}
|
||||
|
||||
describe('terminal error message.complete frames', () => {
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('marks the bubble failed from the structured error field, not the text heuristic', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
await start()
|
||||
await delta('…')
|
||||
|
||||
@@ -89,7 +51,7 @@ describe('terminal error message.complete frames', () => {
|
||||
})
|
||||
|
||||
it('keeps streamed partial text visible on a partial failure', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
await start()
|
||||
await delta('half an ans')
|
||||
|
||||
@@ -107,7 +69,7 @@ describe('terminal error message.complete frames', () => {
|
||||
})
|
||||
|
||||
it('falls back to the frame text when no error field is present', async () => {
|
||||
await mountStream()
|
||||
mountStream()
|
||||
await start()
|
||||
await delta('…')
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { render } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
|
||||
export interface MessageStreamHarnessOptions extends Partial<Parameters<typeof useMessageStream>[0]> {
|
||||
/** Session-state map to mount with, for tests that seed state up front. */
|
||||
states?: Map<string, ClientSessionState>
|
||||
}
|
||||
|
||||
export interface MessageStreamHarness {
|
||||
/** Feed a gateway event into the mounted hook. */
|
||||
handleEvent: (event: RpcEvent) => void
|
||||
/** Push streaming assistant text, bypassing the event envelope. For the specs
|
||||
* about flush scheduling rather than about a particular event. */
|
||||
appendDelta: (sessionId: string, delta: string) => void
|
||||
/** The hook's session-state map, so callers can seed or inspect it directly. */
|
||||
states: Map<string, ClientSessionState>
|
||||
/** State for a session, blank before the hook has written any. */
|
||||
state: (sessionId?: string) => ClientSessionState
|
||||
/** Last state written for any session — for assertions about the write itself. */
|
||||
latest: () => ClientSessionState | null
|
||||
/** Text of the trailing text part on a session's newest message, '' when the
|
||||
* tail is not text. What streamed deltas accumulate into. */
|
||||
text: (sessionId?: string) => string
|
||||
/** Text of the reasoning part on the newest message, '' when there is none.
|
||||
* MoA events land there, so the moa specs read the stream through it. */
|
||||
reasoningText: () => string
|
||||
}
|
||||
|
||||
/** Mount `useMessageStream` with inert dependencies and hand back the event sink
|
||||
* plus the state it produces.
|
||||
*
|
||||
* Every event test needs the same wiring — the real hook, stubbed refresh and
|
||||
* hydrate callbacks, a session-state map it owns — and differs only in which
|
||||
* events it sends and what it asserts. `overrides` takes any of the hook's own
|
||||
* options for the tests that need a seeded state map, a shared query client, a
|
||||
* named gateway profile, or a callback they can assert against. Callers still
|
||||
* own `cleanup()`. */
|
||||
export function renderMessageStream(
|
||||
sessionId: string | null,
|
||||
{ states = new Map<string, ClientSessionState>(), ...overrides }: MessageStreamHarnessOptions = {}
|
||||
): MessageStreamHarness {
|
||||
let dispatch: ((event: RpcEvent) => void) | null = null
|
||||
let appendDelta: ((sessionId: string, delta: string) => void) | null = null
|
||||
let latest: ClientSessionState | null = null
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(sessionId)
|
||||
const sessionStateByRuntimeIdRef = useRef(states)
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (id, updater) => {
|
||||
const next = updater(states.get(id) ?? createClientSessionState())
|
||||
states.set(id, next)
|
||||
latest = next
|
||||
|
||||
return next
|
||||
},
|
||||
...overrides
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
dispatch = stream.handleGatewayEvent
|
||||
appendDelta = stream.appendAssistantDelta
|
||||
}, [stream.appendAssistantDelta, stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
|
||||
const state = (id = sessionId ?? '') => states.get(id) ?? createClientSessionState()
|
||||
|
||||
return {
|
||||
handleEvent: event => {
|
||||
if (!dispatch) {
|
||||
throw new Error('renderMessageStream: the hook never mounted')
|
||||
}
|
||||
|
||||
dispatch(event)
|
||||
},
|
||||
appendDelta: (id, delta) => {
|
||||
if (!appendDelta) {
|
||||
throw new Error('renderMessageStream: the hook never mounted')
|
||||
}
|
||||
|
||||
appendDelta(id, delta)
|
||||
},
|
||||
states,
|
||||
state,
|
||||
latest: () => latest,
|
||||
text: id => {
|
||||
const part = state(id).messages.at(-1)?.parts.at(-1)
|
||||
|
||||
return part?.type === 'text' ? part.text : ''
|
||||
},
|
||||
reasoningText: () => {
|
||||
const part = latest?.messages.at(-1)?.parts.find(p => p.type === 'reasoning')
|
||||
|
||||
return part?.type === 'reasoning' ? part.text : ''
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,18 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
|
||||
|
||||
const SID = 'timeline-session'
|
||||
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
let states: Map<string, ClientSessionState>
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
states.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
let stream: MessageStreamHarness
|
||||
|
||||
const event = (type: string, timestamp: number, payload: Record<string, unknown> = {}) =>
|
||||
act(() => handleEvent?.({ payload: { ...payload, timestamp }, session_id: SID, type }))
|
||||
act(() => stream.handleEvent({ payload: { ...payload, timestamp }, session_id: SID, type }))
|
||||
|
||||
describe('live transcript timeline events', () => {
|
||||
beforeEach(async () => {
|
||||
handleEvent = null
|
||||
states = new Map()
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
stream = renderMessageStream(SID)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -68,7 +29,7 @@ describe('live transcript timeline events', () => {
|
||||
event('message.delta', 105.625, { text: 'The file looks good.' })
|
||||
event('message.complete', 106.875, { text: 'The file looks good.' })
|
||||
|
||||
const assistants = states.get(SID)?.messages.filter(message => message.role === 'assistant') ?? []
|
||||
const assistants = stream.state(SID).messages.filter(message => message.role === 'assistant') ?? []
|
||||
|
||||
expect(assistants).toHaveLength(2)
|
||||
expect([assistants[0].timestamp, assistants[0].completedAt]).toEqual([101.125, 101.75])
|
||||
@@ -88,7 +49,7 @@ describe('live transcript timeline events', () => {
|
||||
event('message.delta', 202.25, { text: 'Then speaking.' })
|
||||
event('tool.start', 203.5, { args: {}, name: 'terminal', tool_id: 'call-2' })
|
||||
|
||||
const assistant = states.get(SID)?.messages.find(message => message.role === 'assistant')
|
||||
const assistant = stream.state(SID).messages.find(message => message.role === 'assistant')
|
||||
|
||||
expect(assistant?.parts.map(part => part.type)).toEqual(['reasoning', 'text', 'tool-call'])
|
||||
expect(assistant?.parts.map(part => part.timestamp)).toEqual([201.125, 202.25, 203.5])
|
||||
@@ -98,7 +59,7 @@ describe('live transcript timeline events', () => {
|
||||
event('message.start', 300)
|
||||
event('error', 301.875, { error: 'provider failed' })
|
||||
|
||||
const assistant = states.get(SID)?.messages.find(message => message.role === 'assistant')
|
||||
const assistant = stream.state(SID).messages.find(message => message.role === 'assistant')
|
||||
|
||||
expect(assistant?.error).toBeTruthy()
|
||||
expect([assistant?.timestamp, assistant?.completedAt]).toEqual([301.875, 301.875])
|
||||
@@ -107,7 +68,7 @@ describe('live transcript timeline events', () => {
|
||||
it('uses the gateway event time for a review summary system row', () => {
|
||||
event('review.summary', 401.625, { text: 'Review saved.' })
|
||||
|
||||
const system = states.get(SID)?.messages.find(message => message.role === 'system')
|
||||
const system = stream.state(SID).messages.find(message => message.role === 'system')
|
||||
|
||||
expect(system?.timestamp).toBe(401.625)
|
||||
expect(system?.parts[0].timestamp).toBe(401.625)
|
||||
@@ -118,7 +79,7 @@ describe('live transcript timeline events', () => {
|
||||
event('tool.start', 501, { args: {}, name: 'terminal', tool_id: 'call-3' })
|
||||
event('session.info', 502.75, { running: false })
|
||||
|
||||
const assistant = states.get(SID)?.messages.find(message => message.role === 'assistant')
|
||||
const assistant = stream.state(SID).messages.find(message => message.role === 'assistant')
|
||||
|
||||
expect(assistant?.completedAt).toBe(502.75)
|
||||
expect(assistant?.parts[0].completedAt).toBe(502.75)
|
||||
|
||||
@@ -1,59 +1,25 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import type { TodoItem } from '@/lib/todos'
|
||||
import { $todosBySession, clearSessionTodos, setSessionTodos } from '@/store/todos'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
|
||||
|
||||
const SID = 'session-1'
|
||||
const todo = (id: string, status: TodoItem['status']): TodoItem => ({ content: `task ${id}`, id, status })
|
||||
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
let stream: MessageStreamHarness
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
function mountStream() {
|
||||
stream = renderMessageStream(SID)
|
||||
}
|
||||
|
||||
async function mountStream() {
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
}
|
||||
|
||||
const complete = () => act(() => handleEvent!({ payload: { text: 'done' }, session_id: SID, type: 'message.complete' }))
|
||||
const complete = () =>
|
||||
act(() => stream.handleEvent({ payload: { text: 'done' }, session_id: SID, type: 'message.complete' }))
|
||||
|
||||
describe('useMessageStream turn-end todo cleanup', () => {
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
clearSessionTodos(SID)
|
||||
})
|
||||
|
||||
@@ -63,8 +29,8 @@ describe('useMessageStream turn-end todo cleanup', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('drops a still-active task list when the turn completes', async () => {
|
||||
await mountStream()
|
||||
it('drops a still-active task list when the turn completes', () => {
|
||||
mountStream()
|
||||
setSessionTodos(SID, [todo('a', 'completed'), todo('b', 'in_progress')])
|
||||
|
||||
complete()
|
||||
@@ -72,8 +38,8 @@ describe('useMessageStream turn-end todo cleanup', () => {
|
||||
expect($todosBySession.get()[SID]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a finished list on completion so its linger shows the final checkmarks', async () => {
|
||||
await mountStream()
|
||||
it('keeps a finished list on completion so its linger shows the final checkmarks', () => {
|
||||
mountStream()
|
||||
setSessionTodos(SID, [todo('a', 'completed')])
|
||||
|
||||
complete()
|
||||
@@ -82,11 +48,11 @@ describe('useMessageStream turn-end todo cleanup', () => {
|
||||
expect($todosBySession.get()[SID]).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('drops a still-active task list when the turn errors out', async () => {
|
||||
await mountStream()
|
||||
it('drops a still-active task list when the turn errors out', () => {
|
||||
mountStream()
|
||||
setSessionTodos(SID, [todo('a', 'in_progress')])
|
||||
|
||||
act(() => handleEvent!({ payload: { message: 'boom' }, session_id: SID, type: 'error' }))
|
||||
act(() => stream.handleEvent({ payload: { message: 'boom' }, session_id: SID, type: 'error' }))
|
||||
|
||||
expect($todosBySession.get()[SID]).toBeUndefined()
|
||||
})
|
||||
|
||||
+14
-45
@@ -1,6 +1,4 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
@@ -8,7 +6,7 @@ import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $draftingToolSessions } from '@/store/tool-drafting'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
|
||||
|
||||
const SID = 'session-1'
|
||||
const OTHER_SID = 'session-2'
|
||||
@@ -16,42 +14,14 @@ const OTHER_SID = 'session-2'
|
||||
// Module-scoped so a test can seed session state (e.g. interrupted) before the
|
||||
// handler reads it — `sessionInterrupted` resolves against this map.
|
||||
const sessionStates = new Map<string, ClientSessionState>()
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
let stream: MessageStreamHarness
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(sessionStates)
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const next = updater(sessionStates.get(sessionId) ?? createClientSessionState())
|
||||
sessionStates.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function mountStream() {
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
function mountStream() {
|
||||
stream = renderMessageStream(SID, { states: sessionStates })
|
||||
}
|
||||
|
||||
function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}, sessionId = SID) {
|
||||
act(() => handleEvent!({ payload, session_id: sessionId, type }))
|
||||
act(() => stream.handleEvent({ payload, session_id: sessionId, type }))
|
||||
}
|
||||
|
||||
function draftedTool(sessionId = SID) {
|
||||
@@ -60,7 +30,6 @@ function draftedTool(sessionId = SID) {
|
||||
|
||||
describe('drafting-tool label lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
sessionStates.clear()
|
||||
$draftingToolSessions.set({})
|
||||
})
|
||||
@@ -72,8 +41,8 @@ describe('drafting-tool label lifecycle', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('names the tool the model is drafting', async () => {
|
||||
await mountStream()
|
||||
it('names the tool the model is drafting', () => {
|
||||
mountStream()
|
||||
|
||||
emit('tool.generating', { name: 'write_file' })
|
||||
|
||||
@@ -92,8 +61,8 @@ describe('drafting-tool label lifecycle', () => {
|
||||
['tool.complete', { name: 'terminal', tool_id: 'tool-1' }],
|
||||
['message.complete', { text: 'done' }],
|
||||
['error', { message: 'boom' }]
|
||||
] as const)('retires the label when %s proves the model moved on', async (type, payload) => {
|
||||
await mountStream()
|
||||
] as const)('retires the label when %s proves the model moved on', (type, payload) => {
|
||||
mountStream()
|
||||
emit('tool.generating', { name: 'write_file' })
|
||||
|
||||
emit(type, payload)
|
||||
@@ -101,8 +70,8 @@ describe('drafting-tool label lifecycle', () => {
|
||||
expect(draftedTool()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves another session’s label alone', async () => {
|
||||
await mountStream()
|
||||
it('leaves another session’s label alone', () => {
|
||||
mountStream()
|
||||
emit('tool.generating', { name: 'patch' }, OTHER_SID)
|
||||
emit('tool.generating', { name: 'write_file' })
|
||||
|
||||
@@ -113,9 +82,9 @@ describe('drafting-tool label lifecycle', () => {
|
||||
})
|
||||
|
||||
// A stopped turn can still emit a frame or two before the backend notices.
|
||||
it('ignores a tool announced after the user hit stop', async () => {
|
||||
it('ignores a tool announced after the user hit stop', () => {
|
||||
sessionStates.set(SID, { ...createClientSessionState(), interrupted: true })
|
||||
await mountStream()
|
||||
mountStream()
|
||||
|
||||
emit('tool.generating', { name: 'write_file' })
|
||||
|
||||
|
||||
@@ -1,59 +1,26 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { act, cleanup } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $currentUsage } from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
import { type MessageStreamHarness, renderMessageStream } from './test-harness'
|
||||
|
||||
const SID = 'session-1'
|
||||
// $currentUsage mirrors the primary session; ClientSessionState.usage drives
|
||||
// the same status bar when a secondary tile is focused.
|
||||
const BASELINE = { calls: 2, input: 500, output: 40, total: 540 }
|
||||
|
||||
let handleEvent: ((event: RpcEvent) => void) | null = null
|
||||
let stream: MessageStreamHarness
|
||||
let sessionStates = new Map<string, ClientSessionState>()
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(sessionStates)
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState: (sessionId, updater) => {
|
||||
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
|
||||
const next = updater(current)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
|
||||
return next
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
handleEvent = stream.handleGatewayEvent
|
||||
}, [stream.handleGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function mountStream() {
|
||||
render(<Harness />)
|
||||
await waitFor(() => expect(handleEvent).not.toBeNull())
|
||||
function mountStream() {
|
||||
stream = renderMessageStream(SID, { states: sessionStates })
|
||||
}
|
||||
|
||||
describe('useMessageStream status-bar usage scoping', () => {
|
||||
beforeEach(() => {
|
||||
handleEvent = null
|
||||
sessionStates = new Map([[SID, { ...createClientSessionState(), usage: { ...BASELINE } }]])
|
||||
$currentUsage.set({ ...BASELINE })
|
||||
})
|
||||
@@ -63,11 +30,11 @@ describe('useMessageStream status-bar usage scoping', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('merges a live session.usage tick from the focused session', async () => {
|
||||
await mountStream()
|
||||
it('merges a live session.usage tick from the focused session', () => {
|
||||
mountStream()
|
||||
|
||||
act(() =>
|
||||
handleEvent!({
|
||||
stream.handleEvent({
|
||||
payload: { usage: { context_percent: 42, input: 1200, total: 1280 } },
|
||||
session_id: SID,
|
||||
type: 'session.usage'
|
||||
@@ -84,11 +51,11 @@ describe('useMessageStream status-bar usage scoping', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('caches a background session.usage tick without overwriting the primary status bar', async () => {
|
||||
await mountStream()
|
||||
it('caches a background session.usage tick without overwriting the primary status bar', () => {
|
||||
mountStream()
|
||||
|
||||
act(() =>
|
||||
handleEvent!({
|
||||
stream.handleEvent({
|
||||
payload: { usage: { input: 9999, total: 9999 } },
|
||||
session_id: 'background-session',
|
||||
type: 'session.usage'
|
||||
@@ -104,11 +71,11 @@ describe('useMessageStream status-bar usage scoping', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('applies message.complete usage from the focused session', async () => {
|
||||
await mountStream()
|
||||
it('applies message.complete usage from the focused session', () => {
|
||||
mountStream()
|
||||
|
||||
act(() =>
|
||||
handleEvent!({
|
||||
stream.handleEvent({
|
||||
payload: { text: 'done', usage: { calls: 3, input: 1500, output: 90, total: 1590 } },
|
||||
session_id: SID,
|
||||
type: 'message.complete'
|
||||
@@ -118,11 +85,11 @@ describe('useMessageStream status-bar usage scoping', () => {
|
||||
expect($currentUsage.get()).toEqual({ calls: 3, input: 1500, output: 90, total: 1590 })
|
||||
})
|
||||
|
||||
it('ignores message.complete usage from a background session', async () => {
|
||||
await mountStream()
|
||||
it('ignores message.complete usage from a background session', () => {
|
||||
mountStream()
|
||||
|
||||
act(() =>
|
||||
handleEvent!({
|
||||
stream.handleEvent({
|
||||
payload: { text: 'done', usage: { calls: 9, input: 9999, output: 999, total: 9999 } },
|
||||
session_id: 'background-session',
|
||||
type: 'message.complete'
|
||||
|
||||
@@ -16,21 +16,13 @@ import {
|
||||
} from '@/store/session'
|
||||
import type * as SessionStates from '@/store/session-states'
|
||||
|
||||
import { deferred } from '../../../test/deferred'
|
||||
|
||||
import { useModelControls } from './use-model-controls'
|
||||
|
||||
const setGlobalModel = vi.fn()
|
||||
const notifyError = vi.fn()
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
|
||||
const promise = new Promise<T>(done => {
|
||||
resolve = done
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getGlobalModelInfo: vi.fn(),
|
||||
setApiRequestProfile: vi.fn(),
|
||||
|
||||
@@ -139,7 +139,7 @@ describe('truncateSubmitParams', () => {
|
||||
})
|
||||
|
||||
it('drops renderer-synthetic message ids but keeps durable row ids', () => {
|
||||
// chat-messages.ts: `${timestamp}-${index}-${role}`
|
||||
// chat-messages/hydration.ts: `${timestamp}-${index}-${role}`
|
||||
expect(truncateSubmitParams(1, '1723456789-0-user', 456)).toEqual({
|
||||
confirm_truncate: true,
|
||||
truncate_before_user_ordinal: 1,
|
||||
@@ -490,7 +490,7 @@ describe('runRewindSubmit durable-address discipline (#87059)', () => {
|
||||
})
|
||||
|
||||
describe('optimistic rewind/reload turn-clock seeding (#86795)', () => {
|
||||
// The no-payload settle gate in gateway-event.ts holds a running=false
|
||||
// The no-payload settle gate in gateway-event/session-info.ts holds a running=false
|
||||
// heartbeat off while an optimistically armed turn waits for the backend —
|
||||
// but only for a bounded grace window measured from turnStartedAt. These
|
||||
// transforms are the arm sites for restore/edit/regenerate, so they must
|
||||
|
||||
@@ -94,7 +94,7 @@ export function rebindSurvivorRowIds(messages: ChatMessage[], survivorRowIds: Su
|
||||
|
||||
/**
|
||||
* Renderer-synthetic message ids (`${timestamp}-${index}-${role}` from
|
||||
* chat-messages.ts, plus older `user-…` / `assistant-…` shapes). Gateway
|
||||
* chat-messages/hydration.ts, plus older `user-…` / `assistant-…` shapes). Gateway
|
||||
* history never carries them — only durable `row_id` / platform message_id.
|
||||
*/
|
||||
export function isSyntheticRendererId(messageId: string | undefined): boolean {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { noteActiveTreeGroup, revealTreePane } from '@/components/pane-shell/tree/store'
|
||||
import {
|
||||
deleteSession,
|
||||
getAllSessionMessages,
|
||||
getLatestSessionMessages,
|
||||
getSession,
|
||||
@@ -56,6 +57,7 @@ import {
|
||||
import { $sessionTiles } from '@/store/session-states'
|
||||
|
||||
import sessionResumeActiveTurn from '../../../../../../tests/fixtures/session-resume-active-turn.json'
|
||||
import { deferred } from '../../../test/deferred'
|
||||
import { sessionRoute } from '../../routes'
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
@@ -92,19 +94,9 @@ vi.mock('@/components/pane-shell/tree/store', async importOriginal => ({
|
||||
|
||||
const RUNTIME_SESSION_ID = 'rt-new-001'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
|
||||
const promise = new Promise<T>(done => {
|
||||
resolve = done
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
type HarnessHandle = Pick<
|
||||
ReturnType<typeof useSessionActions>,
|
||||
'createBackendSessionForSend' | 'selectSidebarItem' | 'startFreshSessionDraft'
|
||||
'createBackendSessionForSend' | 'removeSession' | 'selectSidebarItem' | 'startFreshSessionDraft'
|
||||
>
|
||||
|
||||
function storedSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
@@ -127,19 +119,23 @@ function storedSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
}
|
||||
|
||||
function Harness({
|
||||
activeSessionId = null,
|
||||
navigate = vi.fn(),
|
||||
onReady,
|
||||
requestGateway
|
||||
requestGateway,
|
||||
selectedStoredSessionId = null
|
||||
}: {
|
||||
activeSessionId?: null | string
|
||||
navigate?: ReturnType<typeof vi.fn>
|
||||
onReady: (handle: HarnessHandle) => void
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
selectedStoredSessionId?: null | string
|
||||
}) {
|
||||
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
|
||||
|
||||
const actions = useSessionActions({
|
||||
activeSessionId: null,
|
||||
activeSessionIdRef: ref<string | null>(null),
|
||||
activeSessionId,
|
||||
activeSessionIdRef: ref(activeSessionId),
|
||||
busyRef: ref(false),
|
||||
creatingSessionRef: ref(false),
|
||||
ensureSessionState: () => ({}) as ClientSessionState,
|
||||
@@ -149,8 +145,8 @@ function Harness({
|
||||
requestGateway,
|
||||
resetViewSync: vi.fn(),
|
||||
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
|
||||
selectedStoredSessionId: null,
|
||||
selectedStoredSessionIdRef: ref<string | null>(null),
|
||||
selectedStoredSessionId,
|
||||
selectedStoredSessionIdRef: ref(selectedStoredSessionId),
|
||||
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
|
||||
syncSessionStateToView: vi.fn(),
|
||||
updateSessionState: () => ({}) as ClientSessionState
|
||||
@@ -163,6 +159,55 @@ function Harness({
|
||||
return null
|
||||
}
|
||||
|
||||
describe('connection-qualified session deletion', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
setSessions([])
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('deletes a registry session through its captured connection owner', async () => {
|
||||
const requestGateway = vi.fn().mockResolvedValue({})
|
||||
let actions: HarnessHandle | null = null
|
||||
|
||||
setSessions([
|
||||
storedSession({
|
||||
connection_id: 'source-a',
|
||||
id: 'shared-session',
|
||||
profile: 'worker'
|
||||
})
|
||||
])
|
||||
vi.mocked(deleteSession).mockResolvedValue({ ok: true })
|
||||
vi.mocked(requestGatewayForAgent).mockResolvedValue({} as never)
|
||||
|
||||
render(
|
||||
<Harness
|
||||
activeSessionId="runtime-shared"
|
||||
onReady={value => { actions = value }}
|
||||
requestGateway={requestGateway}
|
||||
selectedStoredSessionId="shared-session"
|
||||
/>
|
||||
)
|
||||
await waitFor(() => expect(actions).not.toBeNull())
|
||||
|
||||
await act(async () => {
|
||||
await actions?.removeSession('shared-session')
|
||||
})
|
||||
|
||||
expect(deleteSession).toHaveBeenCalledWith('shared-session', {
|
||||
connectionId: 'source-a',
|
||||
profile: 'worker'
|
||||
})
|
||||
expect(requestGatewayForAgent).toHaveBeenCalledWith(
|
||||
'source-a',
|
||||
'worker',
|
||||
'session.close',
|
||||
{ session_id: 'runtime-shared' }
|
||||
)
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('session.close', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
function StoredIdRotationHarness({
|
||||
activeSessionIdRef,
|
||||
getRoutedStoredSessionId,
|
||||
|
||||
@@ -1761,6 +1761,12 @@ export function useSessionActions({
|
||||
const closingRuntimeId = wasSelected ? activeSessionId : null
|
||||
const previousMessages = $messages.get()
|
||||
const previousPinned = $pinnedSessionIds.get()
|
||||
const removedOwner: SessionOwnerScope = removed?.connection_id
|
||||
? {
|
||||
connectionId: removed.connection_id,
|
||||
profile: removed.profile || 'default'
|
||||
}
|
||||
: removed?.profile
|
||||
// Pins are keyed on the durable lineage-root id; the stored id may be the
|
||||
// live tip after compression. Drop both so the pin can't linger.
|
||||
const removedPinId = removed ? sessionPinId(removed) : storedSessionId
|
||||
@@ -1783,10 +1789,15 @@ export function useSessionActions({
|
||||
|
||||
try {
|
||||
if (closingRuntimeId) {
|
||||
await requestGateway('session.close', { session_id: closingRuntimeId }).catch(() => undefined)
|
||||
await requestForSessionProfile(
|
||||
removedOwner,
|
||||
requestGateway,
|
||||
'session.close',
|
||||
{ session_id: closingRuntimeId }
|
||||
).catch(() => undefined)
|
||||
}
|
||||
|
||||
await deleteSession(storedSessionId, removed?.profile)
|
||||
await deleteSession(storedSessionId, removedOwner)
|
||||
// A deleted session's cached tail must not resurrect on a recycled id.
|
||||
dropTranscriptTail(storedSessionId)
|
||||
// Only after the RPC lands — the optimistic eviction above can roll
|
||||
|
||||
+18
-18
@@ -62,31 +62,33 @@ describe('resolveStoredSession profile ownership', () => {
|
||||
it('treats a profile-less cache hit as unresolved when multiple profiles exist', async () => {
|
||||
$sessions.set([session({ id: 's1' })])
|
||||
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
|
||||
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
|
||||
mockGetSession.mockResolvedValueOnce(session({ id: 's1', profile: 'default' }))
|
||||
|
||||
const resolved = await resolveStoredSession('s1')
|
||||
|
||||
expect(resolved?.profile).toBe('default')
|
||||
// rung 2 (bare) then rung 3 (stamped probes: active profile first)
|
||||
expect(mockGetSession).toHaveBeenNthCalledWith(1, 's1')
|
||||
expect(mockGetSession).toHaveBeenNthCalledWith(2, 's1', 'meta')
|
||||
expect(mockGetSession).toHaveBeenNthCalledWith(3, 's1', 'default')
|
||||
// rung 2 (active profile) then rung 3 (stamped cross-profile probe)
|
||||
expect(mockGetSession).toHaveBeenNthCalledWith(1, 's1', 'meta')
|
||||
expect(mockGetSession).toHaveBeenNthCalledWith(2, 's1', 'default')
|
||||
})
|
||||
|
||||
it('probes the ACTIVE profile when the unscoped lookup 404s (hidden Bot Mode chat)', async () => {
|
||||
// A hidden Bot Mode canonical chat is never in the sidebar cache, and the
|
||||
// unscoped GET routes to the PRIMARY backend (404). The owner is the
|
||||
// active profile itself — it must be probed, not skipped.
|
||||
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
|
||||
mockGetSession.mockResolvedValueOnce(session({ id: 's1', message_count: 2 }))
|
||||
it('scopes the first by-id lookup so a miss does not skip the active profile', async () => {
|
||||
$activeGatewayProfile.set('brain')
|
||||
$profiles.set(profiles('default', 'brain'))
|
||||
mockGetSession.mockImplementation(async (id, profile) => {
|
||||
if (profile === 'brain') {
|
||||
return session({ id, profile: 'brain' })
|
||||
}
|
||||
|
||||
throw new Error('404: Session not found')
|
||||
})
|
||||
|
||||
const resolved = await resolveStoredSession('s1')
|
||||
|
||||
expect(resolved?.profile).toBe('meta')
|
||||
expect(mockGetSession).toHaveBeenNthCalledWith(1, 's1')
|
||||
expect(mockGetSession).toHaveBeenNthCalledWith(2, 's1', 'meta')
|
||||
expect($sessions.get().find(s => s.id === 's1')?.profile).toBe('meta')
|
||||
expect(resolved?.profile).toBe('brain')
|
||||
expect(mockGetSession).toHaveBeenCalledWith('s1', 'brain')
|
||||
expect(mockGetSession).not.toHaveBeenCalledWith('s1')
|
||||
expect(mockGetSession).not.toHaveBeenCalledWith('s1', 'default')
|
||||
})
|
||||
|
||||
it('accepts a profile-less cache hit for single-profile users', async () => {
|
||||
@@ -105,6 +107,7 @@ describe('resolveStoredSession profile ownership', () => {
|
||||
const resolved = await resolveStoredSession('s1')
|
||||
|
||||
expect(resolved?.profile).toBe('meta')
|
||||
expect(mockGetSession).toHaveBeenCalledWith('s1', 'meta')
|
||||
// the upserted cache row is owned too, so the next hit short-circuits
|
||||
expect($sessions.get().find(s => s.id === 's1')?.profile).toBe('meta')
|
||||
})
|
||||
@@ -113,7 +116,6 @@ describe('resolveStoredSession profile ownership', () => {
|
||||
// Per-profile remote override: Electron strips the desktop alias before
|
||||
// forwarding, so the standalone backend stamps its backend-local root.
|
||||
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
|
||||
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
|
||||
mockGetSession.mockResolvedValueOnce(session({ id: 's1', profile: 'default' }))
|
||||
$activeGatewayProfile.set('default')
|
||||
$profiles.set(profiles('default', 'meta'))
|
||||
@@ -125,7 +127,6 @@ describe('resolveStoredSession profile ownership', () => {
|
||||
})
|
||||
|
||||
it('stamps the probed profile on a scoped hit from an older backend that omits it', async () => {
|
||||
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
|
||||
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
|
||||
mockGetSession.mockResolvedValueOnce(session({ id: 's1' }))
|
||||
|
||||
@@ -137,7 +138,6 @@ describe('resolveStoredSession profile ownership', () => {
|
||||
})
|
||||
|
||||
it('resolveSessionProfile routes a default-profile session from a non-default gateway', async () => {
|
||||
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
|
||||
mockGetSession.mockRejectedValueOnce(new Error('404: Session not found'))
|
||||
mockGetSession.mockResolvedValueOnce(session({ id: 's1', profile: 'default' }))
|
||||
|
||||
|
||||
@@ -1338,17 +1338,19 @@ export async function resolveStoredSession(
|
||||
return cached
|
||||
}
|
||||
|
||||
// Direct by-id on the live backend — one row lookup, no list scan. Covers
|
||||
// single-profile users and any id on the active profile (e.g. an old session
|
||||
// past the sidebar's recent window). 404 just means it's not on this profile.
|
||||
try {
|
||||
const session = await getSession(storedSessionId)
|
||||
// Direct by-id on the active profile — one row lookup, no list scan. Electron
|
||||
// routes an unscoped GET to the primary backend, which may not own the
|
||||
// active profile. A 404 there used to skip that profile in the probes below,
|
||||
// so the session was never found.
|
||||
const activeKey = normalizeProfileKey($activeGatewayProfile.get())
|
||||
|
||||
// Older backends omit `profile` on unscoped GETs; the serving backend is
|
||||
// the active gateway's, so back-fill that rather than caching an unowned
|
||||
// row. A present stamp is preserved: in app-global remote mode a bare hit
|
||||
// can legitimately carry another profile's row (see the branch tests).
|
||||
session.profile ||= normalizeProfileKey($activeGatewayProfile.get())
|
||||
try {
|
||||
const session = await getSession(storedSessionId, activeKey)
|
||||
|
||||
// Older backends can omit `profile`; this request targeted the active
|
||||
// profile, so back-fill that rather than caching an unowned row. A present
|
||||
// stamp is preserved for backend compatibility.
|
||||
session.profile ||= activeKey
|
||||
|
||||
upsertResolvedSession(session, storedSessionId)
|
||||
|
||||
@@ -1357,30 +1359,16 @@ export async function resolveStoredSession(
|
||||
// Not on the active profile — fall through to the cross-profile probe.
|
||||
}
|
||||
|
||||
// Multi-profile only: probe each profile by id (still one cheap lookup
|
||||
// each) rather than pulling every profile's recent sessions. The first hit
|
||||
// carries its owning `profile`, which routes the resume to the right backend.
|
||||
//
|
||||
// The ACTIVE profile is probed too, and first. The unscoped rung above does
|
||||
// NOT cover it: Electron routes a profile-less /api/sessions GET to the
|
||||
// PRIMARY backend, not the active gateway's, so a session owned by the
|
||||
// active non-primary profile 404s there. Skipping the active key then left
|
||||
// it as the ONE profile never probed — a hidden Bot Mode chat (never in the
|
||||
// sidebar cache) owned by the focused bot resolved to undefined on every
|
||||
// switch, the transcript prefetch went unscoped to the primary, and the
|
||||
// thread painted empty until an explicitly-profiled row (right-click →
|
||||
// Sessions) seeded the cache.
|
||||
const activeKey = normalizeProfileKey($activeGatewayProfile.get())
|
||||
// Multi-profile only: probe each remaining profile by id (still one cheap
|
||||
// lookup each) rather than pulling every profile's recent sessions. The
|
||||
// first hit carries its owning `profile`, which routes the resume to the
|
||||
// right backend. The active profile was already tried above.
|
||||
const otherProfiles = $profiles
|
||||
.get()
|
||||
.map(profile => normalizeProfileKey(profile.name))
|
||||
.filter(key => key !== activeKey)
|
||||
|
||||
const probeProfiles = [
|
||||
activeKey,
|
||||
...$profiles
|
||||
.get()
|
||||
.map(profile => normalizeProfileKey(profile.name))
|
||||
.filter(key => key !== activeKey)
|
||||
]
|
||||
|
||||
for (const profile of probeProfiles) {
|
||||
for (const profile of otherProfiles) {
|
||||
try {
|
||||
const session = await getSession(storedSessionId, profile)
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
setSessionsLoading
|
||||
} from '@/store/session'
|
||||
|
||||
import { deferred } from '../../../test/deferred'
|
||||
|
||||
import { useSessionListActions } from './use-session-list-actions'
|
||||
|
||||
// Sidebar refresh hygiene: a content-identical refresh (turn complete,
|
||||
@@ -68,15 +70,6 @@ interface Deferred<T> {
|
||||
}
|
||||
|
||||
/** Create a promise whose completion order the stale-response tests control. */
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void
|
||||
|
||||
const promise = new Promise<T>(done => {
|
||||
resolve = done
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
vi.mock('@/hermes', async importOriginal => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
|
||||
@@ -9,18 +9,10 @@ import {
|
||||
setNewChatWorkspaceTarget
|
||||
} from '@/store/session'
|
||||
|
||||
import { deferred } from '../../test/deferred'
|
||||
|
||||
import { startWorkspaceSession } from './workspace-session-target'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
|
||||
const promise = new Promise<T>(done => {
|
||||
resolve = done
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('startWorkspaceSession', () => {
|
||||
afterEach(() => {
|
||||
setCurrentBranch('')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user