Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14e3bb1f27 | ||
|
|
ba936039be |
@@ -49,8 +49,8 @@ hermes-agent/
|
||||
│ ├── hermes-achievements/ # Gamified achievement tracking
|
||||
│ ├── observability/ # Metrics / traces / logs plugin
|
||||
│ ├── image_gen/ # Image-generation providers
|
||||
│ └── <others>/ # disk-cleanup, google_meet, platforms, spotify,
|
||||
│ # strike-freedom-cockpit, ...
|
||||
│ └── <others>/ # disk-cleanup, example-dashboard, google_meet, platforms,
|
||||
│ # spotify, strike-freedom-cockpit, ...
|
||||
├── optional-skills/ # Heavier/niche skills shipped but NOT active by default
|
||||
├── skills/ # Built-in skills bundled with the repo
|
||||
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
|
||||
|
||||
@@ -1891,7 +1891,6 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
# via `hermes auth openai-codex`.
|
||||
if isinstance(tokens, dict) and tokens.get("access_token"):
|
||||
active_sources.add("device_code")
|
||||
custom_label = str(state.get("label") or "").strip()
|
||||
changed |= _upsert_entry(
|
||||
entries,
|
||||
provider,
|
||||
@@ -1903,7 +1902,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
"refresh_token": tokens.get("refresh_token"),
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
"last_refresh": state.get("last_refresh"),
|
||||
"label": custom_label or label_from_token(tokens.get("access_token", ""), "device_code"),
|
||||
"label": label_from_token(tokens.get("access_token", ""), "device_code"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+5
-34
@@ -6,42 +6,16 @@ gateway/cron startup). The local-CLI backend deliberately leaves it unset and
|
||||
relies on the launch dir. Reading it in one place keeps the system prompt, the
|
||||
tool surfaces, and context-file discovery agreeing on where the agent lives.
|
||||
|
||||
Multi-session gateways can pin a logical cwd via the `_SESSION_CWD`
|
||||
contextvar; CLI/cron fall through to `TERMINAL_CWD`/launch cwd.
|
||||
The #29531 per-session extension point is this function: a future PR adds a
|
||||
contextvar arm inside `resolve_agent_cwd` and `.set()`s it at the
|
||||
`set_session_vars` seam — by design, not a reopening hazard.
|
||||
"""
|
||||
|
||||
import os
|
||||
from contextvars import ContextVar, Token
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_UNSET: Any = object()
|
||||
|
||||
_SESSION_CWD: ContextVar = ContextVar("HERMES_SESSION_CWD", default=_UNSET)
|
||||
|
||||
|
||||
def set_session_cwd(cwd: str | None) -> Token:
|
||||
"""Pin the logical cwd for the current context."""
|
||||
return _SESSION_CWD.set((cwd or "").strip())
|
||||
|
||||
|
||||
def clear_session_cwd() -> None:
|
||||
_SESSION_CWD.set("")
|
||||
|
||||
|
||||
def _session_cwd_override() -> str:
|
||||
value = _SESSION_CWD.get()
|
||||
if value is _UNSET:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def resolve_agent_cwd() -> Path:
|
||||
override = _session_cwd_override()
|
||||
if override:
|
||||
p = Path(override).expanduser()
|
||||
if p.is_dir():
|
||||
return p
|
||||
raw = os.environ.get("TERMINAL_CWD", "").strip()
|
||||
if raw:
|
||||
p = Path(raw).expanduser()
|
||||
@@ -53,10 +27,7 @@ def resolve_agent_cwd() -> Path:
|
||||
def resolve_context_cwd() -> Path | None:
|
||||
# None means "no configured cwd": build_context_files_prompt then falls back
|
||||
# to the launch dir (os.getcwd()) — correct for the local CLI. The gateway
|
||||
# avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py)
|
||||
# or, per session, the _SESSION_CWD contextvar above.
|
||||
override = _session_cwd_override()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
# avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py).
|
||||
# No getcwd arm here: that fallback is owned by the caller, not this resolver.
|
||||
raw = os.environ.get("TERMINAL_CWD", "").strip()
|
||||
return Path(raw).expanduser() if raw else None
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hermes</title>
|
||||
<title>Hermes Setup</title>
|
||||
</head>
|
||||
<body class="h-full antialiased">
|
||||
<div id="root" class="h-full"></div>
|
||||
|
||||
@@ -208,7 +208,7 @@ pub async fn launch_hermes_desktop(
|
||||
/// Walks the well-known electron-builder unpacked-app paths under
|
||||
/// `install_root`. Mirrors the resolver in `cmd_gui` (apps/desktop/release/
|
||||
/// <os>-unpacked/<exe>).
|
||||
pub(crate) fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option<PathBuf> {
|
||||
fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option<PathBuf> {
|
||||
let release_dir = install_root.join("apps").join("desktop").join("release");
|
||||
let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") {
|
||||
&[
|
||||
@@ -232,35 +232,6 @@ pub(crate) fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Opti
|
||||
None
|
||||
}
|
||||
|
||||
/// True when a prior install completed (bootstrap-complete marker present) AND a
|
||||
/// launchable desktop app exists on disk. Used by the installer's launcher fast
|
||||
/// path so a bare re-open just opens Hermes instead of re-running setup.
|
||||
pub(crate) fn hermes_is_installed(install_root: &std::path::Path) -> bool {
|
||||
install_root.join(".hermes-bootstrap-complete").exists()
|
||||
&& resolve_hermes_desktop_exe(install_root).is_some()
|
||||
}
|
||||
|
||||
/// Spawn the already-built desktop app, detached. Returns Err if no built app
|
||||
/// exists or the spawn fails, so the caller can fall back to showing the
|
||||
/// installer UI.
|
||||
pub(crate) fn spawn_installed_desktop(install_root: &std::path::Path) -> std::io::Result<()> {
|
||||
let exe = resolve_hermes_desktop_exe(install_root).ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::NotFound, "no built Hermes desktop app")
|
||||
})?;
|
||||
let mut cmd = std::process::Command::new(&exe);
|
||||
cmd.current_dir(exe.parent().unwrap_or(install_root));
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// DETACHED_PROCESS = 0x00000008 — keep the desktop alive after the
|
||||
// installer exits, mirroring launch_hermes_desktop. Kept correct here
|
||||
// even though the only caller is macOS-gated today, so future reuse on
|
||||
// Windows doesn't reintroduce the relaunch race.
|
||||
cmd.creation_flags(0x0000_0008);
|
||||
}
|
||||
cmd.spawn().map(|_child| ())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bootstrap implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -50,20 +50,6 @@ impl AppMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when the args request a forced installer UI (repair/reinstall)
|
||||
/// via `--reinstall` or `--repair`, which overrides the macOS launcher
|
||||
/// fast-path so a broken install can be repaired. Arg-iterator generic so it's
|
||||
/// unit-testable, mirroring `AppMode::from_args`. Independent of mode selection:
|
||||
/// these flags never flip Install<->Update.
|
||||
pub fn force_setup_from_args<I, S>(args: I) -> bool
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
args.into_iter()
|
||||
.any(|a| a.as_ref() == "--reinstall" || a.as_ref() == "--repair")
|
||||
}
|
||||
|
||||
/// Process-wide install state, shared across Tauri commands.
|
||||
///
|
||||
/// The bootstrap is a one-shot, single-tenant process — we only need one
|
||||
@@ -99,11 +85,7 @@ pub fn run() {
|
||||
let _guard = paths::init_logging();
|
||||
|
||||
let mode = AppMode::from_args(std::env::args().skip(1));
|
||||
// Escape hatch: `--reinstall`/`--repair` forces the installer UI even when
|
||||
// Hermes is already installed, so users can re-run setup to repair a broken
|
||||
// install instead of the launcher fast path silently relaunching the app.
|
||||
let force_setup = force_setup_from_args(std::env::args().skip(1));
|
||||
tracing::info!(?mode, force_setup, "Hermes installer starting");
|
||||
tracing::info!(?mode, "Hermes Setup starting");
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
@@ -111,60 +93,6 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(Arc::new(AppState::new(mode)))
|
||||
.setup(move |app| {
|
||||
use tauri::Manager;
|
||||
// Launcher fast path (macOS only): a bare ("Install") launch when
|
||||
// Hermes is already installed should NOT show the installer or
|
||||
// rebuild — it should just open the app, so the /Applications
|
||||
// "Hermes" doubles as a normal launcher (first run installs, every
|
||||
// later run launches instantly). The window is kept hidden until
|
||||
// here via `"visible": false` so this path never flashes a window.
|
||||
//
|
||||
// Gated to macOS deliberately: on Windows/Linux the installer keeps
|
||||
// its existing behavior (Windows users relaunch via the Start
|
||||
// Menu/Desktop "Hermes" shortcuts that install.ps1 creates, and a
|
||||
// reliable detached relaunch there needs the DETACHED_PROCESS +
|
||||
// startup-grace handling used by launch_hermes_desktop — out of
|
||||
// scope here). So this is a pure no-op on non-macOS.
|
||||
//
|
||||
// `--reinstall`/`--repair` opts out so a broken install can be
|
||||
// repaired by re-running setup instead of launching the bad app.
|
||||
if cfg!(target_os = "macos") && mode == AppMode::Install && !force_setup {
|
||||
let install_root = paths::hermes_home().join("hermes-agent");
|
||||
if bootstrap::hermes_is_installed(&install_root) {
|
||||
match bootstrap::spawn_installed_desktop(&install_root) {
|
||||
Ok(()) => {
|
||||
// Brief grace so the spawned app is registered
|
||||
// before we exit (mirrors launch_hermes_desktop).
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
tracing::info!(
|
||||
"hermes already installed — relaunched desktop; exiting installer"
|
||||
);
|
||||
app.handle().exit(0);
|
||||
return Ok(());
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
?err,
|
||||
"relaunch of installed desktop failed; showing installer UI"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// First run / repair install, or Update mode: reveal the UI.
|
||||
match app.get_webview_window("main") {
|
||||
Some(win) => {
|
||||
if let Err(err) = win.show() {
|
||||
tracing::error!(?err, "failed to show main installer window");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::error!("main installer window not found; installer UI will not appear");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Mode (install vs update)
|
||||
get_mode,
|
||||
@@ -187,7 +115,7 @@ pub fn run() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{force_setup_from_args, AppMode};
|
||||
use super::AppMode;
|
||||
|
||||
#[test]
|
||||
fn bare_args_are_install() {
|
||||
@@ -203,30 +131,4 @@ mod tests {
|
||||
AppMode::Update
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reinstall_and_repair_flags_force_setup() {
|
||||
assert!(force_setup_from_args(["--reinstall"]));
|
||||
assert!(force_setup_from_args(["--repair"]));
|
||||
assert!(force_setup_from_args(["--foo", "--repair", "--bar"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_or_unrelated_args_do_not_force_setup() {
|
||||
assert!(!force_setup_from_args(Vec::<String>::new()));
|
||||
assert!(!force_setup_from_args(["--foo", "bar"]));
|
||||
// --update must not be mistaken for a force-setup flag.
|
||||
assert!(!force_setup_from_args(["--update"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_setup_flags_do_not_affect_mode_selection() {
|
||||
// The repair flags must never flip Install<->Update.
|
||||
assert_eq!(AppMode::from_args(["--reinstall"]), AppMode::Install);
|
||||
assert_eq!(AppMode::from_args(["--repair"]), AppMode::Install);
|
||||
assert_eq!(
|
||||
AppMode::from_args(["--update", "--reinstall"]),
|
||||
AppMode::Update
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Hermes",
|
||||
"productName": "Hermes Setup",
|
||||
"version": "0.0.1",
|
||||
"identifier": "com.nousresearch.hermes.setup",
|
||||
"build": {
|
||||
@@ -13,7 +13,7 @@
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Hermes",
|
||||
"title": "Hermes Setup",
|
||||
"width": 880,
|
||||
"height": 620,
|
||||
"minWidth": 720,
|
||||
@@ -22,8 +22,7 @@
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"center": true,
|
||||
"visible": false
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -34,7 +33,7 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "DeveloperTool",
|
||||
"shortDescription": "Hermes",
|
||||
"shortDescription": "Hermes Setup",
|
||||
"longDescription": "Installs Hermes Agent on your machine. Drives scripts/install.ps1 (Windows) and scripts/install.sh (macOS/Linux).",
|
||||
"publisher": "Nous Research",
|
||||
"copyright": "Copyright © 2026 Nous Research",
|
||||
|
||||
+3
-16
@@ -111,28 +111,15 @@ npm run test:desktop:all
|
||||
|
||||
Boot logs land in `HERMES_HOME/logs/desktop.log` (includes backend output and recent Python tracebacks) — check it first if the app reports a boot failure.
|
||||
|
||||
**macOS / Linux:**
|
||||
|
||||
```bash
|
||||
# Force a clean first-launch setup
|
||||
rm "$HOME/.hermes/hermes-agent/.hermes-bootstrap-complete"
|
||||
rm "$HOME/.hermes/hermes-agent/.hermes-bootstrap-complete" # macOS/Linux
|
||||
# Rebuild a broken Python venv
|
||||
rm -rf "$HOME/.hermes/hermes-agent/venv"
|
||||
# Reset a stuck macOS microphone prompt (macOS only)
|
||||
rm -rf "$HOME/.hermes/hermes-agent/venv" # macOS/Linux
|
||||
# Reset a stuck macOS microphone prompt
|
||||
tccutil reset Microphone com.nousresearch.hermes
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
|
||||
```powershell
|
||||
# Force a clean first-launch setup
|
||||
Remove-Item "$env:LOCALAPPDATA\hermes\hermes-agent\.hermes-bootstrap-complete"
|
||||
# Rebuild a broken Python venv
|
||||
Remove-Item -Recurse -Force "$env:LOCALAPPDATA\hermes\hermes-agent\venv"
|
||||
```
|
||||
|
||||
> The default Hermes home on Windows is `%LOCALAPPDATA%\hermes`. Set the `HERMES_HOME` env var if you've relocated it.
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
|
||||
@@ -482,18 +482,6 @@ async function runBootstrap(opts) {
|
||||
writeMarker // callback to write the bootstrap-complete marker; main.cjs provides
|
||||
} = opts
|
||||
|
||||
// Bail before spawning anything if the user already cancelled — otherwise an
|
||||
// already-aborted signal would still fetch the manifest (a spawn) before the
|
||||
// in-loop abort check fires.
|
||||
if (abortSignal && abortSignal.aborted) {
|
||||
if (typeof onEvent === 'function') {
|
||||
try {
|
||||
onEvent({ type: 'failed', error: 'bootstrap cancelled by user' })
|
||||
} catch {}
|
||||
}
|
||||
return { ok: false, cancelled: true }
|
||||
}
|
||||
|
||||
const runLog = openRunLog(logRoot || path.join(hermesHome, 'logs'))
|
||||
|
||||
// Tee every event to the runLog AND the caller's onEvent. This gives us a
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
|
||||
test('runBootstrap bails immediately when the signal is already aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
const events = []
|
||||
const result = await runBootstrap({
|
||||
installStamp: null,
|
||||
activeRoot: '/tmp/hermes-runner-test',
|
||||
sourceRepoRoot: null,
|
||||
hermesHome: '/tmp/hermes-runner-test',
|
||||
logRoot: '/tmp/hermes-runner-test',
|
||||
onEvent: ev => events.push(ev),
|
||||
abortSignal: controller.signal
|
||||
})
|
||||
|
||||
// Cancelled before any install script is spawned.
|
||||
assert.deepEqual(result, { ok: false, cancelled: true })
|
||||
assert.ok(
|
||||
events.some(ev => ev.type === 'failed' && /cancelled/i.test(ev.error)),
|
||||
'should emit a cancelled failure event'
|
||||
)
|
||||
})
|
||||
@@ -429,22 +429,12 @@ function registerMediaProtocol() {
|
||||
let mainWindow = null
|
||||
let hermesProcess = null
|
||||
let connectionPromise = null
|
||||
// Auto-reload budget for renderer crashes. A deterministic startup crash would
|
||||
// otherwise loop forever (reload → crash → reload), pinning CPU and spamming
|
||||
// logs. Allow a few reloads per rolling window, then stop and leave the dead
|
||||
// window so the user can read the error / quit.
|
||||
const RENDERER_RELOAD_WINDOW_MS = 60_000
|
||||
const RENDERER_RELOAD_MAX = 3
|
||||
let rendererReloadTimes = []
|
||||
// Latched bootstrap failure: when the first-launch install fails, we hold
|
||||
// onto the error so subsequent startHermes() calls (e.g. the renderer's
|
||||
// ensureGatewayOpen retrying after the WS won't open) return the same error
|
||||
// instead of re-running install.ps1 in a hot loop. Cleared explicitly by
|
||||
// the renderer's "Reload and retry" path or by quitting the app.
|
||||
let bootstrapFailure = null
|
||||
// Active first-launch install, so the renderer's Cancel button (and app quit)
|
||||
// can abort the in-flight install.sh/ps1 instead of leaving it running.
|
||||
let bootstrapAbortController = null
|
||||
let connectionConfigCache = null
|
||||
const hermesLog = []
|
||||
const previewWatchers = new Map()
|
||||
@@ -535,39 +525,6 @@ function openExternalUrl(rawUrl) {
|
||||
return false
|
||||
}
|
||||
|
||||
// `file://` URLs come from the artifacts panel (the renderer can't open
|
||||
// them itself because Chromium blocks file:// navigation from the app
|
||||
// origin). Hand them to `shell.openPath`, which dispatches to the OS
|
||||
// file association. If the OS can't open it (`error` is a non-empty
|
||||
// string), fall back to revealing the file in the system file manager.
|
||||
if (parsed.protocol === 'file:') {
|
||||
let localPath
|
||||
try {
|
||||
localPath = fileURLToPath(parsed.toString())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
void shell
|
||||
.openPath(localPath)
|
||||
.then(error => {
|
||||
if (!error) {
|
||||
return
|
||||
}
|
||||
|
||||
rememberLog(`[file] openPath failed: ${error}; revealing in folder instead`)
|
||||
|
||||
try {
|
||||
shell.showItemInFolder(localPath)
|
||||
} catch (revealError) {
|
||||
rememberLog(`[file] showItemInFolder failed: ${revealError.message}`)
|
||||
}
|
||||
})
|
||||
.catch(error => rememberLog(`[file] openPath rejected: ${error.message}`))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (!['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {
|
||||
return false
|
||||
}
|
||||
@@ -1559,18 +1516,10 @@ function resolveRendererIndex() {
|
||||
}
|
||||
|
||||
function resolveHermesCwd() {
|
||||
// In a packaged build, `process.cwd()` resolves to the install root (e.g.
|
||||
// `…/win-unpacked` on Windows or `/Applications/Hermes.app/Contents/...`
|
||||
// on macOS). Sessions spawned there leave files inside the app bundle
|
||||
// and bewilder users when "where did my files go?" is the install dir.
|
||||
// The user-configurable default project directory wins over everything,
|
||||
// followed by env hints (only honored when packaged if they point at a
|
||||
// real directory), then the home dir.
|
||||
const candidates = [
|
||||
readDefaultProjectDir(),
|
||||
process.env.HERMES_DESKTOP_CWD,
|
||||
process.env.INIT_CWD,
|
||||
IS_PACKAGED ? null : process.cwd(),
|
||||
process.cwd(),
|
||||
!IS_PACKAGED ? SOURCE_REPO_ROOT : null,
|
||||
app.getPath('home')
|
||||
]
|
||||
@@ -1584,48 +1533,6 @@ function resolveHermesCwd() {
|
||||
return app.getPath('home')
|
||||
}
|
||||
|
||||
// Persisted "Default project directory" — surfaced as a setting in the
|
||||
// renderer (see app/settings/sessions-settings.tsx). Stored as JSON in
|
||||
// userData so it survives self-updates without bleeding into the new
|
||||
// install. `null` means "no preference, fall back to the usual chain".
|
||||
const DEFAULT_PROJECT_DIR_CONFIG_FILENAME = 'project-dir.json'
|
||||
|
||||
function defaultProjectDirConfigPath() {
|
||||
return path.join(app.getPath('userData'), DEFAULT_PROJECT_DIR_CONFIG_FILENAME)
|
||||
}
|
||||
|
||||
function readDefaultProjectDir() {
|
||||
try {
|
||||
const raw = fs.readFileSync(defaultProjectDirConfigPath(), 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
if (parsed && typeof parsed.dir === 'string' && parsed.dir.trim()) {
|
||||
const resolved = path.resolve(parsed.dir)
|
||||
|
||||
if (directoryExists(resolved)) {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Missing / unreadable / malformed → fall through to the rest of the
|
||||
// candidate chain.
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function writeDefaultProjectDir(dir) {
|
||||
const target = defaultProjectDirConfigPath()
|
||||
const payload = dir ? JSON.stringify({ dir: path.resolve(dir) }, null, 2) : JSON.stringify({}, null, 2)
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||
fs.writeFileSync(target, payload, 'utf8')
|
||||
} catch (error) {
|
||||
rememberLog(`[settings] write default project dir failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function createPythonBackend(root, label, dashboardArgs, options = {}) {
|
||||
const python = findPythonForRoot(root)
|
||||
if (!python) return null
|
||||
@@ -1833,15 +1740,12 @@ async function ensureRuntime(backend) {
|
||||
})
|
||||
} catch {}
|
||||
|
||||
bootstrapAbortController = new AbortController()
|
||||
|
||||
const bootstrapResult = await runBootstrap({
|
||||
installStamp: backend.installStamp,
|
||||
activeRoot: backend.activeRoot,
|
||||
sourceRepoRoot: SOURCE_REPO_ROOT,
|
||||
hermesHome: HERMES_HOME,
|
||||
logRoot: path.join(HERMES_HOME, 'logs'),
|
||||
abortSignal: bootstrapAbortController.signal,
|
||||
onEvent: ev => {
|
||||
// Tee every bootstrap event to (a) the desktop log for forensics
|
||||
// and (b) the renderer for live progress UI. Either may be absent;
|
||||
@@ -1857,16 +1761,6 @@ async function ensureRuntime(backend) {
|
||||
writeMarker: writeBootstrapMarker
|
||||
})
|
||||
|
||||
bootstrapAbortController = null
|
||||
|
||||
if (bootstrapResult.cancelled) {
|
||||
const cancelledError = new Error('Hermes install was cancelled.')
|
||||
cancelledError.isBootstrapFailure = true
|
||||
cancelledError.bootstrapCancelled = true
|
||||
bootstrapFailure = cancelledError
|
||||
throw cancelledError
|
||||
}
|
||||
|
||||
if (!bootstrapResult.ok) {
|
||||
const bootstrapError = new Error(
|
||||
`Hermes bootstrap failed${bootstrapResult.failedStage ? ` at stage '${bootstrapResult.failedStage}'` : ''}: ` +
|
||||
@@ -2785,28 +2679,6 @@ function installContextMenu(window) {
|
||||
)
|
||||
}
|
||||
|
||||
// Spell-check suggestions for the misspelled word under the caret.
|
||||
// Chromium surfaces them on `params.dictionarySuggestions`; we offer the
|
||||
// top 5 plus a "Add to dictionary" affordance.
|
||||
const suggestions = Array.isArray(params.dictionarySuggestions) ? params.dictionarySuggestions : []
|
||||
|
||||
if (isEditable && params.misspelledWord && suggestions.length > 0) {
|
||||
if (template.length) template.push({ type: 'separator' })
|
||||
|
||||
for (const suggestion of suggestions.slice(0, 5)) {
|
||||
template.push({
|
||||
label: suggestion,
|
||||
click: () => window.webContents.replaceMisspelling(suggestion)
|
||||
})
|
||||
}
|
||||
|
||||
template.push({ type: 'separator' })
|
||||
template.push({
|
||||
label: 'Add to dictionary',
|
||||
click: () => window.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord)
|
||||
})
|
||||
}
|
||||
|
||||
if (hasSelection || isEditable) {
|
||||
if (template.length) template.push({ type: 'separator' })
|
||||
if (isEditable) {
|
||||
@@ -3334,51 +3206,6 @@ function createWindow() {
|
||||
openExternalUrl(url)
|
||||
})
|
||||
|
||||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||
rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`)
|
||||
|
||||
if (details?.reason === 'crashed' || details?.reason === 'oom') {
|
||||
const now = Date.now()
|
||||
rendererReloadTimes = rendererReloadTimes.filter(t => now - t < RENDERER_RELOAD_WINDOW_MS)
|
||||
|
||||
if (rendererReloadTimes.length >= RENDERER_RELOAD_MAX) {
|
||||
rememberLog(
|
||||
`[renderer] suppressing reload: ${rendererReloadTimes.length} crashes within ${RENDERER_RELOAD_WINDOW_MS}ms (likely a crash loop)`
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
rendererReloadTimes.push(now)
|
||||
setImmediate(() => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
try {
|
||||
mainWindow.webContents.reload()
|
||||
} catch (err) {
|
||||
rememberLog(`[renderer] reload after crash failed: ${err?.message || err}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.webContents.on('unresponsive', () => rememberLog('[renderer] webContents became unresponsive'))
|
||||
|
||||
// Electron always passes the event first. The canonical (Electron 36+) shape
|
||||
// is (event, messageDetails); the deprecated positional shape is
|
||||
// (event, level, message, line, sourceId). Handle both. `level` is numeric
|
||||
// (0..3), where 3 === error.
|
||||
mainWindow.webContents.on('console-message', (_event, detailsOrLevel, message, line, sourceId) => {
|
||||
const details = detailsOrLevel && typeof detailsOrLevel === 'object' ? detailsOrLevel : null
|
||||
const level = details ? details.level : detailsOrLevel
|
||||
|
||||
if (level !== 3) return
|
||||
|
||||
const text = details ? details.message : message
|
||||
const src = details ? details.sourceUrl : sourceId
|
||||
const lineNo = details ? details.lineNumber : line
|
||||
rememberLog(`[renderer console] ${text} (${src}:${lineNo})`)
|
||||
})
|
||||
|
||||
if (DEV_SERVER) {
|
||||
mainWindow.loadURL(DEV_SERVER)
|
||||
} else {
|
||||
@@ -3429,18 +3256,6 @@ ipcMain.handle('hermes:bootstrap:repair', async () => {
|
||||
resetHermesConnection()
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:bootstrap:cancel', async () => {
|
||||
// Renderer's Cancel button during first-launch install. Abort the running
|
||||
// install script (SIGTERM via the runner's abortSignal). runBootstrap
|
||||
// resolves with { cancelled: true }, which surfaces the recovery overlay.
|
||||
if (bootstrapAbortController) {
|
||||
try {
|
||||
bootstrapAbortController.abort()
|
||||
} catch {}
|
||||
return { ok: true, cancelled: true }
|
||||
}
|
||||
return { ok: false, cancelled: false }
|
||||
})
|
||||
ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
|
||||
ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState())
|
||||
ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig())
|
||||
@@ -3529,21 +3344,13 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => {
|
||||
const properties = options?.directories ? ['openDirectory'] : ['openFile']
|
||||
const properties = ['openFile']
|
||||
if (options?.directories) properties.push('openDirectory')
|
||||
if (options?.multiple !== false) properties.push('multiSelections')
|
||||
|
||||
let resolvedDefaultPath
|
||||
if (options?.defaultPath) {
|
||||
try {
|
||||
resolvedDefaultPath = path.resolve(String(options.defaultPath))
|
||||
} catch {
|
||||
resolvedDefaultPath = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
title: options?.title || 'Add context',
|
||||
defaultPath: resolvedDefaultPath,
|
||||
defaultPath: options?.defaultPath ? path.resolve(String(options.defaultPath)) : undefined,
|
||||
properties,
|
||||
filters: Array.isArray(options?.filters) ? options.filters : undefined
|
||||
})
|
||||
@@ -3602,45 +3409,6 @@ ipcMain.handle('hermes:openExternal', (_event, url) => {
|
||||
}
|
||||
})
|
||||
|
||||
// User-configurable default project directory. The renderer reads this on
|
||||
// settings mount and seeds the value into the picker; writing back persists
|
||||
// it via writeDefaultProjectDir so resolveHermesCwd picks it up on the next
|
||||
// session spawn (no app restart needed).
|
||||
ipcMain.handle('hermes:setting:defaultProjectDir:get', async () => ({
|
||||
dir: readDefaultProjectDir(),
|
||||
defaultLabel: path.join(app.getPath('home'), 'hermes-projects')
|
||||
}))
|
||||
|
||||
ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => {
|
||||
const next = typeof dir === 'string' && dir.trim() ? dir.trim() : null
|
||||
|
||||
if (next) {
|
||||
try {
|
||||
fs.mkdirSync(next, { recursive: true })
|
||||
} catch (error) {
|
||||
throw new Error(`Could not create directory: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
writeDefaultProjectDir(next)
|
||||
|
||||
return { dir: next }
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:setting:defaultProjectDir:pick', async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: 'Choose default project directory',
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
defaultPath: readDefaultProjectDir() || app.getPath('home')
|
||||
})
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { canceled: true, dir: null }
|
||||
}
|
||||
|
||||
return { canceled: false, dir: result.filePaths[0] }
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url))
|
||||
|
||||
ipcMain.handle('hermes:logs:reveal', async () => {
|
||||
@@ -3950,7 +3718,6 @@ app.whenReady().then(() => {
|
||||
installMediaPermissions()
|
||||
registerMediaProtocol()
|
||||
ensureWslWindowsFonts()
|
||||
configureSpellChecker()
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
@@ -3958,37 +3725,7 @@ app.whenReady().then(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// Seed Chromium's spellchecker with the system locale (falling back to en-US).
|
||||
// On macOS Electron uses the native spellchecker which ignores this list, but
|
||||
// on Windows/Linux Chromium downloads Hunspell dictionaries on demand and
|
||||
// won't enable any without an explicit language.
|
||||
function configureSpellChecker() {
|
||||
try {
|
||||
const defaultSession = session.defaultSession
|
||||
|
||||
if (!defaultSession || typeof defaultSession.setSpellCheckerLanguages !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
const available = defaultSession.availableSpellCheckerLanguages || []
|
||||
const locale = (app.getLocale && app.getLocale()) || 'en-US'
|
||||
const candidates = [locale, locale.split('-')[0], 'en-US', 'en']
|
||||
const chosen = candidates.find(lang => available.includes(lang)) || 'en-US'
|
||||
|
||||
defaultSession.setSpellCheckerLanguages([chosen])
|
||||
} catch (error) {
|
||||
rememberLog(`Spellchecker setup failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
app.on('before-quit', () => {
|
||||
// Quitting mid-install should stop the installer, not orphan it.
|
||||
if (bootstrapAbortController) {
|
||||
try {
|
||||
bootstrapAbortController.abort()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (desktopLogFlushTimer) {
|
||||
clearTimeout(desktopLogFlushTimer)
|
||||
desktopLogFlushTimer = null
|
||||
|
||||
@@ -31,11 +31,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
|
||||
settings: {
|
||||
getDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:get'),
|
||||
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
|
||||
pickDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:pick')
|
||||
},
|
||||
revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'),
|
||||
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
|
||||
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
|
||||
@@ -96,7 +91,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getBootstrapState: () => ipcRenderer.invoke('hermes:bootstrap:get'),
|
||||
resetBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:reset'),
|
||||
repairBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:repair'),
|
||||
cancelBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:cancel'),
|
||||
onBootstrapEvent: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:bootstrap:event', listener)
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<link rel="icon" type="image/png" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="shortcut icon" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>Hermes</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs",
|
||||
"type-check": "tsc -b",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
@@ -50,7 +50,6 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hermes/shared": "file:../shared",
|
||||
"@icons-pack/react-simple-icons": "^13.13.0",
|
||||
"@nanostores/react": "^1.1.0",
|
||||
"@nous-research/ui": "^0.13.0",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
|
||||
@@ -23,24 +17,6 @@ import { cn } from '@/lib/utils'
|
||||
import { GHOST_ICON_BTN } from './controls'
|
||||
import type { ChatBarState } from './types'
|
||||
|
||||
const PROMPT_SNIPPETS: readonly PromptSnippet[] = [
|
||||
{
|
||||
description: 'Audit the current change for regressions, dropped edge cases, and missing tests.',
|
||||
label: 'Code review',
|
||||
text: 'Please review this for bugs, regressions, and missing tests.'
|
||||
},
|
||||
{
|
||||
description: 'Outline an approach before touching code so the diff stays focused.',
|
||||
label: 'Implementation plan',
|
||||
text: 'Please make a concise implementation plan before changing code.'
|
||||
},
|
||||
{
|
||||
description: 'Walk through how the selected code works and link to the key files.',
|
||||
label: 'Explain this',
|
||||
text: 'Please explain how this works and point me to the key files.'
|
||||
}
|
||||
]
|
||||
|
||||
export function ContextMenu({
|
||||
state,
|
||||
onInsertText,
|
||||
@@ -49,114 +25,81 @@ export function ContextMenu({
|
||||
onPickFiles,
|
||||
onPickFolders,
|
||||
onPickImages
|
||||
}: ContextMenuProps) {
|
||||
// Prompt snippets used to be a Radix submenu. That submenu didn't open
|
||||
// reliably when the parent menu was positioned at the bottom of the
|
||||
// window (composer "+" anchor), so we promoted it to a real Dialog —
|
||||
// easier to grow with search / descriptions, and no positioning math.
|
||||
const [snippetsOpen, setSnippetsOpen] = useState(false)
|
||||
|
||||
}: {
|
||||
state: ChatBarState
|
||||
onInsertText: (text: string) => void
|
||||
onOpenUrlDialog: () => void
|
||||
onPasteClipboardImage?: () => void
|
||||
onPickFiles?: () => void
|
||||
onPickFolders?: () => void
|
||||
onPickImages?: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={state.tools.label}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
|
||||
)}
|
||||
disabled={!state.tools.enabled}
|
||||
size="icon"
|
||||
title={state.tools.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="1rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
|
||||
<DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85">
|
||||
Attach
|
||||
</DropdownMenuLabel>
|
||||
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
|
||||
Files…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
|
||||
Folder…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
|
||||
Images…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
|
||||
Paste image
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
|
||||
URL…
|
||||
</ContextMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={state.tools.label}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
|
||||
)}
|
||||
disabled={!state.tools.enabled}
|
||||
size="icon"
|
||||
title={state.tools.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="1rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
|
||||
<DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85">
|
||||
Attach
|
||||
</DropdownMenuLabel>
|
||||
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
|
||||
Files…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
|
||||
Folder…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
|
||||
Images…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
|
||||
Paste image
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
|
||||
URL…
|
||||
</ContextMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<ContextMenuItem icon={MessageSquareText} onSelect={() => setSnippetsOpen(true)}>
|
||||
Prompt snippets…
|
||||
</ContextMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<MessageSquareText />
|
||||
<span>Prompt snippets</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-72">
|
||||
{[
|
||||
{ label: 'Code review', text: 'Please review this for bugs, regressions, and missing tests.' },
|
||||
{ label: 'Implementation plan', text: 'Please make a concise implementation plan before changing code.' },
|
||||
{ label: 'Explain this', text: 'Please explain how this works and point me to the key files.' }
|
||||
].map(snippet => (
|
||||
<ContextMenuItem icon={MessageSquareText} key={snippet.label} onSelect={() => onInsertText(snippet.text)}>
|
||||
{snippet.label}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
|
||||
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference files
|
||||
inline.
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<PromptSnippetsDialog
|
||||
onInsertText={onInsertText}
|
||||
onOpenChange={setSnippetsOpen}
|
||||
open={snippetsOpen}
|
||||
snippets={PROMPT_SNIPPETS}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptSnippetsDialog({
|
||||
onInsertText,
|
||||
onOpenChange,
|
||||
open,
|
||||
snippets
|
||||
}: PromptSnippetsDialogProps) {
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md gap-3">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Prompt snippets</DialogTitle>
|
||||
<DialogDescription>Pick a starter prompt to drop into the composer.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ul className="grid gap-1">
|
||||
{snippets.map(snippet => (
|
||||
<li key={snippet.label}>
|
||||
<button
|
||||
className="group/snippet flex w-full cursor-pointer items-start gap-2.5 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) focus-visible:border-(--ui-stroke-tertiary) focus-visible:bg-(--ui-control-hover-background) focus-visible:outline-none"
|
||||
onClick={() => {
|
||||
onInsertText(snippet.text)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquareText className="mt-0.5 size-3.5 shrink-0 text-(--ui-text-tertiary) group-hover/snippet:text-foreground" />
|
||||
<span className="grid min-w-0 gap-0.5">
|
||||
<span className="text-sm font-medium text-foreground">{snippet.label}</span>
|
||||
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{snippet.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
|
||||
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference files
|
||||
inline.
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -165,7 +108,12 @@ export function ContextMenuItem({
|
||||
disabled,
|
||||
icon: Icon,
|
||||
onSelect
|
||||
}: ContextMenuItemProps) {
|
||||
}: {
|
||||
children: string
|
||||
disabled?: boolean
|
||||
icon: IconComponent
|
||||
onSelect?: () => void
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuItem disabled={disabled} onSelect={onSelect}>
|
||||
<Icon />
|
||||
@@ -173,33 +121,3 @@ export function ContextMenuItem({
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
interface ContextMenuItemProps {
|
||||
children: string
|
||||
disabled?: boolean
|
||||
icon: IconComponent
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
onInsertText: (text: string) => void
|
||||
onOpenUrlDialog: () => void
|
||||
onPasteClipboardImage?: () => void
|
||||
onPickFiles?: () => void
|
||||
onPickFolders?: () => void
|
||||
onPickImages?: () => void
|
||||
state: ChatBarState
|
||||
}
|
||||
|
||||
interface PromptSnippet {
|
||||
description: string
|
||||
label: string
|
||||
text: string
|
||||
}
|
||||
|
||||
interface PromptSnippetsDialogProps {
|
||||
onInsertText: (text: string) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
open: boolean
|
||||
snippets: readonly PromptSnippet[]
|
||||
}
|
||||
|
||||
@@ -1024,8 +1024,6 @@ export function ChatBar({
|
||||
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
|
||||
<div
|
||||
aria-label="Message"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
className={cn(
|
||||
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
|
||||
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
|
||||
@@ -1047,7 +1045,6 @@ export function ChatBar({
|
||||
onPaste={handlePaste}
|
||||
ref={editorRef}
|
||||
role="textbox"
|
||||
spellCheck="true"
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
{/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree
|
||||
|
||||
@@ -97,17 +97,6 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
key={tab.id}
|
||||
// Middle-click closes the tab, matching browser/IDE muscle
|
||||
// memory. `onMouseDown` swallows the middle-button press so
|
||||
// Chromium doesn't switch into autoscroll mode.
|
||||
onAuxClick={event => {
|
||||
if (event.button !== 1) return
|
||||
event.preventDefault()
|
||||
closeRightRailTab(tab.id)
|
||||
}}
|
||||
onMouseDown={event => {
|
||||
if (event.button === 1) event.preventDefault()
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
SidebarMenuItem
|
||||
} from '@/components/ui/sidebar'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$pinnedSessionIds,
|
||||
@@ -54,8 +54,7 @@ import {
|
||||
$sessions,
|
||||
$sessionsLoading,
|
||||
$sessionsTotal,
|
||||
$workingSessionIds,
|
||||
sessionPinId
|
||||
$workingSessionIds
|
||||
} from '@/store/session'
|
||||
|
||||
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
|
||||
@@ -67,12 +66,6 @@ import { VirtualSessionList } from './virtual-session-list'
|
||||
|
||||
const VIRTUALIZE_THRESHOLD = 25
|
||||
|
||||
// Render the modifier key the user actually presses on this platform. The
|
||||
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
|
||||
// else) in desktop-controller.tsx, but the hint should match muscle memory.
|
||||
const NEW_SESSION_KBD: readonly string[] =
|
||||
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') ? ['⌘', 'N'] : ['Ctrl', 'N']
|
||||
|
||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{
|
||||
id: 'new-session',
|
||||
@@ -80,12 +73,7 @@ const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
icon: props => <Codicon name="robot" {...props} />,
|
||||
action: 'new-session'
|
||||
},
|
||||
{
|
||||
id: 'skills',
|
||||
label: 'Skills & Tools',
|
||||
icon: props => <Codicon name="symbol-misc" {...props} />,
|
||||
route: SKILLS_ROUTE
|
||||
},
|
||||
{ id: 'skills', label: 'Skills', icon: props => <Codicon name="symbol-misc" {...props} />, route: SKILLS_ROUTE },
|
||||
{ id: 'messaging', label: 'Messaging', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
|
||||
{ id: 'artifacts', label: 'Artifacts', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE }
|
||||
]
|
||||
@@ -132,31 +120,6 @@ const baseName = (path: string) =>
|
||||
.filter(Boolean)
|
||||
.pop()
|
||||
|
||||
// FTS results cover sessions that aren't in the loaded page; synthesize a
|
||||
// minimal SessionInfo so they render in the same row component (resume works
|
||||
// by id; the snippet stands in for the preview).
|
||||
function searchResultToSession(result: SessionSearchResult): SessionInfo {
|
||||
const ts = result.session_started ?? Date.now() / 1000
|
||||
|
||||
return {
|
||||
archived: false,
|
||||
cwd: null,
|
||||
ended_at: null,
|
||||
id: result.session_id,
|
||||
input_tokens: 0,
|
||||
is_active: false,
|
||||
last_active: ts,
|
||||
message_count: 0,
|
||||
model: result.model ?? null,
|
||||
output_tokens: 0,
|
||||
preview: result.snippet?.trim() || null,
|
||||
source: result.source ?? null,
|
||||
started_at: ts,
|
||||
title: null,
|
||||
tool_call_count: 0
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] {
|
||||
const groups = new Map<string, SidebarSessionGroup>()
|
||||
|
||||
@@ -170,14 +133,6 @@ function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] {
|
||||
groups.set(id, group)
|
||||
}
|
||||
|
||||
// Groups keep recency order (Map insertion = first-seen in the recency-sorted
|
||||
// input, so an active project floats up), but rows *within* a group sort by
|
||||
// creation time so they don't reshuffle every time a message lands — keeps
|
||||
// muscle memory intact.
|
||||
for (const group of groups.values()) {
|
||||
group.sessions.sort((a, b) => b.started_at - a.started_at)
|
||||
}
|
||||
|
||||
return [...groups.values()]
|
||||
}
|
||||
|
||||
@@ -224,9 +179,6 @@ export function ChatSidebar({
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
const [agentOrderIds, setAgentOrderIds] = useState<string[]>([])
|
||||
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
|
||||
const trimmedQuery = searchQuery.trim()
|
||||
|
||||
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
|
||||
|
||||
@@ -237,99 +189,24 @@ export function ChatSidebar({
|
||||
|
||||
const sortedSessions = useMemo(() => [...sessions].sort((a, b) => sessionTime(b) - sessionTime(a)), [sessions])
|
||||
|
||||
const sessionsById = useMemo(() => new Map(sessions.map(s => [s.id, s])), [sessions])
|
||||
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])
|
||||
|
||||
// Index sessions by both their live id and their lineage-root id so a pin
|
||||
// stored as the pre-compression root resolves to the live continuation tip.
|
||||
const sessionByAnyId = useMemo(() => {
|
||||
const map = new Map<string, SessionInfo>()
|
||||
const visiblePinnedIds = useMemo(
|
||||
() => pinnedSessionIds.filter(id => sessionsById.has(id)),
|
||||
[pinnedSessionIds, sessionsById]
|
||||
)
|
||||
|
||||
for (const s of sessions) {
|
||||
map.set(s.id, s)
|
||||
const visiblePinnedIdSet = useMemo(() => new Set(visiblePinnedIds), [visiblePinnedIds])
|
||||
|
||||
if (s._lineage_root_id && !map.has(s._lineage_root_id)) {
|
||||
map.set(s._lineage_root_id, s)
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
}, [sessions])
|
||||
|
||||
const pinnedSessions = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
const out: SessionInfo[] = []
|
||||
|
||||
for (const pinId of pinnedSessionIds) {
|
||||
const session = sessionByAnyId.get(pinId)
|
||||
|
||||
if (session && !seen.has(session.id)) {
|
||||
seen.add(session.id)
|
||||
out.push(session)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}, [pinnedSessionIds, sessionByAnyId])
|
||||
|
||||
const pinnedRealIdSet = useMemo(() => new Set(pinnedSessions.map(s => s.id)), [pinnedSessions])
|
||||
|
||||
// Full-text search across *all* sessions (not just the loaded page) so 699
|
||||
// sessions stay findable. Debounced; loaded sessions are matched instantly
|
||||
// client-side and merged ahead of the server hits.
|
||||
useEffect(() => {
|
||||
if (!trimmedQuery) {
|
||||
setServerMatches([])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const id = window.setTimeout(() => {
|
||||
void searchSessions(trimmedQuery)
|
||||
.then(res => {
|
||||
if (!cancelled) {
|
||||
setServerMatches(res.results)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, 200)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(id)
|
||||
}
|
||||
}, [trimmedQuery])
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
if (!trimmedQuery) {
|
||||
return []
|
||||
}
|
||||
|
||||
const needle = trimmedQuery.toLowerCase()
|
||||
const out = new Map<string, SessionInfo>()
|
||||
|
||||
for (const s of sortedSessions) {
|
||||
if (`${s.title ?? ''} ${s.preview ?? ''} ${s.cwd ?? ''}`.toLowerCase().includes(needle)) {
|
||||
out.set(s.id, s)
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of serverMatches) {
|
||||
if (out.has(match.session_id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const loaded = sessionByAnyId.get(match.session_id)
|
||||
out.set(match.session_id, loaded ?? searchResultToSession(match))
|
||||
}
|
||||
|
||||
return [...out.values()]
|
||||
}, [trimmedQuery, sortedSessions, serverMatches, sessionByAnyId])
|
||||
const pinnedSessions = useMemo(
|
||||
() => visiblePinnedIds.map(id => sessionsById.get(id)!).filter(Boolean),
|
||||
[visiblePinnedIds, sessionsById]
|
||||
)
|
||||
|
||||
const unpinnedAgentSessions = useMemo(
|
||||
() => sortedSessions.filter(s => !pinnedRealIdSet.has(s.id)),
|
||||
[sortedSessions, pinnedRealIdSet]
|
||||
() => sortedSessions.filter(s => !visiblePinnedIdSet.has(s.id)),
|
||||
[sortedSessions, visiblePinnedIdSet]
|
||||
)
|
||||
|
||||
const agentSessions = useMemo(
|
||||
@@ -359,10 +236,7 @@ export function ChatSidebar({
|
||||
return
|
||||
}
|
||||
|
||||
// Sortable ids are live session ids; the pinned store is keyed by durable
|
||||
// (lineage-root) ids, so translate before reordering.
|
||||
const dragged = sessionByAnyId.get(String(active.id))
|
||||
reorderPinnedSession(dragged ? sessionPinId(dragged) : String(active.id), newIndex)
|
||||
reorderPinnedSession(String(active.id), newIndex)
|
||||
}
|
||||
|
||||
const handleAgentDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
@@ -444,7 +318,7 @@ export function ChatSidebar({
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
|
||||
{item.id === 'new-session' && (
|
||||
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={[...NEW_SESSION_KBD]} />
|
||||
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={['⇧', 'N']} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -457,56 +331,6 @@ export function ChatSidebar({
|
||||
</SidebarGroup>
|
||||
|
||||
{sidebarOpen && showSessionSections && (
|
||||
<div className="shrink-0 pb-1 pt-1">
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-transparent bg-transparent px-2 transition-colors focus-within:border-(--ui-stroke-tertiary)">
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="search" size="0.75rem" />
|
||||
<input
|
||||
aria-label="Search sessions"
|
||||
className="h-6 min-w-0 flex-1 bg-transparent text-[0.8125rem] text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
|
||||
onChange={event => setSearchQuery(event.target.value)}
|
||||
placeholder="Search sessions…"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
aria-label="Clear search"
|
||||
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-active-background) hover:text-foreground"
|
||||
onClick={() => setSearchQuery('')}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sidebarOpen && showSessionSections && trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
|
||||
emptyState={
|
||||
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
|
||||
No sessions match “{trimmedQuery}”.
|
||||
</div>
|
||||
}
|
||||
label="Results"
|
||||
labelMeta={String(searchResults.length)}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onResumeSession={onResumeSession}
|
||||
onToggle={() => undefined}
|
||||
onTogglePin={pinSession}
|
||||
open
|
||||
pinned={false}
|
||||
rootClassName="min-h-0 flex-1 p-0"
|
||||
sessions={searchResults}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sidebarOpen && showSessionSections && !trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
|
||||
@@ -528,7 +352,7 @@ export function ChatSidebar({
|
||||
/>
|
||||
)}
|
||||
|
||||
{sidebarOpen && showSessionSections && !trimmedQuery && (
|
||||
{sidebarOpen && showSessionSections && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
|
||||
@@ -546,28 +370,23 @@ export function ChatSidebar({
|
||||
forceEmptyState={showSessionSkeletons}
|
||||
groups={agentsGrouped ? agentGroups : undefined}
|
||||
headerAction={
|
||||
// Grouping operates on unpinned recents; if everything is
|
||||
// pinned the toggle does nothing visible, so hide it to avoid
|
||||
// a phantom click target.
|
||||
agentSessions.length > 0 ? (
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
|
||||
className={cn(
|
||||
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setSidebarRecentsOpen(true)
|
||||
setSidebarAgentsGrouped(!agentsGrouped)
|
||||
}}
|
||||
size="icon-xs"
|
||||
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
||||
</Button>
|
||||
) : null
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
|
||||
className={cn(
|
||||
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setSidebarRecentsOpen(true)
|
||||
setSidebarAgentsGrouped(!agentsGrouped)
|
||||
}}
|
||||
size="icon-xs"
|
||||
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
||||
</Button>
|
||||
}
|
||||
label="Sessions"
|
||||
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
|
||||
@@ -644,7 +463,7 @@ function SidebarPinnedEmptyState() {
|
||||
<span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
|
||||
<Codicon name="pin" size="0.75rem" />
|
||||
</span>
|
||||
<span>Shift-click a chat to pin · drag to reorder</span>
|
||||
<span>Shift click to pin a chat</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -717,7 +536,7 @@ function SidebarSessionsSection({
|
||||
isWorking: workingSessionIdSet.has(session.id),
|
||||
onArchive: () => onArchiveSession(session.id),
|
||||
onDelete: () => onDeleteSession(session.id),
|
||||
onPin: () => onTogglePin(sessionPinId(session)),
|
||||
onPin: () => onTogglePin(session.id),
|
||||
onResume: () => onResumeSession(session.id),
|
||||
session
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { type FC, useCallback, useMemo, useRef } from 'react'
|
||||
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { sessionPinId } from '@/store/session'
|
||||
|
||||
import { SidebarSessionRow } from './session-row'
|
||||
|
||||
@@ -78,7 +77,7 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
isWorking: workingSessionIdSet.has(session.id),
|
||||
onArchive: () => onArchiveSession(session.id),
|
||||
onDelete: () => onDeleteSession(session.id),
|
||||
onPin: () => onTogglePin(sessionPinId(session)),
|
||||
onPin: () => onTogglePin(session.id),
|
||||
onResume: () => onResumeSession(session.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,29 +3,37 @@ import {
|
||||
IconBookmark,
|
||||
IconBookmarkFilled,
|
||||
IconDownload,
|
||||
IconLoader2,
|
||||
IconRefresh,
|
||||
IconSparkles,
|
||||
IconTrash
|
||||
} from '@tabler/icons-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
getActionStatus,
|
||||
getAuxiliaryModels,
|
||||
getGlobalModelInfo,
|
||||
getGlobalModelOptions,
|
||||
getLogs,
|
||||
getStatus,
|
||||
getUsageAnalytics,
|
||||
restartGateway,
|
||||
searchSessions,
|
||||
setModelAssignment,
|
||||
updateHermes
|
||||
} from '@/hermes'
|
||||
import type {
|
||||
ActionStatusResponse,
|
||||
AnalyticsResponse,
|
||||
AuxiliaryModelsResponse,
|
||||
ModelOptionProvider,
|
||||
SessionInfo,
|
||||
SessionSearchResult as SessionSearchApiResult,
|
||||
StatusResponse
|
||||
} from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { Activity, AlertCircle, BarChart3, Pin } from '@/lib/icons'
|
||||
import { Activity, AlertCircle, BarChart3, Cpu, Pin } from '@/lib/icons'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { upsertDesktopActionTask } from '@/store/activity'
|
||||
@@ -39,9 +47,30 @@ import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
import { ARTIFACTS_ROUTE, MESSAGING_ROUTE, NEW_CHAT_ROUTE, SETTINGS_ROUTE, SKILLS_ROUTE } from '../routes'
|
||||
|
||||
export type CommandCenterSection = 'sessions' | 'system' | 'usage'
|
||||
export type CommandCenterSection = 'models' | 'sessions' | 'system' | 'usage'
|
||||
|
||||
const SECTIONS = ['sessions', 'system', 'usage'] as const satisfies readonly CommandCenterSection[]
|
||||
const SECTIONS = ['sessions', 'system', 'models', 'usage'] as const satisfies readonly CommandCenterSection[]
|
||||
|
||||
// Mirrors `_AUX_TASK_SLOTS` in hermes_cli/web_server.py. Friendly labels and
|
||||
// hints make the assignments panel readable; raw task keys (vision, mcp, …)
|
||||
// are opaque to most users.
|
||||
interface AuxTaskMeta {
|
||||
hint: string
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const AUX_TASKS: readonly AuxTaskMeta[] = [
|
||||
{ key: 'vision', label: 'Vision', hint: 'Image analysis' },
|
||||
{ key: 'web_extract', label: 'Web extract', hint: 'Page summarization' },
|
||||
{ key: 'compression', label: 'Compression', hint: 'Context compaction' },
|
||||
{ key: 'session_search', label: 'Session search', hint: 'Recall queries' },
|
||||
{ key: 'skills_hub', label: 'Skills hub', hint: 'Skill search' },
|
||||
{ key: 'approval', label: 'Approval', hint: 'Smart auto-approve' },
|
||||
{ key: 'mcp', label: 'MCP', hint: 'MCP tool routing' },
|
||||
{ key: 'title_generation', label: 'Title gen', hint: 'Session titles' },
|
||||
{ key: 'curator', label: 'Curator', hint: 'Skill-usage review' }
|
||||
]
|
||||
|
||||
const USAGE_PERIODS = [7, 30, 90] as const
|
||||
type UsagePeriod = (typeof USAGE_PERIODS)[number]
|
||||
@@ -50,6 +79,7 @@ interface CommandCenterViewProps {
|
||||
initialSection?: CommandCenterSection
|
||||
onClose: () => void
|
||||
onDeleteSession: (sessionId: string) => Promise<void>
|
||||
onMainModelChanged?: (provider: string, model: string) => void
|
||||
onNavigateRoute: (path: string) => void
|
||||
onOpenSession: (sessionId: string) => void
|
||||
}
|
||||
@@ -57,12 +87,14 @@ interface CommandCenterViewProps {
|
||||
const SECTION_LABELS: Record<CommandCenterSection, string> = {
|
||||
sessions: 'Sessions',
|
||||
system: 'System',
|
||||
models: 'Models',
|
||||
usage: 'Usage'
|
||||
}
|
||||
|
||||
const SECTION_DESCRIPTIONS: Record<CommandCenterSection, string> = {
|
||||
sessions: 'Search and manage sessions',
|
||||
system: 'Status, logs, and system actions',
|
||||
models: 'Global and auxiliary model controls',
|
||||
usage: 'Token, cost, and skill activity over time'
|
||||
}
|
||||
|
||||
@@ -83,7 +115,7 @@ interface SectionSearchEntry {
|
||||
const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
|
||||
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New session', detail: 'Start a fresh session' },
|
||||
{ id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' },
|
||||
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills & Tools', detail: 'Enable skills, toolsets, and providers' },
|
||||
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' },
|
||||
{
|
||||
id: 'nav-messaging',
|
||||
route: MESSAGING_ROUTE,
|
||||
@@ -96,6 +128,7 @@ const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
|
||||
const SECTION_SEARCH_ENTRIES: readonly SectionSearchEntry[] = [
|
||||
{ id: 'section-sessions', section: 'sessions', title: 'Sessions panel', detail: 'Search, pin, and manage sessions' },
|
||||
{ id: 'section-system', section: 'system', title: 'System panel', detail: 'Gateway status, logs, restart/update' },
|
||||
{ id: 'section-models', section: 'models', title: 'Models panel', detail: 'Main and auxiliary model assignments' },
|
||||
{ id: 'section-usage', section: 'usage', title: 'Usage panel', detail: 'Token, cost, and skill activity' }
|
||||
]
|
||||
|
||||
@@ -183,6 +216,7 @@ export function CommandCenterView({
|
||||
initialSection,
|
||||
onClose,
|
||||
onDeleteSession,
|
||||
onMainModelChanged,
|
||||
onNavigateRoute,
|
||||
onOpenSession
|
||||
}: CommandCenterViewProps) {
|
||||
@@ -199,6 +233,16 @@ export function CommandCenterView({
|
||||
const [systemLoading, setSystemLoading] = useState(false)
|
||||
const [systemError, setSystemError] = useState('')
|
||||
const [systemAction, setSystemAction] = useState<ActionStatusResponse | null>(null)
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
const [modelsError, setModelsError] = useState('')
|
||||
const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null)
|
||||
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
|
||||
const [selectedProvider, setSelectedProvider] = useState('')
|
||||
const [selectedModel, setSelectedModel] = useState('')
|
||||
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
|
||||
const [applyingModel, setApplyingModel] = useState(false)
|
||||
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
|
||||
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
|
||||
const [usagePeriod, setUsagePeriod] = useState<UsagePeriod>(30)
|
||||
const [usage, setUsage] = useState<AnalyticsResponse | null>(null)
|
||||
const [usageLoading, setUsageLoading] = useState(false)
|
||||
@@ -221,6 +265,11 @@ export function CommandCenterView({
|
||||
[sessions]
|
||||
)
|
||||
|
||||
const selectedProviderModels = useMemo(
|
||||
() => providers.find(provider => provider.slug === selectedProvider)?.models ?? [],
|
||||
[providers, selectedProvider]
|
||||
)
|
||||
|
||||
const searchProviders = useMemo<readonly CommandCenterSearchProvider[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -293,6 +342,29 @@ export function CommandCenterView({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshModels = useCallback(async () => {
|
||||
setModelsLoading(true)
|
||||
setModelsError('')
|
||||
|
||||
try {
|
||||
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([
|
||||
getGlobalModelInfo(),
|
||||
getGlobalModelOptions(),
|
||||
getAuxiliaryModels()
|
||||
])
|
||||
|
||||
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
|
||||
setProviders(modelOptions.providers || [])
|
||||
setSelectedProvider(prev => prev || modelInfo.provider)
|
||||
setSelectedModel(prev => prev || modelInfo.model)
|
||||
setAuxiliary(auxiliaryModels)
|
||||
} catch (error) {
|
||||
setModelsError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setModelsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshUsage = useCallback(async (days: UsagePeriod) => {
|
||||
const requestId = usageRequestRef.current + 1
|
||||
usageRequestRef.current = requestId
|
||||
@@ -358,12 +430,28 @@ export function CommandCenterView({
|
||||
}
|
||||
}, [refreshSystem, section, status, systemLoading])
|
||||
|
||||
useEffect(() => {
|
||||
if (section === 'models' && !mainModel && !modelsLoading) {
|
||||
void refreshModels()
|
||||
}
|
||||
}, [mainModel, modelsLoading, refreshModels, section])
|
||||
|
||||
useEffect(() => {
|
||||
if (section === 'usage') {
|
||||
void refreshUsage(usagePeriod)
|
||||
}
|
||||
}, [refreshUsage, section, usagePeriod])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProviderModels.length) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedProviderModels.includes(selectedModel)) {
|
||||
setSelectedModel(selectedProviderModels[0])
|
||||
}
|
||||
}, [selectedModel, selectedProviderModels])
|
||||
|
||||
const showGlobalSearchResults = debouncedQuery.length > 0
|
||||
const hasGlobalSearchResults = searchGroups.length > 0
|
||||
const sessionListHasResults = filteredSessions.length > 0
|
||||
@@ -409,6 +497,128 @@ export function CommandCenterView({
|
||||
[refreshSystem]
|
||||
)
|
||||
|
||||
const applyMainModel = useCallback(async () => {
|
||||
if (!selectedProvider || !selectedModel) {
|
||||
return
|
||||
}
|
||||
|
||||
setApplyingModel(true)
|
||||
setModelsError('')
|
||||
|
||||
try {
|
||||
const result = await setModelAssignment({
|
||||
model: selectedModel,
|
||||
provider: selectedProvider,
|
||||
scope: 'main'
|
||||
})
|
||||
|
||||
const provider = result.provider || selectedProvider
|
||||
const model = result.model || selectedModel
|
||||
setMainModel({ provider, model })
|
||||
onMainModelChanged?.(provider, model)
|
||||
await refreshModels()
|
||||
} catch (error) {
|
||||
setModelsError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setApplyingModel(false)
|
||||
}
|
||||
}, [onMainModelChanged, refreshModels, selectedModel, selectedProvider])
|
||||
|
||||
const setAuxiliaryToMain = useCallback(
|
||||
async (task: string) => {
|
||||
if (!mainModel) {
|
||||
return
|
||||
}
|
||||
|
||||
setApplyingModel(true)
|
||||
setModelsError('')
|
||||
|
||||
try {
|
||||
await setModelAssignment({
|
||||
model: mainModel.model,
|
||||
provider: mainModel.provider,
|
||||
scope: 'auxiliary',
|
||||
task
|
||||
})
|
||||
await refreshModels()
|
||||
} catch (error) {
|
||||
setModelsError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setApplyingModel(false)
|
||||
}
|
||||
},
|
||||
[mainModel, refreshModels]
|
||||
)
|
||||
|
||||
const applyAuxiliaryDraft = useCallback(
|
||||
async (task: string) => {
|
||||
if (!auxDraft.provider || !auxDraft.model) {
|
||||
return
|
||||
}
|
||||
|
||||
setApplyingModel(true)
|
||||
setModelsError('')
|
||||
|
||||
try {
|
||||
await setModelAssignment({
|
||||
model: auxDraft.model,
|
||||
provider: auxDraft.provider,
|
||||
scope: 'auxiliary',
|
||||
task
|
||||
})
|
||||
setEditingAuxTask(null)
|
||||
await refreshModels()
|
||||
} catch (error) {
|
||||
setModelsError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setApplyingModel(false)
|
||||
}
|
||||
},
|
||||
[auxDraft, refreshModels]
|
||||
)
|
||||
|
||||
const beginAuxiliaryEdit = useCallback(
|
||||
(task: string) => {
|
||||
const current = auxiliary?.tasks.find(entry => entry.task === task)
|
||||
|
||||
const initialProvider =
|
||||
current?.provider && current.provider !== 'auto' ? current.provider : (mainModel?.provider ?? '')
|
||||
|
||||
const initialModel = current?.model || mainModel?.model || ''
|
||||
setAuxDraft({ provider: initialProvider, model: initialModel })
|
||||
setEditingAuxTask(task)
|
||||
},
|
||||
[auxiliary, mainModel]
|
||||
)
|
||||
|
||||
const auxDraftProviderModels = useMemo(
|
||||
() => providers.find(provider => provider.slug === auxDraft.provider)?.models ?? [],
|
||||
[auxDraft.provider, providers]
|
||||
)
|
||||
|
||||
const resetAuxiliaryModels = useCallback(async () => {
|
||||
if (!mainModel) {
|
||||
return
|
||||
}
|
||||
|
||||
setApplyingModel(true)
|
||||
setModelsError('')
|
||||
|
||||
try {
|
||||
await setModelAssignment({
|
||||
model: mainModel.model,
|
||||
provider: mainModel.provider,
|
||||
scope: 'auxiliary',
|
||||
task: '__reset__'
|
||||
})
|
||||
await refreshModels()
|
||||
} catch (error) {
|
||||
setModelsError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setApplyingModel(false)
|
||||
}
|
||||
}, [mainModel, refreshModels])
|
||||
|
||||
const handleSearchSelect = useCallback(
|
||||
(result: CommandCenterSearchResult) => {
|
||||
if (result.kind === 'route') {
|
||||
@@ -448,7 +658,7 @@ export function CommandCenterView({
|
||||
{SECTIONS.map(value => (
|
||||
<OverlayNavItem
|
||||
active={section === value}
|
||||
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : BarChart3}
|
||||
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : value === 'models' ? Cpu : BarChart3}
|
||||
key={value}
|
||||
label={SECTION_LABELS[value]}
|
||||
onClick={() => setSection(value)}
|
||||
@@ -474,6 +684,12 @@ export function CommandCenterView({
|
||||
{usageLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</OverlayActionButton>
|
||||
)}
|
||||
{section === 'models' && (
|
||||
<OverlayActionButton disabled={modelsLoading} onClick={() => void refreshModels()}>
|
||||
<IconRefresh className={cn('mr-1.5 size-3.5', modelsLoading && 'animate-spin')} />
|
||||
{modelsLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</OverlayActionButton>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{showGlobalSearchResults ? (
|
||||
@@ -628,7 +844,7 @@ export function CommandCenterView({
|
||||
period={usagePeriod}
|
||||
usage={usage}
|
||||
/>
|
||||
) : (
|
||||
) : section === 'system' ? (
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] gap-3">
|
||||
<OverlayCard className="p-3 text-sm">
|
||||
{status ? (
|
||||
@@ -686,6 +902,154 @@ export function CommandCenterView({
|
||||
</pre>
|
||||
</OverlayCard>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[auto_auto_minmax(0,1fr)] gap-3">
|
||||
<OverlayCard className="p-3">
|
||||
{mainModel ? (
|
||||
<>
|
||||
<div className="text-sm font-medium text-foreground">Main model</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{mainModel.provider} / {mainModel.model}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">Loading model state...</div>
|
||||
)}
|
||||
</OverlayCard>
|
||||
|
||||
<OverlayCard className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">Set global main model</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="h-8 min-w-36 rounded-md border border-border bg-background px-2 text-xs text-foreground"
|
||||
onChange={event => setSelectedProvider(event.target.value)}
|
||||
value={selectedProvider}
|
||||
>
|
||||
{(providers.length ? providers : [{ name: '—', slug: '', models: [] }]).map(provider => (
|
||||
<option key={provider.slug || 'none'} value={provider.slug}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="h-8 min-w-58 rounded-md border border-border bg-background px-2 text-xs text-foreground"
|
||||
onChange={event => setSelectedModel(event.target.value)}
|
||||
value={selectedModel}
|
||||
>
|
||||
{(selectedProviderModels.length ? selectedProviderModels : ['']).map(model => (
|
||||
<option key={model || 'none'} value={model}>
|
||||
{model || 'No models available'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<OverlayActionButton
|
||||
disabled={!selectedProvider || !selectedModel || applyingModel}
|
||||
onClick={() => void applyMainModel()}
|
||||
>
|
||||
{applyingModel ? (
|
||||
<IconLoader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
) : (
|
||||
<IconSparkles className="mr-1.5 size-3.5" />
|
||||
)}
|
||||
{applyingModel ? 'Applying...' : 'Apply'}
|
||||
</OverlayActionButton>
|
||||
</div>
|
||||
{modelsError && <div className="mt-2 text-xs text-destructive">{modelsError}</div>}
|
||||
</OverlayCard>
|
||||
|
||||
<OverlayCard className="min-h-0 overflow-auto p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">Auxiliary assignments</span>
|
||||
<OverlayActionButton
|
||||
disabled={!mainModel || applyingModel}
|
||||
onClick={() => void resetAuxiliaryModels()}
|
||||
tone="subtle"
|
||||
>
|
||||
Reset all
|
||||
</OverlayActionButton>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
{AUX_TASKS.map(meta => {
|
||||
const current = auxiliary?.tasks.find(entry => entry.task === meta.key)
|
||||
const isAuto = !current || !current.provider || current.provider === 'auto'
|
||||
const isEditing = editingAuxTask === meta.key
|
||||
|
||||
return (
|
||||
<OverlayCard className="px-2 py-1.5" key={meta.key}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-medium text-foreground">{meta.label}</span>
|
||||
<span className="text-[0.62rem] text-muted-foreground/70">{meta.hint}</span>
|
||||
</div>
|
||||
<div className="truncate font-mono text-[0.62rem] text-muted-foreground">
|
||||
{isAuto
|
||||
? 'auto · use main model'
|
||||
: `${current.provider} · ${current.model || '(provider default)'}`}
|
||||
</div>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<>
|
||||
<OverlayActionButton
|
||||
disabled={!mainModel || applyingModel}
|
||||
onClick={() => void setAuxiliaryToMain(meta.key)}
|
||||
tone="subtle"
|
||||
>
|
||||
Set to main
|
||||
</OverlayActionButton>
|
||||
<OverlayActionButton
|
||||
disabled={!providers.length || applyingModel}
|
||||
onClick={() => beginAuxiliaryEdit(meta.key)}
|
||||
>
|
||||
Change
|
||||
</OverlayActionButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 border-t border-border/40 pt-2">
|
||||
<select
|
||||
className="h-7 min-w-28 rounded-md border border-border bg-background px-2 text-[0.7rem] text-foreground"
|
||||
onChange={event =>
|
||||
setAuxDraft(prev => ({ ...prev, provider: event.target.value, model: '' }))
|
||||
}
|
||||
value={auxDraft.provider}
|
||||
>
|
||||
{(providers.length ? providers : [{ name: '—', slug: '', models: [] }]).map(provider => (
|
||||
<option key={provider.slug || 'none'} value={provider.slug}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="h-7 min-w-44 rounded-md border border-border bg-background px-2 text-[0.7rem] text-foreground"
|
||||
onChange={event => setAuxDraft(prev => ({ ...prev, model: event.target.value }))}
|
||||
value={auxDraft.model}
|
||||
>
|
||||
{(auxDraftProviderModels.length ? auxDraftProviderModels : ['']).map(model => (
|
||||
<option key={model || 'none'} value={model}>
|
||||
{model || 'No models available'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<OverlayActionButton
|
||||
disabled={!auxDraft.provider || !auxDraft.model || applyingModel}
|
||||
onClick={() => void applyAuxiliaryDraft(meta.key)}
|
||||
>
|
||||
{applyingModel ? 'Applying...' : 'Apply'}
|
||||
</OverlayActionButton>
|
||||
<OverlayActionButton onClick={() => setEditingAuxTask(null)} tone="subtle">
|
||||
Cancel
|
||||
</OverlayActionButton>
|
||||
</div>
|
||||
)}
|
||||
</OverlayCard>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</OverlayCard>
|
||||
</div>
|
||||
)}
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
|
||||
@@ -428,6 +428,14 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
||||
return (
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
filters={
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
|
||||
<Codicon name="add" />
|
||||
New cron
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
onSearchChange={setQuery}
|
||||
searchPlaceholder="Search cron jobs..."
|
||||
searchTrailingAction={
|
||||
@@ -449,10 +457,6 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
||||
{!jobs ? (
|
||||
<PageLoader label="Loading cron jobs..." />
|
||||
) : visibleJobs.length === 0 ? (
|
||||
// Empty state owns the primary "create" CTA — we used to also have
|
||||
// one in the filters bar but it was redundant. Only show the button
|
||||
// when there are zero jobs total; the search-empty case ("No
|
||||
// matches") just asks the user to broaden their query.
|
||||
<EmptyState
|
||||
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
|
||||
description={
|
||||
@@ -465,19 +469,6 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
{/* Inline header replaces the old top-bar "New cron" button. We
|
||||
still need a single, always-visible affordance to add a job
|
||||
when the list is non-empty (rows themselves only expose
|
||||
edit/pause/trigger/delete). */}
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
|
||||
{enabledCount}/{totalCount} active
|
||||
</span>
|
||||
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
|
||||
<Codicon name="add" />
|
||||
New cron
|
||||
</Button>
|
||||
</div>
|
||||
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobRow
|
||||
@@ -493,6 +484,8 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden">{totalCount === 0 ? 'No scheduled jobs' : `${enabledCount}/${totalCount} active`}</div>
|
||||
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
|
||||
@@ -32,8 +32,6 @@ import {
|
||||
$freshDraftReady,
|
||||
$gatewayState,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
sessionPinId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setCurrentBranch,
|
||||
@@ -226,14 +224,10 @@ export function DesktopController() {
|
||||
return
|
||||
}
|
||||
|
||||
// Pin on the durable lineage-root id so the pin survives auto-compression.
|
||||
const session = $sessions.get().find(s => s.id === sessionId || s._lineage_root_id === sessionId)
|
||||
const pinId = session ? sessionPinId(session) : sessionId
|
||||
|
||||
if ($pinnedSessionIds.get().includes(pinId)) {
|
||||
unpinSession(pinId)
|
||||
if ($pinnedSessionIds.get().includes(sessionId)) {
|
||||
unpinSession(sessionId)
|
||||
} else {
|
||||
pinSession(pinId)
|
||||
pinSession(sessionId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -372,22 +366,14 @@ export function DesktopController() {
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement
|
||||
|
||||
if (event.defaultPrevented || event.repeat || event.altKey || event.code !== 'KeyN') {
|
||||
if (editing || event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey) {
|
||||
return
|
||||
}
|
||||
|
||||
// Two accelerators for "new session":
|
||||
// - Cmd/Ctrl+N (browser-like, works while typing in any input)
|
||||
// - Shift+N (single-key, only when no input is focused)
|
||||
const accelerator = event.metaKey || event.ctrlKey
|
||||
const singleKey = !accelerator && !editing && event.shiftKey
|
||||
|
||||
if (!accelerator && !singleKey) {
|
||||
return
|
||||
if (event.shiftKey && event.code === 'KeyN') {
|
||||
event.preventDefault()
|
||||
startFreshSessionDraft()
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
startFreshSessionDraft()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
@@ -545,13 +531,6 @@ export function DesktopController() {
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
onMainModelChanged={(provider, model) => {
|
||||
setCurrentProvider(provider)
|
||||
setCurrentModel(model)
|
||||
updateModelOptionsCache(provider, model, true)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
@@ -562,6 +541,13 @@ export function DesktopController() {
|
||||
initialSection={commandCenterInitialSection}
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onDeleteSession={removeSession}
|
||||
onMainModelChanged={(provider, model) => {
|
||||
setCurrentProvider(provider)
|
||||
setCurrentModel(model)
|
||||
updateModelOptionsCache(provider, model, true)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
onNavigateRoute={path => navigate(path)}
|
||||
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
|
||||
@@ -21,8 +21,6 @@ import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
import { PlatformAvatar } from './platform-icon'
|
||||
|
||||
interface MessagingViewProps extends React.ComponentProps<'section'> {
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
}
|
||||
@@ -41,6 +39,29 @@ const STATE_LABELS: Record<string, string> = {
|
||||
startup_failed: 'Startup failed'
|
||||
}
|
||||
|
||||
const PLATFORM_TINTS: Record<string, string> = {
|
||||
telegram: 'bg-sky-500/15 text-sky-600 dark:text-sky-300',
|
||||
discord: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-300',
|
||||
slack: 'bg-violet-500/15 text-violet-600 dark:text-violet-300',
|
||||
mattermost: 'bg-blue-500/15 text-blue-600 dark:text-blue-300',
|
||||
matrix: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300',
|
||||
signal: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-300',
|
||||
whatsapp: 'bg-green-500/15 text-green-600 dark:text-green-300',
|
||||
bluebubbles: 'bg-blue-500/15 text-blue-600 dark:text-blue-300',
|
||||
homeassistant: 'bg-teal-500/15 text-teal-600 dark:text-teal-300',
|
||||
email: 'bg-amber-500/15 text-amber-600 dark:text-amber-300',
|
||||
sms: 'bg-rose-500/15 text-rose-600 dark:text-rose-300',
|
||||
dingtalk: 'bg-blue-500/15 text-blue-600 dark:text-blue-300',
|
||||
feishu: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-300',
|
||||
wecom: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300',
|
||||
wecom_callback: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300',
|
||||
weixin: 'bg-green-500/15 text-green-600 dark:text-green-300',
|
||||
qqbot: 'bg-amber-500/15 text-amber-600 dark:text-amber-300',
|
||||
yuanbao: 'bg-orange-500/15 text-orange-600 dark:text-orange-300',
|
||||
api_server: 'bg-slate-500/15 text-slate-600 dark:text-slate-300',
|
||||
webhook: 'bg-zinc-500/15 text-zinc-600 dark:text-zinc-300'
|
||||
}
|
||||
|
||||
const PILL_TONE: Record<StatusTone, string> = {
|
||||
good: 'bg-primary/10 text-primary',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
@@ -421,6 +442,19 @@ function PlatformRow({
|
||||
)
|
||||
}
|
||||
|
||||
function PlatformAvatar({ platformId, platformName }: { platformId: string; platformName: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex size-6 shrink-0 items-center justify-center rounded-md text-[length:var(--conversation-caption-font-size)] font-medium',
|
||||
PLATFORM_TINTS[platformId] || 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)'
|
||||
)}
|
||||
>
|
||||
{platformName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function PlatformDetail({
|
||||
edits,
|
||||
onClear,
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
|
||||
import {
|
||||
SiApple,
|
||||
SiBilibili,
|
||||
SiDiscord,
|
||||
SiGmail,
|
||||
SiHomeassistant,
|
||||
SiMatrix,
|
||||
SiMattermost,
|
||||
SiQq,
|
||||
SiSignal,
|
||||
SiTelegram,
|
||||
SiWechat,
|
||||
SiWhatsapp
|
||||
} from '@icons-pack/react-simple-icons'
|
||||
|
||||
import { Globe, Link as LinkIcon, MessageSquareText } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// We render simpleicons.org brand glyphs for platforms whose owners publish a
|
||||
// usable mark (telegram, discord, matrix, ...). A few brands — Slack, Dingtalk,
|
||||
// Feishu, WeCom — have been removed from Simple Icons at the brand owner's
|
||||
// request, so we fall back to a colored letter monogram for those.
|
||||
//
|
||||
// `iconColor` is the brand's hex from simpleicons.org so we can paint each
|
||||
// glyph in its native color on top of a soft tint. The fallback monogram uses
|
||||
// the same hex to keep visual consistency.
|
||||
type IconKind = 'brand' | 'generic'
|
||||
|
||||
interface PlatformIconSpec {
|
||||
Icon: ComponentType<SVGProps<SVGSVGElement>>
|
||||
color: string
|
||||
kind: IconKind
|
||||
}
|
||||
|
||||
const PLATFORM_ICONS: Record<string, PlatformIconSpec> = {
|
||||
telegram: { Icon: SiTelegram, color: '#26A5E4', kind: 'brand' },
|
||||
discord: { Icon: SiDiscord, color: '#5865F2', kind: 'brand' },
|
||||
// Slack removed from Simple Icons by Salesforce request — letter monogram.
|
||||
mattermost: { Icon: SiMattermost, color: '#0058CC', kind: 'brand' },
|
||||
matrix: { Icon: SiMatrix, color: '#000000', kind: 'brand' },
|
||||
signal: { Icon: SiSignal, color: '#3A76F0', kind: 'brand' },
|
||||
whatsapp: { Icon: SiWhatsapp, color: '#25D366', kind: 'brand' },
|
||||
bluebubbles: { Icon: SiApple, color: '#0BD318', kind: 'brand' },
|
||||
homeassistant: { Icon: SiHomeassistant, color: '#18BCF2', kind: 'brand' },
|
||||
email: { Icon: SiGmail, color: '#EA4335', kind: 'brand' },
|
||||
sms: { Icon: MessageSquareText, color: '#F43F5E', kind: 'generic' },
|
||||
webhook: { Icon: LinkIcon, color: '#71717A', kind: 'generic' },
|
||||
api_server: { Icon: Globe, color: '#64748B', kind: 'generic' },
|
||||
weixin: { Icon: SiWechat, color: '#07C160', kind: 'brand' },
|
||||
qqbot: { Icon: SiQq, color: '#EB1923', kind: 'brand' },
|
||||
yuanbao: { Icon: SiBilibili, color: '#FB7299', kind: 'brand' }
|
||||
}
|
||||
|
||||
interface PlatformAvatarProps {
|
||||
platformId: string
|
||||
platformName: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PlatformAvatar({ className, platformId, platformName }: PlatformAvatarProps) {
|
||||
const spec = PLATFORM_ICONS[platformId]
|
||||
|
||||
const baseClass = cn(
|
||||
'inline-grid size-6 shrink-0 place-items-center rounded-md text-[length:var(--conversation-caption-font-size)] font-medium',
|
||||
className
|
||||
)
|
||||
|
||||
if (!spec) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}
|
||||
>
|
||||
{platformName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const { Icon, color } = spec
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={baseClass}
|
||||
style={{
|
||||
// 16% tint of the brand color so the glyph reads against any surface
|
||||
// without the avatar dominating the row.
|
||||
backgroundColor: `color-mix(in srgb, ${color} 16%, transparent)`,
|
||||
color
|
||||
}}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -28,16 +28,10 @@ export function PageSearchShell({
|
||||
{...props}
|
||||
className={cn('flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)', className)}
|
||||
>
|
||||
{/*
|
||||
This header sits in the titlebar row, so it overlaps the OS window-drag
|
||||
region painted by the shell. Without `-webkit-app-region: no-drag` on
|
||||
the search row, mousedown on the input gets intercepted as a window-
|
||||
drag start and the input never receives focus (visible as "I can't
|
||||
click the search box" on the messaging/cron/etc pages).
|
||||
*/}
|
||||
<div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5 [-webkit-app-region:no-drag]">
|
||||
<div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5">
|
||||
{/* Reserve the top-right titlebar tools + native window-controls
|
||||
footprint so the full-width search input never slides under them. */}
|
||||
footprint so the full-width search input never slides under them
|
||||
(this header sits in the titlebar row at the window top). */}
|
||||
<div
|
||||
style={{
|
||||
paddingRight:
|
||||
|
||||
@@ -11,8 +11,6 @@ const ROW_HEIGHT = 22
|
||||
const INDENT = 10
|
||||
|
||||
interface ProjectTreeProps {
|
||||
collapseNonce: number
|
||||
cwd: string
|
||||
data: TreeNode[]
|
||||
onActivateFile: (path: string) => void
|
||||
onActivateFolder: (path: string) => void
|
||||
@@ -23,8 +21,6 @@ interface ProjectTreeProps {
|
||||
}
|
||||
|
||||
export function ProjectTree({
|
||||
collapseNonce,
|
||||
cwd,
|
||||
data,
|
||||
onActivateFile,
|
||||
onActivateFolder,
|
||||
@@ -67,7 +63,7 @@ export function ProjectTree({
|
||||
|
||||
onNodeOpenChange(id, node.isOpen)
|
||||
|
||||
if (node.isOpen && node.data?.isDirectory && node.data.children === undefined) {
|
||||
if (node.isOpen && node.data.children === undefined) {
|
||||
void onLoadChildren(id)
|
||||
}
|
||||
},
|
||||
@@ -76,7 +72,7 @@ export function ProjectTree({
|
||||
|
||||
const handleActivate = useCallback(
|
||||
(node: NodeApi<TreeNode>) => {
|
||||
if (node.data && !node.data.isDirectory) {
|
||||
if (!node.data.isDirectory) {
|
||||
onPreviewFile?.(node.data.id)
|
||||
}
|
||||
},
|
||||
@@ -87,7 +83,7 @@ export function ProjectTree({
|
||||
<div className="min-h-0 flex-1 overflow-hidden" ref={containerRef}>
|
||||
{size.height > 0 && size.width > 0 ? (
|
||||
<Tree<TreeNode>
|
||||
childrenAccessor={node => (node?.isDirectory ? (node.children ?? []) : null)}
|
||||
childrenAccessor={node => (node.isDirectory ? (node.children ?? []) : null)}
|
||||
data={data}
|
||||
disableDrag
|
||||
disableDrop
|
||||
@@ -95,7 +91,6 @@ export function ProjectTree({
|
||||
height={size.height}
|
||||
indent={INDENT}
|
||||
initialOpenState={openState}
|
||||
key={`${cwd}:${collapseNonce}`}
|
||||
onActivate={handleActivate}
|
||||
onToggle={handleToggle}
|
||||
openByDefault={false}
|
||||
@@ -140,10 +135,6 @@ function ProjectTreeRow({
|
||||
onAttachFolder: (path: string) => void
|
||||
onPreviewFile?: (path: string) => void
|
||||
}) {
|
||||
if (!node.data) {
|
||||
return <div style={style} />
|
||||
}
|
||||
|
||||
const isFolder = node.data.isDirectory
|
||||
const isPlaceholder = node.data.id.endsWith('::__loading__')
|
||||
|
||||
|
||||
@@ -47,20 +47,16 @@ function placeholderChild(parentId: string): TreeNode {
|
||||
}
|
||||
|
||||
export interface UseProjectTreeResult {
|
||||
/** Bumped by collapseAll so callers can remount the tree fully collapsed. */
|
||||
collapseNonce: number
|
||||
data: TreeNode[]
|
||||
openState: Record<string, boolean>
|
||||
rootError: string | null
|
||||
rootLoading: boolean
|
||||
collapseAll: () => void
|
||||
loadChildren: (id: string) => Promise<void>
|
||||
refreshRoot: () => Promise<void>
|
||||
setNodeOpen: (id: string, open: boolean) => void
|
||||
}
|
||||
|
||||
interface ProjectTreeState {
|
||||
collapseNonce: number
|
||||
cwd: string
|
||||
data: TreeNode[]
|
||||
loaded: boolean
|
||||
@@ -71,7 +67,6 @@ interface ProjectTreeState {
|
||||
}
|
||||
|
||||
const initialState: ProjectTreeState = {
|
||||
collapseNonce: 0,
|
||||
cwd: '',
|
||||
data: [],
|
||||
loaded: false,
|
||||
@@ -117,7 +112,6 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
|
||||
}
|
||||
|
||||
$projectTree.set({
|
||||
collapseNonce: current.collapseNonce,
|
||||
cwd,
|
||||
data: [],
|
||||
loaded: false,
|
||||
@@ -180,19 +174,6 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
[cwd]
|
||||
)
|
||||
|
||||
// Clears the recorded open state and bumps the nonce; the tree is keyed on
|
||||
// the nonce so it remounts with everything collapsed (loaded children stay
|
||||
// cached in `data`, just hidden).
|
||||
const collapseAll = useCallback(() => {
|
||||
setProjectTree(current => {
|
||||
if (current.cwd !== cwd) {
|
||||
return current
|
||||
}
|
||||
|
||||
return { ...current, collapseNonce: current.collapseNonce + 1, openState: {} }
|
||||
})
|
||||
}, [cwd])
|
||||
|
||||
const loadChildren = useCallback(
|
||||
async (id: string) => {
|
||||
if (!cwd || inflight.has(id)) {
|
||||
@@ -241,8 +222,6 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
collapseAll,
|
||||
collapseNonce: state.cwd === cwd ? state.collapseNonce : 0,
|
||||
data: state.cwd === cwd ? state.data : [],
|
||||
loadChildren,
|
||||
openState: state.cwd === cwd ? state.openState : {},
|
||||
@@ -252,12 +231,10 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
setNodeOpen
|
||||
}),
|
||||
[
|
||||
collapseAll,
|
||||
cwd,
|
||||
loadChildren,
|
||||
refreshRoot,
|
||||
setNodeOpen,
|
||||
state.collapseNonce,
|
||||
state.cwd,
|
||||
state.data,
|
||||
state.openState,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { ErrorBoundary } from '@/components/error-boundary'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
@@ -53,10 +52,7 @@ export function RightSidebarPane({
|
||||
.pop() ?? currentCwd)
|
||||
: 'No folder selected'
|
||||
|
||||
const { collapseAll, collapseNonce, data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } =
|
||||
useProjectTree(currentCwd)
|
||||
|
||||
const canCollapse = Object.values(openState).some(Boolean)
|
||||
const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd)
|
||||
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
|
||||
|
||||
const chooseFolder = async () => {
|
||||
@@ -101,8 +97,6 @@ export function RightSidebarPane({
|
||||
<TerminalSlot />
|
||||
) : (
|
||||
<FilesystemTab
|
||||
canCollapse={canCollapse}
|
||||
collapseNonce={collapseNonce}
|
||||
cwd={currentCwd}
|
||||
cwdName={cwdName}
|
||||
data={data}
|
||||
@@ -112,7 +106,6 @@ export function RightSidebarPane({
|
||||
onActivateFile={onActivateFile}
|
||||
onActivateFolder={onActivateFolder}
|
||||
onChangeFolder={chooseFolder}
|
||||
onCollapseAll={collapseAll}
|
||||
onLoadChildren={loadChildren}
|
||||
onNodeOpenChange={setNodeOpen}
|
||||
onPreviewFile={previewFile}
|
||||
@@ -167,22 +160,13 @@ function RightSidebarChrome({
|
||||
}
|
||||
|
||||
interface FilesystemTabProps extends FileTreeBodyProps {
|
||||
canCollapse: boolean
|
||||
cwdName: string
|
||||
hasCwd: boolean
|
||||
onChangeFolder: () => Promise<void> | void
|
||||
onCollapseAll: () => void
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
const HEADER_ACTION_CLASS =
|
||||
'size-6 shrink-0 rounded-md text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-2 focus-visible:ring-sidebar-ring'
|
||||
|
||||
const HEADER_ACTION_REVEAL_CLASS = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
|
||||
|
||||
function FilesystemTab({
|
||||
canCollapse,
|
||||
collapseNonce,
|
||||
cwd,
|
||||
cwdName,
|
||||
data,
|
||||
@@ -192,7 +176,6 @@ function FilesystemTab({
|
||||
onActivateFile,
|
||||
onActivateFolder,
|
||||
onChangeFolder,
|
||||
onCollapseAll,
|
||||
onLoadChildren,
|
||||
onNodeOpenChange,
|
||||
onPreviewFile,
|
||||
@@ -205,35 +188,14 @@ function FilesystemTab({
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => void onChangeFolder()}
|
||||
title={hasCwd ? `${cwd} — click to change folder` : 'Open a folder'}
|
||||
title={hasCwd ? cwd : 'No folder selected'}
|
||||
type="button"
|
||||
>
|
||||
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
|
||||
</button>
|
||||
<Button
|
||||
aria-label="Open folder"
|
||||
className={HEADER_ACTION_CLASS}
|
||||
onClick={() => void onChangeFolder()}
|
||||
size="icon"
|
||||
title={hasCwd ? 'Open a different folder' : 'Open a folder'}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="folder-opened" size="0.8125rem" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Collapse all folders"
|
||||
className={HEADER_ACTION_REVEAL_CLASS}
|
||||
disabled={!hasCwd || !canCollapse}
|
||||
onClick={onCollapseAll}
|
||||
size="icon"
|
||||
title="Collapse all folders"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="collapse-all" size="0.8125rem" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Refresh tree"
|
||||
className={HEADER_ACTION_REVEAL_CLASS}
|
||||
className="pointer-events-none size-6 shrink-0 rounded-md text-sidebar-foreground/70 opacity-0 transition-opacity hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-sidebar-ring group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100"
|
||||
disabled={!hasCwd || loading}
|
||||
onClick={onRefresh}
|
||||
size="icon"
|
||||
@@ -244,7 +206,6 @@ function FilesystemTab({
|
||||
</Button>
|
||||
</RightSidebarSectionHeader>
|
||||
<FileTreeBody
|
||||
collapseNonce={collapseNonce}
|
||||
cwd={cwd}
|
||||
data={data}
|
||||
error={error}
|
||||
@@ -265,7 +226,6 @@ export function RightSidebarSectionHeader({ children }: { children: ReactNode })
|
||||
}
|
||||
|
||||
interface FileTreeBodyProps {
|
||||
collapseNonce: number
|
||||
cwd: string
|
||||
data: ReturnType<typeof useProjectTree>['data']
|
||||
error: string | null
|
||||
@@ -279,7 +239,6 @@ interface FileTreeBodyProps {
|
||||
}
|
||||
|
||||
function FileTreeBody({
|
||||
collapseNonce,
|
||||
cwd,
|
||||
data,
|
||||
error,
|
||||
@@ -308,34 +267,15 @@ function FileTreeBody({
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={({ reset }) => (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<EmptyState body="The file tree hit an error rendering this folder." title="Tree error" />
|
||||
<button
|
||||
className="text-[0.68rem] font-medium text-muted-foreground transition hover:text-foreground"
|
||||
onClick={reset}
|
||||
type="button"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
key={cwd}
|
||||
label="file-tree"
|
||||
>
|
||||
<ProjectTree
|
||||
collapseNonce={collapseNonce}
|
||||
cwd={cwd}
|
||||
data={data}
|
||||
onActivateFile={onActivateFile}
|
||||
onActivateFolder={onActivateFolder}
|
||||
onLoadChildren={onLoadChildren}
|
||||
onNodeOpenChange={onNodeOpenChange}
|
||||
onPreviewFile={onPreviewFile}
|
||||
openState={openState}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
<ProjectTree
|
||||
data={data}
|
||||
onActivateFile={onActivateFile}
|
||||
onActivateFolder={onActivateFolder}
|
||||
onLoadChildren={onLoadChildren}
|
||||
onNodeOpenChange={onNodeOpenChange}
|
||||
onPreviewFile={onPreviewFile}
|
||||
openState={openState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -50,23 +50,16 @@ export function useCwdActions({
|
||||
}
|
||||
|
||||
if (!activeSessionId) {
|
||||
setCurrentCwd(trimmed)
|
||||
|
||||
try {
|
||||
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', {
|
||||
key: 'project',
|
||||
cwd: trimmed
|
||||
})
|
||||
|
||||
// Adopt the backend's normalized cwd so the persisted workspace and
|
||||
// branch stay consistent with what the agent will use.
|
||||
if (info.cwd) {
|
||||
setCurrentCwd(info.cwd)
|
||||
}
|
||||
|
||||
setCurrentCwd(info.cwd || trimmed)
|
||||
setCurrentBranch(info.branch || '')
|
||||
} catch {
|
||||
setCurrentBranch('')
|
||||
} catch (err) {
|
||||
notifyError(err, 'Working directory change failed')
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
$currentCwd,
|
||||
$messages,
|
||||
$sessions,
|
||||
getRememberedWorkspaceCwd,
|
||||
setActiveSessionId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
@@ -33,7 +32,6 @@ import {
|
||||
setMessages,
|
||||
setSelectedStoredSessionId,
|
||||
setSessions,
|
||||
setSessionsTotal,
|
||||
setSessionStartedAt,
|
||||
setTurnStartedAt
|
||||
} from '@/store/session'
|
||||
@@ -293,8 +291,7 @@ export function useSessionActions({
|
||||
})
|
||||
setSessionStartedAt(null)
|
||||
setTurnStartedAt(null)
|
||||
// New chats inherit the current workspace.
|
||||
setCurrentCwd(getRememberedWorkspaceCwd())
|
||||
setCurrentCwd('')
|
||||
setCurrentBranch('')
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
@@ -311,7 +308,7 @@ export function useSessionActions({
|
||||
creatingSessionRef.current = true
|
||||
|
||||
try {
|
||||
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
|
||||
const cwd = $currentCwd.get().trim()
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
|
||||
const stored = created.stored_session_id ?? null
|
||||
|
||||
@@ -690,9 +687,6 @@ export function useSessionActions({
|
||||
const previousPinned = $pinnedSessionIds.get()
|
||||
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
|
||||
// doesn't keep claiming the removed row is still on the server.
|
||||
setSessionsTotal(prev => Math.max(0, prev - 1))
|
||||
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
|
||||
|
||||
// Tear down before awaiting so the route effect can't resume the
|
||||
@@ -715,7 +709,6 @@ export function useSessionActions({
|
||||
} catch (err) {
|
||||
if (removed) {
|
||||
setSessions(prev => [removed, ...prev])
|
||||
setSessionsTotal(prev => prev + 1)
|
||||
}
|
||||
|
||||
$pinnedSessionIds.set(previousPinned)
|
||||
@@ -768,10 +761,6 @@ export function useSessionActions({
|
||||
|
||||
// Soft-hide: drop from the sidebar immediately, keep the data.
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
// Archived sessions are hidden by the listSessions(min_messages=1) query
|
||||
// on the next refresh, so they count as "removed" for the load-more
|
||||
// footer math.
|
||||
setSessionsTotal(prev => Math.max(0, prev - 1))
|
||||
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
|
||||
|
||||
if (wasSelected) {
|
||||
@@ -784,7 +773,6 @@ export function useSessionActions({
|
||||
} catch (err) {
|
||||
if (archived) {
|
||||
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
|
||||
setSessionsTotal(prev => prev + 1)
|
||||
}
|
||||
|
||||
$pinnedSessionIds.set(previousPinned)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $busy, $messages, noteSessionActivity, setSessionWorking } from '@/store/session'
|
||||
import { $busy, $messages, setSessionWorking } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
@@ -140,13 +140,6 @@ export function useSessionStateCache({
|
||||
}
|
||||
|
||||
setSessionWorking(next.storedSessionId, next.busy)
|
||||
// Every state update is effectively a "still alive" heartbeat for
|
||||
// streaming events. The session-store watchdog uses this to keep the
|
||||
// working flag alive during long-running turns and to clear it once
|
||||
// the stream goes silent.
|
||||
if (next.busy) {
|
||||
noteSessionActivity(next.storedSessionId)
|
||||
}
|
||||
syncSessionStateToView(sessionId, next)
|
||||
|
||||
return next
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
$updateChecking,
|
||||
$updateStatus,
|
||||
checkUpdates,
|
||||
openUpdatesWindow,
|
||||
refreshDesktopVersion
|
||||
openUpdatesWindow
|
||||
} from '@/store/updates'
|
||||
|
||||
import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||
@@ -47,14 +46,6 @@ export function AboutSettings() {
|
||||
const checking = useStore($updateChecking)
|
||||
const [justChecked, setJustChecked] = useState(false)
|
||||
|
||||
// The version atom is loaded once at app boot, which makes About show a
|
||||
// stale number after a self-update (the running binary is current, the
|
||||
// displayed string is not). Re-read on mount so opening About always
|
||||
// reflects the running build.
|
||||
useEffect(() => {
|
||||
void refreshDesktopVersion()
|
||||
}, [])
|
||||
|
||||
const behind = status?.behind ?? 0
|
||||
const supported = status?.supported !== false
|
||||
const applying = apply.applying || apply.stage === 'restart'
|
||||
|
||||
@@ -18,7 +18,6 @@ import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
|
||||
import { enumOptionsFor, getNested, includesQuery, prettyName, setNested } from './helpers'
|
||||
import { ModelSettings } from './model-settings'
|
||||
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
|
||||
import type { SearchProps } from './types'
|
||||
|
||||
@@ -168,12 +167,10 @@ export function ConfigSettings({
|
||||
query,
|
||||
activeSectionId,
|
||||
onConfigSaved,
|
||||
onMainModelChanged,
|
||||
importInputRef
|
||||
}: SearchProps & {
|
||||
activeSectionId: string
|
||||
onConfigSaved?: () => void
|
||||
onMainModelChanged?: (provider: string, model: string) => void
|
||||
importInputRef: React.RefObject<HTMLInputElement | null>
|
||||
}) {
|
||||
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
||||
@@ -325,11 +322,6 @@ export function ConfigSettings({
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
{activeSectionId === 'model' && !query.trim() && (
|
||||
<div className="mb-6">
|
||||
<ModelSettings onMainModelChanged={onMainModelChanged} />
|
||||
</div>
|
||||
)}
|
||||
{query.trim() && (
|
||||
<div className="mb-4 text-xs text-muted-foreground">
|
||||
{fields.length} result{fields.length === 1 ? '' : 's'}
|
||||
|
||||
@@ -141,7 +141,13 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
'delegation.max_iterations': 'Subagent Turn Limit',
|
||||
'delegation.max_concurrent_children': 'Parallel Subagents',
|
||||
'delegation.child_timeout_seconds': 'Subagent Timeout',
|
||||
'delegation.reasoning_effort': 'Subagent Reasoning Effort'
|
||||
'delegation.reasoning_effort': 'Subagent Reasoning Effort',
|
||||
'auxiliary.vision.provider': 'Vision Provider',
|
||||
'auxiliary.vision.model': 'Vision Model',
|
||||
'auxiliary.compression.provider': 'Compression Provider',
|
||||
'auxiliary.compression.model': 'Compression Model',
|
||||
'auxiliary.title_generation.provider': 'Title Provider',
|
||||
'auxiliary.title_generation.model': 'Title Model'
|
||||
}
|
||||
|
||||
export const FIELD_DESCRIPTIONS: Record<string, string> = {
|
||||
@@ -177,7 +183,7 @@ export const SECTIONS: DesktopConfigSection[] = [
|
||||
id: 'model',
|
||||
label: 'Model',
|
||||
icon: Sparkles,
|
||||
keys: ['model_context_length', 'fallback_providers']
|
||||
keys: ['model', 'model_context_length', 'fallback_providers']
|
||||
},
|
||||
{
|
||||
id: 'chat',
|
||||
@@ -281,7 +287,13 @@ export const SECTIONS: DesktopConfigSection[] = [
|
||||
'delegation.max_iterations',
|
||||
'delegation.max_concurrent_children',
|
||||
'delegation.child_timeout_seconds',
|
||||
'delegation.reasoning_effort'
|
||||
'delegation.reasoning_effort',
|
||||
'auxiliary.vision.provider',
|
||||
'auxiliary.vision.model',
|
||||
'auxiliary.compression.provider',
|
||||
'auxiliary.compression.model',
|
||||
'auxiliary.title_generation.provider',
|
||||
'auxiliary.title_generation.model'
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -299,11 +311,15 @@ export const MODE_OPTIONS: ModeOption[] = [
|
||||
{ id: 'system', label: 'System', description: 'Follow OS appearance', icon: Monitor }
|
||||
]
|
||||
|
||||
export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string> = {
|
||||
export const SEARCH_PLACEHOLDER: Record<
|
||||
'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools',
|
||||
string
|
||||
> = {
|
||||
about: 'About Hermes Desktop',
|
||||
config: 'Search settings...',
|
||||
gateway: 'Gateway connection...',
|
||||
keys: 'Search API keys...',
|
||||
mcp: 'Search MCP servers...',
|
||||
sessions: 'Search archived sessions...'
|
||||
sessions: 'Search archived sessions...',
|
||||
tools: 'Search skills and tools...'
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, Globe, Info, KeyRound, Wrench } from '@/lib/icons'
|
||||
import { Archive, Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
@@ -20,6 +20,7 @@ import { GatewaySettings } from './gateway-settings'
|
||||
import { KeysSettings } from './keys-settings'
|
||||
import { McpSettings } from './mcp-settings'
|
||||
import { SessionsSettings } from './sessions-settings'
|
||||
import { ToolsSettings } from './tools-settings'
|
||||
import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
|
||||
|
||||
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
@@ -28,10 +29,11 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
'keys',
|
||||
'mcp',
|
||||
'sessions',
|
||||
'tools',
|
||||
'about'
|
||||
]
|
||||
|
||||
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||
export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPageProps) {
|
||||
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
|
||||
|
||||
const [queries, setQueries] = useState<Record<SettingsQueryKey, string>>({
|
||||
@@ -40,7 +42,8 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
gateway: '',
|
||||
keys: '',
|
||||
mcp: '',
|
||||
sessions: ''
|
||||
sessions: '',
|
||||
tools: ''
|
||||
})
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -137,6 +140,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
label="API Keys"
|
||||
onClick={() => setActiveView('keys')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={activeView === 'tools'}
|
||||
icon={Package}
|
||||
label="Skills & Tools"
|
||||
onClick={() => setActiveView('tools')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={activeView === 'mcp'}
|
||||
icon={Wrench}
|
||||
@@ -194,15 +203,16 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
activeSectionId={activeView.slice('config:'.length)}
|
||||
importInputRef={importInputRef}
|
||||
onConfigSaved={onConfigSaved}
|
||||
onMainModelChanged={onMainModelChanged}
|
||||
query={queries.config}
|
||||
/>
|
||||
) : activeView === 'keys' ? (
|
||||
<KeysSettings query={queries.keys} />
|
||||
) : activeView === 'mcp' ? (
|
||||
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} query={queries.mcp} />
|
||||
) : (
|
||||
) : activeView === 'sessions' ? (
|
||||
<SessionsSettings query={queries.sessions} />
|
||||
) : (
|
||||
<ToolsSettings query={queries.tools} />
|
||||
)}
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import type { EnvPatch, EnvRowProps, ProviderGroup, SearchProps } from './types'
|
||||
|
||||
const SHOW_ADVANCED_STORAGE_KEY = 'desktop.settings.keys.show_advanced'
|
||||
|
||||
interface EnvActionsProps {
|
||||
varKey: string
|
||||
info: EnvVarInfo
|
||||
@@ -184,11 +186,8 @@ function EnvProviderGroup({
|
||||
group: ProviderGroup
|
||||
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const setCount = group.entries.filter(([, info]) => info.is_set).length
|
||||
// Default-expand providers that already have at least one key set; the
|
||||
// user is much more likely to be coming back to edit those than to start
|
||||
// configuring a fresh provider from scratch.
|
||||
const [expanded, setExpanded] = useState(setCount > 0)
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl bg-background/60">
|
||||
@@ -223,17 +222,27 @@ export function KeysSettings({ query }: SearchProps) {
|
||||
const [revealed, setRevealed] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
|
||||
// We used to hide ~80% of rows behind a global "Show advanced" toggle, but
|
||||
// everything in this view is configuration-level — "advanced" was a poor
|
||||
// distinction. The full list is rendered now and provider groups
|
||||
// default-collapsed-unless-set keep the surface manageable.
|
||||
const [showAdvanced, setShowAdvanced] = useState<boolean>(() => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(SHOW_ADVANCED_STORAGE_KEY)
|
||||
|
||||
if (stored === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return stored === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
|
||||
window.localStorage.setItem(SHOW_ADVANCED_STORAGE_KEY, showAdvanced ? 'true' : 'false')
|
||||
} catch {
|
||||
// Ignore — old key cleanup is best-effort.
|
||||
// Ignore persistence failures and keep in-memory preference.
|
||||
}
|
||||
}, [])
|
||||
}, [showAdvanced])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -253,21 +262,28 @@ export function KeysSettings({ query }: SearchProps) {
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
const filterEnv = useCallback((info: EnvVarInfo, key: string, q: string, cat: string, extra?: string) => {
|
||||
if (asText(info.category) !== cat) {
|
||||
return false
|
||||
}
|
||||
const filterEnv = useCallback(
|
||||
(info: EnvVarInfo, key: string, q: string, cat: string, extra?: string) => {
|
||||
if (asText(info.category) !== cat) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!q) {
|
||||
return true
|
||||
}
|
||||
if (!showAdvanced && Boolean(info.advanced)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
key.toLowerCase().includes(q) ||
|
||||
includesQuery(info.description, q) ||
|
||||
Boolean(extra && extra.toLowerCase().includes(q))
|
||||
)
|
||||
}, [])
|
||||
if (!q) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
key.toLowerCase().includes(q) ||
|
||||
includesQuery(info.description, q) ||
|
||||
Boolean(extra && extra.toLowerCase().includes(q))
|
||||
)
|
||||
},
|
||||
[showAdvanced]
|
||||
)
|
||||
|
||||
const providerGroups = useMemo<ProviderGroup[]>(() => {
|
||||
if (!vars) {
|
||||
@@ -399,6 +415,12 @@ export function KeysSettings({ query }: SearchProps) {
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="mb-4 flex justify-end">
|
||||
<Button onClick={() => setShowAdvanced(s => !s)} size="sm" variant="outline">
|
||||
{showAdvanced ? 'Hide advanced' : 'Show advanced'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<SectionHeading
|
||||
icon={Zap}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getGlobalModelInfo = vi.fn()
|
||||
const getGlobalModelOptions = vi.fn()
|
||||
const getAuxiliaryModels = vi.fn()
|
||||
const setModelAssignment = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getGlobalModelInfo: () => getGlobalModelInfo(),
|
||||
getGlobalModelOptions: () => getGlobalModelOptions(),
|
||||
getAuxiliaryModels: () => getAuxiliaryModels(),
|
||||
setModelAssignment: (body: unknown) => setModelAssignment(body)
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
|
||||
getGlobalModelOptions.mockResolvedValue({
|
||||
providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'] }]
|
||||
})
|
||||
getAuxiliaryModels.mockResolvedValue({
|
||||
main: { provider: 'nous', model: 'hermes-4' },
|
||||
tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }]
|
||||
})
|
||||
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
async function renderModelSettings() {
|
||||
const { ModelSettings } = await import('./model-settings')
|
||||
|
||||
return render(<ModelSettings />)
|
||||
}
|
||||
|
||||
describe('ModelSettings', () => {
|
||||
it('loads and shows the current main model', async () => {
|
||||
await renderModelSettings()
|
||||
|
||||
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalled())
|
||||
expect(screen.getByText('nous / hermes-4')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the auxiliary task rows', async () => {
|
||||
await renderModelSettings()
|
||||
|
||||
expect(await screen.findByText('Vision')).toBeTruthy()
|
||||
expect(screen.getAllByText('auto · use main model').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('assigns an auxiliary task to the main model via setModelAssignment', async () => {
|
||||
await renderModelSettings()
|
||||
|
||||
// One "Set to main" button per task slot; the first is Vision.
|
||||
const setToMainButtons = await screen.findAllByRole('button', { name: 'Set to main' })
|
||||
fireEvent.click(setToMainButtons[0])
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setModelAssignment).toHaveBeenCalledWith({
|
||||
model: 'hermes-4',
|
||||
provider: 'nous',
|
||||
scope: 'auxiliary',
|
||||
task: 'vision'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,358 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes'
|
||||
import type { AuxiliaryModelsResponse, ModelOptionProvider } from '@/hermes'
|
||||
import { Cpu, Loader2, Sparkles } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
|
||||
|
||||
// Mirrors `_AUX_TASK_SLOTS` in hermes_cli/web_server.py. Friendly labels and
|
||||
// hints make the assignments readable; raw task keys (vision, mcp, …) are
|
||||
// opaque to most users.
|
||||
interface AuxTaskMeta {
|
||||
hint: string
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const AUX_TASKS: readonly AuxTaskMeta[] = [
|
||||
{ key: 'vision', label: 'Vision', hint: 'Image analysis' },
|
||||
{ key: 'web_extract', label: 'Web extract', hint: 'Page summarization' },
|
||||
{ key: 'compression', label: 'Compression', hint: 'Context compaction' },
|
||||
{ key: 'session_search', label: 'Session search', hint: 'Recall queries' },
|
||||
{ key: 'skills_hub', label: 'Skills hub', hint: 'Skill search' },
|
||||
{ key: 'approval', label: 'Approval', hint: 'Smart auto-approve' },
|
||||
{ key: 'mcp', label: 'MCP', hint: 'MCP tool routing' },
|
||||
{ key: 'title_generation', label: 'Title gen', hint: 'Session titles' },
|
||||
{ key: 'curator', label: 'Curator', hint: 'Skill-usage review' }
|
||||
]
|
||||
|
||||
const NO_PROVIDERS: readonly ModelOptionProvider[] = [{ name: '—', slug: '', models: [] }]
|
||||
|
||||
interface ModelSettingsProps {
|
||||
/** Notified after the main model is applied, so live UI stores can sync. */
|
||||
onMainModelChanged?: (provider: string, model: string) => void
|
||||
}
|
||||
|
||||
export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null)
|
||||
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
|
||||
const [selectedProvider, setSelectedProvider] = useState('')
|
||||
const [selectedModel, setSelectedModel] = useState('')
|
||||
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
|
||||
const [applying, setApplying] = useState(false)
|
||||
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
|
||||
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([
|
||||
getGlobalModelInfo(),
|
||||
getGlobalModelOptions(),
|
||||
getAuxiliaryModels()
|
||||
])
|
||||
|
||||
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
|
||||
setProviders(modelOptions.providers || [])
|
||||
setSelectedProvider(prev => prev || modelInfo.provider)
|
||||
setSelectedModel(prev => prev || modelInfo.model)
|
||||
setAuxiliary(auxiliaryModels)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
const providerOptions = providers.length ? providers : NO_PROVIDERS
|
||||
|
||||
const selectedProviderModels = useMemo(
|
||||
() => providers.find(provider => provider.slug === selectedProvider)?.models ?? [],
|
||||
[providers, selectedProvider]
|
||||
)
|
||||
|
||||
const auxDraftProviderModels = useMemo(
|
||||
() => providers.find(provider => provider.slug === auxDraft.provider)?.models ?? [],
|
||||
[auxDraft.provider, providers]
|
||||
)
|
||||
|
||||
const applyMainModel = useCallback(async () => {
|
||||
if (!selectedProvider || !selectedModel) {
|
||||
return
|
||||
}
|
||||
|
||||
setApplying(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await setModelAssignment({ model: selectedModel, provider: selectedProvider, scope: 'main' })
|
||||
const provider = result.provider || selectedProvider
|
||||
const model = result.model || selectedModel
|
||||
setMainModel({ provider, model })
|
||||
onMainModelChanged?.(provider, model)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
}, [onMainModelChanged, refresh, selectedModel, selectedProvider])
|
||||
|
||||
const setAuxiliaryToMain = useCallback(
|
||||
async (task: string) => {
|
||||
if (!mainModel) {
|
||||
return
|
||||
}
|
||||
|
||||
setApplying(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await setModelAssignment({ model: mainModel.model, provider: mainModel.provider, scope: 'auxiliary', task })
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
},
|
||||
[mainModel, refresh]
|
||||
)
|
||||
|
||||
const applyAuxiliaryDraft = useCallback(
|
||||
async (task: string) => {
|
||||
if (!auxDraft.provider || !auxDraft.model) {
|
||||
return
|
||||
}
|
||||
|
||||
setApplying(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await setModelAssignment({ model: auxDraft.model, provider: auxDraft.provider, scope: 'auxiliary', task })
|
||||
setEditingAuxTask(null)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
},
|
||||
[auxDraft, refresh]
|
||||
)
|
||||
|
||||
const beginAuxiliaryEdit = useCallback(
|
||||
(task: string) => {
|
||||
const current = auxiliary?.tasks.find(entry => entry.task === task)
|
||||
|
||||
const initialProvider =
|
||||
current?.provider && current.provider !== 'auto' ? current.provider : (mainModel?.provider ?? '')
|
||||
|
||||
const initialModel = current?.model || mainModel?.model || ''
|
||||
setAuxDraft({ provider: initialProvider, model: initialModel })
|
||||
setEditingAuxTask(task)
|
||||
},
|
||||
[auxiliary, mainModel]
|
||||
)
|
||||
|
||||
const resetAuxiliaryModels = useCallback(async () => {
|
||||
if (!mainModel) {
|
||||
return
|
||||
}
|
||||
|
||||
setApplying(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await setModelAssignment({
|
||||
model: mainModel.model,
|
||||
provider: mainModel.provider,
|
||||
scope: 'auxiliary',
|
||||
task: '__reset__'
|
||||
})
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
}, [mainModel, refresh])
|
||||
|
||||
if (loading && !mainModel) {
|
||||
return <LoadingState label="Loading model configuration..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<section>
|
||||
<SectionHeading
|
||||
icon={Sparkles}
|
||||
meta={mainModel ? `${mainModel.provider} / ${mainModel.model}` : undefined}
|
||||
title="Main model"
|
||||
/>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Applies to new sessions. Use the model picker in the composer to hot-swap the active chat.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select onValueChange={setSelectedProvider} value={selectedProvider}>
|
||||
<SelectTrigger className={cn('min-w-40', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder="Provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerOptions.map(provider => (
|
||||
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select onValueChange={setSelectedModel} value={selectedModel}>
|
||||
<SelectTrigger className={cn('min-w-60', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder="Model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(selectedProviderModels.length ? selectedProviderModels : []).map(model => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button disabled={!selectedProvider || !selectedModel || applying} onClick={() => void applyMainModel()} size="sm">
|
||||
{applying ? <Loader2 className="size-3.5 animate-spin" /> : <Sparkles className="size-3.5" />}
|
||||
{applying ? 'Applying...' : 'Apply'}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-2.5 flex items-center justify-between">
|
||||
<SectionHeading icon={Cpu} title="Auxiliary models" />
|
||||
<Button
|
||||
disabled={!mainModel || applying}
|
||||
onClick={() => void resetAuxiliaryModels()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Reset all to main
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Helper tasks run on the main model by default. Assign a dedicated model to any task to override.
|
||||
</p>
|
||||
<div className="divide-y divide-border/40">
|
||||
{AUX_TASKS.map(meta => {
|
||||
const current = auxiliary?.tasks.find(entry => entry.task === meta.key)
|
||||
const isAuto = !current || !current.provider || current.provider === 'auto'
|
||||
const isEditing = editingAuxTask === meta.key
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
!isEditing && (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
disabled={!mainModel || applying}
|
||||
onClick={() => void setAuxiliaryToMain(meta.key)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
Set to main
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!providers.length || applying}
|
||||
onClick={() => beginAuxiliaryEdit(meta.key)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
below={
|
||||
isEditing && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 border-t border-border/40 pt-2">
|
||||
<Select
|
||||
onValueChange={value => setAuxDraft(prev => ({ ...prev, provider: value, model: '' }))}
|
||||
value={auxDraft.provider}
|
||||
>
|
||||
<SelectTrigger className={cn('min-w-32', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder="Provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerOptions.map(provider => (
|
||||
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
onValueChange={value => setAuxDraft(prev => ({ ...prev, model: value }))}
|
||||
value={auxDraft.model}
|
||||
>
|
||||
<SelectTrigger className={cn('min-w-48', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder="Model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(auxDraftProviderModels.length ? auxDraftProviderModels : []).map(model => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
disabled={!auxDraft.provider || !auxDraft.model || applying}
|
||||
onClick={() => void applyAuxiliaryDraft(meta.key)}
|
||||
size="sm"
|
||||
>
|
||||
{applying ? 'Applying...' : 'Apply'}
|
||||
</Button>
|
||||
<Button onClick={() => setEditingAuxTask(null)} size="sm" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
description={
|
||||
<span className="font-mono text-[0.68rem]">
|
||||
{isAuto ? 'auto · use main model' : `${current.provider} · ${current.model || '(provider default)'}`}
|
||||
</span>
|
||||
}
|
||||
key={meta.key}
|
||||
title={
|
||||
<span className="flex items-baseline gap-2">
|
||||
{meta.label}
|
||||
<Pill>{meta.hint}</Pill>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
|
||||
import { Archive, ArchiveOff, Loader2, Trash2 } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { setSessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
@@ -105,8 +105,6 @@ export function SessionsSettings({ query }: SearchProps) {
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<DefaultProjectDirSetting />
|
||||
|
||||
<SectionHeading
|
||||
icon={Archive}
|
||||
meta={sessions.length ? String(sessions.length) : undefined}
|
||||
@@ -168,104 +166,3 @@ export function SessionsSettings({ query }: SearchProps) {
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
// Lets the user pin the default cwd for new sessions. Without this, packaged
|
||||
// builds on Windows used to spawn sessions in the install dir (`win-unpacked`
|
||||
// / Program Files), which buried any files Hermes wrote there.
|
||||
function DefaultProjectDirSetting() {
|
||||
const [dir, setDir] = useState<null | string>(null)
|
||||
const [fallback, setFallback] = useState<string>('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// The bridge is only present when running inside Electron. In a Vitest
|
||||
// / Storybook / non-Electron context `window.hermesDesktop` is
|
||||
// undefined, so guard the WHOLE call chain rather than chaining
|
||||
// `?.settings.getDefaultProjectDir().then(...)` (the latter would
|
||||
// short-circuit to `undefined.then(...)` and throw at runtime).
|
||||
const settings = window.hermesDesktop?.settings
|
||||
|
||||
if (!settings) {
|
||||
return
|
||||
}
|
||||
|
||||
let alive = true
|
||||
|
||||
void settings.getDefaultProjectDir().then(result => {
|
||||
if (!alive) return
|
||||
setDir(result.dir)
|
||||
setFallback(result.defaultLabel)
|
||||
})
|
||||
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const choose = useCallback(async () => {
|
||||
const settings = window.hermesDesktop?.settings
|
||||
|
||||
if (!settings) return
|
||||
|
||||
setBusy(true)
|
||||
|
||||
try {
|
||||
const picked = await settings.pickDefaultProjectDir()
|
||||
|
||||
if (picked.canceled || !picked.dir) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await settings.setDefaultProjectDir(picked.dir)
|
||||
setDir(result.dir)
|
||||
notify({ durationMs: 2_000, kind: 'success', message: 'Default project directory updated' })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not update default directory')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clear = useCallback(async () => {
|
||||
const settings = window.hermesDesktop?.settings
|
||||
|
||||
if (!settings) return
|
||||
|
||||
setBusy(true)
|
||||
|
||||
try {
|
||||
await settings.setDefaultProjectDir(null)
|
||||
setDir(null)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not clear default directory')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<SectionHeading icon={FolderOpen} title="Default project directory" />
|
||||
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
New sessions start in this folder unless you pick another. Leave it unset to use your home directory.
|
||||
</p>
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button disabled={busy} onClick={() => void choose()} size="sm" type="button" variant="outline">
|
||||
<FolderOpen className="size-3.5" />
|
||||
<span>{dir ? 'Change' : 'Choose'}</span>
|
||||
</Button>
|
||||
{dir && (
|
||||
<Button disabled={busy} onClick={() => void clear()} size="sm" type="button" variant="ghost">
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={dir || `Defaults to ${fallback || '~/hermes-projects'}.`}
|
||||
title={dir ? dir : 'Not set'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+6
-32
@@ -1,24 +1,16 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getSkills = vi.fn()
|
||||
const getToolsets = vi.fn()
|
||||
const toggleSkill = vi.fn()
|
||||
const toggleToolset = vi.fn()
|
||||
const getToolsetConfig = vi.fn()
|
||||
const selectToolsetProvider = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getSkills: () => getSkills(),
|
||||
getToolsets: () => getToolsets(),
|
||||
toggleSkill: (name: string, enabled: boolean) => toggleSkill(name, enabled),
|
||||
toggleToolset: (name: string, enabled: boolean) => toggleToolset(name, enabled),
|
||||
getToolsetConfig: (name: string) => getToolsetConfig(name),
|
||||
selectToolsetProvider: (toolset: string, provider: string) => selectToolsetProvider(toolset, provider),
|
||||
deleteEnvVar: vi.fn(),
|
||||
revealEnvVar: vi.fn(),
|
||||
setEnvVar: vi.fn()
|
||||
toggleToolset: (name: string, enabled: boolean) => toggleToolset(name, enabled)
|
||||
}))
|
||||
|
||||
// Notifications hit nanostores/timers we don't care about here.
|
||||
@@ -40,21 +32,10 @@ function toolset(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderSkills() {
|
||||
return import('./index').then(({ SkillsView }) =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/skills?tab=toolsets']}>
|
||||
<SkillsView />
|
||||
</MemoryRouter>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getSkills.mockResolvedValue([])
|
||||
getToolsets.mockResolvedValue([toolset()])
|
||||
toggleToolset.mockResolvedValue({ ok: true, name: 'web', enabled: false })
|
||||
getToolsetConfig.mockResolvedValue({ has_category: false, active_provider: null, providers: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -62,9 +43,10 @@ afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('SkillsView toolset management', () => {
|
||||
describe('ToolsSettings toolset toggle', () => {
|
||||
it('renders a switch for each toolset and toggles it off', async () => {
|
||||
await renderSkills()
|
||||
const { ToolsSettings } = await import('./tools-settings')
|
||||
render(<ToolsSettings query="" />)
|
||||
|
||||
const sw = await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
|
||||
expect(sw.getAttribute('aria-checked')).toBe('true')
|
||||
@@ -75,18 +57,10 @@ describe('SkillsView toolset management', () => {
|
||||
})
|
||||
|
||||
it('keeps the configured pill alongside the switch', async () => {
|
||||
await renderSkills()
|
||||
const { ToolsSettings } = await import('./tools-settings')
|
||||
render(<ToolsSettings query="" />)
|
||||
|
||||
await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
|
||||
expect(screen.getByText('Configured')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('expands the provider config panel when the configured pill is clicked', async () => {
|
||||
await renderSkills()
|
||||
|
||||
const configureBtn = await screen.findByRole('button', { name: 'Configure Web Search' })
|
||||
fireEvent.click(configureBtn)
|
||||
|
||||
await waitFor(() => expect(getToolsetConfig).toHaveBeenCalledWith('web'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes'
|
||||
import { Brain, Wrench } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
|
||||
|
||||
import { asText, includesQuery, prettyName, toolNames } from './helpers'
|
||||
import { ListRow, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import { ToolsetConfigPanel } from './toolset-config-panel'
|
||||
import type { SearchProps } from './types'
|
||||
|
||||
export function ToolsSettings({ query }: SearchProps) {
|
||||
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
|
||||
const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null)
|
||||
const [savingSkill, setSavingSkill] = useState<string | null>(null)
|
||||
const [savingToolset, setSavingToolset] = useState<string | null>(null)
|
||||
const [expandedToolset, setExpandedToolset] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
Promise.all([getSkills(), getToolsets()])
|
||||
.then(([s, t]) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setSkills(s)
|
||||
setToolsets(t)
|
||||
})
|
||||
.catch(err => notifyError(err, 'Capabilities failed to load'))
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
const refreshToolsets = useCallback(() => {
|
||||
getToolsets()
|
||||
.then(setToolsets)
|
||||
.catch(err => notifyError(err, 'Toolsets failed to refresh'))
|
||||
}, [])
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
if (!skills) {
|
||||
return []
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
return skills
|
||||
.filter(s => !q || includesQuery(s.name, q) || includesQuery(s.description, q) || includesQuery(s.category, q))
|
||||
.sort(
|
||||
(a, b) => asText(a.category).localeCompare(asText(b.category)) || asText(a.name).localeCompare(asText(b.name))
|
||||
)
|
||||
}, [query, skills])
|
||||
|
||||
const filteredToolsets = useMemo(() => {
|
||||
if (!toolsets) {
|
||||
return []
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
return toolsets
|
||||
.filter(t => {
|
||||
if (!q) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
includesQuery(t.name, q) ||
|
||||
includesQuery(t.label, q) ||
|
||||
includesQuery(t.description, q) ||
|
||||
toolNames(t).some(n => includesQuery(n, q))
|
||||
)
|
||||
})
|
||||
.sort((a, b) => asText(a.label || a.name).localeCompare(asText(b.label || b.name)))
|
||||
}, [query, toolsets])
|
||||
|
||||
const skillGroups = useMemo(() => {
|
||||
const groups = new Map<string, SkillInfo[]>()
|
||||
|
||||
for (const skill of filteredSkills) {
|
||||
const cat = asText(skill.category) || 'other'
|
||||
groups.set(cat, [...(groups.get(cat) ?? []), skill])
|
||||
}
|
||||
|
||||
return Array.from(groups).sort(([a], [b]) => a.localeCompare(b))
|
||||
}, [filteredSkills])
|
||||
|
||||
async function handleToggleSkill(skill: SkillInfo, enabled: boolean) {
|
||||
setSavingSkill(skill.name)
|
||||
|
||||
try {
|
||||
await toggleSkill(skill.name, enabled)
|
||||
setSkills(c => c?.map(s => (s.name === skill.name ? { ...s, enabled } : s)) ?? c)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? 'Skill enabled' : 'Skill disabled',
|
||||
message: `${skill.name} applies to new sessions.`
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to update ${skill.name}`)
|
||||
} finally {
|
||||
setSavingSkill(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleToolset(toolset: ToolsetInfo, enabled: boolean) {
|
||||
setSavingToolset(toolset.name)
|
||||
|
||||
try {
|
||||
await toggleToolset(toolset.name, enabled)
|
||||
setToolsets(c => c?.map(t => (t.name === toolset.name ? { ...t, enabled, available: enabled } : t)) ?? c)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? 'Toolset enabled' : 'Toolset disabled',
|
||||
message: `${asText(toolset.label || toolset.name)} applies to new sessions.`
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to update ${asText(toolset.label || toolset.name)}`)
|
||||
} finally {
|
||||
setSavingToolset(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!skills || !toolsets) {
|
||||
return <LoadingState label="Loading skills and toolsets..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="mb-6">
|
||||
<SectionHeading icon={Brain} meta={`${filteredSkills.filter(s => s.enabled).length} enabled`} title="Skills" />
|
||||
{skillGroups.map(([category, list]) => (
|
||||
<div className="mt-4 first:mt-0" key={category}>
|
||||
<div className="mb-1 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{prettyName(category)}
|
||||
</div>
|
||||
<div className="divide-y divide-border/40">
|
||||
{list.map(skill => (
|
||||
<ListRow
|
||||
action={
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
disabled={savingSkill === skill.name}
|
||||
onCheckedChange={c => void handleToggleSkill(skill, c)}
|
||||
/>
|
||||
}
|
||||
description={asText(skill.description)}
|
||||
key={asText(skill.name)}
|
||||
title={asText(skill.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<SectionHeading
|
||||
icon={Wrench}
|
||||
meta={`${filteredToolsets.filter(t => t.enabled).length} enabled`}
|
||||
title="Toolsets"
|
||||
/>
|
||||
<div className="divide-y divide-border/40">
|
||||
{filteredToolsets.map(toolset => {
|
||||
const tools = toolNames(toolset)
|
||||
const label = asText(toolset.label || toolset.name)
|
||||
const expanded = expandedToolset === toolset.name
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
aria-label={`Configure ${label}`}
|
||||
className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
onClick={() => setExpandedToolset(c => (c === toolset.name ? null : toolset.name))}
|
||||
type="button"
|
||||
>
|
||||
<Pill tone={toolset.configured ? 'primary' : 'muted'}>
|
||||
{toolset.configured ? 'Configured' : 'Needs keys'}
|
||||
</Pill>
|
||||
</button>
|
||||
<Switch
|
||||
aria-label={`Toggle ${label} toolset`}
|
||||
checked={toolset.enabled}
|
||||
disabled={savingToolset === toolset.name}
|
||||
onCheckedChange={c => void handleToggleToolset(toolset, c)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
below={
|
||||
<>
|
||||
{tools.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{tools.slice(0, 10).map(t => (
|
||||
<span
|
||||
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.64rem] text-muted-foreground"
|
||||
key={t}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
{tools.length > 10 && (
|
||||
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[0.64rem] text-muted-foreground">
|
||||
+{tools.length - 10} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{expanded && (
|
||||
<ToolsetConfigPanel onConfiguredChange={refreshToolsets} toolset={toolset.name} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
description={asText(toolset.description)}
|
||||
key={asText(toolset.name) || label}
|
||||
title={label}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
@@ -26,7 +26,6 @@ function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
|
||||
return {
|
||||
name: 'tts',
|
||||
has_category: true,
|
||||
active_provider: null,
|
||||
providers: [
|
||||
{
|
||||
name: 'Microsoft Edge TTS',
|
||||
@@ -34,8 +33,7 @@ function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
|
||||
tag: 'No API key needed',
|
||||
env_vars: [],
|
||||
post_setup: null,
|
||||
requires_nous_auth: false,
|
||||
is_active: false
|
||||
requires_nous_auth: false
|
||||
},
|
||||
{
|
||||
name: 'ElevenLabs',
|
||||
@@ -45,8 +43,7 @@ function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
|
||||
{ key: 'ELEVENLABS_API_KEY', prompt: 'ElevenLabs API key', url: 'https://x', default: null, is_set: false }
|
||||
],
|
||||
post_setup: null,
|
||||
requires_nous_auth: false,
|
||||
is_active: false
|
||||
requires_nous_auth: false
|
||||
}
|
||||
],
|
||||
...overrides
|
||||
@@ -102,54 +99,4 @@ describe('ToolsetConfigPanel', () => {
|
||||
|
||||
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('ELEVENLABS_API_KEY', 'sk-test-123'))
|
||||
})
|
||||
|
||||
it('expands the active provider on load, not just the first configured one', async () => {
|
||||
// ElevenLabs is the active provider per config, even though the keyless
|
||||
// Edge TTS provider sorts first and is also "configured". The panel must
|
||||
// honor is_active and expand ElevenLabs (so its API-key field renders)
|
||||
// rather than defaulting to the first keyless provider. Regression test
|
||||
// for the GUI showing the wrong provider selected after relaunch.
|
||||
getToolsetConfig.mockResolvedValue(
|
||||
config({
|
||||
active_provider: 'ElevenLabs',
|
||||
providers: [
|
||||
{
|
||||
name: 'Microsoft Edge TTS',
|
||||
badge: 'free',
|
||||
tag: 'No API key needed',
|
||||
env_vars: [],
|
||||
post_setup: null,
|
||||
requires_nous_auth: false,
|
||||
is_active: false
|
||||
},
|
||||
{
|
||||
name: 'ElevenLabs',
|
||||
badge: 'paid',
|
||||
tag: 'Most natural voices',
|
||||
env_vars: [
|
||||
{
|
||||
key: 'ELEVENLABS_API_KEY',
|
||||
prompt: 'ElevenLabs API key',
|
||||
url: 'https://x',
|
||||
default: null,
|
||||
is_set: true
|
||||
}
|
||||
],
|
||||
post_setup: null,
|
||||
requires_nous_auth: false,
|
||||
is_active: true
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
|
||||
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
|
||||
|
||||
// The active provider's env-var field only renders when it's the expanded
|
||||
// one — so finding it proves ElevenLabs (not Edge TTS) was auto-expanded.
|
||||
expect(await screen.findByText('ELEVENLABS_API_KEY')).toBeTruthy()
|
||||
// No provider selection was triggered — this is purely reflecting state.
|
||||
expect(selectToolsetProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -195,23 +195,16 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
|
||||
|
||||
const providers = useMemo(() => cfg?.providers ?? [], [cfg])
|
||||
|
||||
// Default the expanded provider to the one actually active in config
|
||||
// (`is_active` / `cfg.active_provider`, mirroring the CLI picker), then the
|
||||
// first fully-configured provider, else the first provider. Without this the
|
||||
// panel highlighted the first keyless provider (e.g. Nous Portal) even when
|
||||
// the user had already selected another (e.g. DuckDuckGo).
|
||||
// Default the expanded provider to the first one that is fully configured,
|
||||
// else the first provider.
|
||||
useEffect(() => {
|
||||
if (activeProvider || providers.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const selected =
|
||||
providers.find(p => p.is_active) ??
|
||||
(cfg?.active_provider ? providers.find(p => p.name === cfg.active_provider) : undefined) ??
|
||||
providers.find(p => providerConfigured(p, envState)) ??
|
||||
providers[0]
|
||||
setActiveProvider(selected.name)
|
||||
}, [activeProvider, providers, envState, cfg])
|
||||
const configured = providers.find(p => providerConfigured(p, envState))
|
||||
setActiveProvider((configured ?? providers[0]).name)
|
||||
}, [activeProvider, providers, envState])
|
||||
|
||||
async function handleSelect(provider: ToolProvider) {
|
||||
setActiveProvider(provider.name)
|
||||
|
||||
@@ -4,15 +4,14 @@ import type { HermesGateway } from '@/hermes'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | `config:${string}`
|
||||
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions'
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools' | `config:${string}`
|
||||
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools'
|
||||
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
|
||||
|
||||
export interface SettingsPageProps {
|
||||
gateway?: HermesGateway | null
|
||||
onClose: () => void
|
||||
onConfigSaved?: () => void
|
||||
onMainModelChanged?: (provider: string, model: string) => void
|
||||
}
|
||||
|
||||
export interface SearchProps {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { type CommandCenterSection } from '@/app/command-center'
|
||||
import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, NEW_CHAT_ROUTE } from '@/app/routes'
|
||||
|
||||
const SECTIONS = ['sessions', 'system', 'usage'] as const
|
||||
const SECTIONS = ['models', 'sessions', 'system'] as const
|
||||
const OVERLAY_VIEWS = new Set(['settings', 'command-center', 'agents'])
|
||||
|
||||
export function useOverlayRouting() {
|
||||
|
||||
@@ -54,18 +54,14 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* `overflow-x-clip` (not `overflow-x-auto`) so a wide status item — for
|
||||
example "Connecting…" on a fresh/untitled session — can't paint a
|
||||
horizontal scrollbar across the bottom of the window. Items already
|
||||
`truncate` their labels, so clipping is the right behavior. */}
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-auto">
|
||||
{leftItems
|
||||
.filter(item => !item.hidden)
|
||||
.map(item => (
|
||||
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-auto">
|
||||
{items
|
||||
.filter(item => !item.hidden)
|
||||
.map(item => (
|
||||
|
||||
@@ -13,7 +13,7 @@ export const TITLEBAR_FALLBACK_WINDOW_BUTTON_X = 24
|
||||
export const TITLEBAR_EDGE_INSET = 14
|
||||
|
||||
export const titlebarButtonClass =
|
||||
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] cursor-pointer rounded-md text-muted-foreground/85 transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground'
|
||||
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] rounded-md text-muted-foreground/85 transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground'
|
||||
|
||||
export const titlebarHeaderBaseClass =
|
||||
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]'
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
|
||||
import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes'
|
||||
import { getSkills, getToolsets, toggleSkill } from '@/hermes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
|
||||
@@ -14,7 +14,6 @@ import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers'
|
||||
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
const SKILLS_MODES = ['skills', 'toolsets'] as const
|
||||
@@ -74,8 +73,6 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [savingSkill, setSavingSkill] = useState<string | null>(null)
|
||||
const [savingToolset, setSavingToolset] = useState<string | null>(null)
|
||||
const [expandedToolset, setExpandedToolset] = useState<string | null>(null)
|
||||
|
||||
const refreshCapabilities = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
@@ -91,12 +88,6 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshToolsets = useCallback(() => {
|
||||
getToolsets()
|
||||
.then(setToolsets)
|
||||
.catch(err => notifyError(err, 'Toolsets failed to refresh'))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshCapabilities()
|
||||
}, [refreshCapabilities])
|
||||
@@ -157,26 +148,6 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleToolset(toolset: ToolsetInfo, enabled: boolean) {
|
||||
setSavingToolset(toolset.name)
|
||||
|
||||
try {
|
||||
await toggleToolset(toolset.name, enabled)
|
||||
setToolsets(current =>
|
||||
current?.map(row => (row.name === toolset.name ? { ...row, enabled, available: enabled } : row)) ?? current
|
||||
)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? 'Toolset enabled' : 'Toolset disabled',
|
||||
message: `${asText(toolset.label || toolset.name)} applies to new sessions.`
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to update ${asText(toolset.label || toolset.name)}`)
|
||||
} finally {
|
||||
setSavingToolset(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
@@ -277,30 +248,16 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
{visibleToolsets.map(toolset => {
|
||||
const tools = toolNames(toolset)
|
||||
const label = asText(toolset.label || toolset.name)
|
||||
const expanded = expandedToolset === toolset.name
|
||||
|
||||
return (
|
||||
<div className="px-0 py-2.5" key={toolset.name}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="truncate text-sm font-medium">{label}</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
aria-label={`Configure ${label}`}
|
||||
className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
onClick={() => setExpandedToolset(current => (current === toolset.name ? null : toolset.name))}
|
||||
type="button"
|
||||
>
|
||||
<StatusPill active={toolset.configured}>
|
||||
{toolset.configured ? 'Configured' : 'Needs keys'}
|
||||
</StatusPill>
|
||||
</button>
|
||||
<Switch
|
||||
aria-label={`Toggle ${label} toolset`}
|
||||
checked={toolset.enabled}
|
||||
disabled={savingToolset === toolset.name}
|
||||
onCheckedChange={checked => void handleToggleToolset(toolset, checked)}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusPill active={toolset.enabled}>{toolset.enabled ? 'Enabled' : 'Disabled'}</StatusPill>
|
||||
<StatusPill active={toolset.configured}>
|
||||
{toolset.configured ? 'Configured' : 'Needs keys'}
|
||||
</StatusPill>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
@@ -318,7 +275,6 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{expanded && <ToolsetConfigPanel onConfiguredChange={refreshToolsets} toolset={toolset.name} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { ansiColorClass, hasAnsiCodes, parseAnsi } from '@/lib/ansi'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AnsiTextProps {
|
||||
text: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Renders text with embedded ANSI SGR codes as colored / bold spans. Falls
|
||||
* back to a plain string node when no codes are present so the parser cost
|
||||
* is paid only when there's something to colorize. */
|
||||
export const AnsiText: FC<AnsiTextProps> = ({ className, text }) => {
|
||||
const segments = useMemo(() => (hasAnsiCodes(text) ? parseAnsi(text) : null), [text])
|
||||
|
||||
if (!segments) {
|
||||
return <span className={className}>{text}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
{segments.map((segment, index) => (
|
||||
<span
|
||||
className={cn(segment.bold && 'font-semibold', segment.fg && ansiColorClass(segment.fg))}
|
||||
key={`ansi-${index}`}
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -48,8 +48,7 @@ import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/co
|
||||
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
|
||||
import { extractDroppedFiles, HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
|
||||
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
|
||||
import { DirectiveContent, DirectiveText } from '@/components/assistant-ui/directive-text'
|
||||
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
|
||||
import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer'
|
||||
@@ -704,10 +703,9 @@ const UserMessage: FC<{
|
||||
</span>
|
||||
)}
|
||||
{hasBody && (
|
||||
// Render the user's text through a minimal markdown pipeline:
|
||||
// backtick `code` and ``` fenced ``` blocks, with directive chips
|
||||
// (`@file:` etc.) still resolved inside the plain-text spans.
|
||||
<UserMessageText className="wrap-anywhere" text={messageText} />
|
||||
<span className="wrap-anywhere block whitespace-pre-line">
|
||||
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -35,18 +35,7 @@ export interface ToolView {
|
||||
previewTarget?: string
|
||||
rawArgs: string
|
||||
rawResult: string
|
||||
/** Set for tools whose output naturally contains ANSI escape codes
|
||||
* (terminal/execute_code) so the renderer knows to run them through
|
||||
* the ANSI parser instead of printing them as literals. */
|
||||
rendersAnsi?: boolean
|
||||
searchHits?: SearchResultRow[]
|
||||
/** When the backend reports stderr as a separate stream (terminal /
|
||||
* execute_code), the renderer shows it as its own labeled, neutrally
|
||||
* tinted block under stdout — distinct from an error tone. */
|
||||
stderr?: string
|
||||
/** When set, the renderer uses stdout+stderr as separate sections and
|
||||
* ignores the merged `detail`. */
|
||||
stdout?: string
|
||||
status: ToolStatus
|
||||
subtitle: string
|
||||
title: string
|
||||
@@ -1013,10 +1002,6 @@ function toolDetailText(
|
||||
}
|
||||
|
||||
if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
|
||||
// Streams are split out into ToolView.stdout / ToolView.stderr by
|
||||
// buildToolView so the renderer can label them separately. The merged
|
||||
// fallback here is only used when the backend doesn't expose either
|
||||
// stream individually.
|
||||
const output = firstStringField(resultRecord, ['output', 'stdout', 'stderr'])
|
||||
|
||||
const lines = Array.isArray(resultRecord.lines)
|
||||
@@ -1224,18 +1209,6 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
|
||||
|
||||
const resultCount = status === 'error' ? null : toolResultCount(part, argsRecord, resultRecord)
|
||||
|
||||
// For shell/code tools we surface stdout and stderr as separate labeled
|
||||
// streams in the renderer. Many CLIs use stderr for informational
|
||||
// messages (npm progress, git hints), so we deliberately don't paint
|
||||
// stderr destructively even though it's tagged.
|
||||
const rendersAnsi = part.toolName === 'terminal' || part.toolName === 'execute_code'
|
||||
const stdout = rendersAnsi ? firstStringField(resultRecord, ['stdout']) : ''
|
||||
const stderrRaw = rendersAnsi ? firstStringField(resultRecord, ['stderr']) : ''
|
||||
// Only attach stderr when the backend actually returned it as its own
|
||||
// field — otherwise the merged `detail` already covers it and double-
|
||||
// rendering would duplicate output.
|
||||
const hasSplitStreams = rendersAnsi && (Boolean(stdout) || Boolean(stderrRaw))
|
||||
|
||||
return {
|
||||
countLabel: resultCount ? formatCountLabel(resultCount) : undefined,
|
||||
detail,
|
||||
@@ -1247,10 +1220,7 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
|
||||
previewTarget: toolPreviewTarget(part.toolName, argsRecord, resultRecord),
|
||||
rawArgs: prettyJson(part.args),
|
||||
rawResult: prettyJson(part.result),
|
||||
rendersAnsi: rendersAnsi || undefined,
|
||||
searchHits: searchHits?.length ? searchHits : undefined,
|
||||
stderr: hasSplitStreams ? stderrRaw || undefined : undefined,
|
||||
stdout: hasSplitStreams ? stdout || undefined : undefined,
|
||||
status,
|
||||
subtitle,
|
||||
title,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useStore } from '@nanostores/react'
|
||||
import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/shallow'
|
||||
|
||||
import { AnsiText } from '@/components/assistant-ui/ansi-text'
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { CompactMarkdown } from '@/components/chat/compact-markdown'
|
||||
@@ -345,41 +344,11 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
) : view.stdout || view.stderr ? (
|
||||
// Stdout + stderr split: render both as labeled blocks. stderr
|
||||
// is intentionally NOT painted destructive — many CLIs log
|
||||
// informational output there.
|
||||
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
|
||||
{view.detailLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{view.detailLabel}</p>}
|
||||
{view.stdout && (
|
||||
<div className="space-y-0.5">
|
||||
{view.stderr && <p className={TOOL_SECTION_LABEL_CLASS}>stdout</p>}
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
|
||||
{view.rendersAnsi ? <AnsiText text={view.stdout} /> : view.stdout}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{view.stderr && (
|
||||
<div className={cn('space-y-0.5', view.stdout && 'mt-1.5')}>
|
||||
<p className={TOOL_SECTION_LABEL_CLASS}>stderr</p>
|
||||
<pre
|
||||
className={cn(
|
||||
TOOL_SECTION_PRE_CLASS,
|
||||
'whitespace-pre-wrap wrap-anywhere text-(--ui-text-tertiary)'
|
||||
)}
|
||||
>
|
||||
{view.rendersAnsi ? <AnsiText text={view.stderr} /> : view.stderr}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
|
||||
{view.detailLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{view.detailLabel}</p>}
|
||||
{renderDetailAsCode ? (
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
|
||||
{view.rendersAnsi ? <AnsiText text={view.detail} /> : view.detail}
|
||||
</pre>
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>{view.detail}</pre>
|
||||
) : (
|
||||
<CompactMarkdown className={cn(TOOL_SECTION_SURFACE_CLASS, 'wrap-anywhere')} text={view.detail} />
|
||||
)}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
|
||||
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// User messages should render the bare-minimum of markdown: backtick `code`
|
||||
// spans and ``` fenced blocks. We deliberately don't pull in the full
|
||||
// assistant Markdown pipeline (Streamdown + KaTeX + syntax highlighter)
|
||||
// because user input rarely contains structured docs and the heavy pipeline
|
||||
// adds a lot of runtime cost per bubble.
|
||||
//
|
||||
// Directive chips (`@file:`, `@image:`, ...) still resolve via DirectiveContent
|
||||
// inside the plain-text segments.
|
||||
|
||||
interface FenceSegment {
|
||||
kind: 'fence'
|
||||
code: string
|
||||
lang: string | null
|
||||
}
|
||||
|
||||
interface InlineSegment {
|
||||
kind: 'inline'
|
||||
text: string
|
||||
}
|
||||
|
||||
interface InlineCodeSegment {
|
||||
kind: 'inline-code'
|
||||
code: string
|
||||
}
|
||||
|
||||
interface InlineTextSegment {
|
||||
kind: 'inline-text'
|
||||
text: string
|
||||
}
|
||||
|
||||
type TopSegment = FenceSegment | InlineSegment
|
||||
type InlineNode = InlineCodeSegment | InlineTextSegment
|
||||
|
||||
const FENCE_RE = /```([^\n`]*)\n([\s\S]*?)```/g
|
||||
|
||||
// Greedy backtick run length so ``code with `backticks` inside`` works.
|
||||
const INLINE_CODE_RE = /(`+)([^`\n][\s\S]*?)\1/g
|
||||
|
||||
function splitFences(text: string): TopSegment[] {
|
||||
const segments: TopSegment[] = []
|
||||
let cursor = 0
|
||||
|
||||
for (const match of text.matchAll(FENCE_RE)) {
|
||||
const start = match.index ?? 0
|
||||
|
||||
if (start > cursor) {
|
||||
segments.push({ kind: 'inline', text: text.slice(cursor, start) })
|
||||
}
|
||||
|
||||
segments.push({
|
||||
kind: 'fence',
|
||||
lang: (match[1] || '').trim() || null,
|
||||
code: match[2] ?? ''
|
||||
})
|
||||
cursor = start + match[0].length
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
segments.push({ kind: 'inline', text: text.slice(cursor) })
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
function splitInlineCode(text: string): InlineNode[] {
|
||||
const nodes: InlineNode[] = []
|
||||
let cursor = 0
|
||||
|
||||
for (const match of text.matchAll(INLINE_CODE_RE)) {
|
||||
const start = match.index ?? 0
|
||||
|
||||
if (start > cursor) {
|
||||
nodes.push({ kind: 'inline-text', text: text.slice(cursor, start) })
|
||||
}
|
||||
|
||||
nodes.push({ kind: 'inline-code', code: match[2] })
|
||||
cursor = start + match[0].length
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
nodes.push({ kind: 'inline-text', text: text.slice(cursor) })
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
interface UserMessageTextProps {
|
||||
text: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const UserMessageText: FC<UserMessageTextProps> = ({ className, text }) => {
|
||||
const top = useMemo(() => splitFences(text), [text])
|
||||
|
||||
return (
|
||||
<span className={cn('block', className)} data-slot="aui_user-message-text">
|
||||
{top.map((segment, segmentIndex) => {
|
||||
if (segment.kind === 'fence') {
|
||||
return (
|
||||
<pre
|
||||
className="my-1.5 max-w-full overflow-x-auto rounded-md border border-border/45 bg-[color-mix(in_srgb,currentColor_5%,transparent)] px-2.5 py-2 font-mono text-[0.86em] leading-snug"
|
||||
data-slot="aui_user-fence"
|
||||
key={`fence-${segmentIndex}`}
|
||||
>
|
||||
<code className="block whitespace-pre">{segment.code}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={`inline-${segmentIndex}`}>
|
||||
<InlineSegmentView text={segment.text} />
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const InlineSegmentView: FC<{ text: string }> = ({ text }) => {
|
||||
const nodes = useMemo(() => splitInlineCode(text), [text])
|
||||
|
||||
return (
|
||||
<span className="wrap-anywhere block whitespace-pre-line">
|
||||
{nodes.map((node, nodeIndex) =>
|
||||
node.kind === 'inline-code' ? (
|
||||
<code
|
||||
className="mx-px rounded bg-[color-mix(in_srgb,currentColor_8%,transparent)] px-1 py-px font-mono text-[0.92em]"
|
||||
data-slot="aui_user-inline-code"
|
||||
key={`code-${nodeIndex}`}
|
||||
>
|
||||
{node.code}
|
||||
</code>
|
||||
) : (
|
||||
// Pass plain-text bits through DirectiveContent so @file:/@url: chips
|
||||
// still render. DirectiveContent already preserves whitespace.
|
||||
<Fragment key={`text-${nodeIndex}`}>
|
||||
<DirectiveContent text={node.text} />
|
||||
</Fragment>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -62,7 +62,9 @@ function formatStageName(name: string): string {
|
||||
if (name.length <= 3) return name
|
||||
return name
|
||||
.split('-')
|
||||
.map((word, i) => (i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word))
|
||||
.map((word, i) =>
|
||||
i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word
|
||||
)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
@@ -114,10 +116,17 @@ function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
|
||||
state === 'failed' && 'bg-destructive/10'
|
||||
)}
|
||||
>
|
||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center">{icon}</div>
|
||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className={cn('truncate text-sm font-medium', state === 'pending' && 'text-muted-foreground')}>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate text-sm font-medium',
|
||||
state === 'pending' && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{formatStageName(descriptor.name)}
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
@@ -126,7 +135,9 @@ function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
|
||||
{state === 'failed' ? STATE_LABEL[state] : null}
|
||||
</span>
|
||||
</div>
|
||||
{reason && state !== 'pending' && <p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>}
|
||||
{reason && state !== 'pending' && (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
@@ -169,7 +180,7 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
|
||||
durationMs: ev.durationMs ?? null,
|
||||
// Stamp the start time on the running transition so the UI can show
|
||||
// a live elapsed timer; preserve it across repeated running events.
|
||||
startedAt: ev.state === 'running' ? (prev?.startedAt ?? Date.now()) : (prev?.startedAt ?? null),
|
||||
startedAt: ev.state === 'running' ? prev?.startedAt ?? Date.now() : prev?.startedAt ?? null,
|
||||
json: ev.json ?? null,
|
||||
error: ev.error ?? null
|
||||
}
|
||||
@@ -206,7 +217,6 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
const [state, setState] = useState<DesktopBootstrapState>(EMPTY_STATE)
|
||||
const [logOpen, setLogOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const logEndRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
@@ -283,8 +293,8 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
<div className="w-full max-w-xl rounded-xl border bg-card p-8 shadow-xl">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Hermes needs a one-time install</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Automated first-launch install isn{'\u2019'}t available on {platformLabel} yet. Open Terminal and run the
|
||||
command below, then relaunch this app. Subsequent launches will skip this step.
|
||||
Automated first-launch install isn{'\u2019'}t available on {platformLabel} yet. Open Terminal and
|
||||
run the command below, then relaunch this app. Subsequent launches will skip this step.
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
@@ -318,7 +328,11 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Will install to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">{ups.activeRoot}</code>
|
||||
</span>
|
||||
<Button variant="default" size="sm" onClick={() => window.location.reload()}>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
I{'\u2019'}ve run it -- retry
|
||||
</Button>
|
||||
</div>
|
||||
@@ -348,7 +362,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
</h2>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">
|
||||
{failed
|
||||
? 'One of the install steps failed. On Windows, this can happen if another Hermes CLI or desktop instance is running. Stop any running Hermes instances, then retry. Check the details below or the desktop log for the full transcript.'
|
||||
? 'One of the install steps failed. Check the details below or the desktop log for the full transcript.'
|
||||
: 'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. ' +
|
||||
'Subsequent launches will skip this step.'}
|
||||
</p>
|
||||
@@ -368,7 +382,10 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn('h-full transition-all duration-300', failed ? 'bg-destructive' : 'bg-primary')}
|
||||
className={cn(
|
||||
'h-full transition-all duration-300',
|
||||
failed ? 'bg-destructive' : 'bg-primary'
|
||||
)}
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -414,18 +431,14 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
>
|
||||
{logOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
<span>{logOpen ? 'Hide installer output' : 'Show installer output'}</span>
|
||||
<span className="ml-1 tabular-nums">
|
||||
({state.log.length} line{state.log.length === 1 ? '' : 's'})
|
||||
</span>
|
||||
<span className="ml-1 tabular-nums">({state.log.length} line{state.log.length === 1 ? '' : 's'})</span>
|
||||
</button>
|
||||
|
||||
{logOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-2 overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed',
|
||||
failed ? 'max-h-96' : 'max-h-64'
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'mt-2 overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed',
|
||||
failed ? 'max-h-96' : 'max-h-64'
|
||||
)}>
|
||||
{state.log.length === 0 ? (
|
||||
<div className="text-muted-foreground">No output yet.</div>
|
||||
) : (
|
||||
@@ -444,38 +457,12 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active footer: let the user actually cancel a running install. */}
|
||||
{state.active && !failed && (
|
||||
<div className="flex-shrink-0 border-t bg-card p-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
disabled={cancelling}
|
||||
onClick={async () => {
|
||||
setCancelling(true)
|
||||
|
||||
try {
|
||||
await window.hermesDesktop?.cancelBootstrap?.()
|
||||
} catch {
|
||||
// ignore -- the failed/cancelled event will surface the result
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{cancelling ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{cancelling ? 'Cancelling...' : 'Cancel install'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer -- always visible, never scrolls; only renders on failure */}
|
||||
{failed && (
|
||||
<div className="flex-shrink-0 border-t bg-card p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Full transcript saved to{' '}
|
||||
<code className="rounded bg-muted/50 px-1 py-0.5 font-mono">%LOCALAPPDATA%\hermes\logs\</code>
|
||||
Full transcript saved to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">%LOCALAPPDATA%\hermes\logs\</code>
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle, RefreshCw } from '@/lib/icons'
|
||||
|
||||
export interface ErrorBoundaryFallbackProps {
|
||||
error: Error
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
fallback?: (props: ErrorBoundaryFallbackProps) => ReactNode
|
||||
label?: string
|
||||
onError?: (error: Error, info: ErrorInfo) => void
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
state: ErrorBoundaryState = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
const tag = this.props.label ? `[error-boundary:${this.props.label}]` : '[error-boundary]'
|
||||
console.error(tag, error, info.componentStack)
|
||||
this.props.onError?.(error, info)
|
||||
}
|
||||
|
||||
reset = () => {
|
||||
this.setState({ error: null })
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error } = this.state
|
||||
|
||||
if (!error) {
|
||||
return this.props.children
|
||||
}
|
||||
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback({ error, reset: this.reset })
|
||||
}
|
||||
|
||||
return <RootErrorFallback error={error} reset={this.reset} />
|
||||
}
|
||||
}
|
||||
|
||||
function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1500] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
|
||||
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
|
||||
<div className="flex items-start gap-3 border-b border-(--ui-stroke-tertiary) px-5 py-4">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10 text-destructive">
|
||||
<AlertTriangle className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Something broke in the interface</h2>
|
||||
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
|
||||
The view hit an unexpected error. Your chats and settings are safe - try again, or reload the window.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 p-5">
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 font-mono text-[0.7rem] leading-4 text-destructive">
|
||||
{error.message || String(error)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={reset}>
|
||||
<RefreshCw className="size-4" />
|
||||
Try again
|
||||
</Button>
|
||||
<Button onClick={() => window.location.reload()} variant="outline">
|
||||
Reload window
|
||||
</Button>
|
||||
<Button onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)} variant="ghost">
|
||||
Open logs
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-default disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -46,10 +46,7 @@ function DialogContent({
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
// Cap height at 85vh and let long content scroll inside the dialog
|
||||
// instead of overflowing off-screen (long cron titles, tool detail
|
||||
// dumps, etc.). Individual dialogs can still override via className.
|
||||
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid max-h-[85vh] w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-md duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-md duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
data-slot="dialog-content"
|
||||
|
||||
@@ -24,11 +24,8 @@ function DropdownMenuContent({
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
// `dt-portal-scrollbar` reproduces the thin themed scrollbar from
|
||||
// `.scrollbar-dt` for portaled overlays (Radix renders this under
|
||||
// document.body, outside #root's scope). See styles.css.
|
||||
className={cn(
|
||||
'dt-portal-scrollbar z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-content"
|
||||
@@ -191,13 +188,8 @@ function DropdownMenuSubContent({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
// SubContent inherits the same portal/scrollbar issue as Content (Radix
|
||||
// renders it under document.body), so apply `dt-portal-scrollbar`. Use
|
||||
// a fixed `max-h-80` rather than the Radix available-height variable:
|
||||
// that variable is only published on Content, NOT SubContent — using
|
||||
// it here collapses the submenu to 0px height.
|
||||
className={cn(
|
||||
'dt-portal-scrollbar z-50 max-h-80 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
'z-50 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
|
||||
Vendored
+7
-7
@@ -27,11 +27,6 @@ declare global {
|
||||
setPreviewShortcutActive?: (active: boolean) => void
|
||||
openExternal: (url: string) => Promise<void>
|
||||
fetchLinkTitle: (url: string) => Promise<string>
|
||||
settings: {
|
||||
getDefaultProjectDir: () => Promise<{ defaultLabel: string; dir: null | string }>
|
||||
pickDefaultProjectDir: () => Promise<{ canceled: boolean; dir: null | string }>
|
||||
setDefaultProjectDir: (dir: null | string) => Promise<{ dir: null | string }>
|
||||
}
|
||||
revealLogs: () => Promise<{ ok: boolean; path: string; error?: string }>
|
||||
getRecentLogs: () => Promise<{ path: string; lines: string[] }>
|
||||
readDir: (path: string) => Promise<HermesReadDirResult>
|
||||
@@ -53,7 +48,6 @@ declare global {
|
||||
getBootstrapState: () => Promise<DesktopBootstrapState>
|
||||
resetBootstrap: () => Promise<{ ok: boolean }>
|
||||
repairBootstrap: () => Promise<{ ok: boolean }>
|
||||
cancelBootstrap: () => Promise<{ ok: boolean; cancelled: boolean }>
|
||||
onBootstrapEvent: (callback: (payload: DesktopBootstrapEvent) => void) => () => void
|
||||
getVersion: () => Promise<DesktopVersionInfo>
|
||||
updates: {
|
||||
@@ -200,7 +194,12 @@ export interface DesktopBootstrapStageDescriptor {
|
||||
needs_user_input?: boolean
|
||||
}
|
||||
|
||||
export type DesktopBootstrapStageState = 'pending' | 'running' | 'succeeded' | 'skipped' | 'failed'
|
||||
export type DesktopBootstrapStageState =
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'succeeded'
|
||||
| 'skipped'
|
||||
| 'failed'
|
||||
|
||||
export interface DesktopBootstrapStageResult {
|
||||
state: DesktopBootstrapStageState
|
||||
@@ -249,6 +248,7 @@ export type DesktopBootstrapEvent =
|
||||
docsUrl: string
|
||||
}
|
||||
|
||||
|
||||
export interface HermesApiRequest {
|
||||
path: string
|
||||
method?: string
|
||||
|
||||
@@ -114,11 +114,10 @@ export class HermesGateway extends JsonRpcGatewayClient {
|
||||
export async function listSessions(
|
||||
limit = 40,
|
||||
minMessages = 0,
|
||||
archived: 'exclude' | 'include' | 'only' = 'exclude',
|
||||
order: 'created' | 'recent' = 'recent'
|
||||
archived: 'exclude' | 'include' | 'only' = 'exclude'
|
||||
): Promise<PaginatedSessions> {
|
||||
const result = await window.hermesDesktop.api<PaginatedSessions>({
|
||||
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}&order=${order}`
|
||||
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}`
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ansiColorClass, hasAnsiCodes, parseAnsi } from './ansi'
|
||||
|
||||
const ESC = '\x1b'
|
||||
|
||||
describe('parseAnsi', () => {
|
||||
it('returns a single default segment for plain text', () => {
|
||||
expect(parseAnsi('hello world')).toEqual([{ bold: false, fg: null, text: 'hello world' }])
|
||||
})
|
||||
|
||||
it('returns nothing for an empty string', () => {
|
||||
expect(parseAnsi('')).toEqual([])
|
||||
})
|
||||
|
||||
it('parses a basic foreground color sequence and resets', () => {
|
||||
const input = `${ESC}[31merror${ESC}[0m ok`
|
||||
|
||||
expect(parseAnsi(input)).toEqual([
|
||||
{ bold: false, fg: 'red', text: 'error' },
|
||||
{ bold: false, fg: null, text: ' ok' }
|
||||
])
|
||||
})
|
||||
|
||||
it('treats bold (1) and bold-off (22) as toggles without affecting fg', () => {
|
||||
const input = `${ESC}[1mloud${ESC}[22m quiet`
|
||||
|
||||
expect(parseAnsi(input)).toEqual([
|
||||
{ bold: true, fg: null, text: 'loud' },
|
||||
{ bold: false, fg: null, text: ' quiet' }
|
||||
])
|
||||
})
|
||||
|
||||
it('treats default-fg (39) as a foreground-only reset (keeps bold)', () => {
|
||||
const input = `${ESC}[1;31mboth${ESC}[39mbold-only`
|
||||
|
||||
expect(parseAnsi(input)).toEqual([
|
||||
{ bold: true, fg: 'red', text: 'both' },
|
||||
{ bold: true, fg: null, text: 'bold-only' }
|
||||
])
|
||||
})
|
||||
|
||||
it('handles bright colors via the 90-97 range', () => {
|
||||
expect(parseAnsi(`${ESC}[92mgreen`)).toEqual([{ bold: false, fg: 'bright-green', text: 'green' }])
|
||||
})
|
||||
|
||||
it('coalesces adjacent runs with the same style', () => {
|
||||
const input = `${ESC}[31ma${ESC}[31mb${ESC}[31mc`
|
||||
|
||||
expect(parseAnsi(input)).toEqual([{ bold: false, fg: 'red', text: 'abc' }])
|
||||
})
|
||||
|
||||
it('skips 256-color (38;5) trailing args without painting fg or leaking the params as text', () => {
|
||||
// 256-color and truecolor aren't rendered (FG_BY_CODE doesn't cover them),
|
||||
// but the parser must consume the trailing `;5;<n>` / `;2;r;g;b` args so
|
||||
// they never bleed into the visible segment text.
|
||||
const segments = parseAnsi(`${ESC}[38;5;208morange${ESC}[0m`)
|
||||
|
||||
expect(segments).toHaveLength(1)
|
||||
expect(segments[0].fg).toBe(null)
|
||||
expect(segments[0].text).toBe('orange')
|
||||
})
|
||||
|
||||
it('skips truecolor (38;2;r;g;b) trailing args', () => {
|
||||
const segments = parseAnsi(`${ESC}[38;2;10;20;30mrgb${ESC}[0m`)
|
||||
|
||||
expect(segments).toHaveLength(1)
|
||||
expect(segments[0].fg).toBe(null)
|
||||
expect(segments[0].text).toBe('rgb')
|
||||
})
|
||||
|
||||
it('drops non-SGR CSI sequences (cursor motion, erase) without consuming surrounding text', () => {
|
||||
const input = `before${ESC}[2Jmiddle${ESC}[10;5Hafter`
|
||||
|
||||
expect(parseAnsi(input)).toEqual([{ bold: false, fg: null, text: 'beforemiddleafter' }])
|
||||
})
|
||||
|
||||
it('treats an empty SGR parameter (ESC[m) as a full reset', () => {
|
||||
const input = `${ESC}[1;31mfoo${ESC}[mbar`
|
||||
|
||||
expect(parseAnsi(input)).toEqual([
|
||||
{ bold: true, fg: 'red', text: 'foo' },
|
||||
{ bold: false, fg: null, text: 'bar' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAnsiCodes', () => {
|
||||
it('returns false for plain text', () => {
|
||||
expect(hasAnsiCodes('hello world')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when any CSI introducer is present', () => {
|
||||
expect(hasAnsiCodes(`${ESC}[31mred`)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ansiColorClass', () => {
|
||||
it('returns a non-empty Tailwind class string for every supported color', () => {
|
||||
const colors = [
|
||||
'black',
|
||||
'red',
|
||||
'green',
|
||||
'yellow',
|
||||
'blue',
|
||||
'magenta',
|
||||
'cyan',
|
||||
'white',
|
||||
'bright-black',
|
||||
'bright-red',
|
||||
'bright-green',
|
||||
'bright-yellow',
|
||||
'bright-blue',
|
||||
'bright-magenta',
|
||||
'bright-cyan',
|
||||
'bright-white'
|
||||
] as const
|
||||
|
||||
for (const color of colors) {
|
||||
expect(ansiColorClass(color)).toMatch(/\S/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,175 +0,0 @@
|
||||
// Minimal ANSI SGR parser for rendering terminal output inside chat tool
|
||||
// cards. Only handles the SGR codes that show up in practice (color, bold,
|
||||
// reset); cursor motions and other CSI sequences are dropped silently.
|
||||
//
|
||||
// Returns a flat array of styled segments so callers can render them as
|
||||
// React spans without each consumer having to re-implement the parser.
|
||||
|
||||
export interface AnsiSegment {
|
||||
bold: boolean
|
||||
/** Tailwind text-color class or null for the default foreground. */
|
||||
fg: AnsiColor | null
|
||||
text: string
|
||||
}
|
||||
|
||||
export type AnsiColor =
|
||||
| 'black'
|
||||
| 'red'
|
||||
| 'green'
|
||||
| 'yellow'
|
||||
| 'blue'
|
||||
| 'magenta'
|
||||
| 'cyan'
|
||||
| 'white'
|
||||
| 'bright-black'
|
||||
| 'bright-red'
|
||||
| 'bright-green'
|
||||
| 'bright-yellow'
|
||||
| 'bright-blue'
|
||||
| 'bright-magenta'
|
||||
| 'bright-cyan'
|
||||
| 'bright-white'
|
||||
|
||||
const FG_BY_CODE: Record<number, AnsiColor> = {
|
||||
30: 'black',
|
||||
31: 'red',
|
||||
32: 'green',
|
||||
33: 'yellow',
|
||||
34: 'blue',
|
||||
35: 'magenta',
|
||||
36: 'cyan',
|
||||
37: 'white',
|
||||
90: 'bright-black',
|
||||
91: 'bright-red',
|
||||
92: 'bright-green',
|
||||
93: 'bright-yellow',
|
||||
94: 'bright-blue',
|
||||
95: 'bright-magenta',
|
||||
96: 'bright-cyan',
|
||||
97: 'bright-white'
|
||||
}
|
||||
|
||||
// CSI = ESC '[' params 'final'. We only care about SGR (final == 'm'); other
|
||||
// final bytes are matched and consumed so they don't leak into the rendered
|
||||
// text. Range covers the common CSI command set (A-Z / a-z / @).
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CSI_RE = /\x1b\[([\d;]*)([\x40-\x7e])/g
|
||||
// Other escape sequences (single-char OSC/SS3/etc.) — strip silently.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const OTHER_ESCAPE_RE = /\x1b[@-Z\\-_]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g
|
||||
|
||||
export function parseAnsi(input: string): AnsiSegment[] {
|
||||
if (!input) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Strip non-CSI escapes upfront — none of them carry text we want to keep
|
||||
// and CSI_RE wouldn't match them.
|
||||
const cleaned = input.replace(OTHER_ESCAPE_RE, '')
|
||||
|
||||
const segments: AnsiSegment[] = []
|
||||
let cursor = 0
|
||||
let bold = false
|
||||
let fg: AnsiColor | null = null
|
||||
|
||||
const pushText = (text: string) => {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
const last = segments.at(-1)
|
||||
|
||||
if (last && last.bold === bold && last.fg === fg) {
|
||||
last.text += text
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
segments.push({ bold, fg, text })
|
||||
}
|
||||
|
||||
CSI_RE.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = CSI_RE.exec(cleaned)) !== null) {
|
||||
const start = match.index
|
||||
|
||||
if (start > cursor) {
|
||||
pushText(cleaned.slice(cursor, start))
|
||||
}
|
||||
|
||||
if (match[2] === 'm') {
|
||||
const codes = match[1]
|
||||
.split(';')
|
||||
.map(part => (part === '' ? 0 : Number(part)))
|
||||
.filter(value => Number.isFinite(value))
|
||||
|
||||
for (let i = 0; i < codes.length; i += 1) {
|
||||
const code = codes[i]
|
||||
|
||||
if (code === 0) {
|
||||
bold = false
|
||||
fg = null
|
||||
} else if (code === 1) {
|
||||
bold = true
|
||||
} else if (code === 22) {
|
||||
bold = false
|
||||
} else if (code === 39) {
|
||||
fg = null
|
||||
} else if (code in FG_BY_CODE) {
|
||||
fg = FG_BY_CODE[code]
|
||||
} else if (code === 38) {
|
||||
// 256-color / truecolor — skip the trailing args we don't render.
|
||||
if (codes[i + 1] === 5) {
|
||||
i += 2
|
||||
} else if (codes[i + 1] === 2) {
|
||||
i += 4
|
||||
}
|
||||
}
|
||||
// Background colors (40-47, 100-107) and effects we don't render are
|
||||
// intentionally ignored — the segment keeps the prior bold/fg state.
|
||||
}
|
||||
}
|
||||
|
||||
cursor = CSI_RE.lastIndex
|
||||
}
|
||||
|
||||
if (cursor < cleaned.length) {
|
||||
pushText(cleaned.slice(cursor))
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
const TAILWIND_BY_COLOR: Record<AnsiColor, string> = {
|
||||
// Tuned for legibility against the muted bg-(--ui-bg-tertiary) surface used
|
||||
// in tool cards. We don't paint pure ANSI colors (#000, #fff) because they
|
||||
// disappear into the surface.
|
||||
'black': 'text-zinc-700 dark:text-zinc-300',
|
||||
'red': 'text-red-700 dark:text-red-300',
|
||||
'green': 'text-emerald-700 dark:text-emerald-300',
|
||||
'yellow': 'text-amber-700 dark:text-amber-300',
|
||||
'blue': 'text-blue-700 dark:text-blue-300',
|
||||
'magenta': 'text-fuchsia-700 dark:text-fuchsia-300',
|
||||
'cyan': 'text-cyan-700 dark:text-cyan-300',
|
||||
'white': 'text-zinc-600 dark:text-zinc-200',
|
||||
'bright-black': 'text-zinc-500 dark:text-zinc-400',
|
||||
'bright-red': 'text-rose-600 dark:text-rose-300',
|
||||
'bright-green': 'text-emerald-600 dark:text-emerald-200',
|
||||
'bright-yellow': 'text-amber-600 dark:text-amber-200',
|
||||
'bright-blue': 'text-sky-600 dark:text-sky-300',
|
||||
'bright-magenta': 'text-pink-600 dark:text-pink-300',
|
||||
'bright-cyan': 'text-teal-600 dark:text-teal-200',
|
||||
'bright-white': 'text-zinc-500 dark:text-zinc-100'
|
||||
}
|
||||
|
||||
export function ansiColorClass(color: AnsiColor): string {
|
||||
return TAILWIND_BY_COLOR[color]
|
||||
}
|
||||
|
||||
/** Returns true if the input contains at least one CSI sequence. Cheap check
|
||||
* so callers can skip the parser for plain-ASCII output. */
|
||||
export function hasAnsiCodes(input: string): boolean {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return /\x1b\[/.test(input)
|
||||
}
|
||||
@@ -20,11 +20,7 @@ const PRIORITY_KEYS = [
|
||||
] as const
|
||||
|
||||
const ERROR_KEYS = ['error', 'errors', 'failure', 'exception'] as const
|
||||
// 'stderr' deliberately excluded: many CLIs emit informational lines on
|
||||
// stderr (npm progress, git's hint:, gcc's `In file included from`) that
|
||||
// aren't errors. Treating those as error signal flipped tool cards into
|
||||
// destructive styling for healthy commands.
|
||||
const ERROR_MSG_KEYS = ['message', 'reason', 'detail'] as const
|
||||
const ERROR_MSG_KEYS = ['message', 'reason', 'detail', 'stderr'] as const
|
||||
const NON_ERROR_TEXT = new Set(['', '0', 'false', 'none', 'null', 'nil', 'ok', 'success', 'n/a', 'na'])
|
||||
|
||||
type Json = Record<string, unknown>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { createRoot } from 'react-dom/client'
|
||||
import { HashRouter } from 'react-router-dom'
|
||||
|
||||
import App from './app'
|
||||
import { ErrorBoundary } from './components/error-boundary'
|
||||
import { HapticsProvider } from './components/haptics-provider'
|
||||
import { installClipboardShim } from './lib/clipboard'
|
||||
import { ThemeProvider } from './themes/context'
|
||||
@@ -33,16 +32,14 @@ const queryClient = new QueryClient({
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary label="root">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<HapticsProvider>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</HapticsProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<HapticsProvider>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</HapticsProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { sessionPinId } from './session'
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
describe('sessionPinId', () => {
|
||||
it('uses the live id when there is no compression lineage', () => {
|
||||
expect(sessionPinId(session({ id: 'abc' }))).toBe('abc')
|
||||
})
|
||||
|
||||
it('uses the lineage root so a pin survives compression', () => {
|
||||
// After auto-compression the entry surfaces under a fresh tip id but keeps
|
||||
// the original root — pinning on the root keeps the pin stable.
|
||||
expect(sessionPinId(session({ id: 'tip', _lineage_root_id: 'root' }))).toBe('root')
|
||||
})
|
||||
})
|
||||
@@ -3,15 +3,10 @@ import { atom } from 'nanostores'
|
||||
import type { ContextSuggestion } from '@/app/types'
|
||||
import type { HermesConnection } from '@/global'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
import type { SessionInfo, UsageStats } from '@/types/hermes'
|
||||
|
||||
type Updater<T> = T | ((current: T) => T)
|
||||
|
||||
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
|
||||
|
||||
export const getRememberedWorkspaceCwd = (): string => storedString(WORKSPACE_CWD_KEY)?.trim() || ''
|
||||
|
||||
interface AppAtom<T> {
|
||||
get: () => T
|
||||
set: (value: T) => void
|
||||
@@ -21,12 +16,6 @@ function updateAtom<T>(store: AppAtom<T>, next: Updater<T>) {
|
||||
store.set(typeof next === 'function' ? (next as (current: T) => T)(store.get()) : next)
|
||||
}
|
||||
|
||||
/** Durable id for pinning. Auto-compression rotates a conversation's session
|
||||
* id (root -> continuation tip), so pins keyed on the live id evaporate. The
|
||||
* lineage root is stable across every compression, so we pin on that. */
|
||||
export const sessionPinId = (session: Pick<SessionInfo, '_lineage_root_id' | 'id'>): string =>
|
||||
session._lineage_root_id ?? session.id
|
||||
|
||||
export const $connection = atom<HermesConnection | null>(null)
|
||||
export const $gatewayState = atom('idle')
|
||||
export const $sessions = atom<SessionInfo[]>([])
|
||||
@@ -44,7 +33,7 @@ export const $currentProvider = atom('')
|
||||
export const $currentReasoningEffort = atom('')
|
||||
export const $currentServiceTier = atom('')
|
||||
export const $currentFastMode = atom(false)
|
||||
export const $currentCwd = atom(getRememberedWorkspaceCwd())
|
||||
export const $currentCwd = atom('')
|
||||
export const $currentBranch = atom('')
|
||||
export const $currentUsage = atom<UsageStats>({
|
||||
calls: 0,
|
||||
@@ -78,14 +67,7 @@ export const setCurrentProvider = (next: Updater<string>) => updateAtom($current
|
||||
export const setCurrentReasoningEffort = (next: Updater<string>) => updateAtom($currentReasoningEffort, next)
|
||||
export const setCurrentServiceTier = (next: Updater<string>) => updateAtom($currentServiceTier, next)
|
||||
export const setCurrentFastMode = (next: Updater<boolean>) => updateAtom($currentFastMode, next)
|
||||
|
||||
export const setCurrentCwd = (next: Updater<string>) => {
|
||||
updateAtom($currentCwd, next)
|
||||
// Keep localStorage in sync with the atom: a real folder is remembered, an
|
||||
// empty cwd clears the key (|| null → removeItem).
|
||||
persistString(WORKSPACE_CWD_KEY, $currentCwd.get().trim() || null)
|
||||
}
|
||||
|
||||
export const setCurrentCwd = (next: Updater<string>) => updateAtom($currentCwd, next)
|
||||
export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)
|
||||
export const setCurrentUsage = (next: Updater<UsageStats>) => updateAtom($currentUsage, next)
|
||||
export const setSessionStartedAt = (next: Updater<number | null>) => updateAtom($sessionStartedAt, next)
|
||||
@@ -97,53 +79,6 @@ export const setIntroSeed = (next: Updater<number>) => updateAtom($introSeed, ne
|
||||
export const setContextSuggestions = (next: Updater<ContextSuggestion[]>) => updateAtom($contextSuggestions, next)
|
||||
export const setModelPickerOpen = (next: Updater<boolean>) => updateAtom($modelPickerOpen, next)
|
||||
|
||||
// Watchdog tracking — when does a "working" session count as stuck?
|
||||
// Long-running tool calls (LLM inference, long shell commands, web fetches)
|
||||
// can take a few minutes legitimately. We allow 8 minutes of complete
|
||||
// silence on the stream before clearing the working flag; in practice this
|
||||
// catches gateway hangs and dropped streams without false-positive-clearing
|
||||
// real long turns.
|
||||
const SESSION_WATCHDOG_TIMEOUT_MS = 8 * 60 * 1000
|
||||
const sessionWatchdogTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
function armSessionWatchdog(sessionId: string) {
|
||||
const existing = sessionWatchdogTimers.get(sessionId)
|
||||
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
sessionWatchdogTimers.delete(sessionId)
|
||||
// Re-check the latest state at fire-time. If the user already navigated
|
||||
// away or the session genuinely finished, the timer is a no-op.
|
||||
if ($workingSessionIds.get().includes(sessionId)) {
|
||||
setWorkingSessionIds(current => current.filter(id => id !== sessionId))
|
||||
}
|
||||
}, SESSION_WATCHDOG_TIMEOUT_MS)
|
||||
|
||||
sessionWatchdogTimers.set(sessionId, timer)
|
||||
}
|
||||
|
||||
function clearSessionWatchdog(sessionId: string) {
|
||||
const existing = sessionWatchdogTimers.get(sessionId)
|
||||
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
sessionWatchdogTimers.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Call when a streaming event for a session lands. Refreshes the watchdog
|
||||
* so the session keeps its "working" status as long as data keeps coming. */
|
||||
export function noteSessionActivity(sessionId: string | null | undefined) {
|
||||
if (!sessionId || !$workingSessionIds.get().includes(sessionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
armSessionWatchdog(sessionId)
|
||||
}
|
||||
|
||||
export function setSessionWorking(sessionId: string | null | undefined, working: boolean) {
|
||||
if (!sessionId) {
|
||||
return
|
||||
@@ -158,13 +93,4 @@ export function setSessionWorking(sessionId: string | null | undefined, working:
|
||||
|
||||
return alreadyWorking ? current.filter(id => id !== sessionId) : current
|
||||
})
|
||||
|
||||
// Bookend the watchdog: arm it whenever a session enters "working",
|
||||
// disarm it whenever it leaves. A subsequent noteSessionActivity() from
|
||||
// a streaming event will refresh the timer.
|
||||
if (working) {
|
||||
armSessionWatchdog(sessionId)
|
||||
} else {
|
||||
clearSessionWatchdog(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DesktopUpdateStatus } from '@/global'
|
||||
|
||||
const storage = new Map<string, string>()
|
||||
|
||||
vi.mock('@/lib/storage', () => ({
|
||||
persistString: (key: string, value: null | string) => {
|
||||
if (value === null) {
|
||||
storage.delete(key)
|
||||
} else {
|
||||
storage.set(key, value)
|
||||
}
|
||||
},
|
||||
storedString: (key: string) => storage.get(key) ?? null
|
||||
}))
|
||||
|
||||
const notifySpy = vi.fn()
|
||||
const dismissSpy = vi.fn()
|
||||
|
||||
vi.mock('@/store/notifications', () => ({
|
||||
notify: (...args: unknown[]) => notifySpy(...args),
|
||||
dismissNotification: (...args: unknown[]) => dismissSpy(...args)
|
||||
}))
|
||||
|
||||
const { maybeNotifyUpdateAvailable } = await import('./updates')
|
||||
|
||||
const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({
|
||||
supported: true,
|
||||
behind: 3,
|
||||
targetSha: 'sha-a',
|
||||
fetchedAt: 0,
|
||||
...over
|
||||
})
|
||||
|
||||
const lastToast = () => notifySpy.mock.calls.at(-1)?.[0] as { onDismiss: () => void }
|
||||
|
||||
describe('maybeNotifyUpdateAvailable', () => {
|
||||
beforeEach(() => {
|
||||
storage.clear()
|
||||
notifySpy.mockClear()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows when an update is available and not snoozed', () => {
|
||||
maybeNotifyUpdateAvailable(status())
|
||||
expect(notifySpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stays quiet for new commits once the toast was closed', () => {
|
||||
maybeNotifyUpdateAvailable(status())
|
||||
lastToast().onDismiss() // user closes it → cooldown starts
|
||||
notifySpy.mockClear()
|
||||
|
||||
// A different commit lands while still within the cooldown window.
|
||||
maybeNotifyUpdateAvailable(status({ targetSha: 'sha-b', behind: 9 }))
|
||||
expect(notifySpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-shows once the cooldown elapses', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
|
||||
maybeNotifyUpdateAvailable(status())
|
||||
lastToast().onDismiss()
|
||||
notifySpy.mockClear()
|
||||
|
||||
vi.setSystemTime(25 * 60 * 60 * 1000) // > 24h cooldown
|
||||
maybeNotifyUpdateAvailable(status({ targetSha: 'sha-b' }))
|
||||
expect(notifySpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does nothing when already up to date', () => {
|
||||
maybeNotifyUpdateAvailable(status({ behind: 0 }))
|
||||
expect(notifySpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -48,22 +48,7 @@ export const setUpdateOverlayOpen = (open: boolean) => $updateOverlayOpen.set(op
|
||||
export const resetUpdateApplyState = () => $updateApply.set(IDLE)
|
||||
|
||||
const UPDATE_TOAST_ID = 'desktop-update-available'
|
||||
// Time-based snooze instead of per-sha dismissal: this repo lands ~100 commits
|
||||
// a day, so a "don't show this exact sha again" guard re-popped the toast on
|
||||
// every new commit. We instead suppress the toast for a cooldown window that
|
||||
// (re)starts whenever the user closes it.
|
||||
const UPDATE_TOAST_SNOOZE_KEY = 'hermes:update-toast-snooze-until'
|
||||
const UPDATE_TOAST_COOLDOWN_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
function snoozeUpdateToast(): void {
|
||||
persistString(UPDATE_TOAST_SNOOZE_KEY, String(Date.now() + UPDATE_TOAST_COOLDOWN_MS))
|
||||
}
|
||||
|
||||
function isUpdateToastSnoozed(): boolean {
|
||||
const until = Number(storedString(UPDATE_TOAST_SNOOZE_KEY) || 0)
|
||||
|
||||
return Number.isFinite(until) && Date.now() < until
|
||||
}
|
||||
const UPDATE_TOAST_DISMISSED_KEY = 'hermes:update-toast-dismissed-sha'
|
||||
|
||||
// Must match tui_gateway's DESKTOP_BACKEND_CONTRACT that this build was written
|
||||
// against. The backend reports its own value in session runtime info; a lower
|
||||
@@ -89,18 +74,25 @@ export function reportBackendContract(contract: number | undefined): void {
|
||||
durationMs: 0,
|
||||
id: SKEW_TOAST_ID,
|
||||
kind: 'warning',
|
||||
message: 'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
|
||||
message:
|
||||
'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
|
||||
title: 'Backend out of date'
|
||||
})
|
||||
}
|
||||
|
||||
function markToastDismissed(sha: string | undefined) {
|
||||
if (sha) {
|
||||
persistString(UPDATE_TOAST_DISMISSED_KEY, sha)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a toast when an update is available, at most once per cooldown window.
|
||||
* Closing the toast — dismissing it or opening the updates window from it —
|
||||
* (re)starts the cooldown, so a busy upstream branch doesn't re-spam the user
|
||||
* on every new commit. The snooze is persisted, so it survives relaunches too.
|
||||
* Fire a one-shot toast the first time we see a particular target commit so
|
||||
* users don't have to notice the status-bar version pill turning colors.
|
||||
* Dismissal is remembered per-target-sha so the toast doesn't keep popping
|
||||
* back for the same update across restarts.
|
||||
*/
|
||||
export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
if (!status || status.supported === false || status.error || !status.targetSha) {
|
||||
return
|
||||
}
|
||||
@@ -109,7 +101,7 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isUpdateToastSnoozed()) {
|
||||
if (storedString(UPDATE_TOAST_DISMISSED_KEY) === status.targetSha) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -118,12 +110,13 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
}
|
||||
|
||||
const behind = status.behind ?? 0
|
||||
const targetSha = status.targetSha
|
||||
|
||||
notify({
|
||||
action: {
|
||||
label: "See what's new",
|
||||
onClick: () => {
|
||||
snoozeUpdateToast()
|
||||
markToastDismissed(targetSha)
|
||||
openUpdatesWindow()
|
||||
}
|
||||
},
|
||||
@@ -131,7 +124,7 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
id: UPDATE_TOAST_ID,
|
||||
kind: 'info',
|
||||
message: `${behind} new change${behind === 1 ? '' : 's'} available.`,
|
||||
onDismiss: () => snoozeUpdateToast(),
|
||||
onDismiss: () => markToastDismissed(targetSha),
|
||||
title: 'Update ready'
|
||||
})
|
||||
}
|
||||
@@ -145,34 +138,6 @@ export function openUpdatesWindow(): void {
|
||||
void checkUpdates()
|
||||
}
|
||||
|
||||
/** Re-read the running app's version from the Electron main process and
|
||||
* publish it on `$desktopVersion`. Called when the About panel mounts, the
|
||||
* update flow finishes, and the window regains focus, so the About text
|
||||
* stays in sync with the just-installed binary instead of frozen at the
|
||||
* value captured at first-load. */
|
||||
export async function refreshDesktopVersion(): Promise<DesktopVersionInfo | null> {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Best-effort UI sync: callers (checkUpdates, startUpdatePoller, window
|
||||
// focus handler) all kick this off with `void refreshDesktopVersion()`,
|
||||
// so any rejection from the IPC bridge (e.g. main process shutting down
|
||||
// mid-reload, or the bridge not yet ready on first paint) would surface
|
||||
// as an unhandled promise rejection in the renderer. Swallow it.
|
||||
try {
|
||||
const next = await window.hermesDesktop?.getVersion?.()
|
||||
|
||||
if (next) {
|
||||
$desktopVersion.set(next)
|
||||
}
|
||||
|
||||
return next ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
|
||||
const bridge = window.hermesDesktop?.updates
|
||||
|
||||
@@ -186,10 +151,6 @@ export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
|
||||
const status = await bridge.check()
|
||||
$updateStatus.set(status)
|
||||
maybeNotifyUpdateAvailable(status)
|
||||
// The update check pulls the latest hermes_cli + bundled package metadata
|
||||
// into place. Re-read the running version so About reflects the now-fresh
|
||||
// checkout rather than the one captured at process start.
|
||||
void refreshDesktopVersion()
|
||||
|
||||
return status
|
||||
} catch (error) {
|
||||
@@ -281,7 +242,7 @@ export function startUpdatePoller(): void {
|
||||
|
||||
pollerStarted = true
|
||||
void checkUpdates()
|
||||
void refreshDesktopVersion()
|
||||
void window.hermesDesktop?.getVersion?.().then(info => $desktopVersion.set(info))
|
||||
bridge.onProgress(ingestProgress)
|
||||
|
||||
window.addEventListener('focus', onFocus)
|
||||
@@ -307,8 +268,4 @@ function onFocus() {
|
||||
|
||||
lastFocusAt = now
|
||||
void checkUpdates()
|
||||
// Cheap and safe to re-read on every (throttled) focus: the user may have
|
||||
// updated Hermes from another window/CLI between focuses, and About should
|
||||
// catch up without forcing a restart.
|
||||
void refreshDesktopVersion()
|
||||
}
|
||||
|
||||
@@ -641,41 +641,6 @@ canvas {
|
||||
.scrollbar-dt *::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Variant for portaled overlays (Radix DropdownMenu, Popover, etc.) that
|
||||
render under document.body, outside the `.scrollbar-dt` scope on
|
||||
#root. Same visual treatment, applied directly to the overlay
|
||||
container so its (and only its) internal scrollbar is themed. */
|
||||
.dt-portal-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--dt-midground) 28%, transparent) transparent;
|
||||
}
|
||||
|
||||
.dt-portal-scrollbar::-webkit-scrollbar {
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
}
|
||||
|
||||
.dt-portal-scrollbar::-webkit-scrollbar-track,
|
||||
.dt-portal-scrollbar::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dt-portal-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--dt-midground) 28%, transparent);
|
||||
border-radius: 9999rem;
|
||||
border: 0.0625rem solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.dt-portal-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--dt-midground) 50%, transparent);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.dt-portal-scrollbar::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bottom clearance lives on [data-slot='aui_composer-clearance'] —
|
||||
|
||||
@@ -244,10 +244,6 @@ export interface SessionInfo {
|
||||
cwd?: null | string
|
||||
ended_at: null | number
|
||||
id: string
|
||||
/** Original root id of a compression chain, when this entry is a projected
|
||||
* continuation tip. Stable across compressions — used as the durable id for
|
||||
* pins so a pinned conversation survives auto-compression. */
|
||||
_lineage_root_id?: null | string
|
||||
input_tokens: number
|
||||
is_active: boolean
|
||||
last_active: number
|
||||
@@ -475,17 +471,12 @@ export interface ToolProvider {
|
||||
env_vars: ToolEnvVar[]
|
||||
post_setup: string | null
|
||||
requires_nous_auth: boolean
|
||||
/** True when this is the provider currently written to config (mirrors the
|
||||
* CLI `hermes tools` active-provider detection). */
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface ToolsetConfig {
|
||||
name: string
|
||||
has_category: boolean
|
||||
providers: ToolProvider[]
|
||||
/** Name of the currently active provider, or null if none is configured. */
|
||||
active_provider: string | null
|
||||
}
|
||||
|
||||
export interface SessionSearchResult {
|
||||
|
||||
@@ -872,17 +872,6 @@ _cleanup_done = False
|
||||
# Weak reference to the active AIAgent for memory provider shutdown at exit
|
||||
_active_agent_ref = None
|
||||
_deferred_agent_startup_done = False
|
||||
# Set True once the TUI's prompt_toolkit app starts (which enables focus
|
||||
# reporting + mouse tracking). Gates the on-exit terminal reset so non-TUI
|
||||
# one-shot CLI runs — which also register _run_cleanup via atexit — don't emit
|
||||
# escape codes for modes they never enabled (#36823).
|
||||
_tui_input_modes_active = False
|
||||
|
||||
|
||||
def _mark_tui_input_modes_active() -> None:
|
||||
"""Record that the TUI app started, so _run_cleanup resets input modes."""
|
||||
global _tui_input_modes_active
|
||||
_tui_input_modes_active = True
|
||||
|
||||
|
||||
def _prepare_deferred_agent_startup() -> None:
|
||||
@@ -938,12 +927,6 @@ def _run_cleanup():
|
||||
return
|
||||
_cleanup_done = True
|
||||
|
||||
# Reset terminal input modes first, before the slower resource teardown
|
||||
# below (MCP / browser / memory shutdown can take seconds). On Ctrl+C the
|
||||
# user's terminal becomes usable immediately, and a later step raising
|
||||
# can't skip the reset (#36823). No-op unless the TUI actually ran.
|
||||
_reset_terminal_input_modes_on_exit()
|
||||
|
||||
try:
|
||||
_cleanup_all_terminals()
|
||||
except Exception:
|
||||
@@ -989,50 +972,6 @@ def _run_cleanup():
|
||||
pass
|
||||
|
||||
|
||||
def _reset_terminal_input_modes_on_exit() -> None:
|
||||
"""Best-effort: disable focus reporting + mouse tracking on TUI exit so they
|
||||
don't leak into the next shell session sharing the tab.
|
||||
|
||||
prompt_toolkit restores these on a clean teardown, but Ctrl+C, SIGTERM /
|
||||
SIGHUP and crashes can bypass its unwind, leaving the modes enabled. The
|
||||
terminal then emits raw ``ESC[I`` / ``ESC[O`` focus events and fragmented
|
||||
SGR mouse reports as visible text in whatever runs next in the same tab
|
||||
(#36823). Called from ``_run_cleanup`` (atexit-registered + invoked on the
|
||||
normal / EOF / interrupt exit paths) this covers normal quit, Ctrl+C and
|
||||
SIGTERM/SIGHUP. ``kill -9`` is uncatchable, and the kanban worker's
|
||||
``os._exit(0)`` path bypasses ``atexit``; neither runs this — but both are
|
||||
non-TTY / non-TUI, so there is nothing to reset there.
|
||||
|
||||
Gated on ``_tui_input_modes_active`` so one-shot non-TUI CLI runs (which
|
||||
share ``_run_cleanup`` via ``atexit``) never emit these codes. Writes to the
|
||||
controlling terminal directly: by exit, prompt_toolkit's own output is torn
|
||||
down, so ``sys.stdout`` is the real fd; falls back to ``/dev/tty`` when
|
||||
stdout is redirected away from the terminal.
|
||||
"""
|
||||
global _tui_input_modes_active
|
||||
if not _tui_input_modes_active:
|
||||
return
|
||||
# About to disable the modes — clear the flag so a re-armed _run_cleanup (or
|
||||
# a long-lived process that reuses it) doesn't re-emit them.
|
||||
_tui_input_modes_active = False
|
||||
# Prefer stdout when it's the terminal; otherwise the TUI may have driven
|
||||
# /dev/tty while stdout was redirected — reset there instead of nowhere.
|
||||
try:
|
||||
stream = sys.stdout
|
||||
if stream is not None and stream.isatty():
|
||||
stream.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
|
||||
stream.flush()
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with open("/dev/tty", "w", encoding="ascii") as tty:
|
||||
tty.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
|
||||
tty.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Git Worktree Isolation (#652)
|
||||
# =============================================================================
|
||||
@@ -15196,9 +15135,6 @@ class HermesCLI:
|
||||
pass # No running loop -- nothing to patch
|
||||
except Exception:
|
||||
pass
|
||||
# The app enables focus reporting + mouse tracking; record that
|
||||
# so _run_cleanup resets them on exit (#36823).
|
||||
_mark_tui_input_modes_active()
|
||||
app.run()
|
||||
except (EOFError, KeyboardInterrupt, BrokenPipeError):
|
||||
pass
|
||||
|
||||
+4
-11
@@ -361,17 +361,10 @@ class StreamingConfig:
|
||||
# fall back to edit-based when not.
|
||||
# "draft" — explicitly request native drafts; falls back to edit when
|
||||
# the platform/chat doesn't support them.
|
||||
# "edit" — progressive editMessageText only (legacy behaviour).
|
||||
# "edit" — progressive editMessageText only (legacy/default
|
||||
# behaviour).
|
||||
# "off" — disable streaming entirely.
|
||||
#
|
||||
# Default is "auto": prefer native draft streaming on platforms that
|
||||
# support it (Telegram DMs via sendMessageDraft, Bot API 9.5+) and fall
|
||||
# back to edit-based streaming everywhere else. This is safe as a global
|
||||
# default because adapters without draft support (Discord, Slack, Matrix,
|
||||
# …) report supports_draft_streaming() == False and transparently use the
|
||||
# edit path — so "auto" never regresses non-Telegram platforms, it only
|
||||
# upgrades the chats that can render the smoother native preview.
|
||||
transport: str = "auto"
|
||||
transport: str = "edit"
|
||||
edit_interval: float = DEFAULT_STREAMING_EDIT_INTERVAL
|
||||
buffer_threshold: int = DEFAULT_STREAMING_BUFFER_THRESHOLD
|
||||
cursor: str = DEFAULT_STREAMING_CURSOR
|
||||
@@ -400,7 +393,7 @@ class StreamingConfig:
|
||||
return cls()
|
||||
return cls(
|
||||
enabled=_coerce_bool(data.get("enabled"), False),
|
||||
transport=data.get("transport", "auto"),
|
||||
transport=data.get("transport", "edit"),
|
||||
edit_interval=_coerce_float(
|
||||
data.get("edit_interval"), DEFAULT_STREAMING_EDIT_INTERVAL,
|
||||
),
|
||||
|
||||
@@ -1916,84 +1916,6 @@ class BasePlatformAdapter(ABC):
|
||||
f"{type(self).__name__} does not implement send_draft"
|
||||
)
|
||||
|
||||
# ── Structured stream-event rendering ────────────────────────────────
|
||||
#
|
||||
# These methods let an adapter decide *how* to present each structured
|
||||
# streaming event (see gateway/stream_events.py). The default
|
||||
# implementations reproduce the historical behavior exactly: assistant
|
||||
# text/commentary/segment events delegate to the stream consumer, and
|
||||
# tool events render the same "emoji tool_name: preview" chrome the
|
||||
# gateway has always produced. Adapters override these to be more native
|
||||
# to their platform (e.g. Telegram streaming a MarkdownV2 ```bash``` block
|
||||
# as a draft; iMessage eating tool chrome it cannot format).
|
||||
#
|
||||
# The contract is presentation-only: nothing rendered here is persisted to
|
||||
# conversation history. History is owned by the agent; what an adapter
|
||||
# chooses to "eat" must never change the bytes the agent stored.
|
||||
|
||||
def render_message_event(self, event: Any, sink: Any) -> None:
|
||||
"""Render a MessageChunk / MessageStop / Commentary onto the sink.
|
||||
|
||||
Default: map onto the stream consumer's existing primitives, preserving
|
||||
today's behavior 1:1. ``sink`` is a GatewayStreamConsumer.
|
||||
"""
|
||||
from gateway.stream_events import MessageChunk, MessageStop, Commentary
|
||||
|
||||
if isinstance(event, MessageChunk):
|
||||
if event.text:
|
||||
sink.on_delta(event.text)
|
||||
elif isinstance(event, MessageStop):
|
||||
# An intermediate stop (text → tool → text) is a segment break;
|
||||
# the terminal stop is signalled by the gateway via finish(),
|
||||
# not here, so we only break segments on non-final stops.
|
||||
if not event.final:
|
||||
sink.on_segment_break()
|
||||
elif isinstance(event, Commentary):
|
||||
if event.text:
|
||||
sink.on_commentary(event.text)
|
||||
|
||||
def format_tool_event(self, event: Any, *, mode: str = "all",
|
||||
preview_max_len: int = 40) -> Optional[str]:
|
||||
"""Return the rendered chrome for a ToolCallChunk, or None to eat it.
|
||||
|
||||
Reproduces the gateway's historical tool-progress formatting: an emoji
|
||||
for the tool, the tool name, and a short argument preview (or the full
|
||||
args dict in ``verbose`` mode). Adapters that cannot render tool chrome
|
||||
(no message editing, plain-text only) should override to return None so
|
||||
the event is dropped rather than spamming separate bubbles.
|
||||
|
||||
``mode`` is the resolved tool-progress mode ("all" / "new" / "verbose");
|
||||
``preview_max_len`` mirrors the ``tool_preview_length`` config (0 means
|
||||
"no cap" in verbose mode).
|
||||
"""
|
||||
from gateway.stream_events import ToolCallChunk
|
||||
if not isinstance(event, ToolCallChunk):
|
||||
return None
|
||||
|
||||
from agent.display import get_tool_emoji
|
||||
emoji = get_tool_emoji(event.tool_name, default="⚙️")
|
||||
|
||||
if mode == "verbose":
|
||||
if event.args:
|
||||
import json
|
||||
args_str = json.dumps(event.args, ensure_ascii=False, default=str)
|
||||
if preview_max_len > 0 and len(args_str) > preview_max_len:
|
||||
args_str = args_str[:preview_max_len - 3] + "..."
|
||||
return f"{emoji} {event.tool_name}({list(event.args.keys())})\n{args_str}"
|
||||
if event.preview:
|
||||
return f"{emoji} {event.tool_name}: \"{event.preview}\""
|
||||
return f"{emoji} {event.tool_name}..."
|
||||
|
||||
# "all" / "new": short preview, capped (default 40 to keep gateway
|
||||
# progress bubbles compact — they persist as permanent messages).
|
||||
preview = event.preview
|
||||
if preview:
|
||||
cap = preview_max_len if preview_max_len > 0 else 40
|
||||
if len(preview) > cap:
|
||||
preview = preview[:cap - 3] + "..."
|
||||
return f"{emoji} {event.tool_name}: \"{preview}\""
|
||||
return f"{emoji} {event.tool_name}..."
|
||||
|
||||
@property
|
||||
def has_fatal_error(self) -> bool:
|
||||
return self._fatal_error_message is not None
|
||||
|
||||
@@ -2521,55 +2521,31 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
text = content if len(content) <= self.MAX_MESSAGE_LENGTH else \
|
||||
self.truncate_message(content, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len)[0]
|
||||
|
||||
kwargs: Dict[str, Any] = {
|
||||
"chat_id": int(chat_id),
|
||||
"draft_id": int(draft_id),
|
||||
"text": text,
|
||||
}
|
||||
thread_id = self._metadata_thread_id(metadata)
|
||||
if thread_id is not None:
|
||||
kwargs["message_thread_id"] = thread_id
|
||||
|
||||
# Apply the same MarkdownV2 conversion the regular ``send`` path uses
|
||||
# so the animated draft preview renders with identical formatting to
|
||||
# the final message. Without this, the draft streams as raw text and
|
||||
# the final ``sendMessage`` (which DOES use MarkdownV2) snaps into
|
||||
# formatted output, producing a jarring visual shift at the end of the
|
||||
# response. We try MarkdownV2 first and fall back to plain text if a
|
||||
# malformed escape would be rejected — mirroring the (True, False)
|
||||
# retry the streaming send loop uses — so a single bad token never
|
||||
# kills draft streaming for the whole response.
|
||||
for use_markdown in (True, False):
|
||||
kwargs: Dict[str, Any] = {
|
||||
"chat_id": int(chat_id),
|
||||
"draft_id": int(draft_id),
|
||||
"text": self.format_message(text) if use_markdown else text,
|
||||
}
|
||||
if use_markdown:
|
||||
kwargs["parse_mode"] = ParseMode.MARKDOWN_V2
|
||||
if thread_id is not None:
|
||||
kwargs["message_thread_id"] = thread_id
|
||||
|
||||
try:
|
||||
ok = await self._bot.send_message_draft(**kwargs)
|
||||
if ok:
|
||||
# Drafts have no message_id; we report success without one
|
||||
# so the caller knows the animation frame landed.
|
||||
return SendResult(success=True, message_id=None)
|
||||
return SendResult(success=False, error="draft_rejected")
|
||||
except Exception as e:
|
||||
# A MarkdownV2 parse failure (BadRequest "can't parse entities")
|
||||
# is recoverable: retry once as plain text. Any other failure
|
||||
# (chat doesn't allow drafts, transient hiccup) — or a failure
|
||||
# on the plain-text attempt — propagates to the caller, which
|
||||
# treats it as "fall back to edit-based for this response".
|
||||
if use_markdown and self._is_bad_request_error(e):
|
||||
logger.debug(
|
||||
"[%s] sendMessageDraft MarkdownV2 rejected, retrying "
|
||||
"as plain text (chat=%s draft_id=%s): %s",
|
||||
self.name, chat_id, draft_id, e,
|
||||
)
|
||||
continue
|
||||
logger.debug(
|
||||
"[%s] sendMessageDraft failed (chat=%s draft_id=%s): %s",
|
||||
self.name, chat_id, draft_id, e,
|
||||
)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
return SendResult(success=False, error="draft_rejected")
|
||||
try:
|
||||
ok = await self._bot.send_message_draft(**kwargs)
|
||||
if ok:
|
||||
# Drafts have no message_id; we report success without one
|
||||
# so the caller knows the animation frame landed.
|
||||
return SendResult(success=True, message_id=None)
|
||||
return SendResult(success=False, error="draft_rejected")
|
||||
except Exception as e:
|
||||
# Most likely: BadRequest because this bot/chat doesn't allow
|
||||
# drafts, or a transient server hiccup. The caller treats any
|
||||
# failure as "fall back to edit-based for this response".
|
||||
logger.debug(
|
||||
"[%s] sendMessageDraft failed (chat=%s draft_id=%s): %s",
|
||||
self.name, chat_id, draft_id, e,
|
||||
)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def _send_message_with_thread_fallback(self, **kwargs):
|
||||
"""Send a Telegram message, retrying once without message_thread_id
|
||||
|
||||
@@ -107,17 +107,14 @@ def set_session_vars(
|
||||
user_name: str = "",
|
||||
session_key: str = "",
|
||||
message_id: str = "",
|
||||
cwd: str = "",
|
||||
) -> list:
|
||||
"""Set all session context variables and return reset tokens.
|
||||
|
||||
Call ``clear_session_vars(tokens)`` in a ``finally`` block when the handler
|
||||
exits. Note ``clear_session_vars`` resets every var to ``""`` (to suppress
|
||||
the ``os.environ`` fallback) rather than restoring prior values — these
|
||||
helpers are not nestable/stack-safe, and the returned tokens are accepted
|
||||
only for API compatibility.
|
||||
Call ``clear_session_vars(tokens)`` in a ``finally`` block to restore
|
||||
the previous values when the handler exits.
|
||||
|
||||
``cwd`` pins the logical working directory for this context.
|
||||
Returns a list of ``Token`` objects (one per variable) that can be
|
||||
passed to ``clear_session_vars``.
|
||||
"""
|
||||
tokens = [
|
||||
_SESSION_PLATFORM.set(platform),
|
||||
@@ -129,12 +126,6 @@ def set_session_vars(
|
||||
_SESSION_KEY.set(session_key),
|
||||
_SESSION_MESSAGE_ID.set(message_id),
|
||||
]
|
||||
try:
|
||||
from agent.runtime_cwd import set_session_cwd
|
||||
|
||||
set_session_cwd(cwd)
|
||||
except Exception:
|
||||
pass
|
||||
return tokens
|
||||
|
||||
|
||||
@@ -160,12 +151,6 @@ def clear_session_vars(tokens: list) -> None:
|
||||
_SESSION_MESSAGE_ID,
|
||||
):
|
||||
var.set("")
|
||||
try:
|
||||
from agent.runtime_cwd import clear_session_cwd
|
||||
|
||||
clear_session_cwd()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_session_env(name: str, default: str = "") -> str:
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
"""Adapter-driven dispatch of structured stream events to a delivery sink.
|
||||
|
||||
``GatewayEventDispatcher`` is the seam Tobi asked for: the agent emits typed
|
||||
events (gateway/stream_events.py), and the *adapter* decides how each one is
|
||||
delivered. The dispatcher holds an adapter + the stream consumer (sink) + the
|
||||
resolved per-channel presentation settings (tool-progress mode, preview length)
|
||||
and routes each event through the adapter's render hooks.
|
||||
|
||||
Message/commentary/segment events flow into the consumer (native draft on
|
||||
Telegram DMs, edit-in-place elsewhere). Tool events are formatted by the
|
||||
adapter — which may return None to *eat* the event on platforms that can't
|
||||
render tool chrome — and the rendered line is enqueued onto the same tool
|
||||
progress queue the gateway already drains, so the two no longer race through
|
||||
independent code paths.
|
||||
|
||||
This module deliberately has no platform knowledge and no asyncio: it is a thin
|
||||
synchronous router callable from the agent's worker thread, exactly like the
|
||||
callbacks it replaces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from gateway.stream_events import (
|
||||
Commentary,
|
||||
GatewayNotice,
|
||||
LongToolHint,
|
||||
MessageChunk,
|
||||
MessageStop,
|
||||
StreamEvent,
|
||||
ToolCallChunk,
|
||||
ToolCallFinished,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("gateway.stream_events")
|
||||
|
||||
|
||||
class GatewayEventDispatcher:
|
||||
"""Route typed stream events through an adapter onto a delivery sink.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
adapter:
|
||||
The platform adapter. Provides ``render_message_event`` and
|
||||
``format_tool_event`` (BasePlatformAdapter defaults reproduce today's
|
||||
behavior; adapters may override for native rendering).
|
||||
sink:
|
||||
The GatewayStreamConsumer for assistant-text delivery. May be None
|
||||
when streaming is disabled, in which case message events are dropped
|
||||
(the final response still goes out via the normal send path).
|
||||
enqueue_tool_line:
|
||||
Callback that places a rendered tool-progress line onto the gateway's
|
||||
progress queue (the same queue ``send_progress_messages`` drains). May
|
||||
be None when tool progress is disabled for this channel.
|
||||
tool_mode:
|
||||
Resolved tool-progress mode for this channel ("all" / "new" / "verbose"
|
||||
/ "off").
|
||||
preview_max_len:
|
||||
Resolved ``tool_preview_length`` (0 = no cap in verbose mode).
|
||||
on_long_tool / on_notice:
|
||||
Optional hooks for LongToolHint / GatewayNotice events, letting the
|
||||
gateway own the "should I surface this here?" decision.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adapter: Any,
|
||||
sink: Any = None,
|
||||
*,
|
||||
enqueue_tool_line: Optional[Callable[[Any], None]] = None,
|
||||
tool_mode: str = "all",
|
||||
preview_max_len: int = 40,
|
||||
on_long_tool: Optional[Callable[[LongToolHint], None]] = None,
|
||||
on_notice: Optional[Callable[[GatewayNotice], None]] = None,
|
||||
) -> None:
|
||||
self.adapter = adapter
|
||||
self.sink = sink
|
||||
self._enqueue_tool_line = enqueue_tool_line
|
||||
self.tool_mode = tool_mode or "all"
|
||||
self.preview_max_len = preview_max_len
|
||||
self._on_long_tool = on_long_tool
|
||||
self._on_notice = on_notice
|
||||
# "new" mode dedup — only report when the tool changes.
|
||||
self._last_tool: Optional[str] = None
|
||||
|
||||
def dispatch(self, event: StreamEvent) -> None:
|
||||
"""Route a single event. Never raises into the agent's worker thread."""
|
||||
try:
|
||||
self._dispatch(event)
|
||||
except Exception: # presentation must never break the agent loop
|
||||
logger.debug("stream-event dispatch error", exc_info=True)
|
||||
|
||||
def _dispatch(self, event: StreamEvent) -> None:
|
||||
if isinstance(event, (MessageChunk, MessageStop, Commentary)):
|
||||
if self.sink is not None:
|
||||
self.adapter.render_message_event(event, self.sink)
|
||||
return
|
||||
|
||||
if isinstance(event, ToolCallChunk):
|
||||
if self.tool_mode == "off" or self._enqueue_tool_line is None:
|
||||
return
|
||||
# "new" mode: only emit when the tool changes.
|
||||
if self.tool_mode == "new" and event.tool_name == self._last_tool:
|
||||
return
|
||||
self._last_tool = event.tool_name
|
||||
line = self.adapter.format_tool_event(
|
||||
event, mode=self.tool_mode, preview_max_len=self.preview_max_len,
|
||||
)
|
||||
# None == adapter chose to eat this event (can't render tool chrome).
|
||||
if line:
|
||||
self._enqueue_tool_line(line)
|
||||
return
|
||||
|
||||
if isinstance(event, ToolCallFinished):
|
||||
# Default: no chrome on completion (matches today — the gateway only
|
||||
# rendered "started" events). Completion drives onboarding hints.
|
||||
return
|
||||
|
||||
if isinstance(event, LongToolHint):
|
||||
if self._on_long_tool is not None:
|
||||
self._on_long_tool(event)
|
||||
return
|
||||
|
||||
if isinstance(event, GatewayNotice):
|
||||
if self._on_notice is not None:
|
||||
self._on_notice(event)
|
||||
return
|
||||
|
||||
|
||||
__all__ = ["GatewayEventDispatcher"]
|
||||
@@ -1,171 +0,0 @@
|
||||
"""Structured streaming events — the agent→gateway delivery contract.
|
||||
|
||||
Historically the agent drove gateway delivery through a fan of loosely-typed
|
||||
callbacks (``stream_delta_callback(text)``, ``tool_progress_callback(event_type,
|
||||
tool_name, preview, args)``, ``interim_assistant_callback(text)`` …) and each
|
||||
gateway callback decided *both* what to render and how to send it. That
|
||||
coupling is why tool-progress bubbles and the streaming draft raced each other
|
||||
on Telegram, and why tool-call formatting lived agent-side even though only the
|
||||
gateway knows what a given platform can render.
|
||||
|
||||
This module defines a small, typed event vocabulary that names *what happened*
|
||||
without prescribing *how it is delivered*. The gateway's stream consumer
|
||||
(``GatewayStreamConsumer``) is the single sink; the platform adapter decides how
|
||||
to render each event (Telegram can stream a MarkdownV2 ```bash``` block as a
|
||||
native draft; iMessage has no rich formatting and may collapse or drop tool
|
||||
chrome). Separation of concerns: smart agent emits structured data, smart
|
||||
gateway decides delivery.
|
||||
|
||||
These are intentionally plain frozen dataclasses — no behavior, no platform
|
||||
knowledge, no I/O. They are cheap to construct on the agent's worker thread and
|
||||
safe to hand across the thread/async boundary into the consumer queue.
|
||||
|
||||
Design constraints (see hermes-agent-dev skill — message-flow + cache
|
||||
invariants):
|
||||
* Events describe *transport*, never *context*. Nothing here is persisted to
|
||||
conversation history; what the gateway chooses to "eat" (e.g. tool chrome on
|
||||
a platform that can't render it) must never diverge from the bytes stored in
|
||||
the agent's message history. History is owned by the agent; these events are
|
||||
a presentation-layer stream only.
|
||||
* Backward compatible by construction. The gateway adapts its existing
|
||||
callbacks into these events at the boundary; adapters that don't opt into
|
||||
event-native rendering get identical behavior via the base-class default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
|
||||
# ── Message (assistant text) events ──────────────────────────────────────────
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MessageChunk:
|
||||
"""A delta of streamed assistant text.
|
||||
|
||||
``text`` is the incremental content as it arrives from the model. The
|
||||
consumer accumulates chunks and progressively renders them (native draft on
|
||||
Telegram DMs, edit-in-place elsewhere). Reasoning/think-block content is
|
||||
filtered upstream and never arrives as a MessageChunk.
|
||||
"""
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MessageStop:
|
||||
"""The current assistant message segment is complete.
|
||||
|
||||
Emitted when a contiguous run of assistant text ends — either the whole
|
||||
response finished, or a tool boundary interrupts the text so the next
|
||||
segment should render as a fresh message *below* any tool chrome.
|
||||
|
||||
``final`` is True only for the terminal stop of the whole turn; an
|
||||
intermediate stop (text → tool call → more text) carries ``final=False`` so
|
||||
the consumer finalizes the current bubble and prepares a new segment without
|
||||
treating the turn as done.
|
||||
"""
|
||||
final: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Commentary:
|
||||
"""A complete interim assistant message emitted between tool iterations.
|
||||
|
||||
Example: the model says "I'll inspect the repo first." before issuing a tool
|
||||
call. Unlike a MessageChunk this is already-complete text (not a delta); the
|
||||
consumer renders it as its own message so it reads as a distinct beat.
|
||||
"""
|
||||
text: str
|
||||
|
||||
|
||||
# ── Tool-call events ─────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallChunk:
|
||||
"""A tool invocation has started (or its in-progress state changed).
|
||||
|
||||
Carries the raw facts about the call — name, a short argument ``preview``,
|
||||
and the full ``args`` dict — and lets the *gateway* decide presentation
|
||||
(emoji, truncation, verbose vs compact, or eat it entirely on platforms that
|
||||
don't show tool chrome). Previously the agent's gateway callback baked the
|
||||
emoji + preview formatting in; that decision now belongs to the adapter.
|
||||
"""
|
||||
tool_name: str
|
||||
preview: Optional[str] = None
|
||||
args: Optional[Dict[str, Any]] = None
|
||||
# Monotonic per-turn index, so the consumer can correlate a finish with its
|
||||
# start and so "new"-mode dedup (only report when the tool changes) works
|
||||
# without the consumer tracking call order itself.
|
||||
index: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallFinished:
|
||||
"""A tool invocation completed.
|
||||
|
||||
``duration`` is wall-clock seconds. ``ok`` reflects whether the tool
|
||||
returned without raising. The gateway uses this to clear/settle a progress
|
||||
bubble and to drive one-time onboarding hints (e.g. suggest /verbose after a
|
||||
long tool run). No tool *output* travels here — output is the agent's
|
||||
concern and is persisted to history, not streamed as presentation.
|
||||
"""
|
||||
tool_name: str
|
||||
duration: float = 0.0
|
||||
ok: bool = True
|
||||
index: int = 0
|
||||
|
||||
|
||||
# ── Gateway control / lifecycle events ───────────────────────────────────────
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LongToolHint:
|
||||
"""One-shot onboarding nudge when a tool runs longer than the threshold.
|
||||
|
||||
The gateway gates this on platform capability (the /verbose command must be
|
||||
usable) and on the user not having seen the hint before. Modeled as an
|
||||
event so the *gateway* owns the "should I surface this here?" decision rather
|
||||
than the agent.
|
||||
"""
|
||||
tool_name: str = ""
|
||||
duration: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GatewayNotice:
|
||||
"""A gateway-originated control message (restart, online, long-run notice).
|
||||
|
||||
``kind`` is a stable string the adapter can switch on
|
||||
(``"restart"`` / ``"online"`` / ``"long_run"`` / …). ``text`` is the
|
||||
human-readable default the base class renders when an adapter has no
|
||||
platform-specific treatment.
|
||||
"""
|
||||
kind: str
|
||||
text: str = ""
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
# Union of every event the consumer's dispatcher accepts. Kept explicit (rather
|
||||
# than a marker base class) so a missing ``case`` in an exhaustive match is a
|
||||
# visible type error rather than a silent fall-through.
|
||||
StreamEvent = Union[
|
||||
MessageChunk,
|
||||
MessageStop,
|
||||
Commentary,
|
||||
ToolCallChunk,
|
||||
ToolCallFinished,
|
||||
LongToolHint,
|
||||
GatewayNotice,
|
||||
]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MessageChunk",
|
||||
"MessageStop",
|
||||
"Commentary",
|
||||
"ToolCallChunk",
|
||||
"ToolCallFinished",
|
||||
"LongToolHint",
|
||||
"GatewayNotice",
|
||||
"StreamEvent",
|
||||
]
|
||||
+1
-3
@@ -3370,7 +3370,7 @@ def _sync_codex_pool_entries(
|
||||
entry["last_error_reset_at"] = None
|
||||
|
||||
|
||||
def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label: str = None) -> None:
|
||||
def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None:
|
||||
"""Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json)."""
|
||||
if last_refresh is None:
|
||||
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
@@ -3380,8 +3380,6 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label:
|
||||
state["tokens"] = tokens
|
||||
state["last_refresh"] = last_refresh
|
||||
state["auth_mode"] = "chatgpt"
|
||||
if label and str(label).strip():
|
||||
state["label"] = str(label).strip()
|
||||
_save_provider_state(auth_store, "openai-codex", state)
|
||||
_sync_codex_pool_entries(auth_store, tokens, last_refresh)
|
||||
_save_auth_store(auth_store)
|
||||
|
||||
@@ -307,20 +307,28 @@ def auth_add_command(args) -> None:
|
||||
return
|
||||
|
||||
if provider == "openai-codex":
|
||||
# Clear any existing suppression marker so a re-link after `hermes auth
|
||||
# remove openai-codex` works without the new tokens being skipped.
|
||||
auth_mod.unsuppress_credential_source(provider, "device_code")
|
||||
creds = auth_mod._codex_device_code_login()
|
||||
label = (getattr(args, "label", None) or "").strip() or label_from_token(
|
||||
creds["tokens"]["access_token"],
|
||||
_oauth_default_label(provider, len(pool.entries()) + 1),
|
||||
)
|
||||
auth_mod._save_codex_tokens(
|
||||
creds["tokens"],
|
||||
last_refresh=creds.get("last_refresh"),
|
||||
entry = PooledCredential(
|
||||
provider=provider,
|
||||
id=uuid.uuid4().hex[:6],
|
||||
label=label,
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source=f"{SOURCE_MANUAL}:device_code",
|
||||
access_token=creds["tokens"]["access_token"],
|
||||
refresh_token=creds["tokens"].get("refresh_token"),
|
||||
base_url=creds.get("base_url"),
|
||||
last_refresh=creds.get("last_refresh"),
|
||||
)
|
||||
pool = load_pool(provider)
|
||||
entry = next((item for item in pool.entries() if item.source == "device_code"), None)
|
||||
shown_label = entry.label if entry is not None else label
|
||||
print(f'Saved {provider} OAuth device-code credentials: "{shown_label}"')
|
||||
pool.add_entry(entry)
|
||||
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
|
||||
return
|
||||
|
||||
if provider == "xai-oauth":
|
||||
|
||||
+2
-61
@@ -1345,27 +1345,7 @@ DEFAULT_CONFIG = {
|
||||
# responses and content messages are never touched. Default 0
|
||||
# (disabled) preserves prior behavior.
|
||||
"ephemeral_system_ttl": 0,
|
||||
# Per-platform display/streaming overrides. Each key is a gateway
|
||||
# platform ("telegram", "discord", "slack", …) mapping to a dict of
|
||||
# display settings that override the global value for that platform
|
||||
# only. A setting left unset here falls through to the global default.
|
||||
#
|
||||
# Shipped defaults encode the streaming experience that works best
|
||||
# per platform:
|
||||
# - Telegram has native animated draft streaming (sendMessageDraft),
|
||||
# which is smooth, so streaming is on by default there.
|
||||
# - Discord/Slack/etc. only have edit-based streaming (repeated
|
||||
# editMessage), which flickers and is noticeably jankier, so
|
||||
# streaming is off by default there.
|
||||
# These are gap-fillers: a user who explicitly sets, e.g.,
|
||||
# display.platforms.discord.streaming: true keeps their value
|
||||
# (config deep-merge has user values win over defaults). The global
|
||||
# streaming.enabled master switch still gates everything — these
|
||||
# per-platform flags only take effect once streaming is enabled.
|
||||
"platforms": {
|
||||
"telegram": {"streaming": True},
|
||||
"discord": {"streaming": False},
|
||||
},
|
||||
"platforms": {}, # Per-platform display overrides: {"telegram": {"tool_progress": "all"}, "slack": {"tool_progress": "off"}}
|
||||
# Gateway runtime-metadata footer appended to the FINAL message of a turn
|
||||
# (disabled by default to keep replies minimal). When enabled, renders
|
||||
# e.g. `model · 68% · ~/projects/hermes`. Per-platform overrides go under
|
||||
@@ -2057,45 +2037,6 @@ DEFAULT_CONFIG = {
|
||||
"trust_recent_files_seconds": 600,
|
||||
},
|
||||
|
||||
# Real-time token streaming to messaging platforms (Telegram, Discord,
|
||||
# Slack, etc.). Read at the top level by the gateway; absent this block the
|
||||
# gateway falls back to these same defaults, so adding it here only makes
|
||||
# the feature discoverable in config.yaml — it does not change behavior.
|
||||
#
|
||||
# Disabled by default: streaming costs extra edit/draft API calls per
|
||||
# response. Set ``enabled: true`` and restart the gateway to turn it on.
|
||||
"streaming": {
|
||||
# Master switch. When false, each response is delivered as a single
|
||||
# final message (no progressive updates).
|
||||
"enabled": False,
|
||||
# Transport selection:
|
||||
# "auto" — prefer native draft streaming where the platform
|
||||
# supports it (Telegram DMs via sendMessageDraft,
|
||||
# Bot API 9.5+) and fall back to edit-based elsewhere.
|
||||
# Safe global default: platforms without draft support
|
||||
# (Discord, Slack, Matrix, Telegram groups) transparently
|
||||
# use the edit path, so "auto" only upgrades chats that
|
||||
# can render the smoother native preview.
|
||||
# "draft" — explicitly request native drafts; falls back to edit
|
||||
# when the platform/chat doesn't support them.
|
||||
# "edit" — progressive editMessageText only (legacy behavior).
|
||||
# "off" — disable streaming entirely (same as enabled: false).
|
||||
"transport": "auto",
|
||||
# Minimum seconds between progressive edits — tuned for Telegram's
|
||||
# ~1 edit/s flood envelope.
|
||||
"edit_interval": 0.8,
|
||||
# Flush the buffer to the platform once this many characters have
|
||||
# accumulated, so short replies feel near-instant.
|
||||
"buffer_threshold": 24,
|
||||
# Cursor glyph appended to the in-progress message while streaming.
|
||||
"cursor": " \u2589",
|
||||
# When >0, the final edit for a long-running streamed response is
|
||||
# delivered as a fresh message if the preview has been visible at
|
||||
# least this many seconds, so the platform timestamp reflects
|
||||
# completion time. Telegram only; other platforms ignore it.
|
||||
"fresh_final_after_seconds": 60.0,
|
||||
},
|
||||
|
||||
# Session storage — controls automatic cleanup of ~/.hermes/state.db.
|
||||
# state.db accumulates every session, message, tool call, and FTS5 index
|
||||
# entry forever. Without auto-pruning, a heavy user (gateway + cron)
|
||||
@@ -3815,7 +3756,7 @@ _KNOWN_ROOT_KEYS = {
|
||||
"fallback_providers", "credential_pool_strategies", "toolsets",
|
||||
"agent", "terminal", "display", "compression", "delegation",
|
||||
"auxiliary", "custom_providers", "context", "memory", "gateway",
|
||||
"sessions", "streaming",
|
||||
"sessions",
|
||||
}
|
||||
|
||||
# Valid fields inside a custom_providers list entry
|
||||
|
||||
@@ -24,7 +24,7 @@ from fastapi.responses import JSONResponse, RedirectResponse, Response
|
||||
|
||||
from hermes_cli.dashboard_auth import list_providers
|
||||
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
|
||||
from hermes_cli.dashboard_auth.base import ProviderError, RefreshExpiredError
|
||||
from hermes_cli.dashboard_auth.base import ProviderError
|
||||
from hermes_cli.dashboard_auth.cookies import read_session_cookies
|
||||
from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS
|
||||
|
||||
@@ -185,94 +185,44 @@ async def gated_auth_middleware(
|
||||
return await call_next(request)
|
||||
|
||||
at, _rt = read_session_cookies(request)
|
||||
if not at and not _rt:
|
||||
# Neither token present — no session at all. Nothing to verify or
|
||||
# refresh; force login.
|
||||
if not at:
|
||||
return _unauth_response(request, reason="no_cookie")
|
||||
|
||||
# Try every registered provider's verify_session in turn. Providers
|
||||
# MUST return None for tokens they don't recognise (not raise). This
|
||||
# lets multiple providers stack — the first one that recognises a
|
||||
# token wins.
|
||||
#
|
||||
# When the access-token cookie is absent but a refresh-token cookie is
|
||||
# present, skip verification and go straight to the refresh path below.
|
||||
# This is the COMMON expiry case, not an edge case: the access-token
|
||||
# cookie is set with ``Max-Age = access_token_expires_in`` (~15 min), so
|
||||
# the browser EVICTS it the moment the token lapses, while the
|
||||
# refresh-token cookie lives for 30 days. From that point the browser
|
||||
# sends only ``hermes_session_rt``. If we bailed on ``not at`` here we'd
|
||||
# bounce the user to /login on every expiry despite holding a perfectly
|
||||
# good refresh token — defeating the whole transparent-refresh feature.
|
||||
session = None
|
||||
if at:
|
||||
for provider in list_providers():
|
||||
try:
|
||||
session = provider.verify_session(access_token=at)
|
||||
except ProviderError as e:
|
||||
_log.warning(
|
||||
"dashboard-auth: provider %r unreachable during verify: %s",
|
||||
provider.name, e,
|
||||
)
|
||||
audit_log(
|
||||
AuditEvent.SESSION_VERIFY_FAILURE,
|
||||
provider=provider.name,
|
||||
reason="provider_unreachable",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return JSONResponse(
|
||||
{"detail": f"Auth provider {provider.name!r} unreachable"},
|
||||
status_code=503,
|
||||
)
|
||||
if session is not None:
|
||||
break
|
||||
|
||||
if session is None:
|
||||
# Access token is expired/invalid. Before forcing re-login, try to
|
||||
# rotate it using the refresh token (if the session cookie carries
|
||||
# one). On success we re-set the rotated cookies on the response and
|
||||
# serve the request transparently; on RefreshExpiredError (RT dead /
|
||||
# revoked / reuse-detected) we fall through to clear-and-relogin.
|
||||
refreshed = _attempt_refresh(request, refresh_token=_rt)
|
||||
if refreshed is not None:
|
||||
new_session, refreshing_provider = refreshed
|
||||
request.state.session = new_session
|
||||
response = await call_next(request)
|
||||
# Persist the ROTATED tokens. Portal rotates the refresh token on
|
||||
# every refresh and runs reuse-detection, so writing the new RT
|
||||
# back is mandatory: a stale RT cookie would replay a rotated
|
||||
# token on the next refresh and (outside Portal's grace) revoke
|
||||
# the whole session. Bind cookie Secure/Path to the request shape.
|
||||
from hermes_cli.dashboard_auth.cookies import (
|
||||
detect_https,
|
||||
set_session_cookies,
|
||||
)
|
||||
from hermes_cli.dashboard_auth.prefix import prefix_from_request
|
||||
|
||||
set_session_cookies(
|
||||
response,
|
||||
access_token=new_session.access_token,
|
||||
refresh_token=new_session.refresh_token,
|
||||
access_token_expires_in=_expires_in_seconds(new_session),
|
||||
use_https=detect_https(request),
|
||||
prefix=prefix_from_request(request),
|
||||
for provider in list_providers():
|
||||
try:
|
||||
session = provider.verify_session(access_token=at)
|
||||
except ProviderError as e:
|
||||
_log.warning(
|
||||
"dashboard-auth: provider %r unreachable during verify: %s",
|
||||
provider.name, e,
|
||||
)
|
||||
audit_log(
|
||||
AuditEvent.REFRESH_SUCCESS,
|
||||
provider=refreshing_provider,
|
||||
user_id=new_session.user_id,
|
||||
AuditEvent.SESSION_VERIFY_FAILURE,
|
||||
provider=provider.name,
|
||||
reason="provider_unreachable",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return response
|
||||
return JSONResponse(
|
||||
{"detail": f"Auth provider {provider.name!r} unreachable"},
|
||||
status_code=503,
|
||||
)
|
||||
if session is not None:
|
||||
break
|
||||
|
||||
if session is None:
|
||||
audit_log(
|
||||
AuditEvent.SESSION_VERIFY_FAILURE,
|
||||
reason="no_provider_recognises",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
response = _unauth_response(request, reason="invalid_or_expired_session")
|
||||
# Clear the dead cookies so the browser doesn't keep sending them.
|
||||
# Refresh already failed (or there was no RT), so the only correct
|
||||
# Clear the dead cookie so the browser doesn't keep sending it.
|
||||
# Contract v1: no refresh token to retry with, so the only correct
|
||||
# next step is full re-auth via /login. Importing locally avoids a
|
||||
# cycle with cookies → middleware at module load. Pass the active
|
||||
# prefix so the deletion's Path matches the set-Path (otherwise
|
||||
@@ -284,61 +234,3 @@ async def gated_auth_middleware(
|
||||
|
||||
request.state.session = session
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def _expires_in_seconds(session) -> int:
|
||||
"""Seconds until the access token's ``exp``, floored at 60.
|
||||
|
||||
Mirrors the auth-route's ``max(60, exp - now)`` so the access-token
|
||||
cookie's Max-Age tracks the token lifetime even on a slightly skewed
|
||||
clock. ``time`` imported locally to keep the module's import surface
|
||||
minimal.
|
||||
"""
|
||||
import time
|
||||
|
||||
return max(60, int(session.expires_at) - int(time.time()))
|
||||
|
||||
|
||||
def _attempt_refresh(request: Request, *, refresh_token):
|
||||
"""Try to rotate an expired session via the refresh token.
|
||||
|
||||
Returns ``(new_session, provider_name)`` on success, or ``None`` if
|
||||
there's no RT or every provider's ``refresh_session`` failed with
|
||||
``RefreshExpiredError`` (dead/revoked/reuse-detected RT → force re-login).
|
||||
|
||||
A ``ProviderError`` (Portal unreachable) is NOT swallowed into a re-login
|
||||
here — re-raising would 500 the request; instead we log and return None so
|
||||
the caller forces a clean re-login, which is the safer UX than a hard
|
||||
error on a transient network blip during the narrow refresh window.
|
||||
"""
|
||||
if not refresh_token:
|
||||
return None
|
||||
for provider in list_providers():
|
||||
try:
|
||||
new_session = provider.refresh_session(refresh_token=refresh_token)
|
||||
except RefreshExpiredError:
|
||||
# This provider owns the RT but it's dead — stop trying others
|
||||
# (an RT belongs to exactly one provider) and force re-login.
|
||||
audit_log(
|
||||
AuditEvent.REFRESH_FAILURE,
|
||||
provider=provider.name,
|
||||
reason="refresh_expired",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return None
|
||||
except ProviderError as e:
|
||||
_log.warning(
|
||||
"dashboard-auth: provider %r unreachable during refresh: %s",
|
||||
provider.name, e,
|
||||
)
|
||||
audit_log(
|
||||
AuditEvent.REFRESH_FAILURE,
|
||||
provider=provider.name,
|
||||
reason="provider_unreachable",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return None
|
||||
if new_session is not None:
|
||||
return new_session, provider.name
|
||||
return None
|
||||
|
||||
|
||||
+29
-210
@@ -201,7 +201,6 @@ if _try_termux_ultrafast_version():
|
||||
raise SystemExit(0)
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -6870,147 +6869,6 @@ def _desktop_dist_exists(desktop_dir: Path) -> bool:
|
||||
return (desktop_dir / "dist" / "index.html").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Desktop build stamp — content-hash based skip logic
|
||||
# ---------------------------------------------------------------------------
|
||||
# The desktop Electron build is expensive.
|
||||
# Unlike the web UI (which uses mtime comparison), the desktop uses a
|
||||
# SHA-256 content hash of the source tree so that:
|
||||
# - ``git checkout`` / ``git pull`` that touch mtimes but not content
|
||||
# don't trigger a rebuild
|
||||
# - ``hermes update`` can unconditionally call ``hermes desktop --build-only``
|
||||
# and it will skip if nothing actually changed
|
||||
# - ``hermes desktop`` (interactive launch) skips the build when the
|
||||
# stamp matches, making repeated launches fast
|
||||
#
|
||||
# Stamp file: $HERMES_HOME/desktop-build-stamp.json
|
||||
# Schema:
|
||||
# {
|
||||
# "contentHash": "<sha256 hex of source files>",
|
||||
# "sourceMode": true | false,
|
||||
# "builtAt": "<ISO 8601>"
|
||||
# }
|
||||
|
||||
def _compute_desktop_content_hash(project_root: Path) -> str:
|
||||
"""Return a SHA-256 hex digest of all source files that feed the desktop build.
|
||||
|
||||
Covers ``apps/desktop/`` (excluding anything matched by .gitignore)
|
||||
plus the root ``package.json`` / ``package-lock.json`` (workspace config
|
||||
that determines dependency resolution for the desktop workspace).
|
||||
|
||||
Parses the repo-root ``.gitignore`` via *pathspec* so we automatically
|
||||
skip ``node_modules/``, ``dist/``, ``*.pyc``, etc. without maintaining
|
||||
a hardcoded skip-list.
|
||||
"""
|
||||
h = hashlib.sha256()
|
||||
|
||||
def _hash_file(path: Path) -> None:
|
||||
rel = str(path.relative_to(project_root))
|
||||
h.update(rel.encode())
|
||||
h.update(b"\0")
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
h.update(b"\0")
|
||||
|
||||
|
||||
from pathspec import PathSpec
|
||||
|
||||
gitignore = project_root / ".gitignore"
|
||||
lines: list[str] = []
|
||||
if gitignore.is_file():
|
||||
lines = gitignore.read_text(encoding="utf-8").splitlines()
|
||||
spec = PathSpec.from_lines("gitignore", lines)
|
||||
|
||||
# Root workspace config
|
||||
for name in ("package.json", "package-lock.json"):
|
||||
p = project_root / name
|
||||
if p.is_file():
|
||||
rel = str(p.relative_to(project_root))
|
||||
if not spec.match_file(rel):
|
||||
_hash_file(p)
|
||||
|
||||
# Walk apps/desktop/ — prune ignored directories in-place
|
||||
desktop_dir = project_root / "apps" / "desktop"
|
||||
for dirpath, dirnames, filenames in os.walk(desktop_dir, topdown=True):
|
||||
# Prune ignored directories so we never descend into them
|
||||
dirnames[:] = [
|
||||
d for d in dirnames
|
||||
if not spec.match_file(str((Path(dirpath) / d).relative_to(project_root)))
|
||||
]
|
||||
|
||||
for fn in sorted(filenames):
|
||||
fp = Path(dirpath) / fn
|
||||
rel = str(fp.relative_to(project_root))
|
||||
if not spec.match_file(rel):
|
||||
_hash_file(fp)
|
||||
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _desktop_stamp_path() -> Path:
|
||||
"""Return the path to the desktop build stamp file under $HERMES_HOME."""
|
||||
from hermes_constants import get_hermes_home
|
||||
return get_hermes_home() / "desktop-build-stamp.json"
|
||||
|
||||
|
||||
def _desktop_build_needed(desktop_dir: Path, project_root: Path, *, source_mode: bool) -> bool:
|
||||
"""Return True when the desktop build output is stale or missing.
|
||||
|
||||
Compares the current content hash against the saved stamp. Also returns
|
||||
True if the expected build artifact doesn't exist (e.g. first run after
|
||||
``hermes update`` that pulled new source but hasn't built yet).
|
||||
"""
|
||||
# If there's no build output at all, we definitely need to build
|
||||
if source_mode:
|
||||
if not _desktop_dist_exists(desktop_dir):
|
||||
return True
|
||||
else:
|
||||
if _desktop_packaged_executable(desktop_dir) is None:
|
||||
return True
|
||||
|
||||
stamp_file = _desktop_stamp_path()
|
||||
if not stamp_file.is_file():
|
||||
return True
|
||||
|
||||
try:
|
||||
stamp_data = json.loads(stamp_file.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, KeyError):
|
||||
return True
|
||||
|
||||
# If the mode changed (source vs packaged), force a rebuild
|
||||
if stamp_data.get("sourceMode") != source_mode:
|
||||
return True
|
||||
|
||||
saved_hash = stamp_data.get("contentHash")
|
||||
if not saved_hash:
|
||||
return True
|
||||
|
||||
current_hash = _compute_desktop_content_hash(project_root)
|
||||
return current_hash != saved_hash
|
||||
|
||||
|
||||
def _write_desktop_build_stamp(project_root: Path, *, source_mode: bool) -> None:
|
||||
"""Write the desktop build stamp after a successful build."""
|
||||
stamp_file = _desktop_stamp_path()
|
||||
try:
|
||||
stamp_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
content_hash = _compute_desktop_content_hash(project_root)
|
||||
from datetime import datetime, timezone
|
||||
stamp_data = {
|
||||
"contentHash": content_hash,
|
||||
"sourceMode": source_mode,
|
||||
"builtAt": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
stamp_file.write_text(json.dumps(stamp_data, indent=2) + "\n", encoding="utf-8")
|
||||
except Exception as exc:
|
||||
# Never let stamp-writing block or fail a build
|
||||
logger.debug("Failed to write desktop build stamp: %s", exc)
|
||||
|
||||
|
||||
def _desktop_packaged_executable(desktop_dir: Path) -> Optional[Path]:
|
||||
"""Return the current platform's unpacked Electron app executable."""
|
||||
release_dir = desktop_dir / "release"
|
||||
@@ -7070,7 +6928,8 @@ def _desktop_macos_relaunchable_fixup(desktop_dir: Path) -> None:
|
||||
except Exception as exc:
|
||||
print(f" (warning: macOS relaunch fixup skipped: {exc})")
|
||||
|
||||
def cmd_gui(args: argparse.Namespace):
|
||||
|
||||
def cmd_gui(args):
|
||||
"""Build and launch the native Electron desktop GUI."""
|
||||
desktop_dir = PROJECT_ROOT / "apps" / "desktop"
|
||||
if not (desktop_dir / "package.json").exists():
|
||||
@@ -7095,8 +6954,6 @@ def cmd_gui(args: argparse.Namespace):
|
||||
|
||||
source_mode = getattr(args, "source", False)
|
||||
skip_build = getattr(args, "skip_build", False)
|
||||
force_build = getattr(args, "force_build", False)
|
||||
|
||||
packaged_executable = _desktop_packaged_executable(desktop_dir)
|
||||
|
||||
if source_mode or not skip_build:
|
||||
@@ -7108,7 +6965,7 @@ def cmd_gui(args: argparse.Namespace):
|
||||
else:
|
||||
npm = None
|
||||
|
||||
if skip_build:
|
||||
if getattr(args, "skip_build", False):
|
||||
if source_mode:
|
||||
if not _desktop_dist_exists(desktop_dir):
|
||||
print(f"✗ --skip-build --source was passed but no desktop dist found at: {desktop_dir / 'dist'}")
|
||||
@@ -7129,41 +6986,27 @@ def cmd_gui(args: argparse.Namespace):
|
||||
else:
|
||||
print(f"→ Skipping desktop package build (--skip-build); using {packaged_executable}")
|
||||
else:
|
||||
# Check the content-hash stamp before doing any build work.
|
||||
# If the source tree hasn't changed since the last successful build,
|
||||
# skip the npm install + build entirely (saves a ton of useless work).
|
||||
# --force-build overrides the stamp and always rebuilds.
|
||||
build_needed = force_build or _desktop_build_needed(
|
||||
desktop_dir, PROJECT_ROOT, source_mode=source_mode
|
||||
)
|
||||
if not build_needed:
|
||||
build_label = "source build" if source_mode else "packaged app"
|
||||
print(f"✓ Desktop {build_label} is up to date (content stamp matches)")
|
||||
else:
|
||||
print("→ Installing desktop workspace dependencies...")
|
||||
install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False)
|
||||
if install_result.returncode != 0:
|
||||
print("✗ Desktop dependency install failed")
|
||||
print(f" Run manually: cd {PROJECT_ROOT} && npm ci")
|
||||
sys.exit(install_result.returncode or 1)
|
||||
print("→ Installing desktop workspace dependencies...")
|
||||
install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False)
|
||||
if install_result.returncode != 0:
|
||||
print("✗ Desktop dependency install failed")
|
||||
print(f" Run manually: cd {PROJECT_ROOT} && npm ci")
|
||||
sys.exit(install_result.returncode or 1)
|
||||
|
||||
build_label = "source build" if source_mode else "packaged app"
|
||||
print(f"→ Building desktop {build_label}...")
|
||||
build_script = "build" if source_mode else "pack"
|
||||
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
|
||||
if build_result.returncode != 0:
|
||||
print("✗ Desktop GUI build failed")
|
||||
print(f" Run manually: cd apps/desktop && npm run {build_script}")
|
||||
sys.exit(build_result.returncode or 1)
|
||||
packaged_executable = _desktop_packaged_executable(desktop_dir)
|
||||
if not source_mode:
|
||||
# Locally-built apps are ad-hoc signed; make them relaunchable after
|
||||
# an in-place self-update (otherwise macOS reports "Hermes is
|
||||
# damaged"). No-op on non-macOS and on real-identity builds.
|
||||
_desktop_macos_relaunchable_fixup(desktop_dir)
|
||||
|
||||
# Build succeeded — write the stamp so next run can skip
|
||||
_write_desktop_build_stamp(PROJECT_ROOT, source_mode=source_mode)
|
||||
build_label = "source build" if source_mode else "packaged app"
|
||||
print(f"→ Building desktop {build_label}...")
|
||||
build_script = "build" if source_mode else "pack"
|
||||
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
|
||||
if build_result.returncode != 0:
|
||||
print("✗ Desktop GUI build failed")
|
||||
print(f" Run manually: cd apps/desktop && npm run {build_script}")
|
||||
sys.exit(build_result.returncode or 1)
|
||||
packaged_executable = _desktop_packaged_executable(desktop_dir)
|
||||
if not source_mode:
|
||||
# Locally-built apps are ad-hoc signed; make them relaunchable after
|
||||
# an in-place self-update (otherwise macOS reports "Hermes is
|
||||
# damaged"). No-op on non-macOS and on real-identity builds.
|
||||
_desktop_macos_relaunchable_fixup(desktop_dir)
|
||||
|
||||
# --build-only: produce the artifact but do NOT launch. The installer's
|
||||
# --update flow drives the rebuild headlessly and then launches the desktop
|
||||
@@ -9854,25 +9697,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
_update_node_dependencies()
|
||||
_build_web_ui(PROJECT_ROOT / "web")
|
||||
|
||||
# Rebuild the desktop app if the source tree changed since the last
|
||||
# build. ``hermes desktop --build-only`` uses the content-hash stamp
|
||||
# internally, so this is effectively a no-op when nothing changed.
|
||||
# Only bother if the user has a desktop app installed (indicated by
|
||||
# an existing packaged executable or desktop dist); people who have
|
||||
# never run ``hermes desktop`` shouldn't be forced into a full
|
||||
# Electron build by ``hermes update``.
|
||||
desktop_dir = PROJECT_ROOT / "apps" / "desktop"
|
||||
has_desktop_app = _desktop_packaged_executable(desktop_dir) is not None or _desktop_dist_exists(desktop_dir)
|
||||
if (desktop_dir / "package.json").exists() and shutil.which("npm") and has_desktop_app:
|
||||
print("→ Checking if desktop app needs rebuilding...")
|
||||
build_result = subprocess.run(
|
||||
[sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=False,
|
||||
)
|
||||
if build_result.returncode != 0:
|
||||
print(" ⚠ Desktop build failed (non-fatal; run `hermes desktop` to retry)")
|
||||
|
||||
print()
|
||||
print("✓ Code updated!")
|
||||
|
||||
@@ -11540,7 +11364,7 @@ def cmd_dashboard(args):
|
||||
if not _build_web_ui(PROJECT_ROOT / "web", fatal=True):
|
||||
sys.exit(1)
|
||||
elif getattr(args, "skip_build", False):
|
||||
# --build-mode skip trusts the caller to have pre-built the web UI.
|
||||
# --skip-build trusts the caller to have pre-built the web UI.
|
||||
# Verify the dist actually exists; otherwise the server will start
|
||||
# and serve 404s with no obvious cause (issue #23817).
|
||||
_dist_root = (
|
||||
@@ -14909,6 +14733,11 @@ Examples:
|
||||
"Electron app, then launches that packaged artifact."
|
||||
),
|
||||
)
|
||||
gui_parser.add_argument(
|
||||
"--skip-build",
|
||||
action="store_true",
|
||||
help="Skip npm install/package and launch the existing unpacked app from apps/desktop/release",
|
||||
)
|
||||
gui_parser.add_argument(
|
||||
"--source",
|
||||
action="store_true",
|
||||
@@ -14937,16 +14766,6 @@ Examples:
|
||||
"--cwd",
|
||||
help="Initial project directory for Desktop chat sessions (sets HERMES_DESKTOP_CWD)",
|
||||
)
|
||||
gui_parser.add_argument(
|
||||
"--skip-build",
|
||||
action="store_true",
|
||||
help="Skip npm install/package and launch the existing unpacked app from apps/desktop/release",
|
||||
)
|
||||
gui_parser.add_argument(
|
||||
"--force-build",
|
||||
action="store_true",
|
||||
help="Force a full rebuild even if the content stamp matches",
|
||||
)
|
||||
gui_parser.set_defaults(func=cmd_gui)
|
||||
|
||||
# =========================================================================
|
||||
|
||||
@@ -241,12 +241,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"google-gemini-cli": [
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-pro-preview",
|
||||
# Code Assist serves two flash slugs with different access gates
|
||||
# (gemini-cli models.ts): gemini-3-flash-preview is the preview flash
|
||||
# that subscription/free-tier OAuth users actually reach, while
|
||||
# gemini-3.5-flash is GA-channel-gated. Offer both so non-GA users
|
||||
# aren't stuck with a slug cloudcode-pa 404s for them.
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.5-flash",
|
||||
],
|
||||
"zai": [
|
||||
@@ -2112,32 +2106,9 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
|
||||
if api_key:
|
||||
base_raw = os.getenv("OPENAI_BASE_URL", "").strip().rstrip("/")
|
||||
base = base_raw or "https://api.openai.com/v1"
|
||||
# Custom OpenAI-compatible endpoints (proxies, gateways, self-hosted)
|
||||
# may serve a small curated catalog — use the live list verbatim so
|
||||
# discovery works. But the canonical api.openai.com /v1/models dump
|
||||
# is 120+ entries of embeddings, whisper, tts, dall-e, moderation and
|
||||
# legacy chat models — none of which belong in the agent model picker.
|
||||
# For the default endpoint, intersect the live list with our curated
|
||||
# agentic catalog so ``/model`` matches what ``hermes model`` shows.
|
||||
is_default_openai = base.rstrip("/") in (
|
||||
"https://api.openai.com/v1",
|
||||
"https://api.openai.com",
|
||||
)
|
||||
try:
|
||||
live = fetch_api_models(api_key, base)
|
||||
if live:
|
||||
if is_default_openai:
|
||||
live_lower = {m.lower() for m in live}
|
||||
curated = list(_PROVIDER_MODELS.get(normalized, []))
|
||||
# Keep curated order; only surface curated models the
|
||||
# account actually has access to.
|
||||
filtered = [m for m in curated if m.lower() in live_lower]
|
||||
if filtered:
|
||||
return filtered
|
||||
# Account serves none of the curated models (rare —
|
||||
# e.g. org without GPT-5 access). Fall back to curated
|
||||
# so the picker still offers sane defaults.
|
||||
return curated or live
|
||||
return live
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -47,6 +47,7 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
||||
"openrouter": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
is_aggregator=True,
|
||||
extra_env_vars=("OPENAI_API_KEY",),
|
||||
base_url_env_var="OPENROUTER_BASE_URL",
|
||||
),
|
||||
"nous": HermesOverlay(
|
||||
|
||||
+25
-142
@@ -1363,7 +1363,6 @@ async def get_sessions(
|
||||
offset: int = 0,
|
||||
min_messages: int = 0,
|
||||
archived: str = "exclude",
|
||||
order: str = "created",
|
||||
):
|
||||
"""List sessions.
|
||||
|
||||
@@ -1371,22 +1370,12 @@ async def get_sessions(
|
||||
``exclude`` (default) hides them, ``only`` returns just the archived ones
|
||||
(used by the desktop "Archived sessions" settings panel), and ``include``
|
||||
returns both.
|
||||
|
||||
``order`` controls pagination order: ``created`` (default, by original
|
||||
start time) or ``recent`` (by latest activity across the compression
|
||||
chain). ``recent`` keeps a long-running conversation on the first page
|
||||
after it auto-compresses into a fresh continuation id.
|
||||
"""
|
||||
if archived not in ("exclude", "only", "include"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="archived must be one of: exclude, only, include",
|
||||
)
|
||||
if order not in ("created", "recent"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="order must be one of: created, recent",
|
||||
)
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
@@ -1400,7 +1389,6 @@ async def get_sessions(
|
||||
min_message_count=min_message_count,
|
||||
include_archived=include_archived,
|
||||
archived_only=archived_only,
|
||||
order_by_last_active=order == "recent",
|
||||
)
|
||||
total = db.session_count(
|
||||
min_message_count=min_message_count,
|
||||
@@ -3729,12 +3717,31 @@ def _codex_full_login_worker(session_id: str) -> None:
|
||||
if not access_token:
|
||||
raise RuntimeError("token exchange did not return access_token")
|
||||
|
||||
from hermes_cli.auth import _save_codex_tokens
|
||||
|
||||
_save_codex_tokens({
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
})
|
||||
# Persist via credential pool — same shape as auth_commands.add_command
|
||||
from agent.credential_pool import (
|
||||
PooledCredential,
|
||||
load_pool,
|
||||
AUTH_TYPE_OAUTH,
|
||||
SOURCE_MANUAL,
|
||||
)
|
||||
import uuid as _uuid
|
||||
pool = load_pool("openai-codex")
|
||||
base_url = (
|
||||
os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/")
|
||||
or DEFAULT_CODEX_BASE_URL
|
||||
)
|
||||
entry = PooledCredential(
|
||||
provider="openai-codex",
|
||||
id=_uuid.uuid4().hex[:6],
|
||||
label="dashboard device_code",
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source=f"{SOURCE_MANUAL}:dashboard_device_code",
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
base_url=base_url,
|
||||
)
|
||||
pool.add_entry(entry)
|
||||
with _oauth_sessions_lock:
|
||||
sess["status"] = "approved"
|
||||
_log.info("oauth/device: openai-codex login completed (session=%s)", session_id)
|
||||
@@ -3906,117 +3913,6 @@ def _session_latest_descendant(session_id: str):
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# CRITICAL — every literal-path route below MUST be declared BEFORE the
|
||||
# templated ``/api/sessions/{session_id}`` family that follows. FastAPI/
|
||||
# Starlette match routes in registration order, and the ``{session_id}``
|
||||
# pattern is unconstrained — it would otherwise swallow e.g.
|
||||
# ``DELETE /api/sessions/empty``, ``POST /api/sessions/bulk-delete``, or
|
||||
# ``GET /api/sessions/stats`` as "operate on the session with id
|
||||
# 'empty'" / "'bulk-delete'" / "'stats'", which would 404 (or worse,
|
||||
# succeed and delete the wrong row). Same story as the older
|
||||
# ``/api/sessions/search`` endpoint up at line ~1191. If you split or
|
||||
# reorder this block, move every route in it together.
|
||||
class BulkDeleteSessions(BaseModel):
|
||||
ids: List[str]
|
||||
|
||||
|
||||
@app.post("/api/sessions/bulk-delete")
|
||||
async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
|
||||
"""Delete every session in ``body.ids`` in a single DB transaction.
|
||||
|
||||
Backs the dashboard's bulk-select-and-delete flow on the sessions
|
||||
page. POST (not DELETE) because most HTTP clients refuse to send a
|
||||
request body on DELETE and a body is the natural shape for a list
|
||||
of IDs — Starlette accepts both, but POSTing a list keeps proxies,
|
||||
curl, and the browser ``fetch`` API consistent.
|
||||
|
||||
Per-row contract matches :meth:`SessionDB.delete_sessions`:
|
||||
|
||||
* Unknown IDs are silently skipped (the response ``deleted`` count
|
||||
reflects what really happened, not the input length). This is
|
||||
deliberate — UI selection state can race against another tab's
|
||||
delete, and we'd rather succeed-on-the-rest than fail-the-whole-
|
||||
batch.
|
||||
* Children of every deleted parent are orphaned, not cascade-
|
||||
deleted.
|
||||
* Active and archived sessions ARE deleted when explicitly
|
||||
selected — unlike ``DELETE /api/sessions/empty``, the user
|
||||
hand-picked the rows so we trust the selection.
|
||||
* Like the other session-delete endpoints, this does NOT pass a
|
||||
``sessions_dir`` through; on-disk transcript / request-dump
|
||||
cleanup runs at the CLI/agent layer on the next prune pass.
|
||||
|
||||
The response carries the actual deleted count, so the dashboard
|
||||
can surface it in a toast. The IDs that were removed are not
|
||||
echoed back because the client already knows what it asked to
|
||||
delete (unknown IDs are silently skipped — see contract above)
|
||||
and can prune its in-memory list directly from the request.
|
||||
"""
|
||||
# Enforce a hard cap so a runaway/typo'd selection can't lock the
|
||||
# DB writer for an extended window. The dashboard pages 20 rows
|
||||
# at a time; 500 covers a "select all on every page in a
|
||||
# reasonable scrollback" worst case without opening the door to
|
||||
# multi-thousand-row transactions.
|
||||
if len(body.ids) > 500:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ids must contain at most 500 entries",
|
||||
)
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
deleted = db.delete_sessions(body.ids)
|
||||
return {"ok": True, "deleted": deleted}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/api/sessions/empty/count")
|
||||
async def count_empty_sessions_endpoint():
|
||||
"""Return the number of empty, ended, non-archived sessions.
|
||||
|
||||
Drives the dashboard's "Delete empty (N)" button — when N is 0 the
|
||||
UI hides the affordance so users aren't presented with a button
|
||||
that does nothing. Cheap, single-COUNT query.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
return {"count": db.count_empty_sessions()}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.delete("/api/sessions/empty")
|
||||
async def delete_empty_sessions_endpoint():
|
||||
"""Delete every empty (``message_count == 0``), ended,
|
||||
non-archived session in a single transaction.
|
||||
|
||||
Safety contract mirrors :meth:`SessionDB.delete_empty_sessions`:
|
||||
|
||||
* Active sessions are skipped (``ended_at IS NULL``) so a live
|
||||
agent isn't yanked mid-handshake.
|
||||
* Archived sessions are skipped — the user explicitly chose to
|
||||
keep those rows.
|
||||
* Children of deleted parents are orphaned, not cascade-deleted.
|
||||
|
||||
Like the single-session ``DELETE /api/sessions/{id}`` endpoint
|
||||
below, this doesn't pass a ``sessions_dir`` through — the on-disk
|
||||
transcript / request-dump cleanup is wired at the CLI/agent layer
|
||||
but the web server historically leaves file cleanup to the next
|
||||
prune-on-startup pass. Matching that pre-existing trade-off keeps
|
||||
the two delete endpoints' DB-vs-disk behaviour consistent.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
deleted = db.delete_empty_sessions()
|
||||
return {"ok": True, "deleted": deleted}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/api/sessions/stats")
|
||||
async def get_session_stats():
|
||||
"""Session-store statistics for the Sessions page (mirrors `hermes sessions stats`).
|
||||
@@ -4049,7 +3945,6 @@ async def get_session_stats():
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/api/sessions/{session_id}")
|
||||
async def get_session_detail(session_id: str):
|
||||
from hermes_state import SessionDB
|
||||
@@ -5897,7 +5792,6 @@ async def get_toolset_config(name: str):
|
||||
from hermes_cli.tools_config import (
|
||||
TOOL_CATEGORIES,
|
||||
_get_effective_configurable_toolsets,
|
||||
_is_provider_active,
|
||||
_visible_providers,
|
||||
)
|
||||
from hermes_cli.config import get_env_value
|
||||
@@ -5909,7 +5803,6 @@ async def get_toolset_config(name: str):
|
||||
config = load_config()
|
||||
cat = TOOL_CATEGORIES.get(name)
|
||||
providers = []
|
||||
active_provider = None
|
||||
if cat:
|
||||
for prov in _visible_providers(cat, config, force_fresh=True):
|
||||
env_vars = [
|
||||
@@ -5922,13 +5815,6 @@ async def get_toolset_config(name: str):
|
||||
}
|
||||
for e in prov.get("env_vars", [])
|
||||
]
|
||||
# Surface the same active-provider determination the CLI picker
|
||||
# uses (``_is_provider_active``) so the GUI highlights the provider
|
||||
# actually written to config (e.g. web.backend), not just the first
|
||||
# keyless one in the list.
|
||||
is_active = _is_provider_active(prov, config, force_fresh=True)
|
||||
if is_active and active_provider is None:
|
||||
active_provider = prov["name"]
|
||||
providers.append({
|
||||
"name": prov["name"],
|
||||
"badge": prov.get("badge", ""),
|
||||
@@ -5936,13 +5822,11 @@ async def get_toolset_config(name: str):
|
||||
"env_vars": env_vars,
|
||||
"post_setup": prov.get("post_setup"),
|
||||
"requires_nous_auth": bool(prov.get("requires_nous_auth")),
|
||||
"is_active": is_active,
|
||||
})
|
||||
return {
|
||||
"name": name,
|
||||
"has_category": cat is not None,
|
||||
"providers": providers,
|
||||
"active_provider": active_provider,
|
||||
}
|
||||
|
||||
|
||||
@@ -6838,7 +6722,6 @@ def mount_spa(application: FastAPI):
|
||||
_BUILTIN_DASHBOARD_THEMES = [
|
||||
{"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"},
|
||||
{"name": "default-large", "label": "Hermes Teal (Large)", "description": "Hermes Teal with bigger fonts and roomier spacing"},
|
||||
{"name": "nous-blue", "label": "Nous Blue", "description": "Light mode — vivid Nous-blue accents on cream canvas"},
|
||||
{"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"},
|
||||
{"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"},
|
||||
{"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"},
|
||||
|
||||
-172
@@ -3186,178 +3186,6 @@ class SessionDB:
|
||||
self._remove_session_files(sessions_dir, session_id)
|
||||
return deleted
|
||||
|
||||
def delete_sessions(
|
||||
self,
|
||||
session_ids: List[str],
|
||||
sessions_dir: Optional[Path] = None,
|
||||
) -> int:
|
||||
"""Delete every session in *session_ids* in a single transaction.
|
||||
|
||||
Backs the dashboard's bulk-select-then-delete flow on the
|
||||
sessions page (``POST /api/sessions/bulk-delete``). Mirrors the
|
||||
single-session :meth:`delete_session` contract per row:
|
||||
|
||||
* Unknown IDs are silently skipped (no 404) — selection state
|
||||
in the UI can race against another tab's delete, and we'd
|
||||
rather succeed-on-the-rest than fail-the-whole-batch.
|
||||
* Children of every deleted ID are orphaned
|
||||
(``parent_session_id → NULL``), never cascade-deleted, so a
|
||||
branch / subagent transcript survives an inadvertent parent
|
||||
delete.
|
||||
* Messages and the session row both go in one
|
||||
``_execute_write`` call so a partial failure can't leave the
|
||||
DB in a "messages gone but session row still there" state.
|
||||
* On-disk transcript / ``request_dump_*`` files are cleaned up
|
||||
outside the DB transaction when *sessions_dir* is provided,
|
||||
matching :meth:`prune_sessions` and
|
||||
:meth:`delete_empty_sessions`.
|
||||
|
||||
Returns the count of sessions that actually existed and were
|
||||
deleted (may be less than ``len(session_ids)`` if some IDs were
|
||||
already gone).
|
||||
"""
|
||||
if not session_ids:
|
||||
return 0
|
||||
# Dedup + drop any non-string entries up-front. Avoids
|
||||
# double-counting in the WHERE-IN list and protects against
|
||||
# callers that pass a list with stray ``None`` values.
|
||||
unique_ids = list({sid for sid in session_ids if isinstance(sid, str) and sid})
|
||||
if not unique_ids:
|
||||
return 0
|
||||
|
||||
removed_ids: list[str] = []
|
||||
|
||||
def _do(conn):
|
||||
placeholders = ",".join("?" * len(unique_ids))
|
||||
# First, filter to IDs that actually exist — we want to
|
||||
# return the real deleted count, not the input length.
|
||||
cursor = conn.execute(
|
||||
f"SELECT id FROM sessions WHERE id IN ({placeholders})",
|
||||
unique_ids,
|
||||
)
|
||||
existing = [row["id"] for row in cursor.fetchall()]
|
||||
if not existing:
|
||||
return 0
|
||||
|
||||
existing_placeholders = ",".join("?" * len(existing))
|
||||
# Orphan children whose parent is in the kill list so the
|
||||
# FK constraint stays satisfied. Pin children whose parent
|
||||
# is itself in the kill list rather than NULL-ing parents
|
||||
# of survivors — the IN list on ``parent_session_id`` does
|
||||
# exactly this.
|
||||
conn.execute(
|
||||
f"UPDATE sessions SET parent_session_id = NULL "
|
||||
f"WHERE parent_session_id IN ({existing_placeholders})",
|
||||
existing,
|
||||
)
|
||||
conn.execute(
|
||||
f"DELETE FROM messages WHERE session_id IN ({existing_placeholders})",
|
||||
existing,
|
||||
)
|
||||
conn.execute(
|
||||
f"DELETE FROM sessions WHERE id IN ({existing_placeholders})",
|
||||
existing,
|
||||
)
|
||||
removed_ids.extend(existing)
|
||||
return len(existing)
|
||||
|
||||
count = self._execute_write(_do)
|
||||
for sid in removed_ids:
|
||||
self._remove_session_files(sessions_dir, sid)
|
||||
return count
|
||||
|
||||
def count_empty_sessions(self) -> int:
|
||||
"""Return the count of empty, non-active, non-archived sessions.
|
||||
|
||||
"Empty" = ``message_count = 0`` AND the session has ended
|
||||
(``ended_at IS NOT NULL``) AND is not archived. The ``ended_at``
|
||||
guard matches the safety contract used by :meth:`prune_sessions`:
|
||||
only ended sessions are candidates for bulk deletion, so a freshly
|
||||
spawned session whose first message hasn't landed yet — or one
|
||||
held open by the live agent — is never sniped out from under
|
||||
the runtime.
|
||||
|
||||
Backs the ``GET /api/sessions/empty/count`` endpoint that lets the
|
||||
web dashboard hide its "Delete empty" button when there's nothing
|
||||
to clean up, and pre-populate the confirm dialog with the actual
|
||||
count.
|
||||
"""
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM sessions "
|
||||
"WHERE message_count = 0 "
|
||||
"AND ended_at IS NOT NULL "
|
||||
"AND archived = 0"
|
||||
)
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
def delete_empty_sessions(
|
||||
self,
|
||||
sessions_dir: Optional[Path] = None,
|
||||
) -> int:
|
||||
"""Delete every empty, ended, non-archived session.
|
||||
|
||||
Mirrors :meth:`prune_sessions`' transactional shape:
|
||||
|
||||
* Selects candidate IDs first (``message_count = 0`` AND
|
||||
``ended_at IS NOT NULL`` AND ``archived = 0``) so we never
|
||||
touch a live session or one the user deliberately archived.
|
||||
* Orphans any child whose parent is in the kill list — children
|
||||
of an empty parent are kept and re-parented to ``NULL`` rather
|
||||
than cascade-deleted, matching ``delete_session`` /
|
||||
``prune_sessions`` semantics so branch/subagent transcripts
|
||||
survive an inadvertent parent cleanup.
|
||||
* Deletes the rows in a single ``_execute_write`` callback so
|
||||
the operation is atomic — a partial failure (e.g. SIGKILL
|
||||
mid-loop) doesn't leave the DB in a "messages-deleted but
|
||||
session-row-still-there" half-state.
|
||||
* Cleans up on-disk transcript files (``.json`` / ``.jsonl`` /
|
||||
``request_dump_*``) outside the DB transaction when
|
||||
``sessions_dir`` is provided. Empty sessions don't typically
|
||||
have transcript files, but the gateway can leave a stub
|
||||
``request_dump_*`` if it crashed before the first reply —
|
||||
so we still sweep, matching ``prune_sessions``.
|
||||
|
||||
Returns the number of sessions deleted.
|
||||
"""
|
||||
removed_ids: list[str] = []
|
||||
|
||||
def _do(conn):
|
||||
cursor = conn.execute(
|
||||
"SELECT id FROM sessions "
|
||||
"WHERE message_count = 0 "
|
||||
"AND ended_at IS NOT NULL "
|
||||
"AND archived = 0"
|
||||
)
|
||||
session_ids = {row["id"] for row in cursor.fetchall()}
|
||||
|
||||
if not session_ids:
|
||||
return 0
|
||||
|
||||
placeholders = ",".join("?" * len(session_ids))
|
||||
conn.execute(
|
||||
f"UPDATE sessions SET parent_session_id = NULL "
|
||||
f"WHERE parent_session_id IN ({placeholders})",
|
||||
list(session_ids),
|
||||
)
|
||||
|
||||
for sid in session_ids:
|
||||
# DELETE FROM messages is paranoia — by construction
|
||||
# these rows have ``message_count = 0`` — but if a
|
||||
# bookkeeping bug ever lets the counter drift below the
|
||||
# real row count, we still leave a clean FK state.
|
||||
conn.execute(
|
||||
"DELETE FROM messages WHERE session_id = ?", (sid,)
|
||||
)
|
||||
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
|
||||
removed_ids.append(sid)
|
||||
return len(session_ids)
|
||||
|
||||
count = self._execute_write(_do)
|
||||
for sid in removed_ids:
|
||||
self._remove_session_files(sessions_dir, sid)
|
||||
return count
|
||||
|
||||
def prune_sessions(
|
||||
self,
|
||||
older_than_days: int = 90,
|
||||
|
||||
Generated
+1959
-125
File diff suppressed because it is too large
Load Diff
@@ -36,13 +36,8 @@ Key contract points encoded here:
|
||||
- scope is ``agent_dashboard:access`` only (no OIDC scopes).
|
||||
- tokens are RS256 JWTs verified against ``/.well-known/jwks.json``;
|
||||
JWKS is cached for 5 minutes.
|
||||
- the dashboard auth-code grant issues a 24h rotating refresh token
|
||||
(Portal NAS PR #293). ``refresh_session`` posts ``grant_type=refresh_token``
|
||||
to rotate the access token; ``complete_login`` and ``refresh_session``
|
||||
both populate ``Session.refresh_token`` with the (rotating) value the
|
||||
middleware persists back to the HttpOnly cookie. On a dead/expired/
|
||||
reuse-detected refresh token Portal returns 400 → ``RefreshExpiredError``
|
||||
→ middleware redirects to ``/auth/login``.
|
||||
- V1 has NO refresh tokens — ``refresh_session`` always raises
|
||||
``RefreshExpiredError`` so the middleware redirects to ``/auth/login``.
|
||||
- audience claim is the bare ``client_id`` (no ``hermes-cli:`` prefix).
|
||||
- tolerant ``oauth_contract_version`` check: missing → warn + proceed;
|
||||
present and ``!= 1`` → refuse.
|
||||
@@ -54,11 +49,11 @@ of cookie names; this provider just hands back ``{"code_verifier": …,
|
||||
"state": …}`` and the route serializes those into the ``hermes_session_pkce``
|
||||
cookie.
|
||||
|
||||
Refresh-token rotation: Portal rotates the refresh token on every
|
||||
successful refresh and runs reuse-detection (replaying a rotated token
|
||||
outside Portal's 60s grace revokes the whole session). The host
|
||||
middleware therefore MUST persist the rotated ``Session.refresh_token``
|
||||
back to the cookie on every refresh.
|
||||
Forward compatibility: if a future Portal contract starts issuing refresh
|
||||
tokens, ``complete_login`` already captures the value forward-compatibly
|
||||
(populates ``Session.refresh_token``). Wiring the RT cookie back into the
|
||||
middleware's near-expiry refresh path lives in the host application, not
|
||||
here.
|
||||
|
||||
Skip reasons:
|
||||
The plugin exposes a module-level ``LAST_SKIP_REASON`` that the gate's
|
||||
@@ -234,94 +229,12 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
|
||||
except httpx.RequestError as exc:
|
||||
raise ProviderError(f"Portal token endpoint unreachable: {exc}") from exc
|
||||
|
||||
# The dashboard auth-code grant now issues a rotating refresh token
|
||||
# (24h session, reuse-detected) — Portal NAS PR #293. A 400 here means
|
||||
# the code/PKCE/redirect_uri failed, surfaced as InvalidCodeError.
|
||||
return self._token_response_to_session(
|
||||
response, bad_request_exc=InvalidCodeError
|
||||
)
|
||||
|
||||
def refresh_session(self, *, refresh_token: str) -> Session:
|
||||
"""Rotate the access token using the refresh token.
|
||||
|
||||
Posts ``grant_type=refresh_token`` to Portal's token endpoint. The
|
||||
refresh token is sent in the ``X-Refresh-Token`` header (not the body)
|
||||
so it never lands in Portal's request-body access logs — mirroring the
|
||||
device-flow CLI convention; Portal reconciles header vs. body and
|
||||
rejects conflicts.
|
||||
|
||||
Portal rotates the refresh token on every successful refresh, so the
|
||||
returned ``Session.refresh_token`` is a NEW value the caller MUST
|
||||
persist (replacing the old cookie). Failing to persist it means the
|
||||
next refresh replays a rotated token and — outside Portal's 60s grace
|
||||
— trips reuse-detection and revokes the whole session.
|
||||
|
||||
Raises ``RefreshExpiredError`` on a 400 (expired / revoked / reuse-
|
||||
detected), so the middleware clears cookies and forces re-login.
|
||||
Raises ``ProviderError`` if Portal is unreachable.
|
||||
"""
|
||||
if not refresh_token:
|
||||
# No RT to present — treat as a dead session so middleware
|
||||
# forces a clean re-login rather than emitting a malformed POST.
|
||||
raise RefreshExpiredError("no refresh token present in session")
|
||||
|
||||
try:
|
||||
response = httpx.post(
|
||||
self._token_url,
|
||||
# The refresh token goes in BOTH the body and the
|
||||
# ``x-nous-refresh-token`` header. Portal's token endpoint
|
||||
# requires ``refresh_token`` in the body (its request schema
|
||||
# rejects a header-only request as ``invalid_request``), and
|
||||
# additionally reconciles the header against the body — sending
|
||||
# both lets Portal keep the value out of body-access-logs while
|
||||
# still satisfying the schema. The header name must match
|
||||
# Portal's ``REFRESH_TOKEN_HEADER`` exactly (``x-nous-refresh-
|
||||
# token``); any other name is silently ignored. (Verified
|
||||
# against the NAS #293 preview deploy: header-only → 400
|
||||
# invalid_request; body → accepted.)
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self._client_id,
|
||||
"refresh_token": refresh_token,
|
||||
},
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"x-nous-refresh-token": refresh_token,
|
||||
},
|
||||
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise ProviderError(
|
||||
f"Portal token endpoint unreachable: {exc}"
|
||||
) from exc
|
||||
|
||||
# A 400 on refresh means the RT is expired / revoked / reuse-detected;
|
||||
# surface as RefreshExpiredError so middleware forces re-login.
|
||||
return self._token_response_to_session(
|
||||
response, bad_request_exc=RefreshExpiredError
|
||||
)
|
||||
|
||||
def _token_response_to_session(
|
||||
self,
|
||||
response: httpx.Response,
|
||||
*,
|
||||
bad_request_exc: type[Exception],
|
||||
) -> Session:
|
||||
"""Translate a Portal ``/api/oauth/token`` response into a Session.
|
||||
|
||||
Shared by ``complete_login`` (auth-code grant) and ``refresh_session``
|
||||
(refresh grant). ``bad_request_exc`` is the exception type raised on a
|
||||
400 — ``InvalidCodeError`` for the auth-code path, ``RefreshExpiredError``
|
||||
for the refresh path — so the middleware's distinct handling
|
||||
(400-on-callback vs. force-relogin) is preserved.
|
||||
"""
|
||||
if response.status_code == 400:
|
||||
# Contract: invalid_code / invalid_grant / redirect_uri_mismatch
|
||||
# (auth-code) and expired / revoked / reuse-detected (refresh) all
|
||||
# Contract: invalid_code, invalid_grant, redirect_uri_mismatch all
|
||||
# surface as 400 with an OAuth-shaped JSON error envelope.
|
||||
body = self._parse_json_body(response)
|
||||
error_code = body.get("error", "invalid_request")
|
||||
raise bad_request_exc(f"Portal rejected token request: {error_code}")
|
||||
raise InvalidCodeError(f"Portal rejected code: {error_code}")
|
||||
if response.status_code != 200:
|
||||
raise ProviderError(
|
||||
f"Portal token endpoint returned {response.status_code}: "
|
||||
@@ -338,14 +251,21 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
|
||||
raise ProviderError(f"unexpected token_type={token_type!r}")
|
||||
|
||||
claims = self._verify_jwt(access_token)
|
||||
# The dashboard grant issues a rotating refresh token; capture it so
|
||||
# the caller can persist it. Empty string if Portal omitted it (the
|
||||
# session then behaves as access-token-only until expiry).
|
||||
# Contract V1: no refresh token expected. If a future Portal ever
|
||||
# adds one, capture it forward-compatibly.
|
||||
refresh_token = payload.get("refresh_token") or ""
|
||||
if not isinstance(refresh_token, str):
|
||||
refresh_token = ""
|
||||
return self._session_from_claims(access_token, refresh_token, claims)
|
||||
|
||||
def refresh_session(self, *, refresh_token: str) -> Session:
|
||||
# Contract V1 has no refresh tokens — always force re-auth. If a
|
||||
# future Portal contract starts issuing them, this method needs to
|
||||
# be re-implemented; until then it's an unconditional refusal.
|
||||
raise RefreshExpiredError(
|
||||
"Nous Portal does not issue refresh tokens in OAuth contract v1; "
|
||||
"user must re-authenticate via /auth/login."
|
||||
)
|
||||
|
||||
def verify_session(self, *, access_token: str) -> Optional[Session]:
|
||||
# Contract: returns None on expiry/invalidity (middleware then
|
||||
@@ -364,16 +284,9 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
|
||||
return self._session_from_claims(access_token, "", claims)
|
||||
|
||||
def revoke_session(self, *, refresh_token: str) -> None:
|
||||
# Portal exposes no public refresh-token revocation grant on its token
|
||||
# endpoint (revocation is driven from the authenticated /sessions UI,
|
||||
# keyed by sessionId + userId, not by the RT value). So logout is
|
||||
# client-side cookie clearing; the server-side refresh session simply
|
||||
# expires within its 24h TTL. Best-effort no-op, must not raise.
|
||||
#
|
||||
# If Portal later adds a token-endpoint revoke grant (e.g.
|
||||
# grant_type=... + X-Refresh-Token), implement it here so logout
|
||||
# invalidates the RT server-side immediately rather than waiting out
|
||||
# the TTL.
|
||||
# Contract V1: no refresh tokens to revoke, and no Portal revocation
|
||||
# endpoint documented for dashboard tokens. Logout is purely
|
||||
# client-side cookie clearing; this is a best-effort no-op.
|
||||
_ = refresh_token
|
||||
return None
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "example",
|
||||
"label": "Example",
|
||||
"description": "Test-only dashboard plugin fixture — installed by tests that need a stable plugin API endpoint to verify auth + static-asset behaviour",
|
||||
"description": "Example dashboard plugin — used by test suite for auth coverage",
|
||||
"icon": "Sparkles",
|
||||
"version": "1.0.0",
|
||||
"tab": {
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Example dashboard plugin — backend API routes.
|
||||
|
||||
Mounted at /api/plugins/example/ by the dashboard plugin system.
|
||||
|
||||
This minimal plugin exists so the test suite has a stable, side-effect-free
|
||||
GET endpoint to verify that plugin API routes work with auth.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/hello")
|
||||
async def hello():
|
||||
"""Simple greeting endpoint to demonstrate plugin API routes."""
|
||||
return {"message": "Hello from the example plugin!", "plugin": "example", "version": "1.0.0"}
|
||||
@@ -64,8 +64,6 @@ dependencies = [
|
||||
# (which is a silent killer on Windows — see CONTRIBUTING.md) and
|
||||
# `os.killpg` (which doesn't exist on Windows).
|
||||
"psutil==7.2.2",
|
||||
# .gitignore-aware file matching for desktop build stamp.
|
||||
"pathspec==1.1.1",
|
||||
"fastapi>=0.104.0,<1",
|
||||
"uvicorn[standard]>=0.24.0,<1",
|
||||
"ptyprocess>=0.7.0,<1; sys_platform != 'win32'",
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
---
|
||||
name: dynamic-workflow
|
||||
description: Orchestrate large fan-out work as a plan-in-code "workflow" so the agent's context holds only the final verified answer, not the exhaust of hundreds of intermediate steps. Use for codebase-wide sweeps, large migrations, multi-angle research, and any task too big for one context window where the split strategy is known enough to script. Includes the adversarial-convergence verification recipe (independent attempts + refuters, keep only surviving claims).
|
||||
version: 1.0.0
|
||||
author: Hermes Agent + Teknium
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [orchestration, fan-out, subagents, delegation, verification, migration, audit, research]
|
||||
category: autonomous-ai-agents
|
||||
related_skills: []
|
||||
when_to_use:
|
||||
- A task is too big for one context window AND you can describe the split (per-file, per-endpoint, per-source, per-record)
|
||||
- You want orchestration codified as a re-runnable script, not improvised turn-by-turn
|
||||
- Quality matters more than token economy: you want independent attempts cross-checked / refuted before you trust the answer
|
||||
- Codebase-wide bug/security sweep, 100+ file migration, multi-angle research with sources cross-checked
|
||||
when_not_to_use:
|
||||
- Small bounded task (<~10 units) — just call the tool directly or do it inline
|
||||
- Tight serial dependency (B needs A's output) — orchestration overhead is wasted
|
||||
- You need it to survive the user sending a new message — see "The synchronous trap" below; use cron/kanban instead
|
||||
---
|
||||
|
||||
# Dynamic Workflow — plan-in-code fan-out with verification
|
||||
|
||||
This is Hermes's answer to Claude Code's "dynamic workflows" (run hundreds of
|
||||
parallel subagents in one session). The mechanic worth copying is NOT "more
|
||||
subagents" — it is **moving the plan, the loop, and the intermediate results
|
||||
OUT of the context window and INTO a script.** Normally the agent IS the
|
||||
orchestrator: every intermediate result piles into context, which is exactly
|
||||
what caps you at a handful of agents. A workflow keeps only the *final verified
|
||||
answer* in context; the script holds everything else.
|
||||
|
||||
> This skill is self-contained, but it builds on standard fan-out hygiene —
|
||||
> chunk inputs to ~50-70KB per child, route structured output to files (not the
|
||||
> `summary` field, which truncates under load), use delimiter-separated lines
|
||||
> over JSON wrappers, and remember that a "stalled" child often completed its
|
||||
> write anyway (check the filesystem before retrying). If your install has a
|
||||
> `delegate-task-output-patterns` skill, load it for the detailed thresholds;
|
||||
> the rules above are the load-bearing subset.
|
||||
|
||||
## The two orchestration-script layers (pick the right one — they are NOT interchangeable)
|
||||
|
||||
Hermes has no JS runtime. The "orchestration script" is one of two layers, and
|
||||
the split is enforced by a real capability boundary, not a style preference:
|
||||
|
||||
| | Layer A: `execute_code` (Python script) | Layer B: `delegate_task` batch |
|
||||
|---|---|---|
|
||||
| Use for | DETERMINISTIC fan-out — fetch N URLs, parse N files, run N shell commands, template N outputs | LLM-JUDGMENT fan-out — classify, review, decide, write, refute, audit per item |
|
||||
| The script holds | the loop + branching + intermediate vars (real Python) | n/a — you call it once with a `tasks=[...]` array; each task is its own isolated agent |
|
||||
| Tools available inside | `web_search, web_extract, read_file, write_file, search_files, terminal, patch` ONLY (the `SANDBOX_ALLOWED_TOOLS` set) | configured child toolsets, subject to delegate restrictions (leaf children are stripped of `delegate_task`, `clarify`, `memory`, `send_message`, `execute_code` — see `DELEGATE_BLOCKED_TOOLS`) |
|
||||
| Can it call `delegate_task`? | **NO.** `delegate_task` is NOT in `SANDBOX_ALLOWED_TOOLS`. Do not write a script that imports it — it will fail. | itself, if `role='orchestrator'` and `max_spawn_depth>=2` |
|
||||
| Concurrency | you control it in Python (`ThreadPoolExecutor`, batches) | `delegation.max_concurrent_children` (default 3; raise in config.yaml) |
|
||||
| Cost shape | cheap — most steps are tool calls, no per-item LLM unless you call `web_search`/aux | one model call tree PER child task — multiplies linearly, can be very expensive |
|
||||
|
||||
**Rule of thumb:** do the deterministic part in Layer A first (inline, in a
|
||||
script), then fan out ONLY the irreducibly-LLM step via Layer B. This is
|
||||
Pattern 1 from `delegate-task-output-patterns`, applied at workflow scale.
|
||||
Mixing them: a Layer-A script can write a manifest file, and you (the parent)
|
||||
then read that manifest and issue a single Layer-B `delegate_task` batch.
|
||||
|
||||
## The synchronous trap (READ THIS — it is the #1 way a "workflow" disappoints)
|
||||
|
||||
`delegate_task` runs **synchronously inside the parent turn**. If the user sends
|
||||
a new message, hits /stop, or /new, every in-flight child is **cancelled and its
|
||||
work discarded** (status `interrupted`). It does NOT run in the background, and
|
||||
it does NOT survive the turn. There is no cache-resume of a half-finished fan-out.
|
||||
|
||||
So a "workflow" in Hermes is one of:
|
||||
|
||||
1. **Foreground workflow (default):** Layer A and/or one Layer-B batch, completed
|
||||
within a single turn. Good for minutes-long fan-out (dozens of units). The
|
||||
user waits. This is what you build 90% of the time.
|
||||
2. **Durable workflow (hours/days, survives interruption):** use the **kanban
|
||||
swarm** (the SQLite-backed multi-agent kernel that ships with Hermes —
|
||||
`hermes_cli/kanban_swarm.py` + the kanban plugin; if your install has a
|
||||
`kanban-multiagent` skill, load it for the workflow). It
|
||||
writes a task graph (root → parallel workers → verifier → synthesizer) into
|
||||
the SQLite kanban kernel with a JSON blackboard. State persists across turns
|
||||
and restarts. This is the ONLY path that matches Claude Code's "runs into
|
||||
hours and days, resumes where it left off." Reach for it when the foreground
|
||||
path would time out or when the user must be able to walk away.
|
||||
|
||||
Never promise "background, resumable, hundreds of agents over days" from a plain
|
||||
`delegate_task` call. For a durable multi-agent workflow *graph*, the kanban
|
||||
swarm is the right fit. For simpler durable/out-of-turn cases there are lighter
|
||||
options too: a `cronjob` one-shot or scheduled job, or a managed
|
||||
`terminal(background=True, notify_on_complete=True)` process — both survive the
|
||||
turn without standing up a full task graph.
|
||||
|
||||
## Workflow recipe (foreground)
|
||||
|
||||
1. **Decompose into independent units.** What is the unit — a file? an endpoint?
|
||||
a source? a record? Each unit must be answerable WITHOUT the others' output
|
||||
(else it's serial, not fan-out — see when_not_to_use).
|
||||
2. **Deterministic pre-pass (Layer A).** In one `execute_code` script, gather the
|
||||
manifest: list the files, extract the candidate sites, fetch the raw sources,
|
||||
compute anything regex/parse can compute. Write a manifest to a **unique
|
||||
per-run** directory — `/tmp/wf_<name>_<uuid>/manifest.jsonl` (one unit per
|
||||
line), never a bare `/tmp/wf_<name>/` that a prior interrupted run could have
|
||||
left stale outputs in. This is the "plan in code." Print the unit count and
|
||||
the run dir, and stop.
|
||||
3. **Size the fan-out** against `delegate-task-output-patterns`: chunk so each
|
||||
child handles ~8-12 mechanical file edits OR ~2000-3000 lines of reading OR
|
||||
~50-70KB of corpus. Look at the LARGEST unit, not the average. One
|
||||
`delegate_task(tasks=[...])` call is bounded by
|
||||
`delegation.max_concurrent_children` (default 3) — it does NOT queue hundreds
|
||||
of tasks internally. For larger fan-out, issue bounded waves yourself (loop:
|
||||
one batch, collect, next batch) or have the user raise the config
|
||||
intentionally.
|
||||
4. **LLM-judgment fan-out (Layer B).** Issue ONE `delegate_task` with a `tasks=[]`
|
||||
array, one task per chunk. Each task: reads its slice from the manifest,
|
||||
emits delimiter-separated lines to `/tmp/wf_<name>_<uuid>/out_<i>.csv`, prints a
|
||||
status word, stops. Do NOT depend on the `summary` field for content.
|
||||
5. **Synthesize on the parent.** Read the out_*.csv files yourself — verify the
|
||||
file count and freshness (each was written this run) so a stale or missing
|
||||
output from an interrupted child isn't silently read as success — then merge
|
||||
and present. The cross-cutting "whole picture" step stays on the parent — only
|
||||
the per-unit work fanned out.
|
||||
|
||||
## The novel mechanic worth building: adversarial convergence
|
||||
|
||||
This is the part Hermes did NOT already have and the real reason to bother.
|
||||
Claude Code's quality claim ("independent agents try to refute each other's
|
||||
findings; only surviving claims surface; iterate until they converge") maps
|
||||
cleanly onto `delegate_task` batch mode:
|
||||
|
||||
### Recipe: N independent attempts + M refuters
|
||||
|
||||
For a finding-quality task (security audit, "is this code path actually
|
||||
vulnerable?", "does this migration preserve behavior?", a high-stakes plan):
|
||||
|
||||
1. **Independent attempts (round 1).** Fan out the SAME question to N children
|
||||
(N=2-4) with DIFFERENT framings/angles in each `context`, so they don't
|
||||
collapse to the same reasoning. Each writes its claims to
|
||||
`/tmp/wf_<name>/attempt_<i>.md` as a list of discrete, individually-checkable
|
||||
claims (one claim per line — atomicity is what makes refutation possible).
|
||||
2. **Collect + dedupe (parent or Layer A).** Merge all claims into a single
|
||||
numbered list. Identical claims from independent attempts = higher prior;
|
||||
note the agreement count per claim.
|
||||
3. **Refutation round (round 2).** Fan out a refuter batch: each refuter gets the
|
||||
claim list and is told "your job is to BREAK these claims — for each, find the
|
||||
counter-evidence (the auth check that DOES exist, the test that DOES cover it,
|
||||
the edge case the claim ignores). Output `claim_idx|survives|counter_evidence`."
|
||||
Give refuters the codebase/sources, not the original attempts' reasoning.
|
||||
4. **Keep only survivors.** A claim surfaces to the user only if it survived
|
||||
refutation (no refuter produced valid counter-evidence). Filtered claims are
|
||||
dropped, with a one-line note of why if the user asked for completeness.
|
||||
5. **Converge (optional).** If round 2 surfaced NEW claims (refuters often find
|
||||
adjacent issues), feed them back through one more refutation round. Stop when
|
||||
a round produces no new surviving claims — that's convergence. Cap at 3 rounds
|
||||
to bound cost.
|
||||
|
||||
This gives you the "more trustworthy than a single pass" property without a
|
||||
runtime — it's just two `delegate_task` batches and a merge, structured so
|
||||
disagreement is visible and unsupported claims die before they reach the user.
|
||||
|
||||
### Why atomic claims matter
|
||||
A refuter cannot break "the auth layer has problems." It CAN break "endpoint
|
||||
`POST /api/users/:id/role` in src/routes/users.ts:142 has no role check." Force
|
||||
attempts to emit specific, located, individually-falsifiable claims or the
|
||||
refutation round is theater.
|
||||
|
||||
## Cost discipline (this is the thing that bites)
|
||||
|
||||
A workflow can consume dramatically more tokens than a normal turn — that is
|
||||
inherent, not a bug. Two real multipliers:
|
||||
|
||||
- **Each Layer-B child is a full agent tree.** 20 children ≈ 20× the model calls.
|
||||
`delegation.max_concurrent_children` only bounds *concurrency*, not *total*.
|
||||
- **Hermes aux/subagent model defaults to main-model-first.** Children inherit
|
||||
the parent's (often expensive reasoning) model. `delegate_task` does NOT expose
|
||||
a per-task `model` or `profile` field — its per-task keys are
|
||||
`{goal, context, toolsets, role}`. To run the fan-out cheaper you either route
|
||||
delegation globally via `delegation` config (model/provider applied to all
|
||||
children), or — for genuinely model/profile-scoped work — use cron, the kanban
|
||||
swarm, or a separate Hermes process. The cleanest lever for mechanical fan-out
|
||||
is still Layer A: do the deterministic part in a script with no per-item LLM at
|
||||
all.
|
||||
|
||||
Always: start on a SCOPED slice (one directory, 20 records, 10 endpoints), prove
|
||||
the recipe end-to-end, report the token cost, THEN offer to run it at full scale.
|
||||
Never silently fan out hundreds of children — surface the cost first and let the
|
||||
user say go.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Writing `delegate_task` inside an `execute_code` script.** It's not in
|
||||
`SANDBOX_ALLOWED_TOOLS`; the import/stub won't exist. Layer A is deterministic
|
||||
tools only. Fan out LLM judgment from the parent turn, not from inside a script.
|
||||
- **Promising background/resumable from `delegate_task`.** It's synchronous and
|
||||
turn-scoped. Durable = kanban swarm.
|
||||
- **Trusting `summary` fields for content.** Route structured output to files
|
||||
(Pattern 2 in delegate-task-output-patterns).
|
||||
- **Non-atomic claims in the verify recipe.** Unfalsifiable claims survive
|
||||
refutation by default and pollute the output. Force located, specific claims.
|
||||
- **Same framing in all "independent" attempts.** They collapse to one answer and
|
||||
the cross-check is worthless. Vary the angle in each child's context.
|
||||
- **Fanning out a serial task.** If unit B needs unit A's output, parallelism
|
||||
produces wrong/empty results. Re-check independence before fanning out.
|
||||
|
||||
## Verification before you call it done
|
||||
|
||||
- Did the deterministic pre-pass actually run, and does the manifest line-count
|
||||
match the expected unit count? (`wc -l /tmp/wf_<name>/manifest.jsonl`)
|
||||
- Did every fan-out child write its output file? (`ls /tmp/wf_<name>/out_*.csv`) —
|
||||
remember stalled children often completed anyway (Pattern 6).
|
||||
- For the verify recipe: can you point to the refuter counter-evidence for every
|
||||
DROPPED claim, and confirm every SURFACED claim went through refutation?
|
||||
- Did you report token cost on the scoped run before offering full scale?
|
||||
@@ -6,12 +6,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
import agent.runtime_cwd as rt
|
||||
from agent.runtime_cwd import (
|
||||
clear_session_cwd,
|
||||
resolve_agent_cwd,
|
||||
resolve_context_cwd,
|
||||
set_session_cwd,
|
||||
)
|
||||
from agent.runtime_cwd import resolve_agent_cwd, resolve_context_cwd
|
||||
|
||||
|
||||
def _raise_oserror(*args, **kwargs):
|
||||
@@ -82,48 +77,3 @@ class TestResolveContextCwd:
|
||||
# than building Path(" ") and resolving garbage under the launch dir.
|
||||
monkeypatch.setenv("TERMINAL_CWD", " ")
|
||||
assert resolve_context_cwd() is None
|
||||
|
||||
|
||||
class TestSessionCwdOverride:
|
||||
"""The #29531 per-session arm: a contextvar cwd wins over TERMINAL_CWD so a
|
||||
multi-session gateway can pin each session to its own folder."""
|
||||
|
||||
def test_session_cwd_overrides_terminal_cwd(self, monkeypatch, tmp_path):
|
||||
other = tmp_path / "other"
|
||||
other.mkdir()
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
token = set_session_cwd(str(other))
|
||||
try:
|
||||
assert resolve_agent_cwd() == other
|
||||
assert resolve_context_cwd() == other
|
||||
finally:
|
||||
rt._SESSION_CWD.reset(token)
|
||||
|
||||
def test_empty_session_cwd_falls_back_to_terminal_cwd(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
token = set_session_cwd("")
|
||||
try:
|
||||
assert resolve_agent_cwd() == tmp_path
|
||||
assert resolve_context_cwd() == tmp_path
|
||||
finally:
|
||||
rt._SESSION_CWD.reset(token)
|
||||
|
||||
def test_clear_session_cwd_restores_terminal_cwd(self, monkeypatch, tmp_path):
|
||||
other = tmp_path / "other"
|
||||
other.mkdir()
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
token = set_session_cwd(str(other))
|
||||
try:
|
||||
clear_session_cwd()
|
||||
assert resolve_agent_cwd() == tmp_path
|
||||
finally:
|
||||
rt._SESSION_CWD.reset(token)
|
||||
|
||||
def test_nonexistent_session_cwd_falls_back(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
token = set_session_cwd(str(tmp_path / "gone"))
|
||||
try:
|
||||
# resolve_agent_cwd guards on isdir; a missing session cwd must not win.
|
||||
assert resolve_agent_cwd() == tmp_path
|
||||
finally:
|
||||
rt._SESSION_CWD.reset(token)
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
"""Regression tests for GitHub #36823 — the TUI must reset terminal input
|
||||
modes on exit so focus-reporting / mouse-tracking escape sequences don't leak
|
||||
into the next shell session sharing the tab.
|
||||
|
||||
prompt_toolkit restores these on a clean teardown, but Ctrl+C, SIGTERM/SIGHUP
|
||||
and crashes can bypass its unwind. ``_run_cleanup`` (the once-only cleanup that
|
||||
runs on every catchable exit path, including ``atexit``) now emits the disable
|
||||
sequence as its first step via ``_reset_terminal_input_modes_on_exit`` — gated
|
||||
on ``_tui_input_modes_active`` so non-TUI one-shot CLI runs (which share
|
||||
``_run_cleanup`` via ``atexit``) don't emit codes for modes they never set.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import mock_open, patch
|
||||
|
||||
|
||||
def _import_cli():
|
||||
import hermes_cli.config as config_mod
|
||||
|
||||
if not hasattr(config_mod, "save_env_value_secure"):
|
||||
config_mod.save_env_value_secure = lambda key, value: {
|
||||
"success": True,
|
||||
"stored_as": key,
|
||||
"validated": False,
|
||||
}
|
||||
|
||||
import cli as cli_mod
|
||||
|
||||
return cli_mod
|
||||
|
||||
|
||||
class _FakeStream:
|
||||
def __init__(self, isatty: bool = True):
|
||||
self._isatty = isatty
|
||||
self.written: list[str] = []
|
||||
self.flushed = 0
|
||||
|
||||
def isatty(self) -> bool:
|
||||
return self._isatty
|
||||
|
||||
def write(self, s: str) -> int:
|
||||
self.written.append(s)
|
||||
return len(s)
|
||||
|
||||
def flush(self) -> None:
|
||||
self.flushed += 1
|
||||
|
||||
|
||||
class TestResetTerminalInputModes(unittest.TestCase):
|
||||
def test_emits_reset_seq_on_tty_when_tui_ran(self):
|
||||
cli_mod = _import_cli()
|
||||
fake = _FakeStream(isatty=True)
|
||||
with (
|
||||
patch.object(cli_mod, "_tui_input_modes_active", True),
|
||||
patch.object(cli_mod.sys, "stdout", fake),
|
||||
):
|
||||
cli_mod._reset_terminal_input_modes_on_exit()
|
||||
|
||||
written = "".join(fake.written)
|
||||
self.assertEqual(written, cli_mod._TERMINAL_INPUT_MODE_RESET_SEQ)
|
||||
self.assertGreaterEqual(fake.flushed, 1)
|
||||
# The focus-reporting disable is the specific leak the issue reports.
|
||||
self.assertIn("\x1b[?1004l", written)
|
||||
|
||||
def test_noop_when_tui_never_ran(self):
|
||||
"""Non-TUI one-shot CLI runs share _run_cleanup via atexit — they must
|
||||
not emit terminal escape codes they never needed (review finding #1)."""
|
||||
cli_mod = _import_cli()
|
||||
fake = _FakeStream(isatty=True)
|
||||
with (
|
||||
patch.object(cli_mod, "_tui_input_modes_active", False),
|
||||
patch.object(cli_mod.sys, "stdout", fake),
|
||||
# Guard: must not touch the real /dev/tty either.
|
||||
patch("builtins.open", mock_open()) as m_open,
|
||||
):
|
||||
cli_mod._reset_terminal_input_modes_on_exit()
|
||||
|
||||
self.assertEqual(fake.written, [])
|
||||
m_open.assert_not_called()
|
||||
|
||||
def test_noop_when_not_a_tty_and_no_dev_tty(self):
|
||||
"""stdout redirected and /dev/tty unavailable → nothing written, no raise."""
|
||||
cli_mod = _import_cli()
|
||||
fake = _FakeStream(isatty=False)
|
||||
with (
|
||||
patch.object(cli_mod, "_tui_input_modes_active", True),
|
||||
patch.object(cli_mod.sys, "stdout", fake),
|
||||
patch("builtins.open", side_effect=OSError("no /dev/tty")),
|
||||
):
|
||||
cli_mod._reset_terminal_input_modes_on_exit()
|
||||
|
||||
self.assertEqual(fake.written, [], "must not pollute the redirected stream")
|
||||
|
||||
def test_falls_back_to_dev_tty_when_stdout_redirected(self):
|
||||
"""When stdout isn't the terminal, reset via /dev/tty (issue's own
|
||||
suggestion) so a TUI that drove /dev/tty still gets cleaned up."""
|
||||
cli_mod = _import_cli()
|
||||
fake = _FakeStream(isatty=False)
|
||||
m_open = mock_open()
|
||||
with (
|
||||
patch.object(cli_mod, "_tui_input_modes_active", True),
|
||||
patch.object(cli_mod.sys, "stdout", fake),
|
||||
patch("builtins.open", m_open),
|
||||
):
|
||||
cli_mod._reset_terminal_input_modes_on_exit()
|
||||
|
||||
self.assertEqual(fake.written, [])
|
||||
m_open.assert_called_once_with("/dev/tty", "w", encoding="ascii")
|
||||
m_open().write.assert_called_once_with(cli_mod._TERMINAL_INPUT_MODE_RESET_SEQ)
|
||||
|
||||
def test_swallows_stdout_errors(self):
|
||||
cli_mod = _import_cli()
|
||||
|
||||
class _Boom:
|
||||
def isatty(self):
|
||||
raise OSError("stdout closed")
|
||||
|
||||
with (
|
||||
patch.object(cli_mod, "_tui_input_modes_active", True),
|
||||
patch.object(cli_mod.sys, "stdout", _Boom()),
|
||||
patch("builtins.open", side_effect=OSError("no /dev/tty")),
|
||||
):
|
||||
# Cleanup runs at process teardown — it must never raise.
|
||||
cli_mod._reset_terminal_input_modes_on_exit()
|
||||
|
||||
def test_mark_tui_input_modes_active_sets_flag(self):
|
||||
cli_mod = _import_cli()
|
||||
original = cli_mod._tui_input_modes_active
|
||||
cli_mod._tui_input_modes_active = False
|
||||
try:
|
||||
cli_mod._mark_tui_input_modes_active()
|
||||
self.assertTrue(cli_mod._tui_input_modes_active)
|
||||
finally:
|
||||
cli_mod._tui_input_modes_active = original
|
||||
|
||||
def test_flag_cleared_after_reset(self):
|
||||
"""Once the modes are disabled they are no longer active — the flag must
|
||||
flip back so a re-armed cleanup doesn't re-emit the sequence."""
|
||||
cli_mod = _import_cli()
|
||||
fake = _FakeStream(isatty=True)
|
||||
original = cli_mod._tui_input_modes_active
|
||||
cli_mod._tui_input_modes_active = True
|
||||
try:
|
||||
with patch.object(cli_mod.sys, "stdout", fake):
|
||||
cli_mod._reset_terminal_input_modes_on_exit()
|
||||
self.assertIn("\x1b[?1004l", "".join(fake.written))
|
||||
self.assertFalse(
|
||||
cli_mod._tui_input_modes_active, "flag must clear after reset"
|
||||
)
|
||||
finally:
|
||||
cli_mod._tui_input_modes_active = original
|
||||
|
||||
|
||||
class TestRunCleanupWiring(unittest.TestCase):
|
||||
"""_run_cleanup must call the reset, as its first step, on every invocation
|
||||
— even if a later cleanup step raises."""
|
||||
|
||||
def _run_cleanup_isolated(self, cli_mod, **extra_patches):
|
||||
"""Invoke _run_cleanup with heavy/real teardown steps stubbed out so the
|
||||
test is hermetic (review finding #5)."""
|
||||
original_done = cli_mod._cleanup_done
|
||||
cli_mod._cleanup_done = False
|
||||
patches = {
|
||||
"_cleanup_all_terminals": lambda: None,
|
||||
"_cleanup_all_browsers": lambda: None,
|
||||
}
|
||||
try:
|
||||
with (
|
||||
patch.object(
|
||||
cli_mod, "_reset_terminal_input_modes_on_exit"
|
||||
) as mock_reset,
|
||||
patch.object(
|
||||
cli_mod, "_cleanup_all_terminals", patches["_cleanup_all_terminals"]
|
||||
),
|
||||
patch.object(
|
||||
cli_mod, "_cleanup_all_browsers", patches["_cleanup_all_browsers"]
|
||||
),
|
||||
patch("tools.mcp_tool.shutdown_mcp_servers", lambda *a, **k: None),
|
||||
patch(
|
||||
"agent.auxiliary_client.shutdown_cached_clients",
|
||||
lambda *a, **k: None,
|
||||
),
|
||||
patch("hermes_cli.plugins.invoke_hook", lambda *a, **k: None),
|
||||
):
|
||||
if extra_patches.get("terminals_raise"):
|
||||
with patch.object(
|
||||
cli_mod,
|
||||
"_cleanup_all_terminals",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
cli_mod._run_cleanup()
|
||||
else:
|
||||
cli_mod._run_cleanup()
|
||||
return mock_reset
|
||||
finally:
|
||||
cli_mod._cleanup_done = original_done
|
||||
|
||||
def test_run_cleanup_calls_reset(self):
|
||||
cli_mod = _import_cli()
|
||||
mock_reset = self._run_cleanup_isolated(cli_mod)
|
||||
mock_reset.assert_called_once()
|
||||
|
||||
def test_reset_runs_even_when_a_cleanup_step_raises(self):
|
||||
"""The reset is the first step, so a failing teardown step can't skip
|
||||
it — covering the Ctrl+C / crash paths the issue is about."""
|
||||
cli_mod = _import_cli()
|
||||
mock_reset = self._run_cleanup_isolated(cli_mod, terminals_raise=True)
|
||||
mock_reset.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,24 +0,0 @@
|
||||
"""Example dashboard plugin — backend API routes (test fixture).
|
||||
|
||||
This plugin lives under ``tests/fixtures/plugins/`` so it is NOT shipped as
|
||||
part of the bundled-plugins set; a stock hermes-agent install does not see
|
||||
an "Example" tab in its sidebar. The ``_install_example_plugin`` pytest
|
||||
fixture in ``tests/hermes_cli/test_web_server.py`` copies this directory
|
||||
into ``$HERMES_HOME/plugins/example-dashboard/`` and forces the dashboard
|
||||
plugin discovery cache to rescan, so tests that need a stable, side-effect-
|
||||
free GET endpoint to verify plugin API auth + static-asset behaviour can
|
||||
hit ``/api/plugins/example/hello`` (and ``/dashboard-plugins/example/
|
||||
manifest.json``) without depending on any production-facing plugin.
|
||||
|
||||
Mounted at /api/plugins/example/ by the dashboard plugin system.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/hello")
|
||||
async def hello():
|
||||
"""Simple greeting endpoint to demonstrate plugin API routes."""
|
||||
return {"message": "Hello from the example plugin!", "plugin": "example", "version": "1.0.0"}
|
||||
@@ -164,12 +164,9 @@ class TestSessionResetPolicy:
|
||||
|
||||
|
||||
class TestStreamingConfig:
|
||||
def test_defaults_to_auto_transport(self):
|
||||
# "auto" prefers native draft streaming where the platform supports
|
||||
# it (Telegram DMs) and falls back to edit-based everywhere else, so
|
||||
# it is safe as the global out-of-the-box default.
|
||||
def test_defaults_to_edit_transport(self):
|
||||
restored = StreamingConfig.from_dict({"enabled": "true"})
|
||||
assert restored.transport == "auto"
|
||||
assert restored.transport == "edit"
|
||||
|
||||
def test_from_dict_coerces_quoted_false_enabled(self):
|
||||
restored = StreamingConfig.from_dict({"enabled": "false"})
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Per-platform streaming defaults + dashboard exposure.
|
||||
|
||||
Streaming is smooth on Telegram (native sendMessageDraft) but flickers on
|
||||
edit-only platforms like Discord. The shipped defaults encode that:
|
||||
display.platforms.telegram.streaming=true, .discord.streaming=false. These are
|
||||
gap-fillers (user values win via deep-merge) and, because the dashboard schema
|
||||
is generated from DEFAULT_CONFIG, they automatically appear as editable toggles
|
||||
in the web UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_default_per_platform_streaming_flags():
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
plats = DEFAULT_CONFIG["display"]["platforms"]
|
||||
assert plats["telegram"]["streaming"] is True
|
||||
assert plats["discord"]["streaming"] is False
|
||||
|
||||
|
||||
def test_resolver_telegram_on_discord_off_when_global_enabled():
|
||||
"""With global streaming on, the per-platform defaults make Telegram stream
|
||||
and Discord not — matching the platforms' actual streaming quality."""
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
cfg = dict(DEFAULT_CONFIG)
|
||||
cfg["streaming"] = {"enabled": True, "transport": "auto"}
|
||||
|
||||
def streams(plat):
|
||||
ov = resolve_display_setting(cfg, plat, "streaming")
|
||||
# global enabled; None override = follow global (True)
|
||||
return True if ov is None else bool(ov)
|
||||
|
||||
assert streams("telegram") is True
|
||||
assert streams("discord") is False
|
||||
# A platform with no default entry follows the global switch.
|
||||
assert streams("slack") is True
|
||||
|
||||
|
||||
def test_user_override_wins_over_default():
|
||||
"""A user who explicitly enables Discord streaming keeps their value — the
|
||||
default false must not clobber it (config deep-merge: user wins)."""
|
||||
from hermes_cli.config import DEFAULT_CONFIG, _deep_merge
|
||||
|
||||
user = {"display": {"platforms": {"discord": {"streaming": True}}}}
|
||||
merged = _deep_merge(dict(DEFAULT_CONFIG), user)
|
||||
assert merged["display"]["platforms"]["discord"]["streaming"] is True
|
||||
# Partial override must not wipe the sibling telegram default.
|
||||
assert merged["display"]["platforms"]["telegram"]["streaming"] is True
|
||||
|
||||
|
||||
def test_dashboard_schema_exposes_per_platform_streaming():
|
||||
"""Because the web settings schema is built from DEFAULT_CONFIG, the
|
||||
per-platform streaming toggles surface in the dashboard automatically."""
|
||||
import pytest
|
||||
pytest.importorskip("fastapi") # web_server requires fastapi/uvicorn
|
||||
from hermes_cli.web_server import CONFIG_SCHEMA
|
||||
|
||||
assert "display.platforms.telegram.streaming" in CONFIG_SCHEMA
|
||||
assert "display.platforms.discord.streaming" in CONFIG_SCHEMA
|
||||
assert CONFIG_SCHEMA["display.platforms.discord.streaming"]["type"] == "boolean"
|
||||
# Global streaming controls are exposed too.
|
||||
assert "streaming.enabled" in CONFIG_SCHEMA
|
||||
assert "streaming.transport" in CONFIG_SCHEMA
|
||||
@@ -1,182 +0,0 @@
|
||||
"""Structured stream-event protocol + dispatcher behavior.
|
||||
|
||||
Covers the agent→gateway delivery contract introduced to decouple *what
|
||||
happened* (typed events) from *how it's delivered* (adapter decides). The
|
||||
default BasePlatformAdapter rendering must reproduce today's behavior exactly;
|
||||
an adapter may override format_tool_event to eat tool chrome on platforms that
|
||||
can't render it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from gateway.stream_dispatch import GatewayEventDispatcher
|
||||
from gateway.stream_events import (
|
||||
Commentary,
|
||||
GatewayNotice,
|
||||
LongToolHint,
|
||||
MessageChunk,
|
||||
MessageStop,
|
||||
ToolCallChunk,
|
||||
ToolCallFinished,
|
||||
)
|
||||
|
||||
|
||||
def _base_adapter():
|
||||
"""A real BasePlatformAdapter instance (abstractmethods cleared) so we
|
||||
exercise the genuine default render hooks, not a mock."""
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
|
||||
Concrete = type("Concrete", (BasePlatformAdapter,), {})
|
||||
Concrete.__abstractmethods__ = frozenset()
|
||||
return Concrete.__new__(Concrete)
|
||||
|
||||
|
||||
class _FakeSink:
|
||||
def __init__(self):
|
||||
self.deltas = []
|
||||
self.commentary = []
|
||||
self.segment_breaks = 0
|
||||
|
||||
def on_delta(self, text):
|
||||
self.deltas.append(text)
|
||||
|
||||
def on_commentary(self, text):
|
||||
self.commentary.append(text)
|
||||
|
||||
def on_segment_break(self):
|
||||
self.segment_breaks += 1
|
||||
|
||||
|
||||
# ── Message events → sink ────────────────────────────────────────────────────
|
||||
|
||||
def test_message_chunk_flows_to_sink_on_delta():
|
||||
sink = _FakeSink()
|
||||
d = GatewayEventDispatcher(_base_adapter(), sink)
|
||||
d.dispatch(MessageChunk("hello "))
|
||||
d.dispatch(MessageChunk("world"))
|
||||
assert sink.deltas == ["hello ", "world"]
|
||||
|
||||
|
||||
def test_intermediate_message_stop_breaks_segment_but_final_does_not():
|
||||
sink = _FakeSink()
|
||||
d = GatewayEventDispatcher(_base_adapter(), sink)
|
||||
d.dispatch(MessageStop(final=False))
|
||||
d.dispatch(MessageStop(final=True))
|
||||
assert sink.segment_breaks == 1 # only the non-final stop breaks
|
||||
|
||||
|
||||
def test_commentary_flows_to_sink():
|
||||
sink = _FakeSink()
|
||||
d = GatewayEventDispatcher(_base_adapter(), sink)
|
||||
d.dispatch(Commentary("I'll inspect the repo first."))
|
||||
assert sink.commentary == ["I'll inspect the repo first."]
|
||||
|
||||
|
||||
def test_message_events_dropped_when_no_sink():
|
||||
# streaming disabled → no sink → message events are no-ops, no crash.
|
||||
d = GatewayEventDispatcher(_base_adapter(), sink=None)
|
||||
d.dispatch(MessageChunk("x")) # must not raise
|
||||
|
||||
|
||||
# ── Tool events → progress queue, formatted by adapter ───────────────────────
|
||||
|
||||
def test_tool_call_chunk_renders_default_chrome():
|
||||
lines = []
|
||||
d = GatewayEventDispatcher(
|
||||
_base_adapter(), _FakeSink(),
|
||||
enqueue_tool_line=lines.append, tool_mode="all",
|
||||
)
|
||||
d.dispatch(ToolCallChunk(tool_name="terminal", preview="ls -la"))
|
||||
assert len(lines) == 1
|
||||
assert "terminal" in lines[0]
|
||||
assert "ls -la" in lines[0]
|
||||
|
||||
|
||||
def test_tool_preview_truncated_to_cap():
|
||||
lines = []
|
||||
d = GatewayEventDispatcher(
|
||||
_base_adapter(), _FakeSink(),
|
||||
enqueue_tool_line=lines.append, tool_mode="all", preview_max_len=10,
|
||||
)
|
||||
d.dispatch(ToolCallChunk(tool_name="x", preview="0123456789ABCDEF"))
|
||||
# capped at 10 → 7 chars + "..." (then wrapped in quotes by the renderer)
|
||||
assert '"0123456..."' in lines[0]
|
||||
assert "89ABCDEF" not in lines[0]
|
||||
|
||||
|
||||
def test_new_mode_dedups_same_tool():
|
||||
lines = []
|
||||
d = GatewayEventDispatcher(
|
||||
_base_adapter(), _FakeSink(),
|
||||
enqueue_tool_line=lines.append, tool_mode="new",
|
||||
)
|
||||
d.dispatch(ToolCallChunk(tool_name="terminal", preview="a"))
|
||||
d.dispatch(ToolCallChunk(tool_name="terminal", preview="b")) # deduped
|
||||
d.dispatch(ToolCallChunk(tool_name="read_file", preview="c"))
|
||||
assert len(lines) == 2 # terminal once, read_file once
|
||||
|
||||
|
||||
def test_off_mode_emits_nothing():
|
||||
lines = []
|
||||
d = GatewayEventDispatcher(
|
||||
_base_adapter(), _FakeSink(),
|
||||
enqueue_tool_line=lines.append, tool_mode="off",
|
||||
)
|
||||
d.dispatch(ToolCallChunk(tool_name="terminal", preview="ls"))
|
||||
assert lines == []
|
||||
|
||||
|
||||
def test_adapter_can_eat_tool_chrome():
|
||||
"""An adapter that returns None from format_tool_event drops the event —
|
||||
the 'iMessage can't render tool chrome' case."""
|
||||
adapter = _base_adapter()
|
||||
adapter.format_tool_event = lambda event, **kw: None # eat everything
|
||||
lines = []
|
||||
d = GatewayEventDispatcher(
|
||||
adapter, _FakeSink(), enqueue_tool_line=lines.append, tool_mode="all",
|
||||
)
|
||||
d.dispatch(ToolCallChunk(tool_name="terminal", preview="ls"))
|
||||
assert lines == [] # eaten
|
||||
|
||||
|
||||
def test_tool_finished_emits_no_chrome():
|
||||
lines = []
|
||||
d = GatewayEventDispatcher(
|
||||
_base_adapter(), _FakeSink(),
|
||||
enqueue_tool_line=lines.append, tool_mode="all",
|
||||
)
|
||||
d.dispatch(ToolCallFinished(tool_name="terminal", duration=2.0, ok=True))
|
||||
assert lines == []
|
||||
|
||||
|
||||
# ── Control events → gateway-owned hooks ─────────────────────────────────────
|
||||
|
||||
def test_long_tool_hint_routes_to_hook():
|
||||
seen = []
|
||||
d = GatewayEventDispatcher(
|
||||
_base_adapter(), _FakeSink(), on_long_tool=seen.append,
|
||||
)
|
||||
d.dispatch(LongToolHint(tool_name="terminal", duration=45.0))
|
||||
assert len(seen) == 1
|
||||
assert seen[0].tool_name == "terminal"
|
||||
|
||||
|
||||
def test_gateway_notice_routes_to_hook():
|
||||
seen = []
|
||||
d = GatewayEventDispatcher(
|
||||
_base_adapter(), _FakeSink(), on_notice=seen.append,
|
||||
)
|
||||
d.dispatch(GatewayNotice(kind="restart", text="Gateway restarted"))
|
||||
assert seen[0].kind == "restart"
|
||||
|
||||
|
||||
def test_dispatch_swallows_render_errors():
|
||||
"""A render error must never propagate into the agent worker thread."""
|
||||
adapter = _base_adapter()
|
||||
def _boom(event, sink):
|
||||
raise RuntimeError("render blew up")
|
||||
adapter.render_message_event = _boom
|
||||
d = GatewayEventDispatcher(adapter, _FakeSink())
|
||||
d.dispatch(MessageChunk("x")) # must not raise
|
||||
@@ -1,114 +0,0 @@
|
||||
"""TelegramAdapter.send_draft MarkdownV2 formatting parity.
|
||||
|
||||
Bot API 9.5 ``sendMessageDraft`` powers the animated streaming preview in
|
||||
DMs. The regular ``send`` path renders with MarkdownV2, so the draft must
|
||||
too — otherwise the live preview streams as raw text and the final
|
||||
``sendMessage`` snaps into formatted output, producing a jarring visual
|
||||
shift at the end of the response (reported by an external user, May 2026).
|
||||
|
||||
These tests pin:
|
||||
1. The happy path passes ``parse_mode=MARKDOWN_V2`` with format_message'd
|
||||
text (formatting parity with the final message).
|
||||
2. A MarkdownV2 BadRequest triggers a single plain-text retry rather than
|
||||
killing draft streaming for the whole response.
|
||||
3. A non-BadRequest failure propagates so the caller falls back to edit.
|
||||
"""
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
|
||||
def _ensure_telegram_mock():
|
||||
if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
|
||||
return
|
||||
mod = MagicMock()
|
||||
mod.error.NetworkError = type("NetworkError", (OSError,), {})
|
||||
mod.error.TimedOut = type("TimedOut", (OSError,), {})
|
||||
mod.error.BadRequest = type("BadRequest", (Exception,), {})
|
||||
for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"):
|
||||
sys.modules.setdefault(name, mod)
|
||||
sys.modules.setdefault("telegram.error", mod.error)
|
||||
|
||||
|
||||
_ensure_telegram_mock()
|
||||
|
||||
from gateway.platforms import telegram as tg_mod # noqa: E402
|
||||
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
|
||||
|
||||
|
||||
def _make_adapter() -> TelegramAdapter:
|
||||
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***"))
|
||||
adapter._bot = MagicMock()
|
||||
adapter._bot.send_message_draft = AsyncMock(return_value=True)
|
||||
return adapter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_draft_passes_markdownv2_parse_mode():
|
||||
"""Happy path: draft is sent with parse_mode set and format_message'd text."""
|
||||
adapter = _make_adapter()
|
||||
# Make format_message observable and deterministic.
|
||||
adapter.format_message = lambda c: f"FMT::{c}"
|
||||
|
||||
result = await adapter.send_draft("123", 7, "**bold** body")
|
||||
|
||||
assert result.success is True
|
||||
adapter._bot.send_message_draft.assert_awaited_once()
|
||||
kwargs = adapter._bot.send_message_draft.await_args.kwargs
|
||||
assert kwargs["text"] == "FMT::**bold** body"
|
||||
assert kwargs["parse_mode"] is tg_mod.ParseMode.MARKDOWN_V2
|
||||
assert kwargs["chat_id"] == 123
|
||||
assert kwargs["draft_id"] == 7
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_draft_falls_back_to_plain_text_on_markdownv2_error():
|
||||
"""A MarkdownV2 BadRequest retries once as plain text (no parse_mode),
|
||||
instead of aborting draft streaming for the whole response."""
|
||||
adapter = _make_adapter()
|
||||
adapter.format_message = lambda content: f"FMT::{content}"
|
||||
|
||||
# Resolve the BadRequest type the adapter checks via _is_bad_request_error.
|
||||
from telegram.error import BadRequest # type: ignore
|
||||
calls = []
|
||||
|
||||
async def _draft(**kwargs):
|
||||
calls.append(kwargs)
|
||||
if "parse_mode" in kwargs:
|
||||
raise BadRequest("can't parse entities")
|
||||
return True
|
||||
|
||||
adapter._bot.send_message_draft = AsyncMock(side_effect=_draft)
|
||||
|
||||
result = await adapter.send_draft("123", 9, "weird _text")
|
||||
|
||||
assert result.success is True
|
||||
# First attempt: MarkdownV2; second attempt: plain text, no parse_mode.
|
||||
assert len(calls) == 2
|
||||
assert "parse_mode" in calls[0]
|
||||
assert "parse_mode" not in calls[1]
|
||||
assert calls[1]["text"] == "weird _text" # raw, unformatted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_draft_non_badrequest_propagates_without_retry():
|
||||
"""A non-BadRequest failure (e.g. drafts not allowed) returns failure
|
||||
immediately so the caller falls back to the edit transport."""
|
||||
adapter = _make_adapter()
|
||||
adapter.format_message = lambda c: f"FMT::{c}"
|
||||
|
||||
calls = []
|
||||
|
||||
async def _draft(**kwargs):
|
||||
calls.append(kwargs)
|
||||
raise RuntimeError("drafts disabled for this chat")
|
||||
|
||||
adapter._bot.send_message_draft = AsyncMock(side_effect=_draft)
|
||||
|
||||
result = await adapter.send_draft("123", 11, "hi")
|
||||
|
||||
assert result.success is False
|
||||
assert len(calls) == 1 # no plain-text retry on non-BadRequest
|
||||
@@ -303,11 +303,9 @@ def test_auth_add_codex_oauth_persists_pool_entry(tmp_path, monkeypatch):
|
||||
|
||||
payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
|
||||
entries = payload["credential_pool"]["openai-codex"]
|
||||
entry = next(item for item in entries if item["source"] == "device_code")
|
||||
assert payload["active_provider"] == "openai-codex"
|
||||
assert payload["providers"]["openai-codex"]["tokens"]["access_token"] == token
|
||||
entry = next(item for item in entries if item["source"] == "manual:device_code")
|
||||
assert entry["label"] == "codex@example.com"
|
||||
assert entry["source"] == "device_code"
|
||||
assert entry["source"] == "manual:device_code"
|
||||
assert entry["refresh_token"] == "refresh-token"
|
||||
assert entry["base_url"] == "https://chatgpt.com/backend-api/codex"
|
||||
|
||||
@@ -1131,6 +1129,10 @@ def test_auth_remove_codex_manual_source_suppresses_reseed(tmp_path, monkeypatch
|
||||
def test_auth_add_codex_clears_suppression_marker(tmp_path, monkeypatch):
|
||||
"""Re-linking codex via `hermes auth add openai-codex` must clear any suppression marker."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setattr(
|
||||
"agent.credential_pool._seed_from_singletons",
|
||||
lambda provider, entries: (False, set()),
|
||||
)
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -1169,8 +1171,7 @@ def test_auth_add_codex_clears_suppression_marker(tmp_path, monkeypatch):
|
||||
assert "openai-codex" not in payload.get("suppressed_sources", {})
|
||||
# New pool entry must be present
|
||||
entries = payload["credential_pool"]["openai-codex"]
|
||||
assert any(e["source"] == "device_code" for e in entries)
|
||||
assert payload["active_provider"] == "openai-codex"
|
||||
assert any(e["source"] == "manual:device_code" for e in entries)
|
||||
|
||||
|
||||
def test_seed_from_singletons_respects_codex_suppression(tmp_path, monkeypatch):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user