Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb1c886bf9 | ||
|
|
e375c33f70 | ||
|
|
ac177cea87 | ||
|
|
ce50030634 | ||
|
|
f94363d1f0 | ||
|
|
0cbcc75935 | ||
|
|
0c0a707744 | ||
|
|
78122c52cf | ||
|
|
30340eae2f | ||
|
|
9c1bb8d2c7 | ||
|
|
aa52cd3b57 | ||
|
|
da9425bf9b | ||
|
|
8e629b9f38 | ||
|
|
be2c64be02 | ||
|
|
b8234e7599 | ||
|
|
3c231eb397 | ||
|
|
ea266f43e9 | ||
|
|
66a6b9c930 | ||
|
|
e6f7e217ce | ||
|
|
b5d42daa53 | ||
|
|
7ae8aac3b9 | ||
|
|
53bba70854 | ||
|
|
4b2d00f845 | ||
|
|
6f6eb871d8 | ||
|
|
1d9c3ebae0 | ||
|
|
4a1907bd10 | ||
|
|
02d6bf1c39 | ||
|
|
e837856ecd | ||
|
|
2dda393f9f | ||
|
|
14275d7baa | ||
|
|
1c909e75e1 | ||
|
|
cf786593cd | ||
|
|
9af54b2f8c | ||
|
|
3045d54547 | ||
|
|
83c13862f1 | ||
|
|
af8b917dab | ||
|
|
9ca11b35d5 | ||
|
|
ca1fb32c26 | ||
|
|
7583aedacd | ||
|
|
14fee4f112 | ||
|
|
98528c78c1 | ||
|
|
d880b5be09 | ||
|
|
ca8c78e588 | ||
|
|
1a3e608524 | ||
|
|
db204ae203 | ||
|
|
72eb42d9ec | ||
|
|
947e21b3d6 | ||
|
|
d41427504e | ||
|
|
06268f11cc | ||
|
|
3cd1bd971f | ||
|
|
ec46f5912e | ||
|
|
6bf55a473e | ||
|
|
8a9ded5b21 | ||
|
|
3da44dbda7 | ||
|
|
ef5e48f3fd | ||
|
|
2a82519b0d | ||
|
|
397d492b3e | ||
|
|
b459bac02c | ||
|
|
3278b423d5 | ||
|
|
9ab9c923da | ||
|
|
b0d234f068 | ||
|
|
c8e80cd0bf | ||
|
|
ad69d3edc7 | ||
|
|
b1e399de95 | ||
|
|
439f53cab8 | ||
|
|
899ee8c23d | ||
|
|
7309f3bef7 | ||
|
|
736dc0fd86 | ||
|
|
6b77fd2a0f | ||
|
|
46c16b9288 | ||
|
|
391b594752 |
@@ -108,6 +108,12 @@ docs/superpowers/*
|
||||
# logs, and per-session caches are never artifacts of the codebase.
|
||||
.hermes/
|
||||
|
||||
# Desktop/bootstrap install marker written into the managed checkout root by the
|
||||
# bootstrap installer. It is Hermes-managed runtime state, never a code change —
|
||||
# ignore it so `hermes update`'s `git stash push --include-untracked` does not
|
||||
# treat it as a local edit and autostash it on every run (#38529).
|
||||
.hermes-bootstrap-complete
|
||||
|
||||
# Tool Search live-test harness output — non-deterministic model transcripts,
|
||||
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
|
||||
scripts/out/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "hermes-agent",
|
||||
"name": "Hermes Agent",
|
||||
"version": "0.15.1",
|
||||
"version": "0.16.0",
|
||||
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
|
||||
"repository": "https://github.com/NousResearch/hermes-agent",
|
||||
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
|
||||
@@ -9,7 +9,7 @@
|
||||
"license": "MIT",
|
||||
"distribution": {
|
||||
"uvx": {
|
||||
"package": "hermes-agent[acp]==0.15.1",
|
||||
"package": "hermes-agent[acp]==0.16.0",
|
||||
"args": ["hermes-acp"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2720,6 +2720,61 @@ def run_conversation(
|
||||
# compress history and retry, not abort immediately.
|
||||
status_code = getattr(api_error, "status_code", None)
|
||||
|
||||
# ── Respect disabled auto-compaction on overflow ──────
|
||||
# Ported from anomalyco/opencode#30749. When the user has
|
||||
# turned auto-compaction off (``compression.enabled: false``),
|
||||
# NO automatic compaction trigger may fire — including the
|
||||
# provider/request-size overflow recovery paths below
|
||||
# (long-context-tier 429, 413 payload-too-large, and
|
||||
# context-overflow). Without this guard the proactive
|
||||
# threshold path correctly honours the setting (see the
|
||||
# preflight check and the post-response ``should_compress``
|
||||
# gate) but a provider overflow error would still silently
|
||||
# compress + rotate the session, bypassing the user's
|
||||
# explicit choice. Surface a terminal error instead so the
|
||||
# user can compact manually (``/compress``), start fresh
|
||||
# (``/new``), switch to a larger-context model, or reduce
|
||||
# attachments. Forced compaction via ``/compress``
|
||||
# (``force=True``) is unaffected — it never reaches this loop.
|
||||
_overflow_reasons = {
|
||||
FailoverReason.long_context_tier,
|
||||
FailoverReason.payload_too_large,
|
||||
FailoverReason.context_overflow,
|
||||
}
|
||||
if (
|
||||
classified.reason in _overflow_reasons
|
||||
and not getattr(agent, "compression_enabled", True)
|
||||
):
|
||||
agent._flush_status_buffer()
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}❌ Context overflow, but auto-compaction is disabled "
|
||||
f"(compression.enabled: false).",
|
||||
force=True,
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} 💡 Run /compress to compact manually, /new to start fresh, "
|
||||
f"switch to a larger-context model, or reduce attachments.",
|
||||
force=True,
|
||||
)
|
||||
logger.error(
|
||||
f"{agent.log_prefix}Context overflow ({classified.reason.value}) with "
|
||||
f"auto-compaction disabled — not compressing."
|
||||
)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
return {
|
||||
"messages": messages,
|
||||
"completed": False,
|
||||
"api_calls": api_call_count,
|
||||
"error": (
|
||||
"Context overflow and auto-compaction is disabled "
|
||||
"(compression.enabled: false). Run /compress to compact manually, "
|
||||
"/new to start fresh, or switch to a larger-context model."
|
||||
),
|
||||
"partial": True,
|
||||
"failed": True,
|
||||
"compaction_disabled": True,
|
||||
}
|
||||
|
||||
# ── Anthropic Sonnet long-context tier gate ───────────
|
||||
# Anthropic returns HTTP 429 "Extra usage is required for
|
||||
# long context requests" when a Claude Max (or similar)
|
||||
|
||||
@@ -33,6 +33,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
|
||||
# Published max output-token ceiling shared by every current Gemini text model
|
||||
# (2.5 + 3.x: flash, flash-lite, pro). Used as the default when the caller
|
||||
# passes max_tokens=None, because Gemini's native API otherwise applies a low
|
||||
# internal default and truncates output (unlike OpenAI-compat endpoints where
|
||||
# an omitted limit means full budget).
|
||||
GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535
|
||||
|
||||
|
||||
def is_native_gemini_base_url(base_url: str) -> bool:
|
||||
"""Return True when the endpoint speaks Gemini's native REST API."""
|
||||
@@ -414,6 +421,18 @@ def build_gemini_request(
|
||||
generation_config["temperature"] = temperature
|
||||
if max_tokens is not None:
|
||||
generation_config["maxOutputTokens"] = max_tokens
|
||||
else:
|
||||
# Gemini's native generateContent does NOT treat an omitted
|
||||
# maxOutputTokens as "use the model's full output budget" — it applies
|
||||
# a low internal default and the model stops early with
|
||||
# finishReason=MAX_TOKENS, truncating tool calls mid-stream (Hermes
|
||||
# then retries 3× and refuses the incomplete call). Every current
|
||||
# Gemini text model (2.5 + 3.x, flash / flash-lite / pro) caps at
|
||||
# 65,535 output tokens, so default to that ceiling when the caller
|
||||
# passes None ("unlimited"). See the OpenAI-compat path where omitting
|
||||
# the field genuinely means full budget — that assumption does not
|
||||
# hold on the native API.
|
||||
generation_config["maxOutputTokens"] = GEMINI_DEFAULT_MAX_OUTPUT_TOKENS
|
||||
if top_p is not None:
|
||||
generation_config["topP"] = top_p
|
||||
if stop:
|
||||
|
||||
@@ -571,7 +571,28 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
api_kwargs[k] = v
|
||||
|
||||
if extra_body:
|
||||
api_kwargs["extra_body"] = extra_body
|
||||
# Native Gemini (generativelanguage.googleapis.com, non-/openai)
|
||||
# speaks Google's REST schema, not OpenAI's. OpenAI-style extra_body
|
||||
# keys (tags, reasoning, provider, plugins, …) are unknown fields
|
||||
# there and Gemini rejects the whole request with a non-retryable
|
||||
# HTTP 400 ("Invalid JSON payload received. Unknown name 'tags'").
|
||||
# This happens when a profile that emits extra_body (e.g. the Nous
|
||||
# profile's portal `tags`) is active but the resolved endpoint is a
|
||||
# Gemini base_url — typical when only Google credentials are set and
|
||||
# a fallback/aux call lands on Gemini. The native client only reads
|
||||
# thinking_config from extra_body, so drop everything else here.
|
||||
try:
|
||||
from agent.gemini_native_adapter import is_native_gemini_base_url
|
||||
_native_gemini = is_native_gemini_base_url(params.get("base_url"))
|
||||
except Exception:
|
||||
_native_gemini = False
|
||||
if _native_gemini:
|
||||
extra_body = {
|
||||
k: v for k, v in extra_body.items()
|
||||
if k in ("thinking_config", "thinkingConfig")
|
||||
}
|
||||
if extra_body:
|
||||
api_kwargs["extra_body"] = extra_body
|
||||
|
||||
return api_kwargs
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
//! the bootstrap-complete check.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::process::Command;
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
|
||||
/// Returns the canonical Hermes home directory, respecting $HERMES_HOME if set.
|
||||
@@ -103,10 +105,37 @@ pub fn copy_self_to_hermes_home() -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::copy(&src, &dest)?;
|
||||
repair_macos_installer_helper(&dest);
|
||||
tracing::info!(?src, ?dest, "copied installer to HERMES_HOME");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn repair_macos_installer_helper(path: &Path) {
|
||||
// The staged helper may inherit quarantine from the downloaded installer.
|
||||
// Desktop later launches this exact file for in-app updates, so make it
|
||||
// executable before the update handoff reaches LaunchServices/Gatekeeper.
|
||||
let _ = Command::new("/usr/bin/xattr")
|
||||
.args(["-cr"])
|
||||
.arg(path)
|
||||
.status();
|
||||
|
||||
let verify = Command::new("/usr/bin/codesign")
|
||||
.arg("--verify")
|
||||
.arg(path)
|
||||
.status();
|
||||
|
||||
if !matches!(verify, Ok(status) if status.success()) {
|
||||
let _ = Command::new("/usr/bin/codesign")
|
||||
.args(["--force", "--sign", "-"])
|
||||
.arg(path)
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn repair_macos_installer_helper(_path: &Path) {}
|
||||
|
||||
/// Where install.ps1 writes the bootstrap-complete marker (existence-only file
|
||||
/// the Electron app also checks). Per main.cjs:
|
||||
/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
|
||||
|
||||
@@ -171,12 +171,19 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
let child_env = update_child_env(&install_root);
|
||||
let mut update_args: Vec<String> =
|
||||
vec!["update".into(), "--yes".into(), "--gateway".into()];
|
||||
// --force skips `hermes update`'s Windows running-exe guard (which would
|
||||
// `sys.exit(2)` and dead-end the handoff). By contract the desktop has
|
||||
// already exited and waited for the venv shim to unlock before launching
|
||||
// us, and wait_for_venv_free below force-kills any straggler — so by the
|
||||
// time `hermes update` runs there is no legitimate hermes.exe to protect,
|
||||
// and the guard would only produce a false "Hermes is still running" stop.
|
||||
update_args.push("--force".into());
|
||||
update_args.push("--branch".into());
|
||||
update_args.push(update_branch);
|
||||
|
||||
emit_stage(&app, "update", StageState::Running, None, None);
|
||||
let started = Instant::now();
|
||||
let update = run_streamed(
|
||||
let mut update = run_streamed(
|
||||
&app,
|
||||
&hermes,
|
||||
&update_args,
|
||||
@@ -185,6 +192,38 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
Some("update"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Retry-once for the update-boundary crash. `hermes update` lazily imports
|
||||
// the FRESHLY PULLED modules, but the dependency-install step still runs the
|
||||
// already-in-memory pre-pull code for one invocation. A release that changed
|
||||
// an updater-path contract across that boundary (e.g. #39780's `_UvResult`,
|
||||
// whose `__iter__` injected a bool into the argv and crashed Windows
|
||||
// `list2cmdline` with `TypeError: sequence item 1: expected str instance,
|
||||
// bool found`, fixed in #39820) therefore kills the FIRST update on the
|
||||
// parked population — even though the fix is already on disk by then. A
|
||||
// second `hermes update` runs clean because the now-current module is loaded
|
||||
// from the start. Rather than make the parked user click Update twice (and
|
||||
// stare at a scary crash first), retry once automatically. Skip the retry
|
||||
// for the concurrent-instance guard (exit 2) — that's a "close Hermes" state
|
||||
// a retry can't fix.
|
||||
if !matches!(update.exit_code, Some(0) | Some(UPDATE_EXIT_CONCURRENT)) {
|
||||
emit_log(
|
||||
&app,
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
"[update] first update attempt failed; retrying once (the fix it just \
|
||||
pulled loads on the second run)…",
|
||||
);
|
||||
update = run_streamed(
|
||||
&app,
|
||||
&hermes,
|
||||
&update_args,
|
||||
&install_root,
|
||||
&child_env,
|
||||
Some("update"),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let update_ms = started.elapsed().as_millis() as u64;
|
||||
|
||||
match update.exit_code {
|
||||
@@ -366,18 +405,77 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
// Last resort: a backend hermes.exe (or a grandchild it spawned)
|
||||
// is still holding the shim. The desktop should have reaped its
|
||||
// tree before handing off, but SIGTERM races / detached
|
||||
// grandchildren / AV handles can leave a straggler. Rather than
|
||||
// "proceed anyway" straight into uv's "Access is denied", force-kill
|
||||
// every hermes.exe except ourselves, then give the OS a beat to
|
||||
// unload the image.
|
||||
emit_log(
|
||||
app,
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
"[update] timed out waiting for Hermes to exit; proceeding anyway",
|
||||
"[update] Hermes still holding the venv shim; force-killing stragglers…",
|
||||
);
|
||||
force_kill_other_hermes();
|
||||
tokio::time::sleep(Duration::from_millis(800)).await;
|
||||
if !is_locked(&shim) {
|
||||
emit_log(
|
||||
app,
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
"[update] venv shim freed after force-kill",
|
||||
);
|
||||
} else {
|
||||
emit_log(
|
||||
app,
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
"[update] venv shim still locked; proceeding (--force + quarantine will handle it)",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(DESKTOP_EXIT_POLL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Force-kill any `hermes.exe` other than this process. Windows-only; a no-op
|
||||
/// elsewhere (POSIX has no mandatory-lock contention). We can't selectively
|
||||
/// target "the backend" by PID here — the desktop already exited and we never
|
||||
/// knew its children — so we kill the whole `hermes.exe` image tree via
|
||||
/// taskkill, excluding our own PID.
|
||||
///
|
||||
/// Safe w.r.t. our own update child: this runs inside `wait_for_venv_free`,
|
||||
/// which completes BEFORE we spawn `venv\Scripts\hermes.exe update`. At this
|
||||
/// point no update-driven hermes.exe exists yet, so the only hermes.exe images
|
||||
/// are stragglers from the old desktop — exactly what we want gone. (`/FI PID
|
||||
/// ne <self>` also spares this Tauri process, though it isn't named
|
||||
/// hermes.exe.)
|
||||
fn force_kill_other_hermes() {
|
||||
if !cfg!(target_os = "windows") {
|
||||
return;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let my_pid = std::process::id();
|
||||
// /FI excludes our own PID; /T kills the tree; /F forces.
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args([
|
||||
"/F",
|
||||
"/T",
|
||||
"/IM",
|
||||
"hermes.exe",
|
||||
"/FI",
|
||||
&format!("PID ne {my_pid}"),
|
||||
])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort lock probe: try to open the file for read+write. On Windows an
|
||||
/// exclusively-held running .exe refuses the open with a sharing violation.
|
||||
/// On Unix this almost always succeeds (no mandatory locking), which is fine —
|
||||
|
||||
@@ -24,12 +24,6 @@
|
||||
|
||||
### Install with Hermes (recommended)
|
||||
|
||||
Add `--include-desktop` to the [one-line installer](../../README.md#quick-install) and it sets up the agent and builds the desktop app in one go:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --include-desktop
|
||||
```
|
||||
|
||||
Already have the Hermes CLI? Just run:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -18,11 +18,24 @@
|
||||
* this via the public `/api/status` field `auth_required: true`.
|
||||
*/
|
||||
|
||||
// Bare + prefixed variants of the access-token cookie the gateway may set,
|
||||
// Bare + prefixed variants of the session cookies the gateway may set,
|
||||
// depending on its deploy shape (HTTPS direct → __Host-, behind a path prefix
|
||||
// → __Secure-, loopback HTTP → bare). Mirrors
|
||||
// hermes_cli/dashboard_auth/cookies.py.
|
||||
//
|
||||
// Two cookies are in play (see that module):
|
||||
// - hermes_session_at: the OAuth access token. Short-lived (~15 min); its
|
||||
// Max-Age tracks the access-token TTL, so the cookie jar drops it the
|
||||
// instant the AT expires.
|
||||
// - hermes_session_rt: the OAuth refresh token. Long-lived (24h rotating,
|
||||
// reuse-detected — Portal NAS #293 / hermes #37247). When the AT cookie
|
||||
// has lapsed but the RT cookie is still present, the gateway middleware
|
||||
// transparently rotates a fresh AT on the next authenticated request
|
||||
// (POST /api/auth/ws-ticket), so the session is still LIVE even with no
|
||||
// AT cookie. A liveness check that looked only at the AT cookie would
|
||||
// force a needless full re-login every ~15 min — hence cookiesHaveLiveSession.
|
||||
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
|
||||
const RT_COOKIE_VARIANTS = ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt']
|
||||
|
||||
function normalizeRemoteBaseUrl(rawUrl) {
|
||||
const value = String(rawUrl || '').trim()
|
||||
@@ -118,6 +131,41 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
|
||||
return buildGatewayWsUrl(baseUrl, token)
|
||||
}
|
||||
|
||||
// Normalize a profile name to a connection scope key, or null for the global
|
||||
// (default) connection. Shared by the resolver and the IPC layer.
|
||||
function connectionScopeKey(profile) {
|
||||
return String(profile ?? '').trim() || null
|
||||
}
|
||||
|
||||
// Coerce a remote auth mode to one of the two supported values ('token' default).
|
||||
function normAuthMode(mode) {
|
||||
return mode === 'oauth' ? 'oauth' : 'token'
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a profile's explicit remote override from a connection config, or null
|
||||
* when it has none (so the caller falls back to env → global remote → local).
|
||||
*
|
||||
* The config may carry a `profiles` map keyed by name; an entry counts as an
|
||||
* override only with `mode === 'remote'` and a non-empty `url`. Pure: `token`
|
||||
* is the raw stored secret; main.cjs decrypts it. Returns
|
||||
* `{ url, authMode, token } | null`.
|
||||
*/
|
||||
function profileRemoteOverride(config, profile) {
|
||||
const key = connectionScopeKey(profile)
|
||||
const entry = key ? config?.profiles?.[key] : null
|
||||
if (!entry || typeof entry !== 'object' || entry.mode !== 'remote') {
|
||||
return null
|
||||
}
|
||||
|
||||
const url = String(entry.url || '').trim()
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { url, authMode: normAuthMode(entry.authMode), token: entry.token }
|
||||
}
|
||||
|
||||
function tokenPreview(value) {
|
||||
const raw = String(value || '')
|
||||
|
||||
@@ -150,22 +198,56 @@ function resolveAuthMode(inputAuthMode, existingAuthMode) {
|
||||
}
|
||||
|
||||
/**
|
||||
* True if any cookie in `cookies` is a hermes session access-token cookie
|
||||
* True if any cookie in `cookies` is a hermes session ACCESS-token cookie
|
||||
* with a non-empty value. `cookies` is an array of {name, value} (the shape
|
||||
* Electron's session.cookies.get returns).
|
||||
*
|
||||
* Note: this is AT-only. A session whose AT cookie has lapsed but whose RT
|
||||
* cookie is still alive is STILL connectable (the gateway refreshes the AT on
|
||||
* the next request) — use `cookiesHaveLiveSession` for a connectivity/display
|
||||
* check. `cookiesHaveSession` remains exported for callers that specifically
|
||||
* need to know whether an unexpired access token is present right now.
|
||||
*/
|
||||
function cookiesHaveSession(cookies) {
|
||||
if (!Array.isArray(cookies)) return false
|
||||
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the cookie jar holds a credential that can yield an authenticated
|
||||
* request — EITHER a live access-token cookie OR a refresh-token cookie. The
|
||||
* RT cookie outlives the AT cookie (24h vs ~15min), and the gateway middleware
|
||||
* transparently rotates a fresh AT from the RT on the next authenticated
|
||||
* request. Gating connectivity on the AT alone would force a full IDP
|
||||
* re-login every ~15 min even though a valid 24h RT is sitting in the jar.
|
||||
*
|
||||
* This answers "should we even attempt to connect / show as signed in?", not
|
||||
* "is the access token unexpired?". The authoritative liveness check is still
|
||||
* the actual ws-ticket mint at connect time (which surfaces a true 401 when
|
||||
* the RT is also dead/revoked).
|
||||
*/
|
||||
function cookiesHaveLiveSession(cookies) {
|
||||
if (!Array.isArray(cookies)) return false
|
||||
return cookies.some(
|
||||
c =>
|
||||
c &&
|
||||
c.value &&
|
||||
(AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name))
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AT_COOKIE_VARIANTS,
|
||||
RT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
tokenPreview
|
||||
|
||||
@@ -15,16 +15,81 @@ const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
AT_COOKIE_VARIANTS,
|
||||
RT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
tokenPreview
|
||||
} = require('./connection-config.cjs')
|
||||
|
||||
// --- connectionScopeKey / normAuthMode ---
|
||||
|
||||
test('connectionScopeKey trims to a name or null for the global scope', () => {
|
||||
assert.equal(connectionScopeKey(' coder '), 'coder')
|
||||
assert.equal(connectionScopeKey(''), null)
|
||||
assert.equal(connectionScopeKey(null), null)
|
||||
assert.equal(connectionScopeKey(undefined), null)
|
||||
})
|
||||
|
||||
test('normAuthMode coerces to token unless explicitly oauth', () => {
|
||||
assert.equal(normAuthMode('oauth'), 'oauth')
|
||||
assert.equal(normAuthMode('token'), 'token')
|
||||
assert.equal(normAuthMode(undefined), 'token')
|
||||
assert.equal(normAuthMode('weird'), 'token')
|
||||
})
|
||||
|
||||
// --- profileRemoteOverride ---
|
||||
|
||||
test('profileRemoteOverride returns null when no profile is given', () => {
|
||||
const config = { profiles: { coder: { mode: 'remote', url: 'https://x' } } }
|
||||
assert.equal(profileRemoteOverride(config, ''), null)
|
||||
assert.equal(profileRemoteOverride(config, null), null)
|
||||
assert.equal(profileRemoteOverride(config, undefined), null)
|
||||
})
|
||||
|
||||
test('profileRemoteOverride returns null when the profile has no entry', () => {
|
||||
const config = { profiles: { coder: { mode: 'remote', url: 'https://x' } } }
|
||||
assert.equal(profileRemoteOverride(config, 'writer'), null)
|
||||
})
|
||||
|
||||
test('profileRemoteOverride ignores local or url-less profile entries', () => {
|
||||
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'local', url: 'https://x' } } }, 'p'), null)
|
||||
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'remote', url: '' } } }, 'p'), null)
|
||||
assert.equal(profileRemoteOverride({ profiles: { p: { mode: 'remote' } } }, 'p'), null)
|
||||
})
|
||||
|
||||
test('profileRemoteOverride returns the per-profile remote with defaulted auth mode', () => {
|
||||
const config = {
|
||||
profiles: {
|
||||
coder: { mode: 'remote', url: ' https://coder.example.com/hermes ', token: { value: 'sek' } }
|
||||
}
|
||||
}
|
||||
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
|
||||
url: 'https://coder.example.com/hermes',
|
||||
authMode: 'token',
|
||||
token: { value: 'sek' }
|
||||
})
|
||||
})
|
||||
|
||||
test('profileRemoteOverride preserves an explicit oauth auth mode', () => {
|
||||
const config = { profiles: { coder: { mode: 'remote', url: 'https://x', authMode: 'oauth' } } }
|
||||
assert.equal(profileRemoteOverride(config, 'coder').authMode, 'oauth')
|
||||
})
|
||||
|
||||
test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
|
||||
assert.equal(profileRemoteOverride({}, 'coder'), null)
|
||||
assert.equal(profileRemoteOverride({ profiles: null }, 'coder'), null)
|
||||
assert.equal(profileRemoteOverride(null, 'coder'), null)
|
||||
})
|
||||
|
||||
// --- normalizeRemoteBaseUrl ---
|
||||
|
||||
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
|
||||
@@ -131,7 +196,10 @@ test('cookiesHaveSession is false for an empty value', () => {
|
||||
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: '' }]), false)
|
||||
})
|
||||
|
||||
test('cookiesHaveSession ignores unrelated cookies', () => {
|
||||
test('cookiesHaveSession ignores unrelated cookies (AT-only by design)', () => {
|
||||
// cookiesHaveSession is deliberately access-token-only — a lone RT cookie
|
||||
// is NOT an access token, so this returns false. Connectivity callers must
|
||||
// use cookiesHaveLiveSession instead (see below).
|
||||
assert.equal(cookiesHaveSession([{ name: 'hermes_session_rt', value: 'x' }]), false)
|
||||
assert.equal(cookiesHaveSession([{ name: 'other', value: 'x' }]), false)
|
||||
})
|
||||
@@ -146,6 +214,56 @@ test('AT_COOKIE_VARIANTS covers all three deploy shapes', () => {
|
||||
assert.deepEqual(AT_COOKIE_VARIANTS, ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at'])
|
||||
})
|
||||
|
||||
test('RT_COOKIE_VARIANTS covers all three deploy shapes', () => {
|
||||
assert.deepEqual(RT_COOKIE_VARIANTS, ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt'])
|
||||
})
|
||||
|
||||
// --- cookiesHaveLiveSession (AT or RT — the connectivity check) ---
|
||||
|
||||
test('cookiesHaveLiveSession is true for a live access-token cookie', () => {
|
||||
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_at', value: 'x' }]), true)
|
||||
assert.equal(cookiesHaveLiveSession([{ name: '__Host-hermes_session_at', value: 'x' }]), true)
|
||||
assert.equal(cookiesHaveLiveSession([{ name: '__Secure-hermes_session_at', value: 'x' }]), true)
|
||||
})
|
||||
|
||||
test('cookiesHaveLiveSession is true for an RT cookie even with NO access-token cookie', () => {
|
||||
// This is the bug-fix case: the AT cookie has lapsed (dropped from the jar)
|
||||
// but the 24h RT cookie is still alive. The session is still connectable —
|
||||
// the gateway rotates a fresh AT from the RT on the next request.
|
||||
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_rt', value: 'x' }]), true)
|
||||
assert.equal(cookiesHaveLiveSession([{ name: '__Host-hermes_session_rt', value: 'x' }]), true)
|
||||
assert.equal(cookiesHaveLiveSession([{ name: '__Secure-hermes_session_rt', value: 'x' }]), true)
|
||||
})
|
||||
|
||||
test('cookiesHaveLiveSession is true when both AT and RT are present', () => {
|
||||
assert.equal(
|
||||
cookiesHaveLiveSession([
|
||||
{ name: 'hermes_session_at', value: 'a' },
|
||||
{ name: 'hermes_session_rt', value: 'r' }
|
||||
]),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('cookiesHaveLiveSession is false for empty values', () => {
|
||||
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_at', value: '' }]), false)
|
||||
assert.equal(cookiesHaveLiveSession([{ name: 'hermes_session_rt', value: '' }]), false)
|
||||
assert.equal(
|
||||
cookiesHaveLiveSession([
|
||||
{ name: 'hermes_session_at', value: '' },
|
||||
{ name: 'hermes_session_rt', value: '' }
|
||||
]),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('cookiesHaveLiveSession is false for unrelated cookies and non-arrays', () => {
|
||||
assert.equal(cookiesHaveLiveSession([{ name: 'other', value: 'x' }]), false)
|
||||
assert.equal(cookiesHaveLiveSession(null), false)
|
||||
assert.equal(cookiesHaveLiveSession(undefined), false)
|
||||
assert.equal(cookiesHaveLiveSession([]), false)
|
||||
})
|
||||
|
||||
// --- tokenPreview ---
|
||||
|
||||
test('tokenPreview returns null for empty', () => {
|
||||
|
||||
+565
-96
@@ -28,12 +28,17 @@ const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = requ
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const {
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
tokenPreview
|
||||
@@ -1309,6 +1314,136 @@ function resolveUpdaterBinary() {
|
||||
return fileExists(candidate) ? candidate : null
|
||||
}
|
||||
|
||||
function repairMacUpdaterHelper(updater) {
|
||||
if (!IS_MAC || !updater) return
|
||||
|
||||
try {
|
||||
execFileSync('/usr/bin/xattr', ['-cr', updater], { stdio: 'ignore' })
|
||||
} catch (err) {
|
||||
rememberLog(`[updates] macOS updater helper quarantine repair skipped: ${err.message}`)
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync('/usr/bin/codesign', ['--verify', updater], { stdio: 'ignore' })
|
||||
return
|
||||
} catch {
|
||||
// Unsigned or invalid helper. Apply a local ad-hoc signature so Gatekeeper
|
||||
// does not block the staged updater before it can run.
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', updater], { stdio: 'ignore' })
|
||||
rememberLog('[updates] repaired macOS updater helper signature')
|
||||
} catch (err) {
|
||||
rememberLog(`[updates] macOS updater helper signature repair skipped: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Path to the venv shim whose lock decides whether `hermes update` can write
|
||||
// fresh entry points. On Windows this is the file the running backend
|
||||
// `hermes.exe` holds open; on POSIX it's never mandatory-locked.
|
||||
function venvHermesShimPath(updateRoot) {
|
||||
return IS_WINDOWS
|
||||
? path.join(updateRoot, 'venv', 'Scripts', 'hermes.exe')
|
||||
: path.join(updateRoot, 'venv', 'bin', 'hermes')
|
||||
}
|
||||
|
||||
// Best-effort lock probe mirroring the Rust updater's is_locked(): a running
|
||||
// .exe on Windows refuses an O_RDWR open with a sharing violation. On POSIX
|
||||
// this practically always succeeds (no mandatory locking), so it returns false
|
||||
// — correct, since the shim-contention brick is Windows-only.
|
||||
function isShimLocked(shimPath) {
|
||||
if (!IS_WINDOWS) return false
|
||||
let fd
|
||||
try {
|
||||
fd = fs.openSync(shimPath, 'r+')
|
||||
return false
|
||||
} catch (err) {
|
||||
// ENOENT ⇒ not there ⇒ nothing locking it. Anything else (EBUSY/EPERM/
|
||||
// EACCES) on Windows means a live handle holds it.
|
||||
return err && err.code !== 'ENOENT'
|
||||
} finally {
|
||||
if (fd !== undefined) {
|
||||
try {
|
||||
fs.closeSync(fd)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Force-kill the entire process TREE rooted at each PID. Node's child.kill()
|
||||
// only signals the direct child, so on Windows a backend `hermes.exe` that
|
||||
// spawned its own grandchildren (a `hermes` REPL, a pty terminal session, the
|
||||
// gateway) would survive and keep the venv shim locked. taskkill /T /F reaps
|
||||
// the whole tree synchronously. Windows-only: this is called solely from the
|
||||
// Windows shim-unlock path, and the backend is NOT spawned detached (so it's
|
||||
// not a process-group leader — a POSIX negative-pgid kill would be meaningless
|
||||
// here anyway). POSIX teardown stays with the existing before-quit SIGTERM.
|
||||
function forceKillProcessTree(pid) {
|
||||
if (!IS_WINDOWS) return
|
||||
if (!Number.isInteger(pid) || pid <= 0) return
|
||||
try {
|
||||
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
|
||||
} catch {
|
||||
// Already gone, or no permission — best effort; the unlock wait below is
|
||||
// the real gate.
|
||||
}
|
||||
}
|
||||
|
||||
// Before handing off the update on Windows, the desktop MUST stop every backend
|
||||
// it spawned and WAIT for the venv shim to actually unlock. The old code did
|
||||
// `hermesProcess.kill('SIGTERM')` + `app.quit()` fire-and-forget: SIGTERM on
|
||||
// Windows doesn't reap the backend's grandchildren, and quit didn't wait for
|
||||
// teardown, so the updater raced a still-locked `hermes.exe`, the quarantine
|
||||
// rename failed, uv's `pip install` hit "Access is denied", and the git path
|
||||
// bailed into a full ZIP re-download that ALSO couldn't write the locked shim —
|
||||
// a half-applied install (ryanc's update.log). Here we tree-kill the primary +
|
||||
// pool backends and poll the shim until it's writable (or a bounded timeout),
|
||||
// so by the time we spawn the updater the lock is genuinely gone.
|
||||
//
|
||||
// Windows-only: the venv-shim mandatory lock is a Windows phenomenon. On
|
||||
// macOS/Linux there's no REPLACE-on-running-exe block, the existing before-quit
|
||||
// SIGTERM + app.quit() teardown already works (the macOS path is flawless), and
|
||||
// aggressively SIGKILL-ing the backend here would be an untested behavior change
|
||||
// for no benefit. So we no-op off Windows and leave that path exactly as it was.
|
||||
async function releaseBackendLockForUpdate(updateRoot) {
|
||||
if (!IS_WINDOWS) return { unlocked: true }
|
||||
|
||||
// Collect every backend PID the desktop owns: primary window backend + pool.
|
||||
const pids = []
|
||||
if (hermesProcess && Number.isInteger(hermesProcess.pid)) pids.push(hermesProcess.pid)
|
||||
for (const entry of backendPool.values()) {
|
||||
if (entry.process && Number.isInteger(entry.process.pid)) pids.push(entry.process.pid)
|
||||
}
|
||||
|
||||
// Graceful first (lets Python flush), then tree-kill to catch grandchildren.
|
||||
if (hermesProcess && !hermesProcess.killed) {
|
||||
try {
|
||||
hermesProcess.kill('SIGTERM')
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
stopAllPoolBackends()
|
||||
for (const pid of pids) forceKillProcessTree(pid)
|
||||
|
||||
const shim = venvHermesShimPath(updateRoot)
|
||||
const deadlineMs = Date.now() + 15000
|
||||
while (Date.now() < deadlineMs) {
|
||||
if (!isShimLocked(shim)) {
|
||||
rememberLog('[updates] venv shim unlocked; safe to hand off the update')
|
||||
return { unlocked: true }
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
}
|
||||
// Timed out: the updater's own wait_for_venv_free + force-kill is the second
|
||||
// line of defense, and we pass --force so the guard won't dead-end. Log it.
|
||||
rememberLog('[updates] venv shim still locked after 15s; handing off anyway (updater will force)')
|
||||
return { unlocked: false }
|
||||
}
|
||||
|
||||
// applyUpdates — hand off to the installer's --update flow, then exit.
|
||||
//
|
||||
// The desktop is a pure consumer: it does NOT git pull / pip install / rebuild
|
||||
@@ -1364,6 +1499,7 @@ async function applyUpdates(opts = {}) {
|
||||
}
|
||||
|
||||
emitUpdateProgress({ stage: 'restart', message: 'Handing off to the Hermes updater…', percent: 100 })
|
||||
repairMacUpdaterHelper(updater)
|
||||
|
||||
const updateRoot = resolveUpdateRoot()
|
||||
const { branch: configuredBranch } = readDesktopUpdateConfig()
|
||||
@@ -1375,6 +1511,12 @@ async function applyUpdates(opts = {}) {
|
||||
}
|
||||
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
|
||||
|
||||
// Stop our own backend(s) and wait for the venv shim to unlock BEFORE we
|
||||
// spawn the updater. Without this the updater races a still-locked
|
||||
// hermes.exe (held by the backend child / its grandchildren) and the update
|
||||
// bricks. See releaseBackendLockForUpdate for the full failure analysis.
|
||||
await releaseBackendLockForUpdate(updateRoot)
|
||||
|
||||
// Detached so the updater outlives this process — it needs us GONE before
|
||||
// `hermes update` will run (the venv shim is locked while we live).
|
||||
const child = spawn(updater, updaterArgs, {
|
||||
@@ -3167,8 +3309,16 @@ function installMediaPermissions() {
|
||||
// * WebSocket upgrades require a single-use ``?ticket=`` minted at
|
||||
// ``POST /api/auth/ws-ticket`` (cookie-authed). The legacy ``?token=``
|
||||
// path is unconditionally rejected by gated gateways.
|
||||
// * Nous Portal contract v1 issues NO refresh token; the access cookie has
|
||||
// a ~15-min TTL. On 401 we must re-run the login round trip.
|
||||
// * Nous Portal now issues a 24h ROTATING, reuse-detected refresh token
|
||||
// alongside the ~15-min access token (Portal NAS #293 / hermes #37247).
|
||||
// Both are set as HttpOnly cookies (``hermes_session_at`` ~15 min,
|
||||
// ``hermes_session_rt`` 24h). When the AT cookie lapses but the RT cookie
|
||||
// is still alive, the gateway middleware transparently rotates a fresh AT
|
||||
// on the next authenticated request — so connectivity must NOT be gated on
|
||||
// the AT cookie alone. We probe liveness by actually minting a ws-ticket
|
||||
// (which triggers that server-side refresh) and treat a real 401 as
|
||||
// "needs re-login"; the AT-or-RT cookie presence check is only a cheap
|
||||
// "is the user signed in at all?" gate / display signal.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const OAUTH_SESSION_PARTITION = 'persist:hermes-remote-oauth'
|
||||
@@ -3179,8 +3329,9 @@ function getOauthSession() {
|
||||
return oauthSession
|
||||
}
|
||||
|
||||
// Bare + prefixed variants of the access-token cookie live in
|
||||
// connection-config.cjs (cookiesHaveSession). See that module for details.
|
||||
// Bare + prefixed variants of the session cookies live in
|
||||
// connection-config.cjs (cookiesHaveSession / cookiesHaveLiveSession). See
|
||||
// that module for details.
|
||||
|
||||
async function hasOauthSessionCookie(baseUrl) {
|
||||
const sess = getOauthSession()
|
||||
@@ -3201,6 +3352,30 @@ async function hasOauthSessionCookie(baseUrl) {
|
||||
}
|
||||
}
|
||||
|
||||
// Like hasOauthSessionCookie, but returns true when EITHER a live access-token
|
||||
// cookie OR a (longer-lived) refresh-token cookie is present. This is the right
|
||||
// "is the user signed in at all?" check: an expired AT with a live RT is still
|
||||
// a connectable session because the gateway rotates a fresh AT server-side on
|
||||
// the next authenticated request. Gating on the AT alone forces a needless full
|
||||
// re-login every ~15 min. Used for the Settings "connected" indicator and as a
|
||||
// cheap early-out before attempting a network round-trip in resolveRemoteBackend.
|
||||
async function hasLiveOauthSession(baseUrl) {
|
||||
const sess = getOauthSession()
|
||||
if (!sess) return false
|
||||
const parsed = new URL(baseUrl)
|
||||
try {
|
||||
const cookies = await sess.cookies.get({ url: baseUrl })
|
||||
return cookiesHaveLiveSession(cookies)
|
||||
} catch {
|
||||
try {
|
||||
const cookies = await sess.cookies.get({ domain: parsed.hostname })
|
||||
return cookiesHaveLiveSession(cookies)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function clearOauthSession(baseUrl) {
|
||||
const sess = getOauthSession()
|
||||
if (!sess) return
|
||||
@@ -3319,7 +3494,7 @@ function fetchJsonViaOauthSession(url, options = {}) {
|
||||
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
|
||||
return
|
||||
}
|
||||
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
|
||||
const body = serializeJsonBody(options.body)
|
||||
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||
|
||||
const request = electronNet.request({
|
||||
@@ -3329,8 +3504,7 @@ function fetchJsonViaOauthSession(url, options = {}) {
|
||||
useSessionCookies: true,
|
||||
redirect: 'follow'
|
||||
})
|
||||
request.setHeader('Content-Type', 'application/json')
|
||||
if (body) request.setHeader('Content-Length', String(body.length))
|
||||
setJsonRequestHeaders(request)
|
||||
|
||||
let timedOut = false
|
||||
const timer = setTimeout(() => {
|
||||
@@ -3447,6 +3621,38 @@ function decryptDesktopSecret(secret) {
|
||||
return value
|
||||
}
|
||||
|
||||
// Validate + normalize the per-profile remote overrides map read from disk.
|
||||
// Drops malformed names/entries and keeps only the recognized fields so a
|
||||
// hand-edited or stale connection.json can't inject junk into resolution.
|
||||
function sanitizeConnectionProfiles(raw) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return {}
|
||||
}
|
||||
|
||||
const out = {}
|
||||
for (const [name, entry] of Object.entries(raw)) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
continue
|
||||
}
|
||||
if (name !== 'default' && !PROFILE_NAME_RE.test(name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const cleaned = { mode: entry.mode === 'remote' ? 'remote' : 'local' }
|
||||
const url = String(entry.url || '').trim()
|
||||
if (url) {
|
||||
cleaned.url = url
|
||||
}
|
||||
cleaned.authMode = normAuthMode(entry.authMode)
|
||||
if (entry.token && typeof entry.token === 'object') {
|
||||
cleaned.token = entry.token
|
||||
}
|
||||
out[name] = cleaned
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function readDesktopConnectionConfig() {
|
||||
// Check if file changed on disk since last read (e.g. modified by another
|
||||
// process or an external tool). Our own writes update the cache inline
|
||||
@@ -3462,7 +3668,7 @@ function readDesktopConnectionConfig() {
|
||||
return connectionConfigCache
|
||||
}
|
||||
|
||||
let config = { mode: 'local', remote: {} }
|
||||
let config = { mode: 'local', remote: {}, profiles: {} }
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(DESKTOP_CONNECTION_CONFIG_PATH, 'utf8')
|
||||
@@ -3476,7 +3682,11 @@ function readDesktopConnectionConfig() {
|
||||
remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
config = {
|
||||
mode: parsed.mode === 'remote' ? 'remote' : 'local',
|
||||
remote
|
||||
remote,
|
||||
// Per-profile remote overrides: each profile may point at its own
|
||||
// backend (local spawn or its own remote URL). Preserved verbatim so
|
||||
// profileRemoteOverride() can resolve them; normalized lazily on save.
|
||||
profiles: sanitizeConnectionProfiles(parsed.profiles)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -3528,104 +3738,114 @@ function writeActiveDesktopProfile(name) {
|
||||
return value || null
|
||||
}
|
||||
|
||||
async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) {
|
||||
const remoteToken = decryptDesktopSecret(config.remote?.token)
|
||||
const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
const remoteUrl = String(config.remote?.url || '')
|
||||
// Sanitize a connection config into the renderer-facing shape. With no
|
||||
// `profile` this describes the global/default connection (the existing
|
||||
// behavior); with a `profile` it describes that profile's per-profile remote
|
||||
// override (or an empty "local/inherit" view when the profile has none).
|
||||
async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig(), profile = null) {
|
||||
const key = connectionScopeKey(profile)
|
||||
const scoped = key ? config.profiles?.[key] || null : null
|
||||
const block = key ? scoped || {} : config.remote || {}
|
||||
|
||||
const remoteToken = decryptDesktopSecret(block.token)
|
||||
const authMode = normAuthMode(block.authMode)
|
||||
const remoteUrl = String(block.url || '')
|
||||
const mode = (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
|
||||
|
||||
let remoteOauthConnected = false
|
||||
if (authMode === 'oauth' && remoteUrl) {
|
||||
try {
|
||||
remoteOauthConnected = await hasOauthSessionCookie(remoteUrl)
|
||||
// Display signal: treat a live RT cookie as "connected" even if the AT
|
||||
// cookie has lapsed — the gateway refreshes the AT on the next request,
|
||||
// so the session is still usable. The authoritative liveness check is
|
||||
// the ws-ticket mint in resolveRemoteBackend at actual connect time.
|
||||
remoteOauthConnected = await hasLiveOauthSession(remoteUrl)
|
||||
} catch {
|
||||
remoteOauthConnected = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mode: config.mode === 'remote' ? 'remote' : 'local',
|
||||
mode,
|
||||
// Echo the scope back so the UI knows which profile (if any) this reflects.
|
||||
profile: key,
|
||||
remoteAuthMode: authMode,
|
||||
remoteOauthConnected,
|
||||
remoteUrl,
|
||||
remoteTokenPreview: tokenPreview(remoteToken),
|
||||
remoteTokenSet: Boolean(remoteToken),
|
||||
envOverride: Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
|
||||
// The env override only forces the global/primary connection; a per-profile
|
||||
// scope is never overridden by HERMES_DESKTOP_REMOTE_URL.
|
||||
envOverride: key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
|
||||
}
|
||||
}
|
||||
|
||||
// Build + validate a `{ url, authMode, token }` remote block. OAuth gateways
|
||||
// authenticate via the login-window session cookie (verified at connect time in
|
||||
// resolveRemoteBackend), so only token-auth remotes require a saved token.
|
||||
function buildRemoteBlock(remoteUrl, authMode, token) {
|
||||
if (authMode !== 'oauth' && !decryptDesktopSecret(token)) {
|
||||
throw new Error('Remote gateway session token is required.')
|
||||
}
|
||||
return { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token }
|
||||
}
|
||||
|
||||
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) {
|
||||
const persistToken = options.persistToken !== false
|
||||
const key = connectionScopeKey(input.profile)
|
||||
const mode = input.mode === 'remote' ? 'remote' : 'local'
|
||||
const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim()
|
||||
|
||||
// The block being edited: a per-profile entry or the global remote block.
|
||||
const existingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {}
|
||||
const remoteUrl = String(input.remoteUrl ?? existingBlock.url ?? '').trim()
|
||||
// authMode: explicit input wins; otherwise inherit the saved value, default 'token'.
|
||||
const authMode = resolveAuthMode(input.remoteAuthMode, existing.remote?.authMode)
|
||||
const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.authMode)
|
||||
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
|
||||
const existingToken = existing.remote?.token
|
||||
const nextRemote = {
|
||||
url: remoteUrl,
|
||||
authMode,
|
||||
token: incomingToken
|
||||
? persistToken
|
||||
? encryptDesktopSecret(incomingToken)
|
||||
: { encoding: 'plain', value: incomingToken }
|
||||
: existingToken
|
||||
}
|
||||
const nextToken = incomingToken
|
||||
? persistToken
|
||||
? encryptDesktopSecret(incomingToken)
|
||||
: { encoding: 'plain', value: incomingToken }
|
||||
: existingBlock.token
|
||||
|
||||
if (mode === 'remote') {
|
||||
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
|
||||
|
||||
// OAuth gateways authenticate via the session cookie established by the
|
||||
// login window, NOT a static token — so no token is required here. The
|
||||
// cookie presence is verified at connect time (resolveRemoteBackend).
|
||||
if (authMode !== 'oauth' && !decryptDesktopSecret(nextRemote.token)) {
|
||||
throw new Error('Remote gateway session token is required.')
|
||||
if (key) {
|
||||
// Per-profile scope: a remote entry pins this profile to its own backend; a
|
||||
// local entry clears the override so the profile inherits the default.
|
||||
const profiles = { ...(existing.profiles || {}) }
|
||||
if (mode === 'remote') {
|
||||
profiles[key] = { mode: 'remote', ...buildRemoteBlock(remoteUrl, authMode, nextToken) }
|
||||
} else {
|
||||
delete profiles[key]
|
||||
}
|
||||
} else if (remoteUrl) {
|
||||
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
|
||||
return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles }
|
||||
}
|
||||
|
||||
return { mode, remote: nextRemote }
|
||||
const nextRemote =
|
||||
mode === 'remote'
|
||||
? buildRemoteBlock(remoteUrl, authMode, nextToken)
|
||||
: { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken }
|
||||
|
||||
// Preserve per-profile overrides when saving the global connection.
|
||||
return { mode, remote: nextRemote, profiles: existing.profiles || {} }
|
||||
}
|
||||
|
||||
async function resolveRemoteBackend() {
|
||||
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
|
||||
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
|
||||
|
||||
if (rawEnvUrl) {
|
||||
if (!rawEnvToken) {
|
||||
throw new Error(
|
||||
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
|
||||
'Both must be provided to connect to a remote Hermes backend.'
|
||||
)
|
||||
}
|
||||
|
||||
const baseUrl = normalizeRemoteBaseUrl(rawEnvUrl)
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'remote',
|
||||
source: 'env',
|
||||
authMode: 'token',
|
||||
token: rawEnvToken,
|
||||
wsUrl: buildGatewayWsUrl(baseUrl, rawEnvToken)
|
||||
}
|
||||
}
|
||||
|
||||
const config = readDesktopConnectionConfig()
|
||||
|
||||
if (config.mode !== 'remote') {
|
||||
return null
|
||||
}
|
||||
|
||||
const baseUrl = normalizeRemoteBaseUrl(config.remote?.url)
|
||||
const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
// Build a remote backend connection descriptor from an already-resolved remote
|
||||
// config. Handles both auth models (OAuth ws-ticket vs static session token)
|
||||
// and is shared by the per-profile, env, and global resolution paths. `token`
|
||||
// is the DECRYPTED static token (or null in OAuth mode). `source` is a label
|
||||
// for diagnostics ('profile' | 'env' | 'settings').
|
||||
async function buildRemoteConnection(rawUrl, authMode, token, source) {
|
||||
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
|
||||
|
||||
if (authMode === 'oauth') {
|
||||
// OAuth gateway: auth comes from the session cookie in the OAuth partition.
|
||||
// Verify the cookie is present, then mint a single-use WS ticket (the
|
||||
// gateway rejects ?token= in gated mode). A missing cookie / 401 means the
|
||||
// user needs to (re-)log in via Settings → Gateway.
|
||||
if (!(await hasOauthSessionCookie(baseUrl))) {
|
||||
// OAuth gateway: auth comes from the session cookies in the OAuth
|
||||
// partition. Liveness is NOT "is the access-token cookie present?" —
|
||||
// Portal issues a 24h rotating refresh token (hermes #37247), and the
|
||||
// gateway middleware transparently rotates a fresh ~15-min access token
|
||||
// from it on the next authenticated request. So a session with an expired
|
||||
// AT cookie but a live RT cookie is still perfectly connectable. We
|
||||
// early-out only when neither cookie is present, then mint a ws-ticket as
|
||||
// the authoritative liveness check.
|
||||
if (!(await hasLiveOauthSession(baseUrl))) {
|
||||
const err = new Error(
|
||||
'Remote Hermes gateway uses OAuth, but you are not signed in. ' +
|
||||
'Open Settings → Gateway and click "Sign in", or switch back to Local.'
|
||||
@@ -3649,7 +3869,7 @@ async function resolveRemoteBackend() {
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'remote',
|
||||
source: 'settings',
|
||||
source,
|
||||
authMode: 'oauth',
|
||||
// No static token in OAuth mode; REST is cookie-authed via the partition.
|
||||
token: null,
|
||||
@@ -3657,8 +3877,6 @@ async function resolveRemoteBackend() {
|
||||
}
|
||||
}
|
||||
|
||||
const token = decryptDesktopSecret(config.remote?.token)
|
||||
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
'Remote Hermes gateway is selected, but no session token is saved. ' +
|
||||
@@ -3669,13 +3887,92 @@ async function resolveRemoteBackend() {
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'remote',
|
||||
source: 'settings',
|
||||
source,
|
||||
authMode: 'token',
|
||||
token,
|
||||
wsUrl: buildGatewayWsUrl(baseUrl, token)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the remote backend for a given profile, or null when that profile
|
||||
// should run a LOCAL backend. Precedence:
|
||||
// 1. explicit per-profile remote override (connection.json `profiles[name]`)
|
||||
// 2. env override (HERMES_DESKTOP_REMOTE_URL/_TOKEN) — applies app-wide
|
||||
// 3. global remote (connection.json `mode: 'remote'`)
|
||||
// A null/empty profile resolves the env/global remote, so legacy callers and
|
||||
// the connection test (which pass no profile) are unchanged.
|
||||
async function resolveRemoteBackend(profile) {
|
||||
const config = readDesktopConnectionConfig()
|
||||
|
||||
// 1. Per-profile override — "a profile with its own remote host". Wins even
|
||||
// over the env override so an explicitly-configured profile always
|
||||
// reaches its intended backend.
|
||||
const override = profileRemoteOverride(config, profile)
|
||||
if (override) {
|
||||
const token = override.authMode === 'oauth' ? null : decryptDesktopSecret(override.token)
|
||||
return buildRemoteConnection(override.url, override.authMode, token, 'profile')
|
||||
}
|
||||
|
||||
// 2. Env override (global, token-auth only).
|
||||
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
|
||||
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
|
||||
if (rawEnvUrl) {
|
||||
if (!rawEnvToken) {
|
||||
throw new Error(
|
||||
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
|
||||
'Both must be provided to connect to a remote Hermes backend.'
|
||||
)
|
||||
}
|
||||
return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env')
|
||||
}
|
||||
|
||||
// 3. Global remote.
|
||||
if (config.mode !== 'remote') {
|
||||
return null
|
||||
}
|
||||
const authMode = normAuthMode(config.remote?.authMode)
|
||||
const token = authMode === 'oauth' ? null : decryptDesktopSecret(config.remote?.token)
|
||||
return buildRemoteConnection(config.remote?.url, authMode, token, 'settings')
|
||||
}
|
||||
|
||||
// A remote profile's sessions live on its remote host's state.db, not on a local
|
||||
// file the primary can open — so reads for it must route to the remote backend,
|
||||
// not the local-disk fast path. These three helpers drive that (see
|
||||
// interceptSessionReadForRemote).
|
||||
function profileHasRemoteOverride(profile) {
|
||||
return Boolean(profileRemoteOverride(readDesktopConnectionConfig(), profile))
|
||||
}
|
||||
|
||||
function configuredRemoteProfileNames() {
|
||||
const config = readDesktopConnectionConfig()
|
||||
return Object.keys(config.profiles || {}).filter(name => profileRemoteOverride(config, name))
|
||||
}
|
||||
|
||||
// True when the app is in app-global remote mode (Settings → "All profiles" →
|
||||
// Remote, or the env override): a SINGLE remote backend serves every profile via
|
||||
// ?profile=. Distinct from per-profile overrides — here there's one host for all.
|
||||
function globalRemoteActive() {
|
||||
if (process.env.HERMES_DESKTOP_REMOTE_URL) {
|
||||
return true
|
||||
}
|
||||
return readDesktopConnectionConfig().mode === 'remote'
|
||||
}
|
||||
|
||||
// GET a profile's resolved backend (remote pool or local primary), parsed JSON.
|
||||
async function fetchJsonForProfile(profile, path) {
|
||||
return requestJsonForProfile(profile, path, 'GET')
|
||||
}
|
||||
|
||||
// Issue an arbitrary method against a profile's resolved backend, parsed JSON.
|
||||
async function requestJsonForProfile(profile, path, method, body) {
|
||||
const conn = await ensureBackend(profile)
|
||||
const url = `${conn.baseUrl}${path}`
|
||||
const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }
|
||||
return conn.authMode === 'oauth'
|
||||
? fetchJsonViaOauthSession(url, opts)
|
||||
: fetchJson(url, conn.token, opts)
|
||||
}
|
||||
|
||||
async function probeRemoteAuthMode(rawUrl) {
|
||||
// Determine how a remote gateway expects callers to authenticate, WITHOUT
|
||||
// sending any credentials. ``/api/status`` is public on every Hermes
|
||||
@@ -3743,6 +4040,11 @@ async function probeRemoteAuthMode(rawUrl) {
|
||||
|
||||
async function testDesktopConnectionConfig(input = {}) {
|
||||
const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })
|
||||
const key = connectionScopeKey(input.profile)
|
||||
// The block under test: a per-profile entry or the global remote. Coerce has
|
||||
// already normalized the URL and resolved token inheritance for the scope.
|
||||
const block = key ? config.profiles?.[key] || null : config.remote
|
||||
const wantRemote = block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block)
|
||||
// ``/api/status`` is public on every gateway (no creds needed), so a
|
||||
// reachability test works for local, token, and oauth modes alike — we only
|
||||
// need a base URL. For a remote config we normalize the URL from the input;
|
||||
@@ -3750,17 +4052,17 @@ async function testDesktopConnectionConfig(input = {}) {
|
||||
let baseUrl
|
||||
let token = null
|
||||
let authMode = 'token'
|
||||
if (config.mode === 'remote') {
|
||||
baseUrl = normalizeRemoteBaseUrl(config.remote.url)
|
||||
authMode = config.remote.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
if (wantRemote && block?.url) {
|
||||
baseUrl = normalizeRemoteBaseUrl(block.url)
|
||||
authMode = normAuthMode(block.authMode)
|
||||
if (authMode !== 'oauth') {
|
||||
token = decryptDesktopSecret(config.remote.token)
|
||||
token = decryptDesktopSecret(block.token)
|
||||
}
|
||||
} else {
|
||||
const remote = (await resolveRemoteBackend()) || (await startHermes())
|
||||
const remote = (await resolveRemoteBackend(key)) || (await startHermes())
|
||||
baseUrl = remote.baseUrl
|
||||
token = remote.token
|
||||
authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
authMode = normAuthMode(remote.authMode)
|
||||
}
|
||||
const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 })
|
||||
|
||||
@@ -3932,10 +4234,21 @@ function startPoolIdleReaper() {
|
||||
// local-spawn portion of startHermes() but without the boot-progress UI,
|
||||
// bootstrap, or remote handling (those belong to the primary backend only).
|
||||
async function spawnPoolBackend(profile, entry) {
|
||||
// Remote deployments are single-tenant; profiles only apply to local backends.
|
||||
const remote = await resolveRemoteBackend()
|
||||
// A profile may point at its OWN remote backend (connection.json
|
||||
// `profiles[name]`), or inherit the app-wide remote (env / global settings).
|
||||
// In either case there is no local child to spawn — we just verify the
|
||||
// remote is reachable and hand back its connection descriptor. The pool
|
||||
// entry keeps `entry.process === null`, which stopPoolBackend/evict already
|
||||
// tolerate.
|
||||
const remote = await resolveRemoteBackend(profile)
|
||||
if (remote) {
|
||||
throw new Error('Profiles are unavailable when connected to a remote Hermes backend.')
|
||||
await waitForHermes(remote.baseUrl, remote.token)
|
||||
return {
|
||||
...remote,
|
||||
profile,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
}
|
||||
|
||||
const port = await pickPort()
|
||||
@@ -4036,7 +4349,9 @@ async function startHermes() {
|
||||
|
||||
connectionPromise = (async () => {
|
||||
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
|
||||
const remote = await resolveRemoteBackend()
|
||||
// Resolve for the desktop's primary profile so a per-profile remote
|
||||
// override on the active profile is honored (falls back to env / global).
|
||||
const remote = await resolveRemoteBackend(primaryProfileKey())
|
||||
if (remote) {
|
||||
await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24)
|
||||
await waitForHermes(remote.baseUrl, remote.token)
|
||||
@@ -4373,7 +4688,9 @@ ipcMain.handle('hermes:bootstrap:cancel', async () => {
|
||||
})
|
||||
ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
|
||||
ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState())
|
||||
ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig())
|
||||
ipcMain.handle('hermes:connection-config:get', async (_event, profile) =>
|
||||
sanitizeDesktopConnectionConfig(readDesktopConnectionConfig(), profile)
|
||||
)
|
||||
ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload))
|
||||
ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl))
|
||||
ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => {
|
||||
@@ -4388,22 +4705,36 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) =>
|
||||
ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => {
|
||||
const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : ''
|
||||
await clearOauthSession(baseUrl || undefined)
|
||||
return { ok: true, connected: baseUrl ? await hasOauthSessionCookie(baseUrl) : false }
|
||||
// Report against the SAME liveness notion the Settings indicator uses
|
||||
// (AT-or-RT) so a logout that left any session cookie behind is reflected
|
||||
// as still-connected rather than silently signed-out.
|
||||
return { ok: true, connected: baseUrl ? await hasLiveOauthSession(baseUrl) : false }
|
||||
})
|
||||
ipcMain.handle('hermes:connection-config:save', async (_event, payload) => {
|
||||
const config = coerceDesktopConnectionConfig(payload)
|
||||
writeDesktopConnectionConfig(config)
|
||||
|
||||
return sanitizeDesktopConnectionConfig(config)
|
||||
return sanitizeDesktopConnectionConfig(config, payload?.profile)
|
||||
})
|
||||
ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
|
||||
const config = coerceDesktopConnectionConfig(payload)
|
||||
writeDesktopConnectionConfig(config)
|
||||
|
||||
await teardownPrimaryBackendAndWait()
|
||||
const key = connectionScopeKey(payload?.profile)
|
||||
|
||||
mainWindow?.reload()
|
||||
return sanitizeDesktopConnectionConfig(config)
|
||||
if (key && key !== primaryProfileKey()) {
|
||||
// Editing a NON-primary profile's connection: don't disturb the window's
|
||||
// primary backend. Drop the profile's pooled backend so the next switch
|
||||
// re-resolves against the new remote/local target.
|
||||
stopPoolBackend(key)
|
||||
} else {
|
||||
// Global connection, or the primary profile's connection: re-home the
|
||||
// window backend by tearing it down and reloading the renderer.
|
||||
await teardownPrimaryBackendAndWait()
|
||||
mainWindow?.reload()
|
||||
}
|
||||
|
||||
return sanitizeDesktopConnectionConfig(config, payload?.profile)
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:profile:get', async () => ({ profile: readActiveDesktopProfile() }))
|
||||
@@ -4431,7 +4762,145 @@ ipcMain.handle('hermes:requestMicrophoneAccess', async () => {
|
||||
return systemPreferences.askForMediaAccess('microphone')
|
||||
})
|
||||
|
||||
// Re-route remote-profile session requests to the owning remote backend. Returns
|
||||
// `undefined` when not interceptable (caller takes the normal local path), else
|
||||
// the response. Reads tag the profile as ?profile=<name>; mutations carry it in
|
||||
// request.profile. Either way, a remote profile's session lives only on its
|
||||
// remote host, so the request must go there (where it serves its own state.db).
|
||||
// GET /api/profiles/sessions → splice each remote profile's rows in
|
||||
// GET /api/sessions/{id}[/messages] → read from remote
|
||||
// DELETE /api/sessions/{id} → delete on remote
|
||||
// PATCH /api/sessions/{id} → rename/archive on remote
|
||||
async function interceptSessionRequestForRemote(request) {
|
||||
if (typeof request?.path !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
const method = (request.method || 'GET').toUpperCase()
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(request.path, 'http://x')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
const { pathname, searchParams } = parsed
|
||||
|
||||
if (method === 'GET' && pathname === '/api/profiles/sessions') {
|
||||
const remoteProfiles = configuredRemoteProfileNames()
|
||||
if (remoteProfiles.length === 0) {
|
||||
return undefined // no remote profiles → local fast path
|
||||
}
|
||||
const requested = (searchParams.get('profile') || 'all').trim() || 'all'
|
||||
if (requested !== 'all') {
|
||||
return profileHasRemoteOverride(requested) ? remoteSessionList(requested, searchParams) : undefined
|
||||
}
|
||||
return mergeRemoteProfileSessions(searchParams, remoteProfiles)
|
||||
}
|
||||
|
||||
// Per-session read/mutation. Owner is in ?profile= (reads) or request.profile
|
||||
// (mutations). Two remote shapes:
|
||||
// - per-profile override: route to that profile's own remote, sans profile
|
||||
// param (it serves its own state.db natively).
|
||||
// - global remote mode: ONE backend serves every profile via ?profile=, so
|
||||
// route there and KEEP the profile param so it opens the right state.db.
|
||||
if (/^\/api\/sessions\/[^/]+(\/messages)?$/.test(pathname)) {
|
||||
const profile = (searchParams.get('profile') || request.profile || '').trim()
|
||||
if (!profile) {
|
||||
return undefined
|
||||
}
|
||||
if (profileHasRemoteOverride(profile)) {
|
||||
if (method === 'GET') {
|
||||
return fetchJsonForProfile(profile, pathname)
|
||||
}
|
||||
const body = request.body && typeof request.body === 'object' ? { ...request.body } : request.body
|
||||
if (body) delete body.profile
|
||||
return requestJsonForProfile(profile, pathname, method, body)
|
||||
}
|
||||
if (globalRemoteActive()) {
|
||||
// Single global backend: keep ?profile= so it opens the right state.db.
|
||||
const sep = pathname.includes('?') ? '&' : '?'
|
||||
const path = `${pathname}${sep}profile=${encodeURIComponent(profile)}`
|
||||
if (method === 'GET') {
|
||||
return fetchJsonForProfile(null, path)
|
||||
}
|
||||
const body = request.body && typeof request.body === 'object' ? { ...request.body, profile } : { profile }
|
||||
return requestJsonForProfile(null, path, method, body)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const rowsOf = data => (Array.isArray(data?.sessions) ? data.sessions : [])
|
||||
|
||||
// A remote profile's session list, read from its remote host and tagged with the
|
||||
// desktop-facing profile name (the remote's /api/sessions doesn't know it).
|
||||
async function remoteSessionList(profile, searchParams) {
|
||||
const qs = new URLSearchParams(searchParams)
|
||||
qs.delete('profile') // remote serves its own db; no cross-profile read there
|
||||
const data = await fetchJsonForProfile(profile, `/api/sessions?${qs}`)
|
||||
for (const s of rowsOf(data)) {
|
||||
s.profile = profile
|
||||
s.is_default_profile = false
|
||||
}
|
||||
return { ...data, sessions: rowsOf(data) }
|
||||
}
|
||||
|
||||
// Unified list: primary's local aggregate, with each remote profile's stale local
|
||||
// rows/totals swapped for the remote's real ones, re-sorted by recency and
|
||||
// re-windowed to the requested page. A dead remote contributes nothing rather
|
||||
// than breaking the sidebar.
|
||||
async function mergeRemoteProfileSessions(searchParams, remoteProfiles) {
|
||||
const limit = Math.max(1, Number(searchParams.get('limit')) || 20)
|
||||
const offset = Math.max(0, Number(searchParams.get('offset')) || 0)
|
||||
const order = searchParams.get('order') === 'created' ? 'started_at' : 'last_active'
|
||||
|
||||
const primary = await ensureBackend(null)
|
||||
const base = await fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, {
|
||||
method: 'GET',
|
||||
timeoutMs: DEFAULT_FETCH_TIMEOUT_MS
|
||||
}).catch(() => ({ sessions: [], total: 0, profile_totals: {} }))
|
||||
|
||||
// Over-fetch each remote from offset 0 (limit+offset rows) so the merged window
|
||||
// is correct for this page — mirrors the primary's per-profile over-fetch.
|
||||
const remoteParams = new URLSearchParams(searchParams)
|
||||
remoteParams.set('limit', String(limit + offset))
|
||||
remoteParams.set('offset', '0')
|
||||
|
||||
const remoteSet = new Set(remoteProfiles)
|
||||
const merged = rowsOf(base).filter(s => !remoteSet.has(s?.profile))
|
||||
const profileTotals = { ...(base.profile_totals || {}) }
|
||||
let total = (Number(base.total) || 0) - remoteProfiles.reduce((n, p) => n + (profileTotals[p] || 0), 0)
|
||||
|
||||
// Swap each remote profile's stale local rows/total for the remote's real ones.
|
||||
await Promise.all(remoteProfiles.map(async name => {
|
||||
const list = await remoteSessionList(name, remoteParams).catch(() => null)
|
||||
if (!list) {
|
||||
delete profileTotals[name] // dead remote → drop its stale local total too
|
||||
return
|
||||
}
|
||||
const rows = rowsOf(list)
|
||||
merged.push(...rows)
|
||||
profileTotals[name] = Number(list.total) || rows.length
|
||||
total += profileTotals[name]
|
||||
}))
|
||||
|
||||
const recency = s => s?.[order] ?? s?.started_at ?? 0
|
||||
merged.sort((a, b) => recency(b) - recency(a))
|
||||
return { ...base, sessions: merged.slice(offset, offset + limit), total, profile_totals: profileTotals }
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
// Remote-profile session requests would otherwise hit the local primary off
|
||||
// each profile's on-disk state.db — fine for local profiles, but a remote
|
||||
// profile's sessions live on its remote host, so the UI's IDs 404 (or mutations
|
||||
// no-op) the moment they run there. Route reads + mutations to the remote.
|
||||
const rerouted = await interceptSessionRequestForRemote(request)
|
||||
if (rerouted !== undefined) {
|
||||
return rerouted
|
||||
}
|
||||
|
||||
const connection = await ensureBackend(request?.profile)
|
||||
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||
const url = `${connection.baseUrl}${request.path}`
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Helpers for Electron net.request calls that ride the OAuth session partition.
|
||||
*
|
||||
* Electron's ClientRequest forbids app-set restricted headers such as
|
||||
* Content-Length. Let Chromium frame the body itself; only set the JSON content
|
||||
* type here.
|
||||
*/
|
||||
|
||||
function serializeJsonBody(body) {
|
||||
return body === undefined ? undefined : Buffer.from(JSON.stringify(body))
|
||||
}
|
||||
|
||||
function setJsonRequestHeaders(request) {
|
||||
request.setHeader('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
serializeJsonBody,
|
||||
setJsonRequestHeaders
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Tests for OAuth-session Electron net.request helpers.
|
||||
*
|
||||
* Run with: node --test electron/oauth-net-request.test.cjs
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
|
||||
test('serializeJsonBody returns undefined for absent bodies', () => {
|
||||
assert.equal(serializeJsonBody(undefined), undefined)
|
||||
})
|
||||
|
||||
test('serializeJsonBody JSON-encodes request bodies', () => {
|
||||
const body = serializeJsonBody({ archived: true })
|
||||
assert.ok(Buffer.isBuffer(body))
|
||||
assert.equal(body.toString('utf8'), '{"archived":true}')
|
||||
})
|
||||
|
||||
test('setJsonRequestHeaders does not set Electron-restricted Content-Length', () => {
|
||||
const headers = []
|
||||
const request = {
|
||||
setHeader(name, value) {
|
||||
headers.push([name, value])
|
||||
}
|
||||
}
|
||||
|
||||
setJsonRequestHeaders(request)
|
||||
|
||||
assert.deepEqual(headers, [['Content-Type', 'application/json']])
|
||||
assert.equal(headers.some(([name]) => name.toLowerCase() === 'content-length'), false)
|
||||
})
|
||||
@@ -5,7 +5,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
|
||||
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
|
||||
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
||||
getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'),
|
||||
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
|
||||
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
||||
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
|
||||
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
|
||||
|
||||
@@ -35,7 +35,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 electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs",
|
||||
"type-check": "tsc -b",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
@@ -84,7 +84,7 @@
|
||||
"react": "^19.2.5",
|
||||
"react-arborist": "^3.5.0",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"react-shiki": "^0.9.3",
|
||||
"remark-math": "^6.0.0",
|
||||
"shiki": "^4.0.2",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -21,11 +22,11 @@ import { OverlayView } from '../overlays/overlay-view'
|
||||
|
||||
// Mirrors statusGlyph() in tool-fallback.tsx so subagent rows speak the
|
||||
// same visual vocabulary as the chat tool blocks.
|
||||
function statusGlyph(status: SubagentStatus): ReactNode {
|
||||
function statusGlyph(status: SubagentStatus, a: Translations['agents']): ReactNode {
|
||||
if (status === 'running' || status === 'queued') {
|
||||
return (
|
||||
<BrailleSpinner
|
||||
ariaLabel="Running"
|
||||
ariaLabel={a.running}
|
||||
className="size-3.5 shrink-0 text-[0.95rem] text-muted-foreground/80"
|
||||
spinner="breathe"
|
||||
/>
|
||||
@@ -33,10 +34,10 @@ function statusGlyph(status: SubagentStatus): ReactNode {
|
||||
}
|
||||
|
||||
if (status === 'failed' || status === 'interrupted') {
|
||||
return <AlertCircle aria-label="Failed" className="size-3.5 shrink-0 text-destructive" />
|
||||
return <AlertCircle aria-label={a.failed} className="size-3.5 shrink-0 text-destructive" />
|
||||
}
|
||||
|
||||
return <CheckCircle2 aria-label="Done" className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
|
||||
return <CheckCircle2 aria-label={a.done} className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
|
||||
}
|
||||
|
||||
const STREAM_TONE: Record<SubagentStreamEntry['kind'], string> = {
|
||||
@@ -75,6 +76,7 @@ interface AgentsViewProps {
|
||||
}
|
||||
|
||||
export function AgentsView({ onClose }: AgentsViewProps) {
|
||||
const { t } = useI18n()
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const subagentsBySession = useStore($subagentsBySession)
|
||||
|
||||
@@ -87,61 +89,61 @@ export function AgentsView({ onClose }: AgentsViewProps) {
|
||||
|
||||
return (
|
||||
<OverlayView
|
||||
closeLabel="Close agents"
|
||||
closeLabel={t.agents.close}
|
||||
contentClassName="px-5 pt-5 pb-4 sm:px-6"
|
||||
onClose={onClose}
|
||||
rootClassName="mx-auto max-w-3xl"
|
||||
>
|
||||
<header className="mb-3 shrink-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">Spawn tree</h2>
|
||||
<p className="text-xs text-muted-foreground/80">Live subagent activity for the current turn.</p>
|
||||
<h2 className="text-sm font-semibold text-foreground">{t.agents.title}</h2>
|
||||
<p className="text-xs text-muted-foreground/80">{t.agents.subtitle}</p>
|
||||
</header>
|
||||
<SubagentTree tree={tree} />
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
const fmtDuration = (seconds?: number) => {
|
||||
const fmtDuration = (seconds: number | undefined, a: Translations['agents']) => {
|
||||
if (!seconds || seconds <= 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(1)}s`
|
||||
return a.durationSeconds(seconds.toFixed(1))
|
||||
}
|
||||
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.round(seconds % 60)
|
||||
|
||||
return `${m}m ${s}s`
|
||||
return a.durationMinutes(m, s)
|
||||
}
|
||||
|
||||
const fmtTokens = (value?: number) => {
|
||||
const fmtTokens = (value: number | undefined, a: Translations['agents']) => {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return value >= 1000 ? `${(value / 1000).toFixed(1)}k tok` : `${value} tok`
|
||||
return value >= 1000 ? a.tokensK((value / 1000).toFixed(1)) : a.tokens(value)
|
||||
}
|
||||
|
||||
const fmtAge = (updatedAt: number, nowMs: number) => {
|
||||
const fmtAge = (updatedAt: number, nowMs: number, a: Translations['agents']) => {
|
||||
const s = Math.max(0, Math.round((nowMs - updatedAt) / 1000))
|
||||
|
||||
if (s < 2) {
|
||||
return 'now'
|
||||
return a.ageNow
|
||||
}
|
||||
|
||||
if (s < 60) {
|
||||
return `${s}s ago`
|
||||
return a.ageSeconds(s)
|
||||
}
|
||||
|
||||
const m = Math.floor(s / 60)
|
||||
|
||||
if (m < 60) {
|
||||
return `${m}m ago`
|
||||
return a.ageMinutes(m)
|
||||
}
|
||||
|
||||
return `${Math.floor(m / 60)}h ago`
|
||||
return a.ageHours(Math.floor(m / 60))
|
||||
}
|
||||
|
||||
const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] =>
|
||||
@@ -149,7 +151,7 @@ const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] =>
|
||||
|
||||
interface RootGroup {
|
||||
id: string
|
||||
label: string
|
||||
delegationIndex: number
|
||||
nodes: SubagentNode[]
|
||||
taskCount: number
|
||||
}
|
||||
@@ -173,18 +175,19 @@ function groupDelegations(roots: readonly SubagentNode[]): RootGroup[] {
|
||||
|
||||
if (node.taskCount > 1) {
|
||||
n += 1
|
||||
groups.push({ id: `delegation-${n}`, label: `Delegation ${n}`, nodes: [node], taskCount: node.taskCount })
|
||||
groups.push({ id: `delegation-${n}`, delegationIndex: n, nodes: [node], taskCount: node.taskCount })
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
groups.push({ id: node.id, label: '', nodes: [node], taskCount: node.taskCount })
|
||||
groups.push({ id: node.id, delegationIndex: 0, nodes: [node], taskCount: node.taskCount })
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
function SubagentTree({ tree }: { tree: SubagentNode[] }) {
|
||||
const { t } = useI18n()
|
||||
const flat = useMemo(() => flatten(tree), [tree])
|
||||
const groups = useMemo(() => groupDelegations(tree), [tree])
|
||||
const [nowMs, setNowMs] = useState(() => Date.now())
|
||||
@@ -210,21 +213,19 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
|
||||
return (
|
||||
<div className="grid place-items-center gap-3 py-12 text-center">
|
||||
<Sparkles className="size-6 text-muted-foreground/60" />
|
||||
<p className="text-sm font-medium text-foreground/90">No live subagents</p>
|
||||
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">
|
||||
When a turn delegates work, child agents stream their progress here.
|
||||
</p>
|
||||
<p className="text-sm font-medium text-foreground/90">{t.agents.emptyTitle}</p>
|
||||
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">{t.agents.emptyDesc}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const summary = [
|
||||
`${flat.length} ${flat.length === 1 ? 'agent' : 'agents'}`,
|
||||
active > 0 ? `${active} active` : '',
|
||||
failed > 0 ? `${failed} failed` : '',
|
||||
tools > 0 ? `${tools} tools` : '',
|
||||
files > 0 ? `${files} files` : '',
|
||||
tokens > 0 ? fmtTokens(tokens) : '',
|
||||
t.agents.agentsCount(flat.length),
|
||||
active > 0 ? t.agents.activeCount(active) : '',
|
||||
failed > 0 ? t.agents.failedCount(failed) : '',
|
||||
tools > 0 ? t.agents.toolsCount(tools) : '',
|
||||
files > 0 ? t.agents.filesCount(files) : '',
|
||||
tokens > 0 ? fmtTokens(tokens, t.agents) : '',
|
||||
cost > 0 ? `$${cost.toFixed(2)}` : ''
|
||||
].filter(Boolean)
|
||||
|
||||
@@ -243,6 +244,8 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
|
||||
}
|
||||
|
||||
function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number }) {
|
||||
const { t } = useI18n()
|
||||
|
||||
if (group.nodes.length === 1 && group.taskCount <= 1) {
|
||||
return <SubagentRow node={group.nodes[0]!} nowMs={nowMs} />
|
||||
}
|
||||
@@ -252,8 +255,9 @@ function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number })
|
||||
return (
|
||||
<section className="grid min-w-0 gap-3">
|
||||
<p className="text-[0.66rem] font-medium uppercase tracking-wider text-muted-foreground/70">
|
||||
{group.label} <span className="text-muted-foreground/50">·</span> {group.nodes.length} workers
|
||||
{activeWorkers > 0 ? <span className="text-primary/85"> · {activeWorkers} active</span> : null}
|
||||
{group.delegationIndex > 0 ? t.agents.delegation(group.delegationIndex) : ''}{' '}
|
||||
<span className="text-muted-foreground/50">·</span> {t.agents.workers(group.nodes.length)}
|
||||
{activeWorkers > 0 ? <span className="text-primary/85"> · {t.agents.workersActive(activeWorkers)}</span> : null}
|
||||
</p>
|
||||
<div className="grid min-w-0 gap-4">
|
||||
{group.nodes.map(node => (
|
||||
@@ -275,6 +279,7 @@ function StreamLine({
|
||||
parentRunning: boolean
|
||||
rowKey: string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const enterRef = useEnterAnimation(parentRunning, `subagent-stream:${rowKey}`)
|
||||
const isMono = entry.kind === 'tool'
|
||||
const tone = entry.isError ? 'text-destructive' : STREAM_TONE[entry.kind]
|
||||
@@ -286,7 +291,7 @@ function StreamLine({
|
||||
{entry.text}
|
||||
{active ? (
|
||||
<BrailleSpinner
|
||||
ariaLabel="Streaming"
|
||||
ariaLabel={t.agents.streaming}
|
||||
className="ml-1 inline-block size-2.5 align-middle text-muted-foreground/70"
|
||||
spinner="breathe"
|
||||
/>
|
||||
@@ -297,6 +302,7 @@ function StreamLine({
|
||||
}
|
||||
|
||||
function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: number; nowMs: number }) {
|
||||
const { t } = useI18n()
|
||||
const running = node.status === 'running' || node.status === 'queued'
|
||||
const elapsed = useElapsedSeconds(running, `subagent:${node.id}`)
|
||||
|
||||
@@ -317,10 +323,10 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
|
||||
|
||||
const subtitle = [
|
||||
node.model,
|
||||
fmtDuration(durationSeconds),
|
||||
node.toolCount ? `${node.toolCount} tools` : '',
|
||||
fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0)),
|
||||
`updated ${fmtAge(node.updatedAt, nowMs)}`
|
||||
fmtDuration(durationSeconds, t.agents),
|
||||
node.toolCount ? t.agents.toolsCount(node.toolCount) : '',
|
||||
fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0), t.agents),
|
||||
t.agents.updatedAgo(fmtAge(node.updatedAt, nowMs, t.agents))
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
@@ -331,7 +337,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
|
||||
onClick={() => setOpen(v => !v)}
|
||||
type="button"
|
||||
>
|
||||
<span className="mt-0.5 flex h-[1.1rem] shrink-0 items-center">{statusGlyph(node.status)}</span>
|
||||
<span className="mt-0.5 flex h-[1.1rem] shrink-0 items-center">{statusGlyph(node.status, t.agents)}</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
@@ -366,7 +372,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
|
||||
|
||||
{open && fileLines.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-0.5 pl-6">
|
||||
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">Files</p>
|
||||
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">{t.agents.files}</p>
|
||||
{fileLines.slice(0, 8).map(line => (
|
||||
<p className="wrap-break-word font-mono text-[0.67rem] leading-relaxed text-muted-foreground/80" key={line}>
|
||||
{line}
|
||||
@@ -374,7 +380,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
|
||||
))}
|
||||
{fileLines.length > 8 ? (
|
||||
<p className="font-mono text-[0.67rem] leading-relaxed text-muted-foreground/65">
|
||||
+{fileLines.length - 8} more files
|
||||
{t.agents.moreFiles(fileLines.length - 8)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
Pagination,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getSessionMessages, listSessions } from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
|
||||
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons'
|
||||
@@ -311,15 +313,15 @@ function formatArtifactTime(timestamp: number): string {
|
||||
return ARTIFACT_TIME_FMT.format(new Date(timestamp))
|
||||
}
|
||||
|
||||
function pageRangeLabel(total: number, page: number, pageSize: number): string {
|
||||
function pageRangeLabel(total: number, page: number, pageSize: number, a: Translations['artifacts']): string {
|
||||
if (total === 0) {
|
||||
return '0'
|
||||
return a.zero
|
||||
}
|
||||
|
||||
const start = (page - 1) * pageSize + 1
|
||||
const end = Math.min(total, page * pageSize)
|
||||
|
||||
return `${start}-${end} of ${total}`
|
||||
return a.rangeOf(start, end, total)
|
||||
}
|
||||
|
||||
function paginationItems(page: number, pageCount: number): Array<number | 'ellipsis'> {
|
||||
@@ -356,21 +358,25 @@ type CellCtx = {
|
||||
interface ArtifactColumn {
|
||||
Cell: (props: { artifact: ArtifactRecord; ctx: CellCtx }) => React.ReactElement
|
||||
bodyClassName: string
|
||||
header: (filter: ArtifactFilter) => string
|
||||
header: (filter: ArtifactFilter, a: Translations['artifacts']) => string
|
||||
id: 'location' | 'primary' | 'session'
|
||||
width: (filter: ArtifactFilter) => string
|
||||
}
|
||||
|
||||
const itemsLabel = (f: ArtifactFilter) => (f === 'link' ? 'links' : f === 'file' ? 'files' : 'items')
|
||||
const itemsLabel = (f: ArtifactFilter, a: Translations['artifacts']) =>
|
||||
f === 'link' ? a.itemsLink : f === 'file' ? a.itemsFile : a.itemsGeneric
|
||||
|
||||
interface ArtifactsViewProps extends React.ComponentProps<'section'> {
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
}
|
||||
|
||||
export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: ArtifactsViewProps) {
|
||||
const { t } = useI18n()
|
||||
const a = t.artifacts
|
||||
const navigate = useNavigate()
|
||||
const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const [kindFilter, setKindFilter] = useRouteEnumParam('tab', ARTIFACT_FILTERS, 'all')
|
||||
|
||||
@@ -379,6 +385,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
const [filePage, setFilePage] = useState(1)
|
||||
|
||||
const refreshArtifacts = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const sessions = (await listSessions(30, 1)).sessions
|
||||
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
|
||||
@@ -393,12 +401,14 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
nextArtifacts.push(...collectArtifactsForSession(session, result.value.messages))
|
||||
})
|
||||
|
||||
setArtifacts(nextArtifacts.sort((a, b) => b.timestamp - a.timestamp))
|
||||
setArtifacts(nextArtifacts.sort((left, right) => right.timestamp - left.timestamp))
|
||||
} catch (err) {
|
||||
notifyError(err, 'Artifacts failed to load')
|
||||
notifyError(err, a.failedLoad)
|
||||
setArtifacts([])
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
}, [a])
|
||||
|
||||
useRefreshHotkey(refreshArtifacts)
|
||||
|
||||
@@ -479,9 +489,9 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
window.open(href, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, 'Open failed')
|
||||
notifyError(err, a.openFailed)
|
||||
}
|
||||
}, [])
|
||||
}, [a])
|
||||
|
||||
const markImageFailed = useCallback((id: string) => {
|
||||
setFailedImageIds(current => {
|
||||
@@ -503,34 +513,46 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
{...props}
|
||||
onSearchChange={setQuery}
|
||||
searchHidden={counts.all === 0}
|
||||
searchPlaceholder="Search artifacts..."
|
||||
searchPlaceholder={a.search}
|
||||
searchTrailingAction={
|
||||
<Button
|
||||
aria-label={refreshing ? a.refreshing : a.refresh}
|
||||
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refreshArtifacts()}
|
||||
size="icon-xs"
|
||||
title={refreshing ? a.refreshing : a.refresh}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
|
||||
</Button>
|
||||
}
|
||||
searchValue={query}
|
||||
tabs={
|
||||
<>
|
||||
<TextTab active={kindFilter === 'all'} onClick={() => setKindFilter('all')}>
|
||||
All <TextTabMeta>({counts.all})</TextTabMeta>
|
||||
{a.tabAll} <TextTabMeta>({counts.all})</TextTabMeta>
|
||||
</TextTab>
|
||||
<TextTab active={kindFilter === 'image'} onClick={() => setKindFilter('image')}>
|
||||
Images <TextTabMeta>({counts.image})</TextTabMeta>
|
||||
{a.tabImages} <TextTabMeta>({counts.image})</TextTabMeta>
|
||||
</TextTab>
|
||||
<TextTab active={kindFilter === 'file'} onClick={() => setKindFilter('file')}>
|
||||
Files <TextTabMeta>({counts.file})</TextTabMeta>
|
||||
{a.tabFiles} <TextTabMeta>({counts.file})</TextTabMeta>
|
||||
</TextTab>
|
||||
<TextTab active={kindFilter === 'link'} onClick={() => setKindFilter('link')}>
|
||||
Links <TextTabMeta>({counts.link})</TextTabMeta>
|
||||
{a.tabLinks} <TextTabMeta>({counts.link})</TextTabMeta>
|
||||
</TextTab>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{!artifacts ? (
|
||||
<PageLoader label="Indexing recent session artifacts" />
|
||||
<PageLoader label={a.indexing} />
|
||||
) : visibleArtifacts.length === 0 ? (
|
||||
<div className="grid h-full place-items-center px-6 text-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium">No artifacts found</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Generated images and file outputs will appear here as sessions produce them.
|
||||
</div>
|
||||
<div className="text-sm font-medium">{a.noArtifactsTitle}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{a.noArtifactsDesc}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -547,7 +569,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
>
|
||||
<ArtifactsPagination
|
||||
className="ml-auto justify-end px-0"
|
||||
itemLabel="images"
|
||||
itemLabel={a.itemsImage}
|
||||
onPageChange={setImagePage}
|
||||
page={currentImagePage}
|
||||
pageSize={24}
|
||||
@@ -579,7 +601,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
>
|
||||
<ArtifactsPagination
|
||||
className="ml-auto justify-end px-0"
|
||||
itemLabel={itemsLabel(kindFilter)}
|
||||
itemLabel={itemsLabel(kindFilter, a)}
|
||||
onPageChange={setFilePage}
|
||||
page={currentFilePage}
|
||||
pageSize={100}
|
||||
@@ -608,12 +630,14 @@ interface ArtifactsPaginationProps {
|
||||
}
|
||||
|
||||
function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSize, total }: ArtifactsPaginationProps) {
|
||||
const { t } = useI18n()
|
||||
const a = t.artifacts
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-6 items-center justify-between gap-2 px-1', className)}>
|
||||
<div className="shrink-0 text-[0.62rem] text-muted-foreground">
|
||||
{pageRangeLabel(total, page, pageSize)} {itemLabel}
|
||||
{pageRangeLabel(total, page, pageSize, a)} {itemLabel}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<Pagination className="mx-0 w-auto min-w-0 justify-end">
|
||||
@@ -627,7 +651,7 @@ function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSiz
|
||||
<PaginationEllipsis />
|
||||
) : (
|
||||
<PaginationButton
|
||||
aria-label={`Go to ${itemLabel} page ${item}`}
|
||||
aria-label={a.goToPage(itemLabel, item)}
|
||||
isActive={page === item}
|
||||
onClick={() => onPageChange(item)}
|
||||
>
|
||||
@@ -657,6 +681,10 @@ interface ArtifactImageCardProps {
|
||||
}
|
||||
|
||||
function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: ArtifactImageCardProps) {
|
||||
const { t } = useI18n()
|
||||
const a = t.artifacts
|
||||
const kindLabel = artifact.kind === 'image' ? a.kindImage : artifact.kind === 'file' ? a.kindFile : a.kindLink
|
||||
|
||||
return (
|
||||
<article className="group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)">
|
||||
<div
|
||||
@@ -683,7 +711,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
|
||||
<div className="min-w-0">
|
||||
<div className="mb-0.5 flex items-center gap-1 text-[0.625rem] uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
|
||||
<FileImage className="size-3" />
|
||||
{artifact.kind}
|
||||
{kindLabel}
|
||||
</div>
|
||||
<div className="truncate text-[length:var(--conversation-caption-font-size)] font-medium">
|
||||
{artifact.label}
|
||||
@@ -698,7 +726,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="textStrong">
|
||||
<FolderOpen className="size-3" />
|
||||
Chat
|
||||
{a.chat}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -768,9 +796,10 @@ function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx
|
||||
}
|
||||
|
||||
function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx }) {
|
||||
const { t } = useI18n()
|
||||
const isLink = artifact.kind === 'link'
|
||||
const value = isLink ? hostPathLabel(artifact.value) : artifact.value
|
||||
const copyLabel = isLink ? 'Copy URL' : 'Copy path'
|
||||
const copyLabel = isLink ? t.artifacts.copyUrl : t.artifacts.copyPath
|
||||
|
||||
return (
|
||||
<div className="group/location flex min-w-0 items-center gap-1.5">
|
||||
@@ -814,21 +843,22 @@ const ARTIFACT_COLUMNS: readonly ArtifactColumn[] = [
|
||||
{
|
||||
Cell: PrimaryCell,
|
||||
bodyClassName: 'p-0',
|
||||
header: filter => (filter === 'link' ? 'Link title' : filter === 'file' ? 'Name' : 'Title / name'),
|
||||
header: (filter, a) => (filter === 'link' ? a.colTitleLink : filter === 'file' ? a.colTitleFile : a.colTitleDefault),
|
||||
id: 'primary',
|
||||
width: filter => (filter === 'link' ? 'w-[50%]' : 'w-[35%]')
|
||||
},
|
||||
{
|
||||
Cell: LocationCell,
|
||||
bodyClassName: 'px-2.5 py-1.5',
|
||||
header: filter => (filter === 'link' ? 'URL' : filter === 'file' ? 'Path' : 'Location'),
|
||||
header: (filter, a) =>
|
||||
filter === 'link' ? a.colLocationLink : filter === 'file' ? a.colLocationFile : a.colLocationDefault,
|
||||
id: 'location',
|
||||
width: filter => (filter === 'link' ? 'w-[30%]' : 'w-[41%]')
|
||||
},
|
||||
{
|
||||
Cell: SessionCell,
|
||||
bodyClassName: 'p-0',
|
||||
header: () => 'Session',
|
||||
header: (_filter, a) => a.colSession,
|
||||
id: 'session',
|
||||
width: filter => (filter === 'link' ? 'w-[20%]' : 'w-[24%]')
|
||||
}
|
||||
@@ -843,13 +873,15 @@ function ArtifactTable({
|
||||
ctx: CellCtx
|
||||
filter: ArtifactFilter
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<table className="w-full min-w-176 table-fixed text-left text-[length:var(--conversation-caption-font-size)]">
|
||||
<thead className="border-b border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) text-[0.625rem] uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
|
||||
<tr>
|
||||
{ARTIFACT_COLUMNS.map(col => (
|
||||
<th className={cn(col.width(filter), 'px-2.5 py-1.5 font-medium')} key={col.id}>
|
||||
{col.header(filter)}
|
||||
{col.header(filter, t.artifacts)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
@@ -26,6 +27,8 @@ export function AttachmentList({
|
||||
}
|
||||
|
||||
function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
|
||||
const cwd = useStore($currentCwd)
|
||||
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal'
|
||||
@@ -53,12 +56,12 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
||||
const preview = await normalizeOrLocalPreviewTarget(target, cwd || undefined)
|
||||
|
||||
if (!preview) {
|
||||
throw new Error(`Could not preview ${attachment.label}`)
|
||||
throw new Error(c.couldNotPreview(attachment.label))
|
||||
}
|
||||
|
||||
setCurrentSessionPreviewTarget(preview, 'manual', target)
|
||||
} catch (error) {
|
||||
notifyError(error, 'Preview unavailable')
|
||||
notifyError(error, c.previewUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +69,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
||||
<Tip label={attachment.path || attachment.detail || attachment.label}>
|
||||
<div className="group/attachment relative min-w-0 shrink-0">
|
||||
<button
|
||||
aria-label={canPreview ? `Preview ${attachment.label}` : attachment.label}
|
||||
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
|
||||
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
|
||||
disabled={!canPreview}
|
||||
onClick={() => void openPreview()}
|
||||
@@ -97,7 +100,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
||||
</button>
|
||||
{onRemove && (
|
||||
<button
|
||||
aria-label={`Remove ${attachment.label}`}
|
||||
aria-label={c.removeAttachment(attachment.label)}
|
||||
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
|
||||
onClick={() => onRemove(attachment.id)}
|
||||
type="button"
|
||||
|
||||
@@ -11,29 +11,14 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
|
||||
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.'
|
||||
}
|
||||
]
|
||||
const SNIPPET_KEYS = ['codeReview', 'implementationPlan', 'explainThis']
|
||||
|
||||
export function ContextMenu({
|
||||
state,
|
||||
@@ -44,6 +29,8 @@ export function ContextMenu({
|
||||
onPickFolders,
|
||||
onPickImages
|
||||
}: ContextMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
// 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 —
|
||||
@@ -71,78 +58,81 @@ export function ContextMenu({
|
||||
</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
|
||||
{c.attachLabel}
|
||||
</DropdownMenuLabel>
|
||||
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
|
||||
Files…
|
||||
{c.files}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
|
||||
Folder…
|
||||
{c.folder}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
|
||||
Images…
|
||||
{c.images}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
|
||||
Paste image
|
||||
{c.pasteImage}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
|
||||
URL…
|
||||
{c.url}
|
||||
</ContextMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<ContextMenuItem icon={MessageSquareText} onSelect={() => setSnippetsOpen(true)}>
|
||||
Prompt snippets…
|
||||
{c.promptSnippets}
|
||||
</ContextMenuItem>
|
||||
|
||||
<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.
|
||||
{c.tipPre}
|
||||
<kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd>
|
||||
{c.tipPost}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<PromptSnippetsDialog
|
||||
onInsertText={onInsertText}
|
||||
onOpenChange={setSnippetsOpen}
|
||||
open={snippetsOpen}
|
||||
snippets={PROMPT_SNIPPETS}
|
||||
/>
|
||||
<PromptSnippetsDialog onInsertText={onInsertText} onOpenChange={setSnippetsOpen} open={snippetsOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptSnippetsDialog({ onInsertText, onOpenChange, open, snippets }: PromptSnippetsDialogProps) {
|
||||
function PromptSnippetsDialog({ onInsertText, onOpenChange, open }: PromptSnippetsDialogProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
|
||||
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>
|
||||
<DialogTitle>{c.snippetsTitle}</DialogTitle>
|
||||
<DialogDescription>{c.snippetsDesc}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ul className="grid gap-1">
|
||||
{snippets.map(snippet => (
|
||||
<li key={snippet.label}>
|
||||
<button
|
||||
className="group/snippet flex w-full 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}
|
||||
{SNIPPET_KEYS.map(key => {
|
||||
const snippet = c.snippets[key]
|
||||
|
||||
return (
|
||||
<li key={key}>
|
||||
<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>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -175,15 +165,8 @@ interface ContextMenuProps {
|
||||
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[]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AudioLines, Layers3, Loader2, Square } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -55,6 +56,9 @@ export function ComposerControls({
|
||||
voiceStatus: VoiceStatus
|
||||
onDictate: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
|
||||
if (conversation.active) {
|
||||
return <ConversationPill {...conversation} disabled={disabled} />
|
||||
}
|
||||
@@ -65,9 +69,9 @@ export function ComposerControls({
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
{showVoicePrimary ? (
|
||||
<Tip label="Start voice conversation">
|
||||
<Tip label={c.startVoice}>
|
||||
<Button
|
||||
aria-label="Start voice conversation"
|
||||
aria-label={c.startVoice}
|
||||
className={PRIMARY_ICON_BTN}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
@@ -81,9 +85,9 @@ export function ComposerControls({
|
||||
</Button>
|
||||
</Tip>
|
||||
) : (
|
||||
<Tip label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}>
|
||||
<Tip label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}>
|
||||
<Button
|
||||
aria-label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
|
||||
aria-label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}
|
||||
className={PRIMARY_ICON_BTN}
|
||||
disabled={disabled || !canSubmit}
|
||||
type="submit"
|
||||
@@ -113,25 +117,27 @@ function ConversationPill({
|
||||
onToggleMute,
|
||||
status
|
||||
}: ConversationProps & { disabled: boolean }) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const speaking = status === 'speaking'
|
||||
const listening = status === 'listening' && !muted
|
||||
|
||||
const label =
|
||||
status === 'speaking'
|
||||
? 'Speaking'
|
||||
? c.speaking
|
||||
: status === 'transcribing'
|
||||
? 'Transcribing'
|
||||
? c.transcribing
|
||||
: status === 'thinking'
|
||||
? 'Thinking'
|
||||
? c.thinking
|
||||
: muted
|
||||
? 'Muted'
|
||||
: 'Listening'
|
||||
? c.muted
|
||||
: c.listening
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<Tip label={muted ? 'Unmute microphone' : 'Mute microphone'}>
|
||||
<Tip label={muted ? c.unmuteMic : c.muteMic}>
|
||||
<Button
|
||||
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'}
|
||||
aria-label={muted ? c.unmuteMic : c.muteMic}
|
||||
aria-pressed={muted}
|
||||
className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')}
|
||||
disabled={disabled}
|
||||
@@ -148,32 +154,34 @@ function ConversationPill({
|
||||
</Tip>
|
||||
{listening && (
|
||||
<Button
|
||||
aria-label="Stop listening and send"
|
||||
aria-label={c.stopListening}
|
||||
className="h-(--composer-control-size) shrink-0 gap-1.5 rounded-full px-2.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic('submit')
|
||||
onStopTurn()
|
||||
}}
|
||||
title={c.stopListening}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Square className="fill-current" size={11} />
|
||||
<span>Stop</span>
|
||||
<span>{c.stopShort}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
aria-label="End voice conversation"
|
||||
aria-label={c.endConversation}
|
||||
className="h-(--composer-control-size) gap-1.5 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic('close')
|
||||
onEnd()
|
||||
}}
|
||||
title={c.endConversation}
|
||||
type="button"
|
||||
>
|
||||
<ConversationIndicator level={level} listening={listening} speaking={speaking} />
|
||||
<span>End</span>
|
||||
<span>{c.endShort}</span>
|
||||
</Button>
|
||||
<span className="sr-only" role="status">
|
||||
{label}
|
||||
@@ -220,10 +228,12 @@ function DictationButton({
|
||||
status: VoiceStatus
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const active = state.active || status !== 'idle'
|
||||
|
||||
const aria =
|
||||
status === 'recording' ? 'Stop dictation' : status === 'transcribing' ? 'Transcribing dictation' : 'Voice dictation'
|
||||
status === 'recording' ? c.stopDictation : status === 'transcribing' ? c.transcribingDictation : c.voiceDictation
|
||||
|
||||
return (
|
||||
<Tip label={aria}>
|
||||
|
||||
@@ -1,44 +1,32 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
import { COMPLETION_DRAWER_CLASS } from './completion-drawer'
|
||||
|
||||
const COMMON_COMMANDS: [string, string][] = [
|
||||
['/help', 'full list of commands + hotkeys'],
|
||||
['/clear', 'start a new session'],
|
||||
['/resume', 'resume a prior session'],
|
||||
['/details', 'control transcript detail level'],
|
||||
['/copy', 'copy selection or last assistant message'],
|
||||
['/quit', 'exit hermes']
|
||||
]
|
||||
|
||||
const HOTKEYS: [string, string][] = [
|
||||
['@', 'reference files, folders, urls, git'],
|
||||
['/', 'slash command palette'],
|
||||
['?', 'this quick help (delete to dismiss)'],
|
||||
['Enter', 'send · Shift+Enter for newline'],
|
||||
['Cmd/Ctrl+K', 'send next queued turn'],
|
||||
['Cmd/Ctrl+L', 'redraw'],
|
||||
['Esc', 'close popover · cancel run'],
|
||||
['↑ / ↓', 'cycle popover / history']
|
||||
]
|
||||
const COMMON_COMMAND_KEYS = ['/help', '/clear', '/resume', '/details', '/copy', '/quit']
|
||||
const HOTKEY_KEYS = ['@', '/', '?', 'Enter', 'Cmd/Ctrl+K', 'Cmd/Ctrl+L', 'Esc', '↑ / ↓']
|
||||
|
||||
export function HelpHint() {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
|
||||
return (
|
||||
<div className={COMPLETION_DRAWER_CLASS} data-slot="composer-completion-drawer" data-state="open" role="dialog">
|
||||
<Section title="Common commands">
|
||||
{COMMON_COMMANDS.map(([key, desc]) => (
|
||||
<Row description={desc} key={key} keyLabel={key} mono />
|
||||
<Section title={c.commonCommands}>
|
||||
{COMMON_COMMAND_KEYS.map(key => (
|
||||
<Row description={c.commandDescs[key] ?? ''} key={key} keyLabel={key} mono />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="Hotkeys">
|
||||
{HOTKEYS.map(([key, desc]) => (
|
||||
<Row description={desc} key={key} keyLabel={key} />
|
||||
<Section title={c.hotkeys}>
|
||||
{HOTKEY_KEYS.map(key => (
|
||||
<Row description={c.hotkeyDescs[key] ?? ''} key={key} keyLabel={key} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<p className="px-2.5 py-1 text-xs text-muted-foreground/80">
|
||||
<span className="font-mono text-foreground/80">/help</span> opens the full panel · backspace dismisses
|
||||
<span className="font-mono text-foreground/80">/help</span> {c.helpFooter}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
// No global setupFiles registers auto-cleanup, so unmount between tests —
|
||||
// otherwise a second render() leaks the first editor and getByTestId('editor')
|
||||
// matches multiple nodes.
|
||||
afterEach(cleanup)
|
||||
|
||||
// Faithful mirror of index.tsx's composer text wiring for IME input, driven
|
||||
// through REAL DOM composition + input events on a contentEditable.
|
||||
//
|
||||
// Regression repro for #39614: typing committed multi-character IME text (e.g.
|
||||
// Chinese "你好") used to leave the send button hidden. The input events fired
|
||||
// during composition carry uncommitted preedit text and are intentionally
|
||||
// skipped; Chromium then does NOT reliably emit a trailing input event after
|
||||
// compositionend on Windows IMEs, so the finalized text never reached composer
|
||||
// state and `hasPayload` stayed false until an unrelated edit forced a sync.
|
||||
// The fix flushes the live DOM text in onCompositionEnd.
|
||||
function Harness({ onPayload }: { onPayload: (hasPayload: boolean) => void }) {
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const composingRef = useRef(false)
|
||||
const draftRef = useRef('')
|
||||
const [draft, setDraft] = useState('')
|
||||
|
||||
const flushEditorToDraft = (editor: HTMLDivElement) => {
|
||||
const next = editor.textContent ?? ''
|
||||
|
||||
if (next !== draftRef.current) {
|
||||
draftRef.current = next
|
||||
setDraft(next)
|
||||
}
|
||||
}
|
||||
|
||||
onPayload(draft.trim().length > 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
contentEditable
|
||||
data-testid="editor"
|
||||
onCompositionEnd={event => {
|
||||
composingRef.current = false
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
composingRef.current = true
|
||||
}}
|
||||
onInput={event => {
|
||||
if (composingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}}
|
||||
ref={editorRef}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('composer IME composition — send button visibility (#39614)', () => {
|
||||
it('shows the send button after committing CJK text without a trailing edit', async () => {
|
||||
let hasPayload = false
|
||||
const { getByTestId } = render(<Harness onPayload={p => (hasPayload = p)} />)
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
// Compose "你好" the way a Windows Chinese IME does: compositionstart, then
|
||||
// input events carrying uncommitted preedit text, then compositionend with
|
||||
// the committed text already in the DOM — and crucially NO input event
|
||||
// afterwards.
|
||||
await act(async () => {
|
||||
fireEvent.compositionStart(editor)
|
||||
editor.textContent = '你'
|
||||
fireEvent.input(editor)
|
||||
editor.textContent = '你好'
|
||||
fireEvent.input(editor)
|
||||
fireEvent.compositionEnd(editor)
|
||||
})
|
||||
|
||||
// Before the fix this was false (button hidden) until a further edit.
|
||||
expect(hasPayload).toBe(true)
|
||||
expect(editor.textContent).toBe('你好')
|
||||
})
|
||||
|
||||
it('also covers Japanese/Korean and any IME-composed script', async () => {
|
||||
let hasPayload = false
|
||||
const { getByTestId } = render(<Harness onPayload={p => (hasPayload = p)} />)
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
for (const committed of ['こんにちは', '안녕하세요']) {
|
||||
await act(async () => {
|
||||
fireEvent.compositionStart(editor)
|
||||
editor.textContent = committed
|
||||
fireEvent.input(editor)
|
||||
fireEvent.compositionEnd(editor)
|
||||
})
|
||||
|
||||
expect(hasPayload).toBe(true)
|
||||
|
||||
// Clear for the next script.
|
||||
await act(async () => {
|
||||
editor.textContent = ''
|
||||
fireEvent.input(editor)
|
||||
})
|
||||
expect(hasPayload).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -17,15 +17,24 @@ import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-te
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||
import {
|
||||
browseBackward,
|
||||
browseForward,
|
||||
deriveUserHistory,
|
||||
isBrowsingHistory,
|
||||
resetBrowseState
|
||||
} from '@/store/composer-input-history'
|
||||
import {
|
||||
$queuedPromptsBySession,
|
||||
enqueueQueuedPrompt,
|
||||
promoteQueuedPrompt,
|
||||
type QueuedPromptEntry,
|
||||
removeQueuedPrompt,
|
||||
shouldAutoDrainOnSettle,
|
||||
@@ -84,29 +93,6 @@ const COMPOSER_SINGLE_LINE_MAX_PX = 36
|
||||
const COMPOSER_FADE_BACKGROUND =
|
||||
'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))'
|
||||
|
||||
// Resting composer placeholders. New sessions get open-ended starters; an
|
||||
// existing chat gets phrasings that read as a continuation of the thread.
|
||||
// One is picked at random per session (stable until the session changes).
|
||||
const NEW_SESSION_PLACEHOLDERS = [
|
||||
'What are we building?',
|
||||
'Give Hermes a task',
|
||||
"What's on your mind?",
|
||||
'Describe what you need',
|
||||
'What should we tackle?',
|
||||
'Ask anything',
|
||||
'Start with a goal'
|
||||
]
|
||||
|
||||
const FOLLOW_UP_PLACEHOLDERS = [
|
||||
'Send a follow-up',
|
||||
'Add more context',
|
||||
'Refine the request',
|
||||
"What's next?",
|
||||
'Keep it going',
|
||||
'Push it further',
|
||||
'Adjust or continue'
|
||||
]
|
||||
|
||||
const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
|
||||
|
||||
interface QueueEditState {
|
||||
@@ -145,6 +131,7 @@ export function ChatBar({
|
||||
const attachments = useStore($composerAttachments)
|
||||
const queuedPromptsBySession = useStore($queuedPromptsBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const sessionMessages = useStore($messages)
|
||||
const activeQueueSessionKey = queueSessionKey || sessionId || null
|
||||
|
||||
const queuedPrompts = useMemo(
|
||||
@@ -158,12 +145,6 @@ export function ChatBar({
|
||||
const draftRef = useRef(draft)
|
||||
const previousBusyRef = useRef(busy)
|
||||
const drainingQueueRef = useRef(false)
|
||||
// Set when the user explicitly interrupts the running turn via the Stop
|
||||
// button (busy + empty composer). It suppresses the next busy→false
|
||||
// auto-drain so an explicit Stop actually halts instead of immediately
|
||||
// firing the head of the queue. The queue is preserved; the user resumes
|
||||
// it deliberately via Cmd/Ctrl+K, Enter, or the per-row "send now" arrow.
|
||||
const userInterruptedRef = useRef(false)
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const [urlOpen, setUrlOpen] = useState(false)
|
||||
@@ -190,7 +171,10 @@ export function ChatBar({
|
||||
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
|
||||
const showHelpHint = draft === '?'
|
||||
|
||||
const { t } = useI18n()
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const newSessionPlaceholders = t.composer.newSessionPlaceholders
|
||||
const followUpPlaceholders = t.composer.followUpPlaceholders
|
||||
|
||||
// Resting placeholder: a starter for brand-new sessions, a continuation for
|
||||
// existing ones. Picked once and only re-rolled when we genuinely move to a
|
||||
@@ -198,7 +182,7 @@ export function ChatBar({
|
||||
// started session (null → id, on the first send) is treated as the same
|
||||
// conversation so the placeholder doesn't visibly flip mid-stream.
|
||||
const [restingPlaceholder, setRestingPlaceholder] = useState(() =>
|
||||
pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS)
|
||||
pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders)
|
||||
)
|
||||
|
||||
const prevSessionIdRef = useRef(sessionId)
|
||||
@@ -217,16 +201,17 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
setRestingPlaceholder(pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS))
|
||||
}, [sessionId])
|
||||
resetBrowseState(prev)
|
||||
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
|
||||
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
|
||||
|
||||
// When the bar is disabled it's because the gateway isn't open. Distinguish a
|
||||
// cold start ("Starting Hermes...") from a dropped connection we're trying to
|
||||
// restore (e.g. after the Mac slept) so the stuck state reads as recoverable.
|
||||
const placeholder = disabled
|
||||
? gatewayState === 'closed' || gatewayState === 'error'
|
||||
? 'Reconnecting to Hermes…'
|
||||
: 'Starting Hermes...'
|
||||
? t.composer.placeholderReconnecting
|
||||
: t.composer.placeholderStarting
|
||||
: restingPlaceholder
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
@@ -568,16 +553,10 @@ export function ChatBar({
|
||||
}
|
||||
}, [trigger])
|
||||
|
||||
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
// During IME composition the DOM contains uncommitted preedit text
|
||||
// mixed with real content. Skip state writes — compositionend will
|
||||
// deliver the finalized text via a clean input event.
|
||||
if (composingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const editor = event.currentTarget
|
||||
|
||||
// Pull the live contentEditable text into draftRef + the AUI composer state
|
||||
// (which drives `hasComposerPayload` → the send button). Shared by the input
|
||||
// and compositionend paths so committed IME text reaches state through either.
|
||||
const flushEditorToDraft = (editor: HTMLDivElement) => {
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
|
||||
editor.replaceChildren()
|
||||
}
|
||||
@@ -592,6 +571,17 @@ export function ChatBar({
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
// During IME composition the DOM contains uncommitted preedit text
|
||||
// mixed with real content. Skip state writes — compositionend flushes
|
||||
// the finalized text (see onCompositionEnd).
|
||||
if (composingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null =
|
||||
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
|
||||
|
||||
@@ -734,6 +724,74 @@ export function ChatBar({
|
||||
}
|
||||
}
|
||||
|
||||
// ArrowUp/ArrowDown navigate, in priority order: the queue (edit entries in
|
||||
// place) then sent-message history. The history ring is derived from live
|
||||
// session messages each press — single source of truth, no mirror.
|
||||
if (event.key === 'ArrowUp') {
|
||||
const currentDraft = draftRef.current
|
||||
|
||||
// Editing a queued turn → walk to the older entry.
|
||||
if (queueEdit && stepQueuedEdit(-1)) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Empty composer + a queued turn → open the newest queued entry for edit
|
||||
// (the row's pencil), not a text recall. Enter saves it back to the queue.
|
||||
if (!currentDraft.trim() && !queueEdit && queuedPrompts.length > 0) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
beginQueuedEdit(queuedPrompts[queuedPrompts.length - 1]!)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Don't hijack a typed draft unless already browsing — they'd lose it.
|
||||
if (currentDraft.trim() && !isBrowsingHistory(sessionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
||||
const history = deriveUserHistory(sessionMessages, chatMessageText)
|
||||
const entry = browseBackward(sessionId, currentDraft, history)
|
||||
|
||||
if (entry !== null) {
|
||||
loadIntoComposer(entry, $composerAttachments.get())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
// Editing a queued turn → walk to the newer entry (past the newest exits).
|
||||
if (queueEdit) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
stepQueuedEdit(1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Browsing sent history → step toward the present, restoring the draft.
|
||||
if (isBrowsingHistory(sessionId)) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
||||
const history = deriveUserHistory(sessionMessages, chatMessageText)
|
||||
const result = browseForward(sessionId, history)
|
||||
|
||||
if (result !== null) {
|
||||
loadIntoComposer(result.text, $composerAttachments.get())
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -743,7 +801,32 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc),
|
||||
// never a stray Enter after sending. With a payload, submitDraft queues it.
|
||||
if (busy && !hasComposerPayload) {
|
||||
return
|
||||
}
|
||||
|
||||
submitDraft()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
// Editing a queued turn → Esc cancels the edit, restoring the prior draft.
|
||||
if (queueEdit) {
|
||||
event.preventDefault()
|
||||
exitQueuedEdit('cancel')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise Esc interrupts the running turn (Stop-button parity).
|
||||
if (busy) {
|
||||
event.preventDefault()
|
||||
triggerHaptic('cancel')
|
||||
void Promise.resolve(onCancel())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -909,6 +992,42 @@ export function ChatBar({
|
||||
focusInput()
|
||||
}
|
||||
|
||||
// Walk queued entries while editing (ArrowUp = older, ArrowDown = newer),
|
||||
// saving the in-progress edit on each step. Stepping newer past the last
|
||||
// entry exits edit mode and restores the pre-edit draft.
|
||||
const stepQueuedEdit = (direction: -1 | 1) => {
|
||||
if (!queueEdit) {
|
||||
return false
|
||||
}
|
||||
|
||||
const index = queuedPrompts.findIndex(e => e.id === queueEdit.entryId)
|
||||
const target = index + direction
|
||||
|
||||
if (index < 0 || target < 0) {
|
||||
return index >= 0 // at the oldest: swallow; missing entry: let it fall through
|
||||
}
|
||||
|
||||
const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, {
|
||||
attachments: cloneAttachments($composerAttachments.get()),
|
||||
text: draftRef.current
|
||||
})
|
||||
|
||||
const next = queuedPrompts[target]
|
||||
|
||||
if (next) {
|
||||
setQueueEdit({ ...queueEdit, entryId: next.id })
|
||||
loadIntoComposer(next.text, next.attachments)
|
||||
} else {
|
||||
setQueueEdit(null)
|
||||
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
|
||||
}
|
||||
|
||||
triggerHaptic(saved ? 'success' : 'selection')
|
||||
focusInput()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => {
|
||||
if (!queueEdit) {
|
||||
return false
|
||||
@@ -977,13 +1096,14 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
removeQueuedPrompt(activeQueueSessionKey, entry.id)
|
||||
resetBrowseState(sessionId)
|
||||
|
||||
return true
|
||||
} finally {
|
||||
drainingQueueRef.current = false
|
||||
}
|
||||
},
|
||||
[activeQueueSessionKey, onSubmit, queuedPrompts]
|
||||
[activeQueueSessionKey, onSubmit, queuedPrompts, sessionId]
|
||||
)
|
||||
|
||||
const drainNextQueued = useCallback(
|
||||
@@ -997,41 +1117,40 @@ export function ChatBar({
|
||||
)
|
||||
|
||||
const sendQueuedNow = useCallback(
|
||||
(id: string) => runDrain(entries => entries.find(e => e.id === id && id !== queueEdit?.entryId)),
|
||||
[queueEdit, runDrain]
|
||||
(id: string) => {
|
||||
if (!activeQueueSessionKey || id === queueEdit?.entryId) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (busy) {
|
||||
// Promote to the head, then interrupt. The gateway always emits a
|
||||
// settle (message.complete + session.info running:false) when the
|
||||
// turn unwinds, and the busy→false auto-drain below sends this entry.
|
||||
promoteQueuedPrompt(activeQueueSessionKey, id)
|
||||
triggerHaptic('selection')
|
||||
void Promise.resolve(onCancel())
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return runDrain(entries => entries.find(e => e.id === id))
|
||||
},
|
||||
[activeQueueSessionKey, busy, onCancel, queueEdit, runDrain]
|
||||
)
|
||||
|
||||
// Auto-drain on busy → false (turn settled). An explicit user interrupt
|
||||
// (Stop button) sets userInterruptedRef so we skip exactly one auto-drain:
|
||||
// the user asked to halt, so we must not immediately re-send the queue.
|
||||
// The queued turns stay intact and the user resumes them on demand.
|
||||
// Auto-drain on busy → false (turn settled). Queued turns always flow once
|
||||
// the session is idle again — whether the turn finished naturally or the
|
||||
// user interrupted it. Interrupting to reach a queued message is the whole
|
||||
// point of the queue, so we never suppress the drain. To cancel queued
|
||||
// turns, the user deletes them from the panel.
|
||||
useEffect(() => {
|
||||
const wasBusy = previousBusyRef.current
|
||||
previousBusyRef.current = busy
|
||||
|
||||
// Clear the interrupt latch when a new turn starts (false → true). This
|
||||
// guards the sub-frame race where a Stop click lands after busy already
|
||||
// flipped false (button not yet unmounted): the stale latch can no longer
|
||||
// survive into the next turn and wrongly suppress its natural auto-drain.
|
||||
if (busy && !wasBusy) {
|
||||
userInterruptedRef.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const interrupted = userInterruptedRef.current
|
||||
|
||||
// Consume the interrupt latch on any settle so a later natural completion
|
||||
// is not wrongly suppressed.
|
||||
if (!busy && wasBusy && interrupted) {
|
||||
userInterruptedRef.current = false
|
||||
}
|
||||
|
||||
if (
|
||||
shouldAutoDrainOnSettle({
|
||||
isBusy: busy,
|
||||
queueLength: queuedPrompts.length,
|
||||
userInterrupted: interrupted,
|
||||
wasBusy
|
||||
})
|
||||
) {
|
||||
@@ -1072,12 +1191,8 @@ export function ChatBar({
|
||||
} else if (hasComposerPayload) {
|
||||
queueCurrentDraft()
|
||||
} else {
|
||||
// Stop button: an explicit interrupt must actually halt the running
|
||||
// turn. Mark the interrupt so the busy→false auto-drain effect skips
|
||||
// re-sending the queue — otherwise a queued follow-up would fire the
|
||||
// instant we cancel and Stop would appear to "never work". Queued
|
||||
// turns are preserved; the user sends them on demand.
|
||||
userInterruptedRef.current = true
|
||||
// Stop button (the only way to reach here while busy with an empty
|
||||
// composer — empty Enter is short-circuited in the keydown handler).
|
||||
triggerHaptic('cancel')
|
||||
void Promise.resolve(onCancel())
|
||||
}
|
||||
@@ -1086,6 +1201,7 @@ export function ChatBar({
|
||||
} else if (draft.trim() || attachments.length > 0) {
|
||||
const submitted = draft
|
||||
triggerHaptic('submit')
|
||||
resetBrowseState(sessionId)
|
||||
clearDraft()
|
||||
clearComposerAttachments()
|
||||
void onSubmit(submitted, { attachments })
|
||||
@@ -1155,6 +1271,7 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
triggerHaptic('submit')
|
||||
resetBrowseState(sessionId)
|
||||
clearDraft()
|
||||
await onSubmit(text)
|
||||
}
|
||||
@@ -1213,7 +1330,7 @@ export function ChatBar({
|
||||
const input = (
|
||||
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
|
||||
<div
|
||||
aria-label="Message"
|
||||
aria-label={t.composer.message}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
@@ -1227,8 +1344,17 @@ export function ChatBar({
|
||||
data-placeholder={placeholder}
|
||||
data-slot={RICH_INPUT_SLOT}
|
||||
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
||||
onCompositionEnd={() => {
|
||||
onCompositionEnd={event => {
|
||||
composingRef.current = false
|
||||
|
||||
// The input events fired *during* composition were skipped (they
|
||||
// carried uncommitted preedit text), and Chromium does NOT reliably
|
||||
// emit a trailing input event after compositionend on Windows IMEs.
|
||||
// Without flushing here, committed multi-character IME input (e.g.
|
||||
// Chinese "你好", Japanese, Korean) never reaches composer state, so
|
||||
// `hasComposerPayload` stays false and the send button stays hidden
|
||||
// until an unrelated edit forces a sync (#39614).
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
composingRef.current = true
|
||||
@@ -1303,7 +1429,11 @@ export function ChatBar({
|
||||
)}
|
||||
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
|
||||
{activeQueueSessionKey && queuedPrompts.length > 0 && (
|
||||
<div className="relative z-6 mb-1 px-0.5">
|
||||
// Out of flow so the queue never inflates the composer's measured
|
||||
// height (that drives thread bottom padding → chat resizes on
|
||||
// queue). Overlaps -mb-2 onto the surface's top border for a shared
|
||||
// edge; capped + scrollable. Overlays the chat instead of pushing it.
|
||||
<div className="absolute inset-x-0 bottom-full z-6 -mb-2 max-h-[40vh] overflow-y-auto">
|
||||
<QueuePanel
|
||||
busy={busy}
|
||||
editingId={queueEdit?.entryId ?? null}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { ArrowUp, Pencil, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { QueuedPromptEntry } from '@/store/composer-queue'
|
||||
@@ -16,37 +17,40 @@ interface QueuePanelProps {
|
||||
onSendNow: (id: string) => void
|
||||
}
|
||||
|
||||
const entryPreview = (entry: QueuedPromptEntry) =>
|
||||
entry.text.trim() || (entry.attachments.length > 0 ? 'Attachment-only turn' : 'Empty turn')
|
||||
const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
|
||||
entry.text.trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn)
|
||||
|
||||
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] py-0.5 shadow-[0_0_0_1px_color-mix(in_srgb,var(--dt-card)_30%,transparent)_inset]">
|
||||
<div className="rounded-t-2xl border border-b-0 border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] pt-0.5 pb-1">
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 px-2.5 py-1 text-left text-[0.72rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90"
|
||||
className="flex w-full items-center gap-1.5 px-2 py-0.5 text-left text-[0.72rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90"
|
||||
onClick={() => setCollapsed(open => !open)}
|
||||
type="button"
|
||||
>
|
||||
<DisclosureCaret className="shrink-0" open={!collapsed} size="0.875rem" />
|
||||
<span className="truncate">{entries.length} Queued</span>
|
||||
<span className="truncate">{c.queued(entries.length)}</span>
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="space-y-0.5 px-1.5 pb-0.5">
|
||||
<div className="space-y-0.5 px-1 pb-0.5">
|
||||
{entries.map(entry => {
|
||||
const isEditing = editingId === entry.id
|
||||
const attachmentsCount = entry.attachments.length
|
||||
const sendLabel = busy ? c.sendQueuedNext : c.sendQueuedNow
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/queue-row flex items-center gap-1.5 rounded-lg border border-transparent px-1.5 py-1',
|
||||
'group/queue-row flex items-center gap-1.5 rounded-lg border border-transparent px-1.5 py-0.5',
|
||||
'transition-colors duration-300 ease-out hover:bg-(--chrome-action-hover) hover:transition-none',
|
||||
isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
|
||||
)}
|
||||
@@ -57,17 +61,17 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
className="h-3.5 w-3.5 shrink-0 rounded-full border border-foreground/35 bg-transparent"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry)}</p>
|
||||
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p>
|
||||
{(attachmentsCount > 0 || isEditing) && (
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
|
||||
{attachmentsCount > 0 && (
|
||||
<span>
|
||||
{attachmentsCount} attachment{attachmentsCount === 1 ? '' : 's'}
|
||||
{c.attachments(attachmentsCount)}
|
||||
</span>
|
||||
)}
|
||||
{isEditing && (
|
||||
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
|
||||
Editing in composer
|
||||
{c.editingInComposer}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -81,9 +85,9 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
: 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100'
|
||||
)}
|
||||
>
|
||||
<Tip label="Edit queued turn">
|
||||
<Tip label={c.editQueued}>
|
||||
<Button
|
||||
aria-label="Edit queued turn"
|
||||
aria-label={c.editQueued}
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={Boolean(editingId) && !isEditing}
|
||||
onClick={() => onEdit(entry)}
|
||||
@@ -94,11 +98,11 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
<Pencil size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label="Send queued turn now">
|
||||
<Tip label={sendLabel}>
|
||||
<Button
|
||||
aria-label="Send queued turn now"
|
||||
aria-label={sendLabel}
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={busy || isEditing}
|
||||
disabled={isEditing}
|
||||
onClick={() => onSendNow(entry.id)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
@@ -107,9 +111,9 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
<ArrowUp size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label="Delete queued turn">
|
||||
<Tip label={c.deleteQueued}>
|
||||
<Button
|
||||
aria-label="Delete queued turn"
|
||||
aria-label={c.deleteQueued}
|
||||
className="h-5 w-5 rounded-md"
|
||||
onClick={() => onDelete(entry.id)}
|
||||
size="icon-xs"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useI18n } from '@/i18n'
|
||||
import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { useTheme } from '@/themes/context'
|
||||
@@ -10,6 +11,8 @@ interface SkinSlashPopoverProps {
|
||||
}
|
||||
|
||||
export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const { availableThemes, themeName } = useTheme()
|
||||
const match = draft.match(/^\/skin\s+(\S*)$/i)
|
||||
|
||||
@@ -21,7 +24,7 @@ export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label="Desktop theme suggestions"
|
||||
aria-label={c.themeSuggestions}
|
||||
className={COMPLETION_DRAWER_CLASS}
|
||||
data-slot="composer-skin-completion-drawer"
|
||||
data-state="open"
|
||||
@@ -29,8 +32,10 @@ export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
|
||||
>
|
||||
<div className="grid gap-0.5 pt-0.5">
|
||||
{items.length === 0 ? (
|
||||
<CompletionDrawerEmpty title="No matching themes.">
|
||||
Try <span className="font-mono text-foreground/80">/skin list</span>.
|
||||
<CompletionDrawerEmpty title={c.noMatchingThemes}>
|
||||
{c.themeTryPre}
|
||||
<span className="font-mono text-foreground/80">/skin list</span>
|
||||
{c.themeTryPost}
|
||||
</CompletionDrawerEmpty>
|
||||
) : (
|
||||
items.map(item => (
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Globe } from '@/lib/icons'
|
||||
|
||||
const URL_HINT = /^https?:\/\//i
|
||||
@@ -29,6 +30,8 @@ export function UrlDialog({
|
||||
open: boolean
|
||||
value: string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const trimmed = value.trim()
|
||||
const looksLikeUrl = trimmed.length > 0 && URL_HINT.test(trimmed)
|
||||
|
||||
@@ -43,8 +46,8 @@ export function UrlDialog({
|
||||
<Globe className="size-4" />
|
||||
</span>
|
||||
<div className="grid gap-0.5 text-left">
|
||||
<DialogTitle>Attach a URL</DialogTitle>
|
||||
<DialogDescription>Hermes will fetch the page and include it as context for this turn.</DialogDescription>
|
||||
<DialogTitle>{c.attachUrlTitle}</DialogTitle>
|
||||
<DialogDescription>{c.attachUrlDesc}</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<form
|
||||
@@ -60,23 +63,24 @@ export function UrlDialog({
|
||||
autoCorrect="off"
|
||||
inputMode="url"
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder="https://example.com/post"
|
||||
placeholder={c.urlPlaceholder}
|
||||
ref={inputRef}
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
/>
|
||||
{trimmed.length > 0 && !looksLikeUrl && (
|
||||
<p className="text-xs text-muted-foreground/85">
|
||||
Include the full URL, e.g. <span className="font-mono">https://…</span>
|
||||
{c.urlHintPre}
|
||||
<span className="font-mono">https://…</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)} type="button" variant="ghost">
|
||||
Cancel
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={!looksLikeUrl} type="submit">
|
||||
Attach
|
||||
{c.attach}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Loader2, Mic, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { stopVoicePlayback } from '@/lib/voice-playback'
|
||||
@@ -163,12 +164,14 @@ function PlaybackWaveform({ audioElement }: { audioElement: HTMLAudioElement | n
|
||||
}
|
||||
|
||||
export function VoiceActivity({ state }: { state: VoiceActivityState }) {
|
||||
const { t } = useI18n()
|
||||
|
||||
if (state.status === 'idle') {
|
||||
return null
|
||||
}
|
||||
|
||||
const recording = state.status === 'recording'
|
||||
const title = recording ? 'Dictating' : 'Transcribing'
|
||||
const title = recording ? t.composer.dictating : t.composer.transcribing
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -201,6 +204,7 @@ export function VoiceActivity({ state }: { state: VoiceActivityState }) {
|
||||
}
|
||||
|
||||
export function VoicePlaybackActivity() {
|
||||
const { t } = useI18n()
|
||||
const playback = useStore($voicePlayback)
|
||||
|
||||
if (playback.status === 'idle') {
|
||||
@@ -210,10 +214,10 @@ export function VoicePlaybackActivity() {
|
||||
const preparing = playback.status === 'preparing'
|
||||
|
||||
const title = preparing
|
||||
? 'Preparing audio'
|
||||
? t.composer.preparingAudio
|
||||
: playback.source === 'voice-conversation'
|
||||
? 'Speaking response'
|
||||
: 'Reading aloud'
|
||||
? t.composer.speakingResponse
|
||||
: t.composer.readingAloud
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback } from 'react'
|
||||
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
|
||||
import { fsReadFileDataUrl, selectPaths } from '@/lib/desktop-fs'
|
||||
import {
|
||||
addComposerAttachment,
|
||||
type ComposerAttachment,
|
||||
@@ -36,6 +37,27 @@ function isImagePath(filePath: string): boolean {
|
||||
return IMAGE_EXTENSION_PATTERN.test(filePath)
|
||||
}
|
||||
|
||||
// Thumbnail source for an attached image. Locally-held paths (drag/paste saves,
|
||||
// local picks) read off the client; when that fails on a remote backend the
|
||||
// path lives on the gateway host, so fall back to the gateway data-url read.
|
||||
async function loadImagePreviewDataUrl(filePath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const local = await window.hermesDesktop?.readFileDataUrl(filePath)
|
||||
|
||||
if (local) {
|
||||
return local
|
||||
}
|
||||
} catch {
|
||||
// Path isn't on the client (remote-picked image) — try the gateway below.
|
||||
}
|
||||
|
||||
try {
|
||||
return await fsReadFileDataUrl(filePath)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export interface DroppedFile {
|
||||
/** Browser-native File handle. Absent for in-app drags (e.g. project tree). */
|
||||
file?: File
|
||||
@@ -228,7 +250,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
|
||||
const pickContextPaths = useCallback(
|
||||
async (kind: 'file' | 'folder') => {
|
||||
const paths = await window.hermesDesktop?.selectPaths({
|
||||
const paths = await selectPaths({
|
||||
title: kind === 'file' ? 'Add files as context' : 'Add folders as context',
|
||||
defaultPath: currentCwd || undefined,
|
||||
directories: kind === 'folder'
|
||||
@@ -291,19 +313,13 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
|
||||
attachToMain(baseAttachment)
|
||||
|
||||
try {
|
||||
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
|
||||
const previewUrl = await loadImagePreviewDataUrl(filePath)
|
||||
|
||||
if (previewUrl) {
|
||||
addComposerAttachment({ ...baseAttachment, previewUrl })
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
notifyError(err, 'Image preview failed')
|
||||
|
||||
return true
|
||||
if (previewUrl) {
|
||||
addComposerAttachment({ ...baseAttachment, previewUrl })
|
||||
}
|
||||
|
||||
return true
|
||||
}, [])
|
||||
|
||||
const attachImageBlob = useCallback(
|
||||
@@ -338,7 +354,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
)
|
||||
|
||||
const pickImages = useCallback(async () => {
|
||||
const paths = await window.hermesDesktop?.selectPaths({
|
||||
const paths = await selectPaths({
|
||||
title: 'Attach images',
|
||||
defaultPath: currentCwd || undefined,
|
||||
filters: [
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Streamdown } from 'streamdown'
|
||||
|
||||
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { fsReadFileDataUrl, fsReadFileText } from '@/lib/desktop-fs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
|
||||
@@ -179,21 +180,19 @@ function looksBinaryBytes(bytes: Uint8Array) {
|
||||
}
|
||||
|
||||
async function readTextPreview(filePath: string) {
|
||||
if (window.hermesDesktop.readFileText) {
|
||||
try {
|
||||
return await window.hermesDesktop.readFileText(filePath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
try {
|
||||
return await fsReadFileText(filePath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
|
||||
throw error
|
||||
}
|
||||
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Back-compat for a running Electron process whose preload hasn't been
|
||||
// restarted since readFileText was added. readFileDataUrl already existed.
|
||||
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
|
||||
const dataUrl = await fsReadFileDataUrl(filePath)
|
||||
const [, metadata = '', data = ''] = dataUrl.match(/^data:([^,]*),(.*)$/) || []
|
||||
const base64 = metadata.includes(';base64')
|
||||
const mimeType = metadata.replace(/;base64$/, '') || undefined
|
||||
@@ -441,7 +440,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
|
||||
try {
|
||||
if (isImage) {
|
||||
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
|
||||
const dataUrl = await fsReadFileDataUrl(filePath)
|
||||
|
||||
if (active) {
|
||||
setState({ dataUrl, loading: false })
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { profileColor } from '@/lib/profile-color'
|
||||
import { sessionMatchesSearch } from '@/lib/session-search'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -176,13 +177,13 @@ function searchResultToSession(result: SessionSearchResult): SessionInfo {
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] {
|
||||
function workspaceGroupsFor(sessions: SessionInfo[], noWorkspaceLabel: string): SidebarSessionGroup[] {
|
||||
const groups = new Map<string, SidebarSessionGroup>()
|
||||
|
||||
for (const session of sessions) {
|
||||
const path = session.cwd?.trim() || ''
|
||||
const id = path || '__no_workspace__'
|
||||
const label = baseName(path) || path || 'No workspace'
|
||||
const label = baseName(path) || path || noWorkspaceLabel
|
||||
|
||||
const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] }
|
||||
group.sessions.push(session)
|
||||
@@ -233,6 +234,8 @@ export function ChatSidebar({
|
||||
onArchiveSession,
|
||||
onNewSessionInWorkspace
|
||||
}: ChatSidebarProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const agentsGrouped = useStore($sidebarAgentsGrouped)
|
||||
@@ -402,8 +405,8 @@ export function ChatSidebar({
|
||||
)
|
||||
|
||||
const agentGroups = useMemo(
|
||||
() => orderByIds(workspaceGroupsFor(agentSessions), g => g.id, workspaceOrderIds),
|
||||
[agentSessions, workspaceOrderIds]
|
||||
() => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
|
||||
[agentSessions, s.noWorkspace, workspaceOrderIds]
|
||||
)
|
||||
|
||||
const loadMoreForProfileGroup = useCallback(
|
||||
@@ -589,13 +592,15 @@ export function ChatSidebar({
|
||||
|
||||
onNavigate(item)
|
||||
}}
|
||||
tooltip={item.label}
|
||||
tooltip={s.nav[item.id] ?? item.label}
|
||||
type="button"
|
||||
>
|
||||
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
|
||||
{sidebarOpen && (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
|
||||
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">
|
||||
{s.nav[item.id] ?? item.label}
|
||||
</span>
|
||||
{isNewSession && (
|
||||
<KbdGroup
|
||||
className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
|
||||
@@ -615,9 +620,9 @@ export function ChatSidebar({
|
||||
{sidebarOpen && showSessionSections && (
|
||||
<div className="shrink-0 px-2 pb-1 pt-1">
|
||||
<SearchField
|
||||
aria-label="Search sessions"
|
||||
aria-label={s.searchAria}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search sessions…"
|
||||
placeholder={s.searchPlaceholder}
|
||||
value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
@@ -629,10 +634,10 @@ export function ChatSidebar({
|
||||
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}”.
|
||||
{s.noMatch(trimmedQuery)}
|
||||
</div>
|
||||
}
|
||||
label="Results"
|
||||
label={s.results}
|
||||
labelMeta={String(searchResults.length)}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
@@ -653,7 +658,7 @@ export function ChatSidebar({
|
||||
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
|
||||
dndSensors={dndSensors}
|
||||
emptyState={<SidebarPinnedEmptyState />}
|
||||
label="Pinned"
|
||||
label={s.pinned}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onReorder={handlePinnedDragEnd}
|
||||
@@ -703,9 +708,9 @@ export function ChatSidebar({
|
||||
// view (always grouped by profile), so hide the button (not the slot).
|
||||
<div className="grid size-6 shrink-0 place-items-center">
|
||||
{!showAllProfiles && agentSessions.length > 0 ? (
|
||||
<Tip label={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}>
|
||||
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
|
||||
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
|
||||
className={cn(
|
||||
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
@@ -724,7 +729,7 @@ export function ChatSidebar({
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
label="Sessions"
|
||||
label={s.sessions}
|
||||
labelMeta={recentsMeta}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
@@ -795,19 +800,25 @@ function SidebarSessionSkeletons() {
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarAllPinnedState = () => (
|
||||
<div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-(--ui-text-tertiary)">
|
||||
Everything here is pinned. Unpin a chat to show it in recents.
|
||||
</div>
|
||||
)
|
||||
function SidebarAllPinnedState() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-(--ui-text-tertiary)">
|
||||
{t.sidebar.allPinned}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarPinnedEmptyState() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-7 items-center gap-1.5 rounded-lg pl-2 text-[0.75rem] text-(--ui-text-tertiary)">
|
||||
<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</span>
|
||||
<span>{t.sidebar.shiftClickHint}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1006,6 +1017,8 @@ function SidebarWorkspaceGroup({
|
||||
ref,
|
||||
...rest
|
||||
}: SidebarWorkspaceGroupProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
const isProfileGroup = group.mode === 'profile'
|
||||
const pageStep = isProfileGroup ? PROFILE_INITIAL_PAGE : WORKSPACE_PAGE
|
||||
const [open, setOpen] = useState(true)
|
||||
@@ -1052,9 +1065,9 @@ function SidebarWorkspaceGroup({
|
||||
/>
|
||||
</button>
|
||||
{(onNewSession || isProfileGroup) && (
|
||||
<Tip label={`New session in ${group.label}`}>
|
||||
<Tip label={s.newSessionIn(group.label)}>
|
||||
<button
|
||||
aria-label={`New session in ${group.label}`}
|
||||
aria-label={s.newSessionIn(group.label)}
|
||||
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
|
||||
// Profile groups start a fresh session in that profile but keep the
|
||||
// all-profiles browse view (newSessionInProfile leaves the scope
|
||||
@@ -1069,7 +1082,7 @@ function SidebarWorkspaceGroup({
|
||||
{reorderable && (
|
||||
<span
|
||||
{...dragHandleProps}
|
||||
aria-label={`Reorder workspace ${group.label}`}
|
||||
aria-label={s.reorderWorkspace(group.label)}
|
||||
className="ml-auto -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
@@ -1091,9 +1104,9 @@ function SidebarWorkspaceGroup({
|
||||
(isProfileGroup ? (
|
||||
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
|
||||
) : (
|
||||
<Tip label={`Show ${nextCount} more in ${group.label}`}>
|
||||
<Tip label={s.showMoreIn(nextCount, group.label)}>
|
||||
<button
|
||||
aria-label={`Show ${nextCount} more in ${group.label}`}
|
||||
aria-label={s.showMoreIn(nextCount, group.label)}
|
||||
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
|
||||
type="button"
|
||||
@@ -1144,7 +1157,8 @@ interface SidebarLoadMoreRowProps {
|
||||
}
|
||||
|
||||
function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) {
|
||||
const label = loading ? 'Loading…' : step > 0 ? `Load ${step} more` : 'Load more'
|
||||
const { t } = useI18n()
|
||||
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { renameSession } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
@@ -43,13 +44,15 @@ interface ItemSpec {
|
||||
}
|
||||
|
||||
function useSessionActions({ sessionId, title, pinned = false, profile, onPin, onArchive, onDelete }: SessionActions) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
|
||||
const items: ItemSpec[] = [
|
||||
{
|
||||
disabled: !onPin,
|
||||
icon: 'pin',
|
||||
label: pinned ? 'Unpin' : 'Pin',
|
||||
label: pinned ? r.unpin : r.pin,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
@@ -58,17 +61,17 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'copy',
|
||||
label: 'Copy ID',
|
||||
label: r.copyId,
|
||||
onSelect: event => {
|
||||
event.preventDefault()
|
||||
triggerHaptic('selection')
|
||||
void writeClipboardText(sessionId).catch(err => notifyError(err, 'Could not copy session ID'))
|
||||
void writeClipboardText(sessionId).catch(err => notifyError(err, r.copyIdFailed))
|
||||
}
|
||||
},
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'cloud-download',
|
||||
label: 'Export',
|
||||
label: r.export,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
void exportSession(sessionId, { title })
|
||||
@@ -77,7 +80,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'edit',
|
||||
label: 'Rename',
|
||||
label: r.rename,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
setRenameOpen(true)
|
||||
@@ -86,7 +89,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
{
|
||||
disabled: !onArchive,
|
||||
icon: 'archive',
|
||||
label: 'Archive',
|
||||
label: r.archive,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onArchive?.()
|
||||
@@ -96,7 +99,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
className: 'text-destructive focus:text-destructive',
|
||||
disabled: !onDelete,
|
||||
icon: 'trash',
|
||||
label: 'Delete',
|
||||
label: t.common.delete,
|
||||
onSelect: () => {
|
||||
triggerHaptic('warning')
|
||||
onDelete?.()
|
||||
@@ -132,6 +135,7 @@ interface SessionActionsMenuProps
|
||||
}
|
||||
|
||||
export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const { renameDialog, renderItems } = useSessionActions(actions)
|
||||
|
||||
return (
|
||||
@@ -140,7 +144,7 @@ export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ..
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
aria-label={`Actions for ${actions.title}`}
|
||||
aria-label={t.sidebar.row.actionsFor(actions.title)}
|
||||
className="w-40"
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
@@ -157,13 +161,14 @@ interface SessionContextMenuProps extends SessionActions {
|
||||
}
|
||||
|
||||
export function SessionContextMenu({ children, ...actions }: SessionContextMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const { renameDialog, renderItems } = useSessionActions(actions)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent aria-label={`Actions for ${actions.title}`} className="w-40">
|
||||
<ContextMenuContent aria-label={t.sidebar.row.actionsFor(actions.title)} className="w-40">
|
||||
{renderItems(ContextMenuItem)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
@@ -181,6 +186,8 @@ interface RenameSessionDialogProps {
|
||||
}
|
||||
|
||||
function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, profile }: RenameSessionDialogProps) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
const [value, setValue] = useState(currentTitle)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -211,10 +218,10 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
|
||||
const result = await renameSession(sessionId, next, profile)
|
||||
const finalTitle = result.title || next || ''
|
||||
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
|
||||
notify({ durationMs: 2_000, kind: 'success', message: 'Renamed' })
|
||||
notify({ durationMs: 2_000, kind: 'success', message: r.renamed })
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Rename failed')
|
||||
notifyError(err, r.renameFailed)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -224,8 +231,8 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename session</DialogTitle>
|
||||
<DialogDescription>Give this chat a memorable title. Leave empty to clear.</DialogDescription>
|
||||
<DialogTitle>{r.renameTitle}</DialogTitle>
|
||||
<DialogDescription>{r.renameDesc}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
autoFocus
|
||||
@@ -239,16 +246,16 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
|
||||
onOpenChange(false)
|
||||
}
|
||||
}}
|
||||
placeholder="Untitled session"
|
||||
placeholder={r.untitledPlaceholder}
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost">
|
||||
Cancel
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={submitting} onClick={() => void submit()} type="button">
|
||||
Save
|
||||
{t.common.save}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -26,22 +27,22 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLElement>
|
||||
}
|
||||
|
||||
const AGE_TICKS: ReadonlyArray<[number, string]> = [
|
||||
[86_400_000, 'd'],
|
||||
[3_600_000, 'h'],
|
||||
[60_000, 'm']
|
||||
const AGE_TICKS: ReadonlyArray<[number, 'ageDay' | 'ageHour' | 'ageMin']> = [
|
||||
[86_400_000, 'ageDay'],
|
||||
[3_600_000, 'ageHour'],
|
||||
[60_000, 'ageMin']
|
||||
]
|
||||
|
||||
function formatAge(seconds: number): string {
|
||||
function formatAge(seconds: number, r: Translations['sidebar']['row']): string {
|
||||
const delta = Math.max(0, Date.now() - seconds * 1000)
|
||||
|
||||
for (const [ms, suffix] of AGE_TICKS) {
|
||||
for (const [ms, key] of AGE_TICKS) {
|
||||
if (delta >= ms) {
|
||||
return `${Math.floor(delta / ms)}${suffix}`
|
||||
return `${Math.floor(delta / ms)}${r[key]}`
|
||||
}
|
||||
}
|
||||
|
||||
return 'now'
|
||||
return r.ageNow
|
||||
}
|
||||
|
||||
export function SidebarSessionRow({
|
||||
@@ -61,8 +62,10 @@ export function SidebarSessionRow({
|
||||
ref,
|
||||
...rest
|
||||
}: SidebarSessionRowProps) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
const title = sessionTitle(session)
|
||||
const age = formatAge(session.last_active || session.started_at)
|
||||
const age = formatAge(session.last_active || session.started_at, r)
|
||||
const handleLabel = `Reorder ${title}`
|
||||
// Subscribe per-row (the leaf) instead of drilling a set through the list —
|
||||
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
|
||||
@@ -196,10 +199,10 @@ export function SidebarSessionRow({
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
aria-label={`Actions for ${title}`}
|
||||
aria-label={r.actionsFor(title)}
|
||||
className="size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
|
||||
size="icon"
|
||||
title="Session actions"
|
||||
title={r.sessionActions}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.875rem" />
|
||||
@@ -220,6 +223,9 @@ function SidebarRowDot({
|
||||
needsInput?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
|
||||
// "Needs input" wins over "working": a clarify-blocked session is technically
|
||||
// still running, but the actionable state is that it's waiting on the user.
|
||||
// Amber + steady (no ping) reads as "your turn", distinct from the accent
|
||||
@@ -227,17 +233,17 @@ function SidebarRowDot({
|
||||
if (needsInput) {
|
||||
return (
|
||||
<span
|
||||
aria-label="Needs your input"
|
||||
aria-label={r.needsInput}
|
||||
className={cn('quest-glow relative size-1.5 rounded-full bg-amber-500', className)}
|
||||
role="status"
|
||||
title="Waiting for your answer"
|
||||
title={r.waitingForAnswer}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-label={isWorking ? 'Session running' : undefined}
|
||||
aria-label={isWorking ? r.sessionRunning : undefined}
|
||||
className={cn(
|
||||
'rounded-full',
|
||||
isWorking
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import type * as React from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
|
||||
interface CronJobActions {
|
||||
@@ -32,12 +33,15 @@ export function CronJobActionsMenu({
|
||||
sideOffset = 6,
|
||||
title
|
||||
}: CronJobActionsMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.cron
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
aria-label={`Actions for ${title}`}
|
||||
aria-label={c.actionsFor(title)}
|
||||
className="w-44"
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
@@ -49,7 +53,7 @@ export function CronJobActionsMenu({
|
||||
}}
|
||||
>
|
||||
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
|
||||
<span>{isPaused ? 'Resume' : 'Pause'}</span>
|
||||
<span>{isPaused ? c.resumeTitle : c.pauseTitle}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
@@ -60,7 +64,7 @@ export function CronJobActionsMenu({
|
||||
}}
|
||||
>
|
||||
<Codicon name="zap" size="0.875rem" />
|
||||
<span>Trigger now</span>
|
||||
<span>{c.triggerNow}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
@@ -70,7 +74,7 @@ export function CronJobActionsMenu({
|
||||
}}
|
||||
>
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
<span>Edit</span>
|
||||
<span>{c.edit}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
@@ -81,7 +85,7 @@ export function CronJobActionsMenu({
|
||||
variant="destructive"
|
||||
>
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
<span>Delete</span>
|
||||
<span>{t.common.delete}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -93,12 +97,14 @@ interface CronJobActionsTriggerProps extends Omit<React.ComponentProps<typeof Bu
|
||||
}
|
||||
|
||||
export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={`Actions for ${title}`}
|
||||
aria-label={t.cron.actionsFor(title)}
|
||||
className={className}
|
||||
size="icon-sm"
|
||||
title="Cron job actions"
|
||||
title={t.cron.actionsTitle}
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
|
||||
+178
-179
@@ -1,10 +1,9 @@
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Badge, type BadgeProps } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
@@ -27,78 +25,49 @@ import {
|
||||
triggerCronJob,
|
||||
updateCronJob
|
||||
} from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertTriangle, Clock } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
import { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu'
|
||||
|
||||
const DEFAULT_DELIVER = 'local'
|
||||
|
||||
const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [
|
||||
{ label: 'This desktop', value: 'local' },
|
||||
{ label: 'Telegram', value: 'telegram' },
|
||||
{ label: 'Discord', value: 'discord' },
|
||||
{ label: 'Slack', value: 'slack' },
|
||||
{ label: 'Email', value: 'email' }
|
||||
]
|
||||
const DELIVERY_VALUES: readonly string[] = ['local', 'telegram', 'discord', 'slack', 'email']
|
||||
|
||||
const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
|
||||
{
|
||||
expr: '0 9 * * *',
|
||||
hint: 'Every day at 9:00 AM',
|
||||
label: 'Daily',
|
||||
value: 'daily'
|
||||
},
|
||||
{
|
||||
expr: '0 9 * * 1-5',
|
||||
hint: 'Monday through Friday at 9:00 AM',
|
||||
label: 'Weekdays',
|
||||
value: 'weekdays'
|
||||
},
|
||||
{
|
||||
expr: '0 9 * * 1',
|
||||
hint: 'Every Monday at 9:00 AM',
|
||||
label: 'Weekly',
|
||||
value: 'weekly'
|
||||
},
|
||||
{
|
||||
expr: '0 9 1 * *',
|
||||
hint: 'The first day of each month at 9:00 AM',
|
||||
label: 'Monthly',
|
||||
value: 'monthly'
|
||||
},
|
||||
{
|
||||
expr: '0 * * * *',
|
||||
hint: 'At the top of every hour',
|
||||
label: 'Hourly',
|
||||
value: 'hourly'
|
||||
},
|
||||
{
|
||||
expr: '*/15 * * * *',
|
||||
hint: 'Every 15 minutes',
|
||||
label: 'Every 15 minutes',
|
||||
value: 'every-15-minutes'
|
||||
},
|
||||
{
|
||||
hint: 'Cron syntax or natural language',
|
||||
label: 'Custom',
|
||||
value: 'custom'
|
||||
}
|
||||
{ expr: '0 9 * * *', value: 'daily' },
|
||||
{ expr: '0 9 * * 1-5', value: 'weekdays' },
|
||||
{ expr: '0 9 * * 1', value: 'weekly' },
|
||||
{ expr: '0 9 1 * *', value: 'monthly' },
|
||||
{ expr: '0 * * * *', value: 'hourly' },
|
||||
{ expr: '*/15 * * * *', value: 'every-15-minutes' },
|
||||
{ value: 'custom' }
|
||||
]
|
||||
|
||||
const STATE_VARIANT: Record<string, BadgeProps['variant']> = {
|
||||
enabled: 'default',
|
||||
scheduled: 'default',
|
||||
running: 'default',
|
||||
const STATE_TONE: Record<string, 'good' | 'muted' | 'warn' | 'bad'> = {
|
||||
enabled: 'good',
|
||||
scheduled: 'good',
|
||||
running: 'good',
|
||||
paused: 'warn',
|
||||
disabled: 'muted',
|
||||
error: 'destructive',
|
||||
error: 'bad',
|
||||
completed: 'muted'
|
||||
}
|
||||
|
||||
const PILL_TONE: Record<'good' | 'muted' | 'warn' | 'bad', string> = {
|
||||
good: 'bg-primary/10 text-primary',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
|
||||
bad: 'bg-destructive/10 text-destructive'
|
||||
}
|
||||
|
||||
const asText = (value: unknown): string => (typeof value === 'string' ? value : '')
|
||||
|
||||
const truncate = (value: string, max = 80): string => (value.length > max ? `${value.slice(0, max)}…` : value)
|
||||
@@ -155,19 +124,8 @@ function cronParts(expr: string): null | string[] {
|
||||
return parts.length === 5 ? parts : null
|
||||
}
|
||||
|
||||
function dayName(value: string): string {
|
||||
const names: Record<string, string> = {
|
||||
'0': 'Sunday',
|
||||
'1': 'Monday',
|
||||
'2': 'Tuesday',
|
||||
'3': 'Wednesday',
|
||||
'4': 'Thursday',
|
||||
'5': 'Friday',
|
||||
'6': 'Saturday',
|
||||
'7': 'Sunday'
|
||||
}
|
||||
|
||||
return names[value] ?? `day ${value}`
|
||||
function dayName(value: string, c: Translations['cron']): string {
|
||||
return c.days[value] ?? c.dayFallback(value)
|
||||
}
|
||||
|
||||
function formatCronTime(minute: string, hour: string): string {
|
||||
@@ -243,36 +201,36 @@ function scheduleOptionForExpr(expr: string): ScheduleOption {
|
||||
return SCHEDULE_OPTIONS[SCHEDULE_OPTIONS.length - 1]
|
||||
}
|
||||
|
||||
function scheduleSummary(option: ScheduleOption, expr: string): string {
|
||||
function scheduleSummary(option: ScheduleOption, expr: string, c: Translations['cron']): string {
|
||||
const parts = cronParts(expr)
|
||||
|
||||
if (!parts) {
|
||||
return option.hint
|
||||
return c.scheduleHints[option.value] ?? ''
|
||||
}
|
||||
|
||||
const [minute, hour, dayOfMonth, , dayOfWeek] = parts
|
||||
|
||||
if (option.value === 'daily') {
|
||||
return `Every day at ${formatCronTime(minute, hour)}`
|
||||
return c.everyDayAt(formatCronTime(minute, hour))
|
||||
}
|
||||
|
||||
if (option.value === 'weekdays') {
|
||||
return `Weekdays at ${formatCronTime(minute, hour)}`
|
||||
return c.weekdaysAt(formatCronTime(minute, hour))
|
||||
}
|
||||
|
||||
if (option.value === 'weekly') {
|
||||
return `Every ${dayName(dayOfWeek)} at ${formatCronTime(minute, hour)}`
|
||||
return c.everyDayOfWeekAt(dayName(dayOfWeek, c), formatCronTime(minute, hour))
|
||||
}
|
||||
|
||||
if (option.value === 'monthly') {
|
||||
return `Monthly on day ${dayOfMonth} at ${formatCronTime(minute, hour)}`
|
||||
return c.monthlyOnDayAt(dayOfMonth, formatCronTime(minute, hour))
|
||||
}
|
||||
|
||||
if (option.value === 'hourly') {
|
||||
return minute === '0' ? 'At the top of every hour' : `Every hour at :${minute.padStart(2, '0')}`
|
||||
return minute === '0' ? c.topOfHour : c.everyHourAt(minute.padStart(2, '0'))
|
||||
}
|
||||
|
||||
return option.hint
|
||||
return c.scheduleHints[option.value] ?? ''
|
||||
}
|
||||
|
||||
function formatTime(iso?: null | string): string {
|
||||
@@ -301,26 +259,35 @@ function matchesQuery(job: CronJob, q: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
interface CronViewProps {
|
||||
interface CronViewProps extends React.ComponentProps<'section'> {
|
||||
onClose: () => void
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
}
|
||||
|
||||
export function CronView({ onClose }: CronViewProps) {
|
||||
export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: CronViewProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.cron
|
||||
const [jobs, setJobs] = useState<CronJob[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [busyJobId, setBusyJobId] = useState<null | string>(null)
|
||||
|
||||
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
|
||||
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const result = await getCronJobs()
|
||||
setJobs(result)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to load cron jobs')
|
||||
notifyError(err, c.failedLoad)
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
}, [c])
|
||||
|
||||
useRefreshHotkey(refresh)
|
||||
|
||||
@@ -348,11 +315,11 @@ export function CronView({ onClose }: CronViewProps) {
|
||||
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: isPaused ? 'Cron resumed' : 'Cron paused',
|
||||
title: isPaused ? c.resumed : c.paused,
|
||||
message: truncate(jobTitle(job), 60)
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to update cron job')
|
||||
notifyError(err, c.failedUpdate)
|
||||
} finally {
|
||||
setBusyJobId(null)
|
||||
}
|
||||
@@ -364,14 +331,33 @@ export function CronView({ onClose }: CronViewProps) {
|
||||
try {
|
||||
const updated = await triggerCronJob(job.id)
|
||||
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
|
||||
notify({ kind: 'success', title: 'Cron triggered', message: truncate(jobTitle(job), 60) })
|
||||
notify({ kind: 'success', title: c.triggered, message: truncate(jobTitle(job), 60) })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to trigger cron job')
|
||||
notifyError(err, c.failedTrigger)
|
||||
} finally {
|
||||
setBusyJobId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmDelete() {
|
||||
if (!pendingDelete) {
|
||||
return
|
||||
}
|
||||
|
||||
setDeleting(true)
|
||||
|
||||
try {
|
||||
await deleteCronJob(pendingDelete.id)
|
||||
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
|
||||
notify({ kind: 'success', title: c.deleted, message: truncate(jobTitle(pendingDelete), 60) })
|
||||
setPendingDelete(null)
|
||||
} catch (err) {
|
||||
notifyError(err, c.failedDelete)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditorSave(values: EditorValues) {
|
||||
if (editor.mode === 'create') {
|
||||
const created = await createCronJob({
|
||||
@@ -382,7 +368,7 @@ export function CronView({ onClose }: CronViewProps) {
|
||||
})
|
||||
|
||||
setJobs(current => (current ? [...current, created] : [created]))
|
||||
notify({ kind: 'success', title: 'Cron created', message: truncate(jobTitle(created), 60) })
|
||||
notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) })
|
||||
} else if (editor.mode === 'edit') {
|
||||
const updated = await updateCronJob(editor.job.id, {
|
||||
prompt: values.prompt,
|
||||
@@ -392,61 +378,67 @@ export function CronView({ onClose }: CronViewProps) {
|
||||
})
|
||||
|
||||
setJobs(current => (current ? current.map(row => (row.id === updated.id ? updated : row)) : current))
|
||||
notify({ kind: 'success', title: 'Cron updated', message: truncate(jobTitle(updated), 60) })
|
||||
notify({ kind: 'success', title: c.updated, message: truncate(jobTitle(updated), 60) })
|
||||
}
|
||||
|
||||
setEditor({ mode: 'closed' })
|
||||
}
|
||||
|
||||
return (
|
||||
<OverlayView closeLabel="Close cron" onClose={onClose}>
|
||||
<div className="flex min-h-0 flex-1 flex-col pt-[calc(var(--titlebar-height)+0.5rem)]">
|
||||
{totalCount > 0 && (
|
||||
<div className="mx-auto flex w-full max-w-4xl items-center gap-2 px-4 pb-2">
|
||||
<SearchField
|
||||
containerClassName="max-w-[60vw]"
|
||||
onChange={setQuery}
|
||||
placeholder="Search cron jobs…"
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<OverlayView closeLabel={c.close} onClose={onClose}>
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
onSearchChange={setQuery}
|
||||
searchPlaceholder={c.search}
|
||||
searchTrailingAction={
|
||||
<Button
|
||||
aria-label={refreshing ? c.refreshing : c.refresh}
|
||||
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refresh()}
|
||||
size="icon-xs"
|
||||
title={refreshing ? c.refreshing : c.refresh}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
|
||||
</Button>
|
||||
}
|
||||
searchValue={query}
|
||||
>
|
||||
{!jobs ? (
|
||||
<PageLoader label="Loading cron jobs..." />
|
||||
<PageLoader label={c.loading} />
|
||||
) : 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={
|
||||
totalCount === 0
|
||||
? 'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.'
|
||||
: 'Try a broader search query.'
|
||||
}
|
||||
actionLabel={totalCount === 0 ? c.createFirst : undefined}
|
||||
description={totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}
|
||||
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
|
||||
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
|
||||
title={totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch}
|
||||
/>
|
||||
) : (
|
||||
<div className="mx-auto w-full max-w-4xl min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
||||
<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
|
||||
{c.active(enabledCount, totalCount)}
|
||||
</span>
|
||||
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
|
||||
<Codicon name="add" />
|
||||
New cron
|
||||
{c.newCron}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobRow
|
||||
busy={busyJobId === job.id}
|
||||
c={c}
|
||||
job={job}
|
||||
key={job.id}
|
||||
onDelete={() => setPendingDelete(job)}
|
||||
@@ -458,42 +450,40 @@ export function CronView({ onClose }: CronViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
|
||||
<ConfirmDialog
|
||||
busyLabel="Deleting…"
|
||||
confirmLabel="Delete"
|
||||
description={
|
||||
pendingDelete ? (
|
||||
<>
|
||||
This will remove{' '}
|
||||
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span> permanently.
|
||||
It will stop firing immediately.
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
destructive
|
||||
doneLabel="Deleted"
|
||||
onClose={() => setPendingDelete(null)}
|
||||
onConfirm={async () => {
|
||||
if (!pendingDelete) {
|
||||
return
|
||||
}
|
||||
|
||||
await deleteCronJob(pendingDelete.id)
|
||||
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
|
||||
notify({ kind: 'success', message: truncate(jobTitle(pendingDelete), 60), title: 'Cron deleted' })
|
||||
}}
|
||||
open={pendingDelete !== null}
|
||||
title="Delete cron job?"
|
||||
/>
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{c.deleteTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pendingDelete ? (
|
||||
<>
|
||||
{c.deleteDescPrefix}
|
||||
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span>
|
||||
{c.deleteDescSuffix}
|
||||
</>
|
||||
) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
|
||||
{deleting ? c.deleting : t.common.delete}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageSearchShell>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
function CronJobRow({
|
||||
busy,
|
||||
c,
|
||||
job,
|
||||
onDelete,
|
||||
onEdit,
|
||||
@@ -501,6 +491,7 @@ function CronJobRow({
|
||||
onTrigger
|
||||
}: {
|
||||
busy: boolean
|
||||
c: Translations['cron']
|
||||
job: CronJob
|
||||
onDelete: () => void
|
||||
onEdit: () => void
|
||||
@@ -516,19 +507,15 @@ function CronJobRow({
|
||||
return (
|
||||
<div className="grid gap-3 px-3 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
|
||||
<button
|
||||
className="min-w-0 rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
className="min-w-0 cursor-pointer rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
onClick={onEdit}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{jobTitle(job)}</span>
|
||||
<Badge className="capitalize" variant={STATE_VARIANT[state] ?? 'muted'}>
|
||||
{state}
|
||||
</Badge>
|
||||
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</StatePill>
|
||||
{deliver && deliver !== DEFAULT_DELIVER && (
|
||||
<Badge className="capitalize" variant="muted">
|
||||
{deliver}
|
||||
</Badge>
|
||||
<StatePill tone="muted">{c.deliveryLabels[deliver] ?? deliver}</StatePill>
|
||||
)}
|
||||
</div>
|
||||
{hasName && prompt && <p className="mt-1 truncate text-xs text-muted-foreground">{truncate(prompt, 120)}</p>}
|
||||
@@ -537,8 +524,12 @@ function CronJobRow({
|
||||
<Clock className="size-3" />
|
||||
{jobScheduleDisplay(job)}
|
||||
</span>
|
||||
<span>Last: {formatTime(job.last_run_at)}</span>
|
||||
<span>Next: {formatTime(job.next_run_at)}</span>
|
||||
<span>
|
||||
{c.last} {formatTime(job.last_run_at)}
|
||||
</span>
|
||||
<span>
|
||||
{c.next} {formatTime(job.next_run_at)}
|
||||
</span>
|
||||
</div>
|
||||
{job.last_error && (
|
||||
<p className="mt-1 inline-flex items-start gap-1 text-[0.68rem] text-destructive">
|
||||
@@ -569,6 +560,16 @@ function CronJobRow({
|
||||
)
|
||||
}
|
||||
|
||||
function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem] capitalize', PILL_TONE[tone])}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
actionLabel,
|
||||
description,
|
||||
@@ -605,6 +606,8 @@ function CronEditorDialog({
|
||||
onClose: () => void
|
||||
onSave: (values: EditorValues) => Promise<void>
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.cron
|
||||
const open = editor.mode !== 'closed'
|
||||
const isEdit = editor.mode === 'edit'
|
||||
const initial = isEdit ? editor.job : null
|
||||
@@ -647,7 +650,7 @@ function CronEditorDialog({
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleHint = scheduleSummary(selectedScheduleOption, schedule)
|
||||
const scheduleHint = scheduleSummary(selectedScheduleOption, schedule, c)
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
@@ -655,7 +658,7 @@ function CronEditorDialog({
|
||||
const trimmedSchedule = schedule.trim()
|
||||
|
||||
if (!trimmedPrompt || !trimmedSchedule) {
|
||||
setError('Prompt and schedule are required.')
|
||||
setError(c.promptScheduleRequired)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -671,7 +674,7 @@ function CronEditorDialog({
|
||||
schedule: trimmedSchedule
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save cron job')
|
||||
setError(err instanceof Error ? err.message : c.failedSave)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -681,60 +684,56 @@ function CronEditorDialog({
|
||||
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Edit cron job' : 'New cron job'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? 'Update the schedule, prompt, or delivery target. Changes apply on next run.'
|
||||
: 'Schedule a prompt to run automatically. Use cron syntax or a natural phrase like "every 15 minutes".'}
|
||||
</DialogDescription>
|
||||
<DialogTitle>{isEdit ? c.editTitle : c.createTitle}</DialogTitle>
|
||||
<DialogDescription>{isEdit ? c.editDesc : c.createDesc}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||
<Field htmlFor="cron-name" label="Name" optional>
|
||||
<Field htmlFor="cron-name" label={c.nameLabel} optional optionalLabel={c.optional}>
|
||||
<Input
|
||||
autoFocus
|
||||
id="cron-name"
|
||||
onChange={event => setName(event.target.value)}
|
||||
placeholder="Morning briefing"
|
||||
placeholder={c.namePlaceholder}
|
||||
value={name}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field htmlFor="cron-prompt" label="Prompt">
|
||||
<Field htmlFor="cron-prompt" label={c.promptLabel}>
|
||||
<Textarea
|
||||
className="min-h-24 font-mono"
|
||||
id="cron-prompt"
|
||||
onChange={event => setPrompt(event.target.value)}
|
||||
placeholder="Summarize my unread Slack threads and email me the top 5..."
|
||||
placeholder={c.promptPlaceholder}
|
||||
value={prompt}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||
<Field htmlFor="cron-frequency" label="Frequency">
|
||||
<Field htmlFor="cron-frequency" label={c.frequencyLabel}>
|
||||
<Select onValueChange={handleSchedulePresetChange} value={schedulePreset}>
|
||||
<SelectTrigger id="cron-frequency">
|
||||
<SelectTrigger className="h-9 rounded-md" id="cron-frequency">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCHEDULE_OPTIONS.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{c.scheduleLabels[option.value]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field htmlFor="cron-deliver" label="Deliver to">
|
||||
<Field htmlFor="cron-deliver" label={c.deliverLabel}>
|
||||
<Select onValueChange={setDeliver} value={deliver}>
|
||||
<SelectTrigger id="cron-deliver">
|
||||
<SelectTrigger className="h-9 rounded-md" id="cron-deliver">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DELIVERY_OPTIONS.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{DELIVERY_VALUES.map(value => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{c.deliveryLabels[value]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -743,15 +742,15 @@ function CronEditorDialog({
|
||||
</div>
|
||||
|
||||
{schedulePreset === 'custom' ? (
|
||||
<Field htmlFor="cron-schedule" label="Custom schedule">
|
||||
<Field htmlFor="cron-schedule" label={c.customScheduleLabel}>
|
||||
<Input
|
||||
className="font-mono"
|
||||
id="cron-schedule"
|
||||
onChange={event => setSchedule(event.target.value)}
|
||||
placeholder="0 9 * * * or weekdays at 9am"
|
||||
placeholder={c.customPlaceholder}
|
||||
value={schedule}
|
||||
/>
|
||||
<FieldHint>Cron expression, or phrases like "every hour" or "weekdays at 9am".</FieldHint>
|
||||
<FieldHint>{c.customHint}</FieldHint>
|
||||
</Field>
|
||||
) : (
|
||||
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2">
|
||||
@@ -771,10 +770,10 @@ function CronEditorDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
|
||||
Cancel
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={saving} type="submit">
|
||||
{saving ? 'Saving...' : isEdit ? 'Save changes' : 'Create cron'}
|
||||
{saving ? t.common.saving : isEdit ? c.saveChanges : c.createAction}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -787,18 +786,20 @@ function Field({
|
||||
children,
|
||||
htmlFor,
|
||||
label,
|
||||
optional
|
||||
optional,
|
||||
optionalLabel
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
htmlFor: string
|
||||
label: string
|
||||
optional?: boolean
|
||||
optionalLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<label className="flex items-baseline gap-2 text-xs font-medium text-foreground" htmlFor={htmlFor}>
|
||||
{label}
|
||||
{optional && <span className="text-[0.65rem] font-normal text-muted-foreground">Optional</span>}
|
||||
{optional && <span className="text-[0.65rem] font-normal text-muted-foreground">{optionalLabel}</span>}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
@@ -820,7 +821,5 @@ interface EditorValues {
|
||||
|
||||
interface ScheduleOption {
|
||||
expr?: string
|
||||
hint: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
unpinSession
|
||||
} from '../store/layout'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
|
||||
import { $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
|
||||
import { $activeGatewayProfile, $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentCwd,
|
||||
@@ -68,6 +68,7 @@ import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
|
||||
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
|
||||
import { ModelPickerOverlay } from './model-picker-overlay'
|
||||
import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
||||
import { RemotePathPicker } from './remote-path-picker'
|
||||
import { RightSidebarPane } from './right-sidebar'
|
||||
import { $terminalTakeover } from './right-sidebar/store'
|
||||
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
||||
@@ -506,6 +507,25 @@ export function DesktopController() {
|
||||
startFreshSessionDraft()
|
||||
}, [freshSessionRequest, startFreshSessionDraft])
|
||||
|
||||
// Swapping the live gateway to another profile must re-pull that profile's
|
||||
// global model + active-profile pill. Both are nanostores, so the blanket
|
||||
// invalidateQueries() the profile store fires on swap doesn't touch them —
|
||||
// without this the statusbar keeps showing the previous profile's model
|
||||
// (the "forgets the LLM setting" report). gatewayState stays 'open' across a
|
||||
// swap (background sockets persist), so the open→open effect won't re-run.
|
||||
const activeGatewayProfile = useStore($activeGatewayProfile)
|
||||
const lastGatewayProfileRef = useRef(activeGatewayProfile)
|
||||
|
||||
useEffect(() => {
|
||||
if (activeGatewayProfile === lastGatewayProfileRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastGatewayProfileRef.current = activeGatewayProfile
|
||||
void refreshCurrentModel()
|
||||
void refreshActiveProfile()
|
||||
}, [activeGatewayProfile, refreshCurrentModel])
|
||||
|
||||
const composer = useComposerActions({
|
||||
activeSessionId,
|
||||
currentCwd,
|
||||
@@ -653,6 +673,7 @@ export function DesktopController() {
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
<CommandPalette />
|
||||
<RemotePathPicker />
|
||||
|
||||
{settingsOpen && (
|
||||
<Suspense fallback={null}>
|
||||
@@ -681,6 +702,7 @@ export function DesktopController() {
|
||||
initialSection={commandCenterInitialSection}
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onDeleteSession={removeSession}
|
||||
onNavigateRoute={path => navigate(path)}
|
||||
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
|
||||
import { useGatewayBoot } from './use-gateway-boot'
|
||||
|
||||
// End-to-end-ish repro of the "remote VPS → stuck on CONNECTING, no Settings"
|
||||
// bug that drives the REAL useGatewayBoot hook + REAL HermesGateway through a
|
||||
// fake WebSocket we fully control. No Docker / no real port: from the desktop's
|
||||
// point of view a "remote VPS" is just a WebSocket that opens once and later
|
||||
// refuses to reopen, so that is exactly (and only) what we fake.
|
||||
//
|
||||
// The previous test (gateway-connecting-overlay.test.tsx) hand-set the stores
|
||||
// and asserted the overlays; this one proves the HOOK actually PRODUCES that
|
||||
// stuck store combo — closing the "inferred by reading code" gap on the
|
||||
// post-boot reconnect loop.
|
||||
|
||||
type Listener = (ev: unknown) => void
|
||||
|
||||
// Minimal WebSocket stand-in implementing only what json-rpc-gateway.connect()
|
||||
// touches: readyState, add/removeEventListener('open'|'error'|'close'), close().
|
||||
class FakeWebSocket {
|
||||
static OPEN = 1
|
||||
static CLOSED = 3
|
||||
// Flipped by the test: 'open' = next socket connects; 'fail' = next socket
|
||||
// errors (a dead remote). Mirrors a VPS going away after the first connect.
|
||||
static mode: 'open' | 'fail' = 'open'
|
||||
static instances: FakeWebSocket[] = []
|
||||
|
||||
readyState = 0
|
||||
private listeners: Record<string, Set<Listener>> = {}
|
||||
|
||||
constructor(public url: string) {
|
||||
FakeWebSocket.instances.push(this)
|
||||
const willOpen = FakeWebSocket.mode === 'open'
|
||||
// Resolve on the next microtask/macrotask so connect()'s promise wiring is
|
||||
// in place before open/error fires (matches real async socket handshake).
|
||||
setTimeout(() => {
|
||||
if (willOpen) {
|
||||
this.readyState = FakeWebSocket.OPEN
|
||||
this.emit('open', {})
|
||||
} else {
|
||||
this.readyState = FakeWebSocket.CLOSED
|
||||
this.emit('error', {})
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
addEventListener(type: string, fn: Listener) {
|
||||
;(this.listeners[type] ??= new Set()).add(fn)
|
||||
}
|
||||
|
||||
removeEventListener(type: string, fn: Listener) {
|
||||
this.listeners[type]?.delete(fn)
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = FakeWebSocket.CLOSED
|
||||
this.emit('close', {})
|
||||
}
|
||||
|
||||
// Force-drop an open socket, as a sleeping laptop / restarted remote would.
|
||||
drop() {
|
||||
this.readyState = FakeWebSocket.CLOSED
|
||||
this.emit('close', {})
|
||||
}
|
||||
|
||||
private emit(type: string, ev: unknown) {
|
||||
for (const fn of this.listeners[type] ?? []) fn(ev)
|
||||
}
|
||||
}
|
||||
|
||||
function fakeDesktop() {
|
||||
const conn = {
|
||||
authMode: 'token' as const,
|
||||
baseUrl: 'https://vps.example.com',
|
||||
profile: 'default',
|
||||
token: 't',
|
||||
wsUrl: 'wss://vps.example.com/api/ws?token=t'
|
||||
}
|
||||
|
||||
return {
|
||||
getConnection: vi.fn(async () => conn),
|
||||
getGatewayWsUrl: vi.fn(async () => conn.wsUrl),
|
||||
getBootProgress: vi.fn(async () => ({
|
||||
error: null,
|
||||
fakeMode: false,
|
||||
message: '',
|
||||
phase: 'init',
|
||||
progress: 0,
|
||||
running: true,
|
||||
timestamp: Date.now()
|
||||
})),
|
||||
onBootProgress: vi.fn(() => () => undefined),
|
||||
onBackendExit: vi.fn(() => () => undefined),
|
||||
onPowerResume: vi.fn(() => () => undefined),
|
||||
onWindowStateChanged: vi.fn(() => () => undefined),
|
||||
touchBackend: vi.fn(async () => undefined),
|
||||
profile: { get: vi.fn(async () => ({ profile: 'default' })) }
|
||||
}
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
useGatewayBoot({
|
||||
handleGatewayEvent: () => undefined,
|
||||
onConnectionReady: () => undefined,
|
||||
onGatewayReady: () => undefined,
|
||||
refreshHermesConfig: async () => undefined,
|
||||
refreshSessions: async () => undefined
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const originalWebSocket = globalThis.WebSocket
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
FakeWebSocket.mode = 'open'
|
||||
FakeWebSocket.instances = []
|
||||
;(globalThis as { WebSocket: unknown }).WebSocket = FakeWebSocket
|
||||
;(window as { hermesDesktop?: unknown }).hermesDesktop = fakeDesktop()
|
||||
$gatewayState.set('idle')
|
||||
$desktopBoot.set({
|
||||
error: null,
|
||||
fakeMode: false,
|
||||
message: '',
|
||||
phase: 'init',
|
||||
progress: 0,
|
||||
running: true,
|
||||
timestamp: Date.now(),
|
||||
visible: true
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
;(globalThis as { WebSocket: unknown }).WebSocket = originalWebSocket
|
||||
delete (window as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
// Let pending microtasks (awaits) AND the queued 0ms socket open/error fire.
|
||||
async function flushAsync() {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
}
|
||||
|
||||
// Drive the exponential backoff forward by its full cap so the next scheduled
|
||||
// reconnect attempt actually runs (1s,2s,4s,8s,15s,15s…). Returns after the
|
||||
// attempt's async work settles.
|
||||
async function advanceBackoff() {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
})
|
||||
}
|
||||
|
||||
describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => {
|
||||
it('INITIAL boot against a dead VPS: getConnection hangs (waitForHermes) → app sits in the connecting combo, then fails', async () => {
|
||||
// The report's actual path: a fresh launch pointed at an unreachable VPS.
|
||||
// startHermes()'s remote branch awaits waitForHermes() for 45s before it
|
||||
// throws, so the renderer's `await desktop.getConnection()` stays pending
|
||||
// that whole window. During it: gatewayState is still 'idle' (connect was
|
||||
// never reached) and boot.error is null → connecting=true → the fullscreen
|
||||
// CONNECTING overlay, latched, blocking Settings.
|
||||
let rejectConn: (e: Error) => void = () => undefined
|
||||
const desktop = fakeDesktop()
|
||||
desktop.getConnection = vi.fn(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectConn = reject
|
||||
})
|
||||
)
|
||||
;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop
|
||||
|
||||
render(<Harness />)
|
||||
await flushAsync()
|
||||
|
||||
// getConnection is still pending — the dead-VPS wait. No socket was ever
|
||||
// created, gatewayState never left idle, boot.error is null.
|
||||
expect(FakeWebSocket.instances).toHaveLength(0)
|
||||
expect($gatewayState.get()).not.toBe('open')
|
||||
expect($desktopBoot.get().error).toBeNull()
|
||||
// ^ connecting === true here → fullscreen CONNECTING, no Settings.
|
||||
|
||||
// After ~45s waitForHermes gives up and getConnection rejects → boot()
|
||||
// catch → failDesktopBoot → the BootFailureOverlay recovery surface.
|
||||
await act(async () => {
|
||||
rejectConn(new Error('Hermes backend did not become ready: timeout'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
expect($desktopBoot.get().error).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a remote that drops post-boot keeps looping with NO boot.error (the dead-end CONNECTING combo)', async () => {
|
||||
render(<Harness />)
|
||||
await flushAsync()
|
||||
|
||||
// Initial boot connected.
|
||||
expect($gatewayState.get()).toBe('open')
|
||||
expect($desktopBoot.get().error).toBeNull()
|
||||
expect(FakeWebSocket.instances).toHaveLength(1)
|
||||
|
||||
// The remote VPS goes away: drop the live socket, and make every reopen
|
||||
// fail from here on.
|
||||
FakeWebSocket.mode = 'fail'
|
||||
act(() => FakeWebSocket.instances[0].drop())
|
||||
await flushAsync()
|
||||
|
||||
// Burn a couple backoff cycles BEFORE the escalation threshold (<6 attempts,
|
||||
// ~the first ~15s). This is the window where stock and fixed behave the
|
||||
// same: socket down, hook retrying, gatewayState non-open, boot.error still
|
||||
// null → CONNECTING covers the screen with no recovery surface. (Past ~45s
|
||||
// the fix raises boot.error; that's asserted in the next test.)
|
||||
await advanceBackoff()
|
||||
|
||||
expect($gatewayState.get()).not.toBe('open')
|
||||
expect($desktopBoot.get().error).toBeNull()
|
||||
// It is actively retrying, not idle — more sockets were minted.
|
||||
expect(FakeWebSocket.instances.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('FIX: after the prolonged drop the hook raises a recoverable boot error (the escape hatch)', async () => {
|
||||
render(<Harness />)
|
||||
await flushAsync()
|
||||
expect($desktopBoot.get().error).toBeNull()
|
||||
|
||||
FakeWebSocket.mode = 'fail'
|
||||
act(() => FakeWebSocket.instances[0].drop())
|
||||
await flushAsync()
|
||||
|
||||
// Walk the backoff past the >=6 attempt threshold (~45s of failures).
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
await advanceBackoff()
|
||||
}
|
||||
|
||||
// The hook surfaced the recoverable error → BootFailureOverlay (Use local
|
||||
// gateway / Sign in / Retry) becomes reachable instead of CONNECTING.
|
||||
expect($desktopBoot.get().error).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FIX: a successful reconnect clears the recoverable error', async () => {
|
||||
render(<Harness />)
|
||||
await flushAsync()
|
||||
|
||||
FakeWebSocket.mode = 'fail'
|
||||
act(() => FakeWebSocket.instances[0].drop())
|
||||
await flushAsync()
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
await advanceBackoff()
|
||||
}
|
||||
expect($desktopBoot.get().error).toBeTruthy()
|
||||
|
||||
// The remote comes back: next reconnect attempt opens.
|
||||
FakeWebSocket.mode = 'open'
|
||||
await advanceBackoff()
|
||||
|
||||
expect($gatewayState.get()).toBe('open')
|
||||
expect($desktopBoot.get().error).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
|
||||
|
||||
import type { HermesConnection } from '@/global'
|
||||
import { HermesGateway } from '@/hermes'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import {
|
||||
$desktopBoot,
|
||||
@@ -151,7 +152,7 @@ export function useGatewayBoot({
|
||||
// backoff in the finally block below.
|
||||
if (!cancelled && isGatewayReauthRequired(err) && !reauthNotified) {
|
||||
reauthNotified = true
|
||||
notifyError(err, 'Gateway sign-in required')
|
||||
notifyError(err, translateNow('boot.errors.gatewaySignInRequired'))
|
||||
}
|
||||
} finally {
|
||||
reconnecting = false
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { StatusDot, type StatusTone } from '@/components/status-dot'
|
||||
import { Badge, type BadgeProps } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
type MessagingPlatformInfo,
|
||||
updateMessagingPlatform
|
||||
} from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
@@ -33,31 +33,15 @@ interface MessagingViewProps extends React.ComponentProps<'section'> {
|
||||
|
||||
type EditMap = Record<string, Record<string, string>>
|
||||
|
||||
const STATE_LABELS: Record<string, string> = {
|
||||
connected: 'Connected',
|
||||
connecting: 'Connecting',
|
||||
disabled: 'Disabled',
|
||||
fatal: 'Error',
|
||||
gateway_stopped: 'Messaging gateway stopped',
|
||||
not_configured: 'Needs setup',
|
||||
pending_restart: 'Restart needed',
|
||||
retrying: 'Retrying',
|
||||
startup_failed: 'Startup failed'
|
||||
const PILL_TONE: Record<StatusTone, string> = {
|
||||
good: 'bg-primary/10 text-primary',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
|
||||
bad: 'bg-destructive/10 text-destructive'
|
||||
}
|
||||
|
||||
const TONE_VARIANT: Record<StatusTone, BadgeProps['variant']> = {
|
||||
good: 'default',
|
||||
muted: 'muted',
|
||||
warn: 'warn',
|
||||
bad: 'destructive'
|
||||
}
|
||||
|
||||
const HINT_BY_STATE: Record<string, string> = {
|
||||
pending_restart: 'Restart the gateway from the status bar to apply this change.',
|
||||
gateway_stopped: 'Start the gateway from the status bar to connect.'
|
||||
}
|
||||
|
||||
const stateLabel = (state?: null | string) => (state ? STATE_LABELS[state] || state.replace(/_/g, ' ') : 'Unknown')
|
||||
const stateLabel = (state: null | string | undefined, m: Translations['messaging']) =>
|
||||
state ? m.states[state] || state.replace(/_/g, ' ') : m.unknown
|
||||
|
||||
function stateTone({ enabled, state }: MessagingPlatformInfo): StatusTone {
|
||||
if (!enabled) {
|
||||
@@ -86,7 +70,7 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
|
||||
TELEGRAM_BOT_TOKEN: {
|
||||
label: 'Bot token',
|
||||
help: 'Create a bot with @BotFather, then paste the token it gives you.',
|
||||
placeholder: '123456:ABC...'
|
||||
placeholder: 'Paste Telegram bot token'
|
||||
},
|
||||
TELEGRAM_ALLOWED_USERS: {
|
||||
label: 'Allowed Telegram user IDs',
|
||||
@@ -153,13 +137,13 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
|
||||
},
|
||||
SLACK_BOT_TOKEN: {
|
||||
label: 'Slack bot token',
|
||||
help: 'Starts with xoxb-. Found under OAuth & Permissions after installing your Slack app.',
|
||||
placeholder: 'xoxb-...'
|
||||
help: 'Use the bot token from OAuth & Permissions after installing your Slack app.',
|
||||
placeholder: 'Paste Slack bot token'
|
||||
},
|
||||
SLACK_APP_TOKEN: {
|
||||
label: 'Slack app token',
|
||||
help: 'Starts with xapp-. Required for Socket Mode.',
|
||||
placeholder: 'xapp-...'
|
||||
help: 'Use the app-level token required for Socket Mode.',
|
||||
placeholder: 'Paste Slack app token'
|
||||
},
|
||||
SLACK_ALLOWED_USERS: {
|
||||
label: 'Allowed Slack user IDs',
|
||||
@@ -219,18 +203,21 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
|
||||
}
|
||||
}
|
||||
|
||||
function fieldCopy(field: MessagingEnvVarInfo) {
|
||||
function fieldCopy(field: MessagingEnvVarInfo, m: Translations['messaging']) {
|
||||
const copy = FIELD_COPY[field.key] || {}
|
||||
const localized = m.fieldCopy[field.key] || {}
|
||||
|
||||
return {
|
||||
label: copy.label || field.prompt || field.key,
|
||||
help: copy.help || field.description,
|
||||
placeholder: copy.placeholder || field.prompt,
|
||||
label: localized.label || copy.label || field.prompt || field.key,
|
||||
help: localized.help || copy.help || field.description,
|
||||
placeholder: localized.placeholder || copy.placeholder || field.prompt,
|
||||
advanced: Boolean(copy.advanced || field.advanced)
|
||||
}
|
||||
}
|
||||
|
||||
export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: MessagingViewProps) {
|
||||
const { t } = useI18n()
|
||||
const m = t.messaging
|
||||
const [platforms, setPlatforms] = useState<MessagingPlatformInfo[] | null>(null)
|
||||
const [edits, setEdits] = useState<EditMap>({})
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -249,14 +236,14 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
setPlatforms(result.platforms)
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
notifyError(err, 'Messaging platforms failed to load')
|
||||
notifyError(err, m.loadFailed)
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [m])
|
||||
|
||||
useRefreshHotkey(() => void refreshPlatforms())
|
||||
|
||||
@@ -330,11 +317,11 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? `${platform.name} enabled` : `${platform.name} disabled`,
|
||||
message: 'Restart the gateway for this change to take effect.'
|
||||
title: enabled ? m.platformEnabled(platform.name) : m.platformDisabled(platform.name),
|
||||
message: m.restartToApply
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to update ${platform.name}`)
|
||||
notifyError(err, m.failedUpdate(platform.name))
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
@@ -355,11 +342,11 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
await refreshPlatforms()
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: `${platform.name} setup saved`,
|
||||
message: 'Restart the gateway to reconnect with the new credentials.'
|
||||
title: m.setupSaved(platform.name),
|
||||
message: m.restartToReconnect
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${platform.name}`)
|
||||
notifyError(err, m.failedSave(platform.name))
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
@@ -378,9 +365,9 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
}
|
||||
}))
|
||||
await refreshPlatforms()
|
||||
notify({ kind: 'success', title: `${key} cleared`, message: `${platform.name} setup was updated.` })
|
||||
notify({ kind: 'success', title: m.keyCleared(key), message: m.setupUpdated(platform.name) })
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to clear ${key}`)
|
||||
notifyError(err, m.failedClear(key))
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
@@ -391,11 +378,11 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
{...props}
|
||||
onSearchChange={setQuery}
|
||||
searchHidden={(platforms?.length ?? 0) === 0}
|
||||
searchPlaceholder="Search messaging..."
|
||||
searchPlaceholder={m.search}
|
||||
searchValue={query}
|
||||
>
|
||||
{!platforms ? (
|
||||
<PageLoader label="Loading messaging platforms..." />
|
||||
<PageLoader label={m.loading} />
|
||||
) : (
|
||||
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[14rem_minmax(0,1fr)]">
|
||||
<aside className="min-h-0 overflow-y-auto p-2">
|
||||
@@ -485,12 +472,14 @@ function PlatformDetail({
|
||||
platform: MessagingPlatformInfo
|
||||
saving: string | null
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const m = t.messaging
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
const hasEdits = Object.keys(trimEdits(edits)).length > 0
|
||||
const requiredFields = platform.env_vars.filter(field => field.required)
|
||||
const optionalFields = platform.env_vars.filter(field => !field.required && !fieldCopy(field).advanced)
|
||||
const advancedFields = platform.env_vars.filter(field => !field.required && fieldCopy(field).advanced)
|
||||
const optionalFields = platform.env_vars.filter(field => !field.required && !fieldCopy(field, m).advanced)
|
||||
const advancedFields = platform.env_vars.filter(field => !field.required && fieldCopy(field, m).advanced)
|
||||
const hiddenCount = advancedFields.length
|
||||
const isSavingEnv = saving === `env:${platform.id}`
|
||||
|
||||
@@ -506,11 +495,11 @@ function PlatformDetail({
|
||||
{platform.description}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state)}</StatePill>
|
||||
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state, m)}</StatePill>
|
||||
<SetupPill active={platform.configured}>
|
||||
{platform.configured ? 'Credentials set' : 'Needs setup'}
|
||||
{platform.configured ? m.credentialsSet : m.needsSetup}
|
||||
</SetupPill>
|
||||
{!platform.gateway_running && <SetupPill active={false}>Messaging gateway stopped</SetupPill>}
|
||||
{!platform.gateway_running && <SetupPill active={false}>{m.gatewayStopped}</SetupPill>}
|
||||
</div>
|
||||
<PlatformHint platform={platform} />
|
||||
</div>
|
||||
@@ -524,14 +513,14 @@ function PlatformDetail({
|
||||
)}
|
||||
|
||||
<section>
|
||||
<SectionTitle>Get your credentials</SectionTitle>
|
||||
<SectionTitle>{m.getCredentials}</SectionTitle>
|
||||
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{introCopy(platform)}
|
||||
{introCopy(platform, m)}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button asChild size="sm" variant="textStrong">
|
||||
<a href={platform.docs_url} rel="noreferrer" target="_blank">
|
||||
Open setup guide
|
||||
{m.openSetupGuide}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
@@ -539,7 +528,7 @@ function PlatformDetail({
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionTitle>Required</SectionTitle>
|
||||
<SectionTitle>{m.required}</SectionTitle>
|
||||
<div className="mt-3 grid gap-1">
|
||||
{requiredFields.length > 0 ? (
|
||||
requiredFields.map(field => (
|
||||
@@ -554,7 +543,7 @@ function PlatformDetail({
|
||||
))
|
||||
) : (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
This platform does not need a token here. Use the setup guide above, then enable it below.
|
||||
{m.noTokenNeeded}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -562,7 +551,7 @@ function PlatformDetail({
|
||||
|
||||
{optionalFields.length > 0 && (
|
||||
<section>
|
||||
<SectionTitle>Recommended</SectionTitle>
|
||||
<SectionTitle>{m.recommended}</SectionTitle>
|
||||
<div className="mt-3 grid gap-1">
|
||||
{optionalFields.map(field => (
|
||||
<MessagingField
|
||||
@@ -585,7 +574,7 @@ function PlatformDetail({
|
||||
onClick={() => setShowAdvanced(value => !value)}
|
||||
type="button"
|
||||
>
|
||||
<span>Advanced ({hiddenCount})</span>
|
||||
<span>{m.advanced(hiddenCount)}</span>
|
||||
<DisclosureCaret open={showAdvanced} size="0.875rem" />
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
@@ -609,19 +598,23 @@ function PlatformDetail({
|
||||
|
||||
<footer className="bg-(--ui-chat-surface-background) px-5 py-2.5">
|
||||
<div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2">
|
||||
<Switch
|
||||
aria-label={platform.enabled ? `Disable ${platform.name}` : `Enable ${platform.name}`}
|
||||
checked={platform.enabled}
|
||||
disabled={saving === `enabled:${platform.id}`}
|
||||
onCheckedChange={onToggle}
|
||||
size="xs"
|
||||
/>
|
||||
<label className="flex shrink-0 items-center gap-2 rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2.5 py-1.5 text-[length:var(--conversation-text-font-size)]">
|
||||
<Switch
|
||||
aria-label={platform.enabled ? m.disableAria(platform.name) : m.enableAria(platform.name)}
|
||||
checked={platform.enabled}
|
||||
disabled={saving === `enabled:${platform.id}`}
|
||||
onCheckedChange={onToggle}
|
||||
/>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{platform.enabled ? m.enabled : m.disabled}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{hasEdits && <span className="text-xs text-muted-foreground">Unsaved changes</span>}
|
||||
{hasEdits && <span className="text-xs text-muted-foreground">{m.unsavedChanges}</span>}
|
||||
<Button disabled={!hasEdits || isSavingEnv} onClick={onSave} size="sm">
|
||||
<Save />
|
||||
{isSavingEnv ? 'Saving...' : 'Save changes'}
|
||||
{isSavingEnv ? m.saving : m.saveChanges}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -636,7 +629,7 @@ const PLATFORM_INTRO: Record<string, string> = {
|
||||
discord:
|
||||
'Open the Discord Developer Portal, create an application, add a Bot, then copy its token. Invite the bot to your server with the right scopes.',
|
||||
slack:
|
||||
'Create a Slack app, enable Socket Mode, install it to your workspace, then copy the Bot token (xoxb-) and App-level token (xapp-).',
|
||||
'Create a Slack app, enable Socket Mode, install it to your workspace, then copy the bot token and app-level token.',
|
||||
mattermost:
|
||||
'On your Mattermost server, create a bot account or personal access token, then paste the server URL and token here.',
|
||||
matrix: 'Sign in to your homeserver with the bot account, then copy the access token, user ID, and homeserver URL.',
|
||||
@@ -667,7 +660,8 @@ const PLATFORM_INTRO: Record<string, string> = {
|
||||
'Run an HTTP server that other tools (GitHub, GitLab, custom apps) can POST to. Use the secret to verify signatures.'
|
||||
}
|
||||
|
||||
const introCopy = (platform: MessagingPlatformInfo) => PLATFORM_INTRO[platform.id] || platform.description
|
||||
const introCopy = (platform: MessagingPlatformInfo, m: Translations['messaging']) =>
|
||||
m.platformIntro[platform.id] || PLATFORM_INTRO[platform.id] || platform.description
|
||||
|
||||
function MessagingField({
|
||||
edits,
|
||||
@@ -682,7 +676,9 @@ function MessagingField({
|
||||
onEdit: (key: string, value: string) => void
|
||||
saving: string | null
|
||||
}) {
|
||||
const copy = fieldCopy(field)
|
||||
const { t } = useI18n()
|
||||
const m = t.messaging
|
||||
const copy = fieldCopy(field, m)
|
||||
const fieldId = `messaging-field-${field.key}`
|
||||
|
||||
return (
|
||||
@@ -693,12 +689,12 @@ function MessagingField({
|
||||
className={CREDENTIAL_CONTROL_CLASS}
|
||||
id={fieldId}
|
||||
onChange={event => onEdit(field.key, event.target.value)}
|
||||
placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder}
|
||||
placeholder={field.is_set ? field.redacted_value || m.replaceValue : copy.placeholder}
|
||||
type={field.is_password ? 'password' : 'text'}
|
||||
value={edits[field.key] || ''}
|
||||
/>
|
||||
{field.url && (
|
||||
<Button asChild className="size-8 shrink-0" title="Open docs" variant="ghost">
|
||||
<Button asChild className="size-8 shrink-0" title={m.openDocs} variant="ghost">
|
||||
<a href={field.url} rel="noreferrer" target="_blank">
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
@@ -709,7 +705,7 @@ function MessagingField({
|
||||
className="size-8 shrink-0"
|
||||
disabled={saving === `clear:${field.key}`}
|
||||
onClick={() => onClear(field.key)}
|
||||
title={`Clear ${field.key}`}
|
||||
title={m.clearField(field.key)}
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
@@ -721,7 +717,7 @@ function MessagingField({
|
||||
title={
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<label htmlFor={fieldId}>{copy.label}</label>
|
||||
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">Saved</span>}
|
||||
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">{m.saved}</span>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
@@ -733,24 +729,45 @@ function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) {
|
||||
const { t } = useI18n()
|
||||
|
||||
if (!platform.enabled || platform.state === 'connected') {
|
||||
return null
|
||||
}
|
||||
|
||||
const hint = HINT_BY_STATE[platform.state || ''] || (platform.gateway_running ? null : HINT_BY_STATE.gateway_stopped)
|
||||
const hint =
|
||||
platform.state === 'pending_restart'
|
||||
? t.messaging.hintPendingRestart
|
||||
: platform.gateway_running
|
||||
? null
|
||||
: t.messaging.hintGatewayStopped
|
||||
|
||||
return hint ? <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p> : null
|
||||
}
|
||||
|
||||
function StatePill({ children, tone }: { children: string; tone: StatusTone }) {
|
||||
return (
|
||||
<Badge variant={TONE_VARIANT[tone]}>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-[0.66rem] font-medium',
|
||||
PILL_TONE[tone]
|
||||
)}
|
||||
>
|
||||
<StatusDot tone={tone} />
|
||||
{children}
|
||||
</Badge>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SetupPill({ active, children }: { active: boolean; children: string }) {
|
||||
return <Badge variant={active ? 'default' : 'muted'}>{children}</Badge>
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-[0.66rem] font-medium',
|
||||
PILL_TONE[active ? 'good' : 'muted']
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { RefObject } from 'react'
|
||||
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface OverlaySearchInputProps {
|
||||
containerClassName?: string
|
||||
inputRef?: RefObject<HTMLInputElement | null>
|
||||
loading?: boolean
|
||||
onChange: (value: string) => void
|
||||
placeholder: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export function OverlaySearchInput({
|
||||
containerClassName,
|
||||
inputRef,
|
||||
loading = false,
|
||||
onChange,
|
||||
placeholder,
|
||||
value
|
||||
}: OverlaySearchInputProps) {
|
||||
return (
|
||||
<SearchField
|
||||
containerClassName={cn(
|
||||
'rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2 shadow-sm focus-within:border-(--ui-stroke-secondary)',
|
||||
containerClassName
|
||||
)}
|
||||
inputClassName="h-8 text-[0.8125rem]"
|
||||
inputRef={inputRef}
|
||||
loading={loading}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ interface PageSearchShellProps extends React.ComponentProps<'section'> {
|
||||
filters?: ReactNode
|
||||
onSearchChange: (value: string) => void
|
||||
searchPlaceholder: string
|
||||
searchTrailingAction?: ReactNode
|
||||
searchValue: string
|
||||
/** Hide the search field when there's nothing to search (empty dataset). */
|
||||
searchHidden?: boolean
|
||||
@@ -23,6 +24,7 @@ export function PageSearchShell({
|
||||
filters,
|
||||
onSearchChange,
|
||||
searchPlaceholder,
|
||||
searchTrailingAction,
|
||||
searchValue,
|
||||
searchHidden = false,
|
||||
...props
|
||||
@@ -58,6 +60,7 @@ export function PageSearchShell({
|
||||
containerClassName="max-w-[45vw]"
|
||||
onChange={onSearchChange}
|
||||
placeholder={searchPlaceholder}
|
||||
trailingAction={searchTrailingAction}
|
||||
value={searchValue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,234 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import type { HermesReadDirEntry } from '@/global'
|
||||
import { fsReadDir } from '@/lib/desktop-fs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $remotePathPicker, resolveRemotePathPicker } from '@/store/remote-path-picker'
|
||||
|
||||
function parentDir(path: string): string | null {
|
||||
const trimmed = path.replace(/[\\/]+$/, '')
|
||||
const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
||||
|
||||
if (idx <= 0) {
|
||||
return idx === 0 ? '/' : null
|
||||
}
|
||||
|
||||
return trimmed.slice(0, idx)
|
||||
}
|
||||
|
||||
function baseName(path: string): string {
|
||||
return (
|
||||
path
|
||||
.replace(/[\\/]+$/, '')
|
||||
.split(/[\\/]+/)
|
||||
.filter(Boolean)
|
||||
.pop() ?? path
|
||||
)
|
||||
}
|
||||
|
||||
// Browses the GATEWAY filesystem (via fs.list) so users on a remote backend can
|
||||
// pick files/folders that exist on the agent host rather than their own machine.
|
||||
// Mirrors the native selectPaths contract: resolves with absolute gateway paths
|
||||
// (or [] when cancelled).
|
||||
export function RemotePathPicker() {
|
||||
const request = useStore($remotePathPicker)
|
||||
|
||||
if (!request) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <RemotePathPickerDialog key={request.id} />
|
||||
}
|
||||
|
||||
function RemotePathPickerDialog() {
|
||||
const request = useStore($remotePathPicker)
|
||||
const options = request?.options ?? {}
|
||||
const directoriesMode = Boolean(options.directories)
|
||||
const allowMultiple = options.multiple !== false && !directoriesMode
|
||||
|
||||
const [dir, setDir] = useState<string>(options.defaultPath ?? '')
|
||||
const [entries, setEntries] = useState<HermesReadDirEntry[]>([])
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const allowedExtensions = useMemo(() => {
|
||||
const exts = (options.filters ?? []).flatMap(filter => filter.extensions)
|
||||
|
||||
return exts.length > 0 ? new Set(exts.map(ext => ext.toLowerCase().replace(/^\./, ''))) : null
|
||||
}, [options.filters])
|
||||
|
||||
const load = useCallback(async (target: string) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const result = await fsReadDir(target)
|
||||
|
||||
setDir(result.path ?? target)
|
||||
setEntries(result.entries ?? [])
|
||||
setError(result.error ?? null)
|
||||
setSelected(new Set())
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
// Loads the initial directory. `load` is stable and defaultPath is fixed for
|
||||
// this keyed instance, so this runs once; navigation calls `load` directly.
|
||||
useEffect(() => {
|
||||
void load(options.defaultPath ?? '')
|
||||
}, [load, options.defaultPath])
|
||||
|
||||
const visibleEntries = useMemo(() => {
|
||||
return entries.filter(entry => {
|
||||
if (entry.isDirectory) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (directoriesMode) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!allowedExtensions) {
|
||||
return true
|
||||
}
|
||||
|
||||
const ext = baseName(entry.name).split('.').pop()?.toLowerCase() ?? ''
|
||||
|
||||
return allowedExtensions.has(ext)
|
||||
})
|
||||
}, [allowedExtensions, directoriesMode, entries])
|
||||
|
||||
const cancel = useCallback(() => resolveRemotePathPicker([]), [])
|
||||
|
||||
const confirm = useCallback(() => {
|
||||
if (directoriesMode) {
|
||||
resolveRemotePathPicker([dir])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (selected.size > 0) {
|
||||
resolveRemotePathPicker([...selected])
|
||||
}
|
||||
}, [dir, directoriesMode, selected])
|
||||
|
||||
const onEntryClick = useCallback(
|
||||
(entry: HermesReadDirEntry) => {
|
||||
if (entry.isDirectory) {
|
||||
void load(entry.path)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (directoriesMode) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!allowMultiple) {
|
||||
resolveRemotePathPicker([entry.path])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
|
||||
if (next.has(entry.path)) {
|
||||
next.delete(entry.path)
|
||||
} else {
|
||||
next.add(entry.path)
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
},
|
||||
[allowMultiple, directoriesMode, load]
|
||||
)
|
||||
|
||||
const parent = parentDir(dir)
|
||||
const title = options.title || (directoriesMode ? 'Select a folder' : 'Select files')
|
||||
const confirmLabel = directoriesMode ? 'Use this folder' : `Attach${selected.size > 1 ? ` (${selected.size})` : ''}`
|
||||
const confirmDisabled = directoriesMode ? !dir : selected.size === 0
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={value => !value && cancel()} open>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-xs text-(--ui-text-tertiary)">
|
||||
<Button
|
||||
aria-label="Up one folder"
|
||||
disabled={!parent || loading}
|
||||
onClick={() => parent && void load(parent)}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="arrow-up" size="0.9rem" />
|
||||
</Button>
|
||||
<span className="truncate font-mono" title={dir}>
|
||||
{dir || '…'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-72 overflow-y-auto rounded-md border border-(--ui-stroke-secondary) bg-background/40">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-full items-center justify-center px-4 text-center text-xs text-destructive">
|
||||
Could not read this folder ({error}).
|
||||
</div>
|
||||
) : visibleEntries.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center px-4 text-center text-xs text-(--ui-text-tertiary)">
|
||||
{directoriesMode ? 'No subfolders here.' : 'No matching files here.'}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="py-1">
|
||||
{visibleEntries.map(entry => {
|
||||
const isSelected = selected.has(entry.path)
|
||||
|
||||
return (
|
||||
<li key={entry.path}>
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-(--chrome-action-hover)',
|
||||
isSelected && 'bg-(--chrome-action-hover)'
|
||||
)}
|
||||
onClick={() => onEntryClick(entry)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon
|
||||
className={entry.isDirectory ? 'text-(--ui-accent)' : 'text-(--ui-text-tertiary)'}
|
||||
name={entry.isDirectory ? 'folder' : 'file'}
|
||||
size="0.95rem"
|
||||
/>
|
||||
<span className="flex-1 truncate">{entry.name}</span>
|
||||
{!entry.isDirectory && isSelected && <Codicon name="check" size="0.9rem" />}
|
||||
{entry.isDirectory && <Codicon name="chevron-right" size="0.85rem" />}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={cancel} type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={confirmDisabled} onClick={confirm} type="button">
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import ignore from 'ignore'
|
||||
|
||||
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
|
||||
import { fsGitRoot, fsReadDir, fsReadFileDataUrl } from '@/lib/desktop-fs'
|
||||
|
||||
export type ProjectTreeEntry = HermesReadDirEntry
|
||||
|
||||
@@ -63,15 +64,11 @@ function ancestorDirs(root: string, dir: string) {
|
||||
}
|
||||
|
||||
async function gitRootFor(start: string) {
|
||||
if (!window.hermesDesktop?.gitRoot) {
|
||||
return null
|
||||
}
|
||||
|
||||
const key = clean(start)
|
||||
let cached = gitRootCache.get(key)
|
||||
|
||||
if (!cached) {
|
||||
cached = window.hermesDesktop.gitRoot(key)
|
||||
cached = fsGitRoot(key)
|
||||
gitRootCache.set(key, cached)
|
||||
}
|
||||
|
||||
@@ -80,18 +77,14 @@ async function gitRootFor(start: string) {
|
||||
|
||||
/** Read .gitignore at `dir` if it actually exists — never probe missing files. */
|
||||
async function readGitignore(dir: string): Promise<GitignoreRule | null> {
|
||||
if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const listing = await window.hermesDesktop.readDir(dir)
|
||||
const listing = await fsReadDir(dir)
|
||||
|
||||
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
|
||||
const text = decodeDataUrl(await fsReadFileDataUrl(`${dir}/.gitignore`))
|
||||
|
||||
return { base: dir, ig: ignore().add(text) }
|
||||
} catch {
|
||||
@@ -138,11 +131,7 @@ async function filterIgnored(entries: HermesReadDirEntry[], rootPath: string, di
|
||||
}
|
||||
|
||||
export async function readProjectDir(dirPath: string, rootPath = dirPath): Promise<HermesReadDirResult> {
|
||||
if (!window.hermesDesktop) {
|
||||
return { entries: [], error: 'no-bridge' }
|
||||
}
|
||||
|
||||
const result = await window.hermesDesktop.readDir(dirPath)
|
||||
const result = await fsReadDir(dirPath)
|
||||
|
||||
return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { selectPaths } from '@/lib/desktop-fs'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $panesFlipped } from '@/store/layout'
|
||||
@@ -68,7 +69,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
|
||||
|
||||
const chooseFolder = async () => {
|
||||
const selected = await window.hermesDesktop?.selectPaths({
|
||||
const selected = await selectPaths({
|
||||
defaultPath: hasCwd ? currentCwd : undefined,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
|
||||
@@ -437,11 +437,18 @@ export function useMessageStream({
|
||||
|
||||
const completedState = updateSessionState(sessionId, state => {
|
||||
// Late completion from an already-cancelled turn: cancelRun has
|
||||
// already finalized the bubble and added the [interrupted] marker;
|
||||
// re-running the dedupe below would erase that marker and replace
|
||||
// the partial with the (just-cancelled) full text.
|
||||
// already finalized the bubble (kept the partial text, dropped it if
|
||||
// empty). Re-running the dedupe below would replace the partial with
|
||||
// the just-cancelled full text, so we settle and bail instead.
|
||||
if (state.interrupted) {
|
||||
return state
|
||||
return {
|
||||
...state,
|
||||
awaitingResponse: false,
|
||||
busy: false,
|
||||
needsInput: false,
|
||||
pendingBranchGroup: null,
|
||||
streamId: null
|
||||
}
|
||||
}
|
||||
|
||||
const streamId = state.streamId
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { SessionInfo } from '@/types/hermes'
|
||||
import { usePromptActions } from './use-prompt-actions'
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getProfiles: vi.fn(async () => ({ profiles: [] })),
|
||||
setApiRequestProfile: vi.fn(),
|
||||
transcribeAudio: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -39,27 +41,31 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
}
|
||||
|
||||
interface HarnessHandle {
|
||||
submitText: (text: string) => Promise<boolean>
|
||||
submitText: (text: string, options?: { attachments?: never[]; fromQueue?: boolean }) => Promise<boolean>
|
||||
}
|
||||
|
||||
function Harness({
|
||||
busyRef,
|
||||
onReady,
|
||||
onSeedState,
|
||||
refreshSessions,
|
||||
requestGateway
|
||||
}: {
|
||||
busyRef?: MutableRefObject<boolean>
|
||||
onReady: (handle: HarnessHandle) => void
|
||||
onSeedState?: (state: Record<string, unknown>) => void
|
||||
refreshSessions: () => Promise<void>
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}) {
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
||||
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
||||
const busyRef = { current: false }
|
||||
const localBusyRef = busyRef ?? { current: false }
|
||||
|
||||
const actions = usePromptActions({
|
||||
activeSessionId: RUNTIME_SESSION_ID,
|
||||
activeSessionIdRef,
|
||||
branchCurrentSession: async () => true,
|
||||
busyRef,
|
||||
busyRef: localBusyRef,
|
||||
createBackendSessionForSend: async () => RUNTIME_SESSION_ID,
|
||||
handleSkinCommand: () => '',
|
||||
refreshSessions,
|
||||
@@ -67,8 +73,18 @@ function Harness({
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft: () => undefined,
|
||||
sttEnabled: false,
|
||||
updateSessionState: (_sessionId, updater) =>
|
||||
updater({ messages: [], busy: false, awaitingResponse: false } as never)
|
||||
updateSessionState: (_sessionId, updater) => {
|
||||
// Seed with interrupted:true so we can prove a fresh submit clears it.
|
||||
const next = updater({
|
||||
messages: [],
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
interrupted: true
|
||||
} as never) as unknown as Record<string, unknown>
|
||||
onSeedState?.(next)
|
||||
|
||||
return next as never
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -164,3 +180,82 @@ describe('usePromptActions /title', () => {
|
||||
expect($sessions.get()[0]?.title).toBe('Old title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions submit / queue drain semantics', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('clears a leftover interrupted flag on a fresh submit (so the new turn streams)', async () => {
|
||||
const seeds: Record<string, unknown>[] = []
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={s => seeds.push(s)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.submitText('hello after a stop')
|
||||
|
||||
// The optimistic seed must reset interrupted:false even though the prior
|
||||
// session state had interrupted:true — otherwise the message stream drops
|
||||
// every delta of this brand-new turn.
|
||||
expect(seeds.length).toBeGreaterThan(0)
|
||||
expect(seeds.every(s => s.interrupted === false)).toBe(true)
|
||||
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
text: 'hello after a stop'
|
||||
})
|
||||
})
|
||||
|
||||
it('a fromQueue drain sends even when busyRef is still true on the settle edge', async () => {
|
||||
// busyRef lags $busy by one effect tick on the busy→false settle edge, so a
|
||||
// drained queue send would otherwise hit the busy guard and silently no-op.
|
||||
const busyRef = { current: true }
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
busyRef={busyRef}
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
const accepted = await handle!.submitText('queued message', { fromQueue: true })
|
||||
|
||||
expect(accepted).toBe(true)
|
||||
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
text: 'queued message'
|
||||
})
|
||||
})
|
||||
|
||||
it('a normal (non-queue) submit still respects the busyRef guard', async () => {
|
||||
const busyRef = { current: true }
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
busyRef={busyRef}
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
const accepted = await handle!.submitText('should be blocked')
|
||||
|
||||
expect(accepted).toBe(false)
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('prompt.submit', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,10 +2,9 @@ import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
|
||||
import { type MutableRefObject, useCallback } from 'react'
|
||||
|
||||
import { getProfiles, transcribeAudio } from '@/hermes'
|
||||
import { appendTextPart, branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||
import {
|
||||
attachmentDisplayText,
|
||||
INTERRUPTED_MARKER,
|
||||
parseCommandDispatch,
|
||||
parseSlashCommand,
|
||||
pathLabel,
|
||||
@@ -178,6 +177,42 @@ export function usePromptActions({
|
||||
[selectedStoredSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
// Remote gateways (e.g. a VPS over tailscale) cannot see the client's local
|
||||
// filesystem, so a path-based `image.attach` fails with "image not found".
|
||||
// Fall back to uploading the bytes the Electron client already holds.
|
||||
const uploadImageAttachmentBytes = useCallback(
|
||||
async (sessionId: string, attachment: ComposerAttachment): Promise<ImageAttachResponse | null> => {
|
||||
const path = attachment.path
|
||||
|
||||
if (!path) {
|
||||
return null
|
||||
}
|
||||
|
||||
let data = attachment.previewUrl
|
||||
|
||||
if (!data && window.hermesDesktop?.readFileDataUrl) {
|
||||
try {
|
||||
data = await window.hermesDesktop.readFileDataUrl(path)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
|
||||
session_id: sessionId,
|
||||
filename: pathLabel(path),
|
||||
data
|
||||
})
|
||||
|
||||
return result.attached ? result : null
|
||||
},
|
||||
[requestGateway]
|
||||
)
|
||||
|
||||
const syncImageAttachmentsForSubmit = useCallback(
|
||||
async (
|
||||
sessionId: string,
|
||||
@@ -192,14 +227,28 @@ export function usePromptActions({
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await requestGateway<ImageAttachResponse>('image.attach', {
|
||||
session_id: sessionId,
|
||||
path: attachment.path
|
||||
})
|
||||
let result: ImageAttachResponse | null = null
|
||||
|
||||
if (!result.attached) {
|
||||
try {
|
||||
const pathResult = await requestGateway<ImageAttachResponse>('image.attach', {
|
||||
session_id: sessionId,
|
||||
path: attachment.path
|
||||
})
|
||||
|
||||
if (pathResult.attached) {
|
||||
result = pathResult
|
||||
}
|
||||
} catch {
|
||||
result = null
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
result = await uploadImageAttachmentBytes(sessionId, attachment)
|
||||
}
|
||||
|
||||
if (!result?.attached) {
|
||||
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
|
||||
throw new Error(result.message || `Could not attach ${label}`)
|
||||
throw new Error(result?.message || `Could not attach ${label}`)
|
||||
}
|
||||
|
||||
const attachedPath = result.path || attachment.path
|
||||
@@ -215,7 +264,7 @@ export function usePromptActions({
|
||||
}
|
||||
}
|
||||
},
|
||||
[requestGateway]
|
||||
[requestGateway, uploadImageAttachmentBytes]
|
||||
)
|
||||
|
||||
const submitPromptText = useCallback(
|
||||
@@ -237,7 +286,11 @@ export function usePromptActions({
|
||||
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
|
||||
(hasImage ? 'What do you see in this image?' : '')
|
||||
|
||||
if (!text || busyRef.current) {
|
||||
// Queue drains fire on the busy→false settle edge, where busyRef (synced
|
||||
// from $busy by a separate effect) may still read true — honoring it would
|
||||
// bounce the drained send. The drain lock serializes them; the user path
|
||||
// keeps the guard so a stray Enter mid-turn can't double-submit.
|
||||
if (!text || (!options?.fromQueue && busyRef.current)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -270,7 +323,10 @@ export function usePromptActions({
|
||||
awaitingResponse: true,
|
||||
pendingBranchGroup: null,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: state.interrupted
|
||||
// Fresh submit = new turn — clear any leftover interrupt flag, else
|
||||
// mutateStream/completeAssistantMessage drop every delta of this turn
|
||||
// (what made drained-after-interrupt sends go silent).
|
||||
interrupted: false
|
||||
}),
|
||||
selectedStoredSessionIdRef.current
|
||||
)
|
||||
@@ -531,6 +587,7 @@ export function usePromptActions({
|
||||
session_id: sessionId,
|
||||
title: arg
|
||||
})
|
||||
|
||||
const finalTitle = (result?.title || arg).trim()
|
||||
const queued = result?.pending === true
|
||||
|
||||
@@ -689,24 +746,24 @@ export function usePromptActions({
|
||||
const cancelRun = useCallback(async () => {
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
|
||||
const finalizeMessages = (messages: ChatMessage[]) =>
|
||||
messages.map(message =>
|
||||
message.pending
|
||||
? {
|
||||
...message,
|
||||
parts: chatMessageText(message).trim()
|
||||
? appendTextPart(message.parts, INTERRUPTED_MARKER)
|
||||
: [...message.parts, textPart(INTERRUPTED_MARKER.trim())],
|
||||
pending: false
|
||||
}
|
||||
: message
|
||||
)
|
||||
// Interrupting keeps whatever was already generated and just
|
||||
// stops — no "[interrupted]" marker. A pending/streaming message with no
|
||||
// body text is dropped entirely so we never leave an empty bubble behind.
|
||||
const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) =>
|
||||
messages
|
||||
.filter(
|
||||
message =>
|
||||
!((message.pending || message.id === streamId) && !chatMessageText(message).trim())
|
||||
)
|
||||
.map(message =>
|
||||
message.pending || message.id === streamId ? { ...message, pending: false } : message
|
||||
)
|
||||
|
||||
if (!sessionId) {
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
setMessages(finalizeMessages($messages.get()))
|
||||
|
||||
return
|
||||
@@ -715,24 +772,12 @@ export function usePromptActions({
|
||||
updateSessionState(sessionId, state => {
|
||||
const streamId = state.streamId
|
||||
|
||||
const messages = streamId
|
||||
? state.messages.map(message =>
|
||||
message.id === streamId
|
||||
? {
|
||||
...message,
|
||||
parts: chatMessageText(message).trim()
|
||||
? appendTextPart(message.parts, INTERRUPTED_MARKER)
|
||||
: [...message.parts, textPart(INTERRUPTED_MARKER.trim())],
|
||||
pending: false
|
||||
}
|
||||
: message
|
||||
)
|
||||
: finalizeMessages(state.messages)
|
||||
const messages = finalizeMessages(state.messages, streamId)
|
||||
|
||||
return {
|
||||
...state,
|
||||
messages,
|
||||
busy: false,
|
||||
busy: true,
|
||||
awaitingResponse: false,
|
||||
streamId: null,
|
||||
pendingBranchGroup: null,
|
||||
@@ -743,6 +788,8 @@ export function usePromptActions({
|
||||
try {
|
||||
await requestGateway('session.interrupt', { session_id: sessionId })
|
||||
} catch (err) {
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
notifyError(err, 'Stop failed')
|
||||
}
|
||||
}, [activeSessionId, activeSessionIdRef, busyRef, requestGateway, updateSessionState])
|
||||
|
||||
@@ -331,7 +331,14 @@ export function useSessionActions({
|
||||
// so single-profile users are unaffected).
|
||||
await ensureGatewayProfile($newChatProfile.get())
|
||||
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
|
||||
// Pass the owning profile so a new chat under a non-launch profile (global
|
||||
// remote mode) builds its agent + persists against THAT profile's home/db.
|
||||
const newChatProfile = $newChatProfile.get()
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
...(cwd && { cwd }),
|
||||
...(newChatProfile ? { profile: newChatProfile } : {})
|
||||
})
|
||||
const stored = created.stored_session_id ?? null
|
||||
|
||||
if (
|
||||
@@ -453,15 +460,31 @@ export function useSessionActions({
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
|
||||
void requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
|
||||
.then(usage => {
|
||||
if (isCurrentResume() && usage) {
|
||||
setCurrentUsage(current => ({ ...current, ...usage }))
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
try {
|
||||
const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
|
||||
|
||||
return
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (usage) {
|
||||
setCurrentUsage(current => ({ ...current, ...usage }))
|
||||
}
|
||||
|
||||
return
|
||||
} catch {
|
||||
// The cached runtime id was minted by a prior backend instance. A
|
||||
// pooled profile backend that gets idle-reaped (pruneSecondaryGateways)
|
||||
// and respawned across a profile swap mints fresh ids, so this mapping
|
||||
// now 404s ("session not found"). Drop it and fall through to a full
|
||||
// resume that rebinds a live runtime id.
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
}
|
||||
|
||||
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
|
||||
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
|
||||
}
|
||||
}
|
||||
|
||||
setFreshDraftReady(false)
|
||||
@@ -513,7 +536,11 @@ export function useSessionActions({
|
||||
|
||||
const resumed = await requestGateway<SessionResumeResponse>('session.resume', {
|
||||
session_id: storedSessionId,
|
||||
cols: 96
|
||||
cols: 96,
|
||||
// Owning profile: in app-global remote mode one backend serves every
|
||||
// profile, so the gateway opens this profile's state.db + home to
|
||||
// resume + persist the right session (no-op for single/launch profile).
|
||||
...(sessionProfile ? { profile: sessionProfile } : {})
|
||||
})
|
||||
|
||||
if (!isCurrentResume()) {
|
||||
@@ -747,7 +774,7 @@ export function useSessionActions({
|
||||
await requestGateway('session.close', { session_id: closingRuntimeId }).catch(() => undefined)
|
||||
}
|
||||
|
||||
await deleteSession(storedSessionId)
|
||||
await deleteSession(storedSessionId, removed?.profile)
|
||||
clearQueuedPrompts(storedSessionId)
|
||||
|
||||
if (closingRuntimeId) {
|
||||
@@ -823,7 +850,7 @@ export function useSessionActions({
|
||||
}
|
||||
|
||||
try {
|
||||
await setSessionArchived(storedSessionId, true)
|
||||
await setSessionArchived(storedSessionId, true, archived?.profile)
|
||||
notify({ durationMs: 2_000, kind: 'success', message: 'Archived' })
|
||||
} catch (err) {
|
||||
if (archived) {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2, RefreshCw, Sparkles } from '@/lib/icons'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$desktopVersion,
|
||||
@@ -18,29 +19,31 @@ import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||
|
||||
const RELEASE_NOTES_URL = 'https://github.com/NousResearch/hermes-agent/releases'
|
||||
|
||||
function relativeTime(ms: number | undefined) {
|
||||
function relativeTime(ms: number | undefined, a: Translations['settings']['about']) {
|
||||
if (!ms) {
|
||||
return 'never'
|
||||
return a.never
|
||||
}
|
||||
|
||||
const diff = Date.now() - ms
|
||||
|
||||
if (diff < 60_000) {
|
||||
return 'just now'
|
||||
return a.justNow
|
||||
}
|
||||
|
||||
if (diff < 3_600_000) {
|
||||
return `${Math.round(diff / 60_000)} min ago`
|
||||
return a.minAgo(Math.round(diff / 60_000))
|
||||
}
|
||||
|
||||
if (diff < 86_400_000) {
|
||||
return `${Math.round(diff / 3_600_000)} hours ago`
|
||||
return a.hoursAgo(Math.round(diff / 3_600_000))
|
||||
}
|
||||
|
||||
return `${Math.round(diff / 86_400_000)} days ago`
|
||||
return a.daysAgo(Math.round(diff / 86_400_000))
|
||||
}
|
||||
|
||||
export function AboutSettings() {
|
||||
const { t } = useI18n()
|
||||
const a = t.settings.about
|
||||
const version = useStore($desktopVersion)
|
||||
const status = useStore($updateStatus)
|
||||
const apply = useStore($updateApply)
|
||||
@@ -69,21 +72,21 @@ export function AboutSettings() {
|
||||
let statusTone: 'idle' | 'available' | 'error' = 'idle'
|
||||
|
||||
if (!supported) {
|
||||
statusLine = status?.message ?? "This build can't update itself from inside the app."
|
||||
statusLine = status?.message ?? a.cantUpdate
|
||||
statusTone = 'error'
|
||||
} else if (status?.error) {
|
||||
statusLine = "We couldn't reach the update server."
|
||||
statusLine = a.cantReach
|
||||
statusTone = 'error'
|
||||
} else if (applying) {
|
||||
statusLine = 'An update is currently installing.'
|
||||
statusLine = a.installing
|
||||
statusTone = 'available'
|
||||
} else if (behind > 0) {
|
||||
statusLine = `A new update is ready (${behind} change${behind === 1 ? '' : 's'} included).`
|
||||
statusLine = a.updateReady(behind)
|
||||
statusTone = 'available'
|
||||
} else if (status) {
|
||||
statusLine = "You're on the latest version."
|
||||
statusLine = a.onLatest
|
||||
} else {
|
||||
statusLine = 'Tap "Check now" to look for updates.'
|
||||
statusLine = a.tapCheck
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -93,15 +96,15 @@ export function AboutSettings() {
|
||||
<Sparkles className="size-8" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Hermes Desktop</h2>
|
||||
<h2 className="text-lg font-semibold tracking-tight">{a.heading}</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{version?.appVersion ? `Version ${version.appVersion}` : 'Version unavailable'}
|
||||
{version?.appVersion ? a.version(version.appVersion) : a.versionUnavailable}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-4 w-full max-w-2xl">
|
||||
<SectionHeading icon={RefreshCw} title="Updates" />
|
||||
<SectionHeading icon={RefreshCw} title={a.updates} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
@@ -111,12 +114,19 @@ export function AboutSettings() {
|
||||
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{statusLine}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Last checked {relativeTime(status?.fetchedAt)}
|
||||
{justChecked && !checking ? ' · just now' : ''}
|
||||
</p>
|
||||
<div className="flex items-start gap-2">
|
||||
{statusTone === 'available' ? (
|
||||
<Sparkles className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
) : statusTone === 'error' ? null : (
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{statusLine}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{a.lastChecked(relativeTime(status?.fetchedAt, a))}
|
||||
{justChecked && !checking ? a.justNowSuffix : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4">
|
||||
@@ -126,13 +136,13 @@ export function AboutSettings() {
|
||||
size="sm"
|
||||
variant="textStrong"
|
||||
>
|
||||
{checking && <Loader2 className="size-3 animate-spin" />}
|
||||
{checking ? 'Checking…' : 'Check now'}
|
||||
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||
{checking ? a.checking : a.checkNow}
|
||||
</Button>
|
||||
|
||||
{behind > 0 && supported && !applying && (
|
||||
<Button onClick={() => openUpdatesWindow()} size="sm">
|
||||
See what's new
|
||||
{a.seeWhatsNew}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -146,16 +156,17 @@ export function AboutSettings() {
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Release notes
|
||||
<ExternalLink className="size-3" />
|
||||
{a.releaseNotes}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ListRow
|
||||
description="Hermes checks for updates automatically in the background and lets you know when one is ready."
|
||||
hint={`Branch ${status?.branch ?? 'unknown'} · Commit ${status?.currentSha?.slice(0, 7) ?? 'unknown'}`}
|
||||
title="Automatic updates"
|
||||
description={a.automaticUpdatesDesc}
|
||||
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
|
||||
title={a.automaticUpdates}
|
||||
/>
|
||||
</div>
|
||||
</SettingsContent>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { type Locale, LOCALE_META, useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check } from '@/lib/icons'
|
||||
import { Check, Palette } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
||||
import { useTheme } from '@/themes/context'
|
||||
import { BUILTIN_THEMES } from '@/themes/presets'
|
||||
|
||||
import { MODE_OPTIONS } from './constants'
|
||||
import { SettingsContent } from './primitives'
|
||||
import { Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
|
||||
function ThemePreview({ name }: { name: string }) {
|
||||
const t = BUILTIN_THEMES[name]
|
||||
@@ -52,80 +52,193 @@ function ThemePreview({ name }: { name: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHead({ title, description, control }: { title: string; description: string; control?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{title}</div>
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
{control && <div className="shrink-0">{control}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const { t, isSavingLocale, locale, setLocale } = useI18n()
|
||||
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
|
||||
const toolViewMode = useStore($toolViewMode)
|
||||
const activeTheme = availableThemes.find(theme => theme.name === themeName)
|
||||
const a = t.settings.appearance
|
||||
const locales = Object.keys(LOCALE_META) as Locale[]
|
||||
|
||||
const selectLocale = async (code: Locale) => {
|
||||
if (code === locale || isSavingLocale) {
|
||||
return
|
||||
}
|
||||
|
||||
triggerHaptic('selection')
|
||||
|
||||
try {
|
||||
await setLocale(code)
|
||||
triggerHaptic('success')
|
||||
} catch (error) {
|
||||
notifyError(error, t.language.saveError)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="grid gap-8">
|
||||
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and
|
||||
chat surface styling.
|
||||
</p>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<SectionHeading icon={Palette} title={a.title} />
|
||||
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{a.intro}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<SectionHead
|
||||
control={
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('crisp')
|
||||
setMode(id)
|
||||
}}
|
||||
options={MODE_OPTIONS}
|
||||
value={mode}
|
||||
/>
|
||||
}
|
||||
description="Pick a fixed mode or let Hermes follow your system setting."
|
||||
title="Color Mode"
|
||||
/>
|
||||
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t.language.label}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{t.language.description}</div>
|
||||
{isSavingLocale && <div className="mt-1 text-xs text-muted-foreground">{t.language.saving}</div>}
|
||||
</div>
|
||||
<Pill>{LOCALE_META[locale].name}</Pill>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{locales.map(code => {
|
||||
const active = locale === code
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
disabled={isSavingLocale}
|
||||
key={code}
|
||||
onClick={() => void selectLocale(code)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{LOCALE_META[code].name}
|
||||
</div>
|
||||
{active && (
|
||||
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] uppercase tracking-wide text-(--ui-text-tertiary)">
|
||||
{code}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHead
|
||||
control={
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('selection')
|
||||
setToolViewMode(id)
|
||||
}}
|
||||
options={
|
||||
[
|
||||
{ id: 'product', label: 'Product' },
|
||||
{ id: 'technical', label: 'Technical' }
|
||||
] as const
|
||||
}
|
||||
value={toolViewMode}
|
||||
/>
|
||||
}
|
||||
description="Product hides raw tool payloads; Technical shows full input/output."
|
||||
title="Tool Call Display"
|
||||
/>
|
||||
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{a.colorMode}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{a.colorModeDesc}</div>
|
||||
</div>
|
||||
<Pill>{t.settings.modeOptions[mode].label}</Pill>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{MODE_OPTIONS.map(({ id, icon: Icon }) => {
|
||||
const active = mode === id
|
||||
const copy = t.settings.modeOptions[id]
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
key={id}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
setMode(id)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="flex size-9 items-center justify-center rounded-lg bg-muted text-foreground transition group-hover:bg-background">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
{active && (
|
||||
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-[length:var(--conversation-text-font-size)] font-medium">{copy.label}</div>
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{copy.description}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3">
|
||||
<SectionHead description="Desktop palettes only. The selected mode is applied on top." title="Theme" />
|
||||
<div className="grid gap-x-4 gap-y-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{a.toolViewTitle}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{a.toolViewDesc}</div>
|
||||
</div>
|
||||
<Pill>{toolViewMode === 'technical' ? a.technical : a.product}</Pill>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{(
|
||||
[
|
||||
{ id: 'product', label: a.product, description: a.productDesc },
|
||||
{ id: 'technical', label: a.technical, description: a.technicalDesc }
|
||||
] as const
|
||||
).map(option => {
|
||||
const active = toolViewMode === option.id
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
key={option.id}
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
setToolViewMode(option.id)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{option.label}</div>
|
||||
{active && (
|
||||
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{option.description}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{a.themeTitle}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{a.themeDesc}</div>
|
||||
</div>
|
||||
{activeTheme && <Pill>{activeTheme.label}</Pill>}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{availableThemes.map(theme => {
|
||||
const active = themeName === theme.name
|
||||
|
||||
return (
|
||||
<button
|
||||
className="group text-left"
|
||||
className={cn(
|
||||
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
key={theme.name}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
@@ -133,17 +246,8 @@ export function AppearanceSettings() {
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl transition',
|
||||
active
|
||||
? 'ring-2 ring-primary ring-offset-2 ring-offset-background'
|
||||
: 'opacity-90 group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<ThemePreview name={theme.name} />
|
||||
</div>
|
||||
<div className="mt-2.5 flex items-start justify-between gap-2 px-0.5">
|
||||
<ThemePreview name={theme.name} />
|
||||
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
@@ -152,7 +256,11 @@ export function AppearanceSettings() {
|
||||
{theme.description}
|
||||
</div>
|
||||
</div>
|
||||
{active && <Check className="mt-0.5 size-4 shrink-0 text-primary" />}
|
||||
{active && (
|
||||
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getHermesConfigSchema,
|
||||
saveHermesConfig
|
||||
} from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
|
||||
@@ -37,9 +38,20 @@ function ConfigField({
|
||||
optionLabels?: Record<string, string>
|
||||
onChange: (value: unknown) => void
|
||||
}) {
|
||||
const label = FIELD_LABELS[schemaKey] ?? prettyName(schemaKey.split('.').pop() ?? schemaKey)
|
||||
const { t } = useI18n()
|
||||
|
||||
const label =
|
||||
t.settings.fieldLabels[schemaKey] ?? FIELD_LABELS[schemaKey] ?? prettyName(schemaKey.split('.').pop() ?? schemaKey)
|
||||
|
||||
const normalize = (v: string) => v.toLowerCase().replace(/[^a-z0-9]+/g, '')
|
||||
const rawDescription = (FIELD_DESCRIPTIONS[schemaKey] ?? schema.description ?? '').trim()
|
||||
|
||||
const rawDescription = (
|
||||
t.settings.fieldDescriptions[schemaKey] ??
|
||||
FIELD_DESCRIPTIONS[schemaKey] ??
|
||||
schema.description ??
|
||||
''
|
||||
).trim()
|
||||
|
||||
const normalizedDesc = normalize(rawDescription)
|
||||
|
||||
const description =
|
||||
|
||||
@@ -241,7 +241,8 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
|
||||
'memory.provider': ['', 'builtin', 'honcho'],
|
||||
'stt.elevenlabs.model_id': ['scribe_v2', 'scribe_v1'],
|
||||
'stt.local.model': ['tiny', 'base', 'small', 'medium', 'large-v3'],
|
||||
'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']
|
||||
'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'],
|
||||
'updates.non_interactive_local_changes': ['stash', 'discard']
|
||||
}
|
||||
|
||||
export const FIELD_LABELS: Record<string, string> = {
|
||||
@@ -309,7 +310,8 @@ 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',
|
||||
'updates.non_interactive_local_changes': 'In-App Update Local Changes'
|
||||
}
|
||||
|
||||
export const FIELD_DESCRIPTIONS: Record<string, string> = {
|
||||
@@ -336,7 +338,9 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = {
|
||||
'voice.auto_tts': 'Automatically speak assistant responses.',
|
||||
'stt.enabled': 'Enable local or provider-backed speech transcription.',
|
||||
'stt.elevenlabs.language_code': 'Optional ISO-639-3 language code. Blank lets ElevenLabs auto-detect.',
|
||||
'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.'
|
||||
'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.',
|
||||
'updates.non_interactive_local_changes':
|
||||
'When Hermes updates itself from the app (no terminal prompt), keep local source edits (stash) or throw them away (discard). Terminal updates always ask.'
|
||||
}
|
||||
|
||||
// Curated desktop config surface: only fields a user might tune from the app.
|
||||
@@ -449,7 +453,8 @@ export const SECTIONS: DesktopConfigSection[] = [
|
||||
'delegation.max_iterations',
|
||||
'delegation.max_concurrent_children',
|
||||
'delegation.child_timeout_seconds',
|
||||
'delegation.reasoning_effort'
|
||||
'delegation.reasoning_effort',
|
||||
'updates.non_interactive_local_changes'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -6,6 +7,7 @@ import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global
|
||||
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $profiles, refreshActiveProfile } from '@/store/profile'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
|
||||
@@ -74,6 +76,23 @@ function ModeCard({
|
||||
)
|
||||
}
|
||||
|
||||
function ScopeChip({ active, label, onSelect }: { active: boolean; label: string; onSelect: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1 text-[length:var(--conversation-caption-font-size)] transition',
|
||||
active
|
||||
? 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary) text-(--ui-text-primary)'
|
||||
: 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover)'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function GatewaySettings() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -83,6 +102,16 @@ export function GatewaySettings() {
|
||||
const [remoteToken, setRemoteToken] = useState('')
|
||||
const [lastTest, setLastTest] = useState<null | string>(null)
|
||||
|
||||
// Connection scope: null = the global/default connection (the original
|
||||
// behavior); a profile name = that profile's per-profile remote override, so
|
||||
// each profile can point at its own backend.
|
||||
const [scope, setScope] = useState<null | string>(null)
|
||||
const profiles = useStore($profiles)
|
||||
|
||||
useEffect(() => {
|
||||
void refreshActiveProfile()
|
||||
}, [])
|
||||
|
||||
// Auth-mode probe: as the user types a remote URL we ask the gateway (via
|
||||
// its public /api/status) whether it gates with OAuth or a static session
|
||||
// token, so we can show the right control (login button vs token box).
|
||||
@@ -100,8 +129,14 @@ export function GatewaySettings() {
|
||||
return () => void (cancelled = true)
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
// Clear scope-local entry state so a token from one scope can't leak into
|
||||
// the next when switching profiles.
|
||||
setRemoteToken('')
|
||||
setLastTest(null)
|
||||
|
||||
desktop
|
||||
.getConnectionConfig()
|
||||
.getConnectionConfig(scope)
|
||||
.then(config => {
|
||||
if (cancelled) {
|
||||
return
|
||||
@@ -117,7 +152,7 @@ export function GatewaySettings() {
|
||||
})
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
}, [scope])
|
||||
|
||||
// Debounced probe of the entered remote URL. Only runs in remote mode with a
|
||||
// syntactically plausible URL. The probe result drives whether we render the
|
||||
@@ -223,6 +258,10 @@ export function GatewaySettings() {
|
||||
return providers.length > 0 && providers.every(p => p.supportsPassword)
|
||||
}, [probe])
|
||||
|
||||
// The 'default' profile uses the global ("All profiles") connection, so the
|
||||
// per-profile scopes are the named, non-default profiles.
|
||||
const namedProfiles = useMemo(() => profiles.filter(profile => profile.name !== 'default'), [profiles])
|
||||
|
||||
const oauthConnected = state.remoteOauthConnected
|
||||
|
||||
const canUseRemote = useMemo(() => {
|
||||
@@ -239,6 +278,7 @@ export function GatewaySettings() {
|
||||
|
||||
const payload = () => ({
|
||||
mode: state.mode,
|
||||
profile: scope ?? undefined,
|
||||
remoteAuthMode: authMode,
|
||||
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
|
||||
remoteUrl: trimmedUrl
|
||||
@@ -296,6 +336,7 @@ export function GatewaySettings() {
|
||||
// oauth mode is persisted, without yet flipping the live connection.
|
||||
const saved = await window.hermesDesktop.saveConnectionConfig({
|
||||
mode: state.mode,
|
||||
profile: scope ?? undefined,
|
||||
remoteAuthMode: 'oauth',
|
||||
remoteUrl: trimmedUrl
|
||||
})
|
||||
@@ -305,7 +346,7 @@ export function GatewaySettings() {
|
||||
const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
|
||||
|
||||
if (result.connected) {
|
||||
const refreshed = await window.hermesDesktop.getConnectionConfig()
|
||||
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
|
||||
setState(refreshed)
|
||||
notify({ kind: 'success', title: 'Signed in', message: `Connected to ${providerLabel}.` })
|
||||
} else {
|
||||
@@ -327,7 +368,7 @@ export function GatewaySettings() {
|
||||
|
||||
try {
|
||||
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)
|
||||
const refreshed = await window.hermesDesktop.getConnectionConfig()
|
||||
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
|
||||
setState(refreshed)
|
||||
notify({ kind: 'success', title: 'Signed out', message: 'Cleared the remote gateway session.' })
|
||||
} catch (err) {
|
||||
@@ -357,6 +398,7 @@ export function GatewaySettings() {
|
||||
try {
|
||||
const result = await window.hermesDesktop.testConnectionConfig({
|
||||
mode: 'remote',
|
||||
profile: scope ?? undefined,
|
||||
remoteAuthMode: authMode,
|
||||
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
|
||||
remoteUrl: trimmedUrl
|
||||
@@ -395,10 +437,35 @@ export function GatewaySettings() {
|
||||
</div>
|
||||
<p className="mt-2 max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control
|
||||
an already-running Hermes backend on another machine or behind a trusted proxy.
|
||||
an already-running Hermes backend on another machine or behind a trusted proxy. Pick a profile below to give it
|
||||
its own remote host.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{namedProfiles.length > 0 ? (
|
||||
<div className="mb-5 grid gap-2">
|
||||
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
|
||||
Applies to
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<ScopeChip active={scope === null} label="All profiles" onSelect={() => setScope(null)} />
|
||||
{namedProfiles.map(profile => (
|
||||
<ScopeChip
|
||||
active={scope === profile.name}
|
||||
key={profile.name}
|
||||
label={profile.name}
|
||||
onSelect={() => setScope(profile.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{scope === null
|
||||
? 'Default connection for every profile that has no override of its own.'
|
||||
: `Connection used only when “${scope}” is the active profile. Set it to Local to inherit the default.`}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.envOverride ? (
|
||||
<div className="mb-5 flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-[length:var(--conversation-caption-font-size)] text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useRef } from 'react'
|
||||
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
@@ -34,6 +35,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
]
|
||||
|
||||
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||
const { t } = useI18n()
|
||||
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
|
||||
// Providers subnav (Accounts vs API keys) lives in its own param so each
|
||||
// sub-view is deep-linkable and survives a refresh.
|
||||
@@ -64,12 +66,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
URL.revokeObjectURL(url)
|
||||
triggerHaptic('success')
|
||||
} catch (err) {
|
||||
notifyError(err, 'Export failed')
|
||||
notifyError(err, t.settings.exportFailed)
|
||||
}
|
||||
}
|
||||
|
||||
const resetConfig = async () => {
|
||||
if (!window.confirm('Reset all settings to Hermes defaults?')) {
|
||||
if (!window.confirm(t.settings.resetConfirm)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -78,12 +80,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
triggerHaptic('success')
|
||||
onConfigSaved?.()
|
||||
} catch (err) {
|
||||
notifyError(err, 'Reset failed')
|
||||
notifyError(err, t.settings.resetFailed)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<OverlayView closeLabel="Close settings" onClose={onClose}>
|
||||
<OverlayView closeLabel={t.settings.closeSettings} onClose={onClose}>
|
||||
<OverlaySplitLayout>
|
||||
<OverlaySidebar>
|
||||
{SECTIONS.map(s => {
|
||||
@@ -94,7 +96,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
active={activeView === view}
|
||||
icon={s.icon}
|
||||
key={s.id}
|
||||
label={s.label}
|
||||
label={t.settings.sections[s.id] ?? s.label}
|
||||
onClick={() => setActiveView(view)}
|
||||
/>
|
||||
)
|
||||
@@ -127,13 +129,13 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
<OverlayNavItem
|
||||
active={activeView === 'gateway'}
|
||||
icon={Globe}
|
||||
label="Gateway"
|
||||
label={t.settings.nav.gateway}
|
||||
onClick={() => setActiveView('gateway')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={activeView === 'keys'}
|
||||
icon={KeyRound}
|
||||
label="Tools & Keys"
|
||||
label={t.settings.nav.apiKeys}
|
||||
onClick={() => setActiveView('keys')}
|
||||
/>
|
||||
{activeView === 'keys' && (
|
||||
@@ -157,29 +159,29 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
<OverlayNavItem
|
||||
active={activeView === 'mcp'}
|
||||
icon={Wrench}
|
||||
label="MCP"
|
||||
label={t.settings.nav.mcp}
|
||||
onClick={() => setActiveView('mcp')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={activeView === 'sessions'}
|
||||
icon={Archive}
|
||||
label="Archived Chats"
|
||||
label={t.settings.nav.archivedChats}
|
||||
onClick={() => setActiveView('sessions')}
|
||||
/>
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<OverlayNavItem
|
||||
active={activeView === 'about'}
|
||||
icon={Info}
|
||||
label="About"
|
||||
label={t.settings.nav.about}
|
||||
onClick={() => setActiveView('about')}
|
||||
/>
|
||||
<div className="mt-auto flex items-center gap-1 pt-2">
|
||||
<Tip label="Export config">
|
||||
<Tip label={t.settings.exportConfig}>
|
||||
<OverlayIconButton onClick={() => void exportConfig()}>
|
||||
<IconDownload className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
</Tip>
|
||||
<Tip label="Import config">
|
||||
<Tip label={t.settings.importConfig}>
|
||||
<OverlayIconButton
|
||||
onClick={() => {
|
||||
triggerHaptic('open')
|
||||
@@ -189,7 +191,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
<IconUpload className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
</Tip>
|
||||
<Tip label="Reset to defaults">
|
||||
<Tip label={t.settings.resetToDefaults}>
|
||||
<OverlayIconButton
|
||||
className="hover:text-destructive"
|
||||
onClick={() => {
|
||||
|
||||
@@ -57,7 +57,7 @@ export function SessionsSettings() {
|
||||
setBusyId(session.id)
|
||||
|
||||
try {
|
||||
await setSessionArchived(session.id, false)
|
||||
await setSessionArchived(session.id, false, session.profile)
|
||||
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
|
||||
// Surface it again in the sidebar without waiting for a full refresh.
|
||||
setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)])
|
||||
@@ -78,7 +78,7 @@ export function SessionsSettings() {
|
||||
setBusyId(session.id)
|
||||
|
||||
try {
|
||||
await deleteSession(session.id)
|
||||
await deleteSession(session.id, session.profile)
|
||||
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
|
||||
triggerHaptic('warning')
|
||||
} catch (err) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
|
||||
@@ -44,6 +45,7 @@ interface TitlebarControlsProps extends ComponentProps<'div'> {
|
||||
}
|
||||
|
||||
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
|
||||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const hapticsMuted = useStore($hapticsMuted)
|
||||
@@ -76,7 +78,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
{
|
||||
icon: <Codicon name="layout-sidebar-left" />,
|
||||
id: 'sidebar',
|
||||
label: `${leftEdge.open ? 'Hide' : 'Show'} left sidebar`,
|
||||
label: leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar,
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
leftEdge.toggle()
|
||||
@@ -85,12 +87,12 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
{
|
||||
icon: <Codicon name="arrow-swap" />,
|
||||
id: 'flip-panes',
|
||||
label: 'Swap sidebar sides',
|
||||
label: t.titlebar.swapSidebarSides,
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
togglePanesFlipped()
|
||||
},
|
||||
title: 'Swap the sessions and file browser sides'
|
||||
title: t.titlebar.swapSidebarSidesTitle
|
||||
},
|
||||
...leftTools
|
||||
]
|
||||
@@ -98,7 +100,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
const rightSidebarTool: TitlebarTool = {
|
||||
icon: <Codicon name="layout-sidebar-right" />,
|
||||
id: 'right-sidebar',
|
||||
label: `${rightEdge.open ? 'Hide' : 'Show'} right sidebar`,
|
||||
label: rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar,
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
rightEdge.toggle()
|
||||
@@ -111,13 +113,13 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
active: hapticsMuted,
|
||||
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
|
||||
id: 'haptics',
|
||||
label: hapticsMuted ? 'Unmute haptics' : 'Mute haptics',
|
||||
label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics,
|
||||
onSelect: toggleHaptics
|
||||
},
|
||||
{
|
||||
icon: <Codicon name="settings-gear" />,
|
||||
id: 'settings',
|
||||
label: 'Open settings',
|
||||
label: t.titlebar.openSettings,
|
||||
onSelect: () => {
|
||||
triggerHaptic('open')
|
||||
onOpenSettings()
|
||||
@@ -199,6 +201,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={tool.title ?? tool.label}
|
||||
>
|
||||
{tool.icon}
|
||||
</a>
|
||||
@@ -221,6 +224,7 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
|
||||
}}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
size="icon-titlebar"
|
||||
title={tool.title ?? tool.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('SkillsView toolset management', () => {
|
||||
|
||||
await renderSkills()
|
||||
|
||||
expect(screen.getByText('Cron Jobs')).toBeTruthy()
|
||||
expect(await screen.findByText('Cron Jobs')).toBeTruthy()
|
||||
expect(screen.queryByText(/⏰/)).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -3,16 +3,18 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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 { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PAGE_INSET_X } from '../layout-constants'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers'
|
||||
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
|
||||
@@ -70,33 +72,39 @@ interface SkillsViewProps extends React.ComponentProps<'section'> {
|
||||
}
|
||||
|
||||
export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: SkillsViewProps) {
|
||||
const { t } = useI18n()
|
||||
const [mode, setMode] = useRouteEnumParam('tab', SKILLS_MODES, 'skills')
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
|
||||
const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null)
|
||||
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)
|
||||
|
||||
try {
|
||||
const [nextSkills, nextToolsets] = await Promise.all([getSkills(), getToolsets()])
|
||||
setSkills(nextSkills)
|
||||
setToolsets(nextToolsets)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Skills failed to load')
|
||||
notifyError(err, t.skills.skillsLoadFailed)
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useRefreshHotkey(refreshCapabilities)
|
||||
}, [t])
|
||||
|
||||
const refreshToolsets = useCallback(() => {
|
||||
getToolsets()
|
||||
.then(setToolsets)
|
||||
.catch(err => notifyError(err, 'Toolsets failed to refresh'))
|
||||
}, [])
|
||||
.catch(err => notifyError(err, t.skills.toolsetsRefreshFailed))
|
||||
}, [t])
|
||||
|
||||
useRefreshHotkey(refreshCapabilities)
|
||||
|
||||
useEffect(() => {
|
||||
void refreshCapabilities()
|
||||
@@ -148,11 +156,11 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
setSkills(current => current?.map(row => (row.name === skill.name ? { ...row, enabled } : row)) ?? current)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? 'Skill enabled' : 'Skill disabled',
|
||||
message: `${skill.name} applies to new sessions.`
|
||||
title: enabled ? t.skills.skillEnabled : t.skills.skillDisabled,
|
||||
message: t.skills.appliesToNewSessions(skill.name)
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to update ${skill.name}`)
|
||||
notifyError(err, t.skills.failedToUpdate(skill.name))
|
||||
} finally {
|
||||
setSavingSkill(null)
|
||||
}
|
||||
@@ -169,11 +177,11 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? 'Toolset enabled' : 'Toolset disabled',
|
||||
message: `${toolsetDisplayLabel(toolset)} applies to new sessions.`
|
||||
title: enabled ? t.skills.toolsetEnabled : t.skills.toolsetDisabled,
|
||||
message: t.skills.appliesToNewSessions(toolsetDisplayLabel(toolset))
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to update ${toolsetDisplayLabel(toolset)}`)
|
||||
notifyError(err, t.skills.failedToUpdate(toolsetDisplayLabel(toolset)))
|
||||
} finally {
|
||||
setSavingToolset(null)
|
||||
}
|
||||
@@ -183,54 +191,66 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
filters={
|
||||
mode === 'skills' && categories.length > 0 ? (
|
||||
<>
|
||||
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
|
||||
All <TextTabMeta>{totalSkills}</TextTabMeta>
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
|
||||
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
|
||||
{t.skills.tabSkills}
|
||||
</TextTab>
|
||||
{categories.map(category => (
|
||||
<TextTab
|
||||
active={activeCategory === category.key}
|
||||
key={category.key}
|
||||
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
|
||||
>
|
||||
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
|
||||
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
|
||||
{t.skills.tabToolsets}
|
||||
</TextTab>
|
||||
</div>
|
||||
{mode === 'skills' && categories.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-x-2 gap-y-1">
|
||||
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
|
||||
{t.skills.all} <TextTabMeta>{totalSkills}</TextTabMeta>
|
||||
</TextTab>
|
||||
))}
|
||||
</>
|
||||
) : undefined
|
||||
{categories.map(category => (
|
||||
<TextTab
|
||||
active={activeCategory === category.key}
|
||||
key={category.key}
|
||||
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
|
||||
>
|
||||
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
|
||||
</TextTab>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onSearchChange={setQuery}
|
||||
searchHidden={mode === 'skills' ? (skills?.length ?? 0) === 0 : (toolsets?.length ?? 0) === 0}
|
||||
searchPlaceholder={mode === 'skills' ? 'Search skills...' : 'Search toolsets...'}
|
||||
searchValue={query}
|
||||
tabs={
|
||||
<>
|
||||
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
|
||||
Skills
|
||||
</TextTab>
|
||||
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
|
||||
Toolsets
|
||||
</TextTab>
|
||||
</>
|
||||
searchPlaceholder={mode === 'skills' ? t.skills.searchSkills : t.skills.searchToolsets}
|
||||
searchTrailingAction={
|
||||
<Button
|
||||
aria-label={refreshing ? t.skills.refreshing : t.skills.refresh}
|
||||
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refreshCapabilities()}
|
||||
size="icon-xs"
|
||||
title={refreshing ? t.skills.refreshing : t.skills.refresh}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
|
||||
</Button>
|
||||
}
|
||||
searchValue={query}
|
||||
>
|
||||
{!skills || !toolsets ? (
|
||||
<PageLoader label="Loading capabilities..." />
|
||||
<PageLoader label={t.skills.loading} />
|
||||
) : mode === 'skills' ? (
|
||||
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
{visibleSkills.length === 0 ? (
|
||||
<EmptyState description="Try a broader search or different category." title="No skills found" />
|
||||
<EmptyState description={t.skills.noSkillsDesc} title={t.skills.noSkillsTitle} />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{skillGroups.map(([category, list]) => (
|
||||
<div className="space-y-1.5" key={category}>
|
||||
{activeCategory === null && (
|
||||
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{prettyName(category)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{prettyName(category)}
|
||||
</div>
|
||||
<div className="divide-y divide-(--ui-stroke-quaternary)">
|
||||
{list.map(skill => (
|
||||
<div
|
||||
className="grid gap-3 px-0 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
|
||||
@@ -239,7 +259,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{skill.name}</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{asText(skill.description) || 'No description.'}
|
||||
{asText(skill.description) || t.skills.noDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -256,15 +276,15 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
{visibleToolsets.length === 0 ? (
|
||||
<EmptyState description="Try a broader search query." title="No toolsets found" />
|
||||
<EmptyState description={t.skills.noToolsetsDesc} title={t.skills.noToolsetsTitle} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{enabledToolsets}/{toolsets.length} toolsets enabled
|
||||
{t.skills.toolsetsEnabled(enabledToolsets, toolsets.length)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="divide-y divide-(--ui-stroke-quaternary)">
|
||||
{visibleToolsets.map(toolset => {
|
||||
const tools = toolNames(toolset)
|
||||
const label = toolsetDisplayLabel(toolset)
|
||||
@@ -277,19 +297,19 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
aria-label={`Configure ${label}`}
|
||||
className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
aria-label={t.skills.configureToolset(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'}
|
||||
{toolset.configured ? t.skills.configured : t.skills.needsKeys}
|
||||
</StatusPill>
|
||||
</button>
|
||||
<Switch
|
||||
aria-label={`Toggle ${label} toolset`}
|
||||
aria-label={t.skills.toggleToolset(label)}
|
||||
checked={toolset.enabled}
|
||||
disabled={savingToolset === toolset.name}
|
||||
onCheckedChange={checked => void handleToggleToolset(toolset, checked)}
|
||||
@@ -297,7 +317,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{asText(toolset.description) || 'No description.'}
|
||||
{asText(toolset.description) || t.skills.noDescription}
|
||||
</p>
|
||||
{tools.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
|
||||
@@ -117,10 +117,6 @@ function messageContentText(content: unknown): string {
|
||||
return Array.isArray(content) ? content.map(partText).join('').trim() : ''
|
||||
}
|
||||
|
||||
const INTERRUPTED_ONLY_RE = /^_?\[interrupted\]_?$/i
|
||||
|
||||
const isInterruptedOnlyMessage = (text: string) => INTERRUPTED_ONLY_RE.test(text.trim())
|
||||
|
||||
export const Thread: FC<{
|
||||
clampToComposer?: boolean
|
||||
cwd?: string | null
|
||||
@@ -220,7 +216,6 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
|
||||
const messageStatus = useAuiState(s => s.message.status?.type)
|
||||
const isPlaceholder = messageStatus === 'running' && content.length === 0
|
||||
const interruptedOnly = useMemo(() => isInterruptedOnlyMessage(messageText), [messageText])
|
||||
const enterRef = useEnterAnimation(messageStatus === 'running', `assistant-message:${messageId}`)
|
||||
|
||||
if (isPlaceholder) {
|
||||
@@ -236,10 +231,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
ref={enterRef}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground',
|
||||
interruptedOnly && 'text-[0.8rem] leading-5 text-muted-foreground/82'
|
||||
)}
|
||||
className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground"
|
||||
data-slot="aui_assistant-message-content"
|
||||
>
|
||||
{hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
|
||||
@@ -260,7 +252,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
</ErrorPrimitive.Root>
|
||||
</MessagePrimitive.Error>
|
||||
</div>
|
||||
{messageText.trim().length > 0 && !interruptedOnly && (
|
||||
{messageText.trim().length > 0 && (
|
||||
<AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} />
|
||||
)}
|
||||
</MessagePrimitive.Root>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { DesktopConnectionConfig } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertTriangle, FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons'
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
@@ -27,6 +28,7 @@ type BusyAction = 'local' | 'repair' | 'retry' | 'signin' | null
|
||||
export function BootFailureOverlay() {
|
||||
const boot = useStore($desktopBoot)
|
||||
const onboarding = useStore($desktopOnboarding)
|
||||
const { t } = useI18n()
|
||||
const [busy, setBusy] = useState<BusyAction>(null)
|
||||
const [logs, setLogs] = useState<string[]>([])
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
@@ -141,7 +143,7 @@ export function BootFailureOverlay() {
|
||||
const result = await window.hermesDesktop?.oauthLoginConnectionConfig(remoteReauth.url)
|
||||
|
||||
if (result?.connected) {
|
||||
notify({ kind: 'success', title: 'Signed in', message: 'Reconnecting to the remote gateway…' })
|
||||
notify({ kind: 'success', title: t.boot.failure.signedInTitle, message: t.boot.failure.signedInMessage })
|
||||
window.location.reload()
|
||||
|
||||
return
|
||||
@@ -149,19 +151,24 @@ export function BootFailureOverlay() {
|
||||
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Sign-in incomplete',
|
||||
message: 'The login window closed before authentication finished.'
|
||||
title: t.boot.failure.signInIncompleteTitle,
|
||||
message: t.boot.failure.signInIncompleteMessage
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, 'Sign-in failed')
|
||||
notifyError(err, t.boot.failure.signInFailed)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const openLogs = () => void window.hermesDesktop?.revealLogs().catch(() => undefined)
|
||||
const copy = t.boot.failure
|
||||
|
||||
const label = signInLabel(remoteReauth)
|
||||
const label = signInLabel(remoteReauth, {
|
||||
identityProvider: copy.identityProvider,
|
||||
remoteGateway: copy.signInToRemoteGateway,
|
||||
withProvider: copy.signInWithProvider
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
|
||||
@@ -172,12 +179,10 @@ export function BootFailureOverlay() {
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[0.9375rem] font-semibold tracking-tight">
|
||||
{remoteReauth ? 'Remote gateway sign-in required' : "Hermes couldn't start"}
|
||||
{remoteReauth ? copy.remoteTitle : copy.title}
|
||||
</h2>
|
||||
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
|
||||
{remoteReauth
|
||||
? 'Your remote gateway session has expired (the dashboard likely restarted). Sign in again to reconnect — nothing here deletes your chats or settings.'
|
||||
: "The background gateway didn't come up. Try one of the recovery steps below — nothing here deletes your chats or settings."}
|
||||
{remoteReauth ? copy.remoteDescription : copy.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,28 +202,26 @@ export function BootFailureOverlay() {
|
||||
) : (
|
||||
<Button disabled={Boolean(busy)} onClick={() => void retry()}>
|
||||
{busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
|
||||
Retry
|
||||
{copy.retry}
|
||||
</Button>
|
||||
)}
|
||||
{!remoteReauth ? (
|
||||
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline">
|
||||
{busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />}
|
||||
Repair install
|
||||
{copy.repairInstall}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="outline">
|
||||
{busy === 'local' ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Use local gateway
|
||||
{copy.useLocalGateway}
|
||||
</Button>
|
||||
<Button onClick={openLogs} variant="ghost">
|
||||
<FileText className="size-4" />
|
||||
Open logs
|
||||
{copy.openLogs}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{remoteReauth
|
||||
? 'Opens the gateway login window. Use “Use local gateway” to switch to the bundled backend instead.'
|
||||
: 'Repair re-runs the installer and can take a few minutes on a fresh machine.'}
|
||||
{remoteReauth ? copy.remoteSignInHint : copy.repairHint}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -229,7 +232,7 @@ export function BootFailureOverlay() {
|
||||
onClick={() => setShowLogs(v => !v)}
|
||||
type="button"
|
||||
>
|
||||
{showLogs ? 'Hide' : 'Show'} recent logs
|
||||
{showLogs ? copy.hideRecentLogs : copy.showRecentLogs}
|
||||
</button>
|
||||
{showLogs ? (
|
||||
<pre className="max-h-48 overflow-auto rounded-2xl border border-border bg-secondary/30 p-3 font-mono text-[0.7rem] leading-4 text-muted-foreground">
|
||||
|
||||
@@ -8,6 +8,7 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec
|
||||
return {
|
||||
envOverride: false,
|
||||
mode: 'remote',
|
||||
profile: null,
|
||||
remoteAuthMode: 'oauth',
|
||||
remoteOauthConnected: false,
|
||||
remoteTokenPreview: null,
|
||||
|
||||
@@ -14,6 +14,18 @@ export interface RemoteReauth {
|
||||
providerLabel: string
|
||||
}
|
||||
|
||||
interface SignInCopy {
|
||||
identityProvider: string
|
||||
remoteGateway: string
|
||||
withProvider: (provider: string) => string
|
||||
}
|
||||
|
||||
const DEFAULT_SIGN_IN_COPY: SignInCopy = {
|
||||
identityProvider: 'your identity provider',
|
||||
remoteGateway: 'Sign in to remote gateway',
|
||||
withProvider: provider => `Sign in with ${provider}`
|
||||
}
|
||||
|
||||
// A remote, gated (oauth-bucket), not-currently-connected gateway is a
|
||||
// remote-reauth boot failure: the access cookie lapsed (e.g. the remote
|
||||
// dashboard restarted) and the local-recovery buttons (Retry/Repair) can't
|
||||
@@ -58,10 +70,12 @@ export function deriveProviderShape(providers: DesktopAuthProvider[] | null | un
|
||||
}
|
||||
|
||||
// Button copy for the remote sign-in action.
|
||||
export function signInLabel(reauth: RemoteReauth | null): string {
|
||||
export function signInLabel(reauth: RemoteReauth | null, copy: SignInCopy = DEFAULT_SIGN_IN_COPY): string {
|
||||
if (reauth?.isPassword) {
|
||||
return 'Sign in to remote gateway'
|
||||
return copy.remoteGateway
|
||||
}
|
||||
|
||||
return `Sign in with ${reauth?.providerLabel ?? 'your identity provider'}`
|
||||
const provider = reauth?.providerLabel === DEFAULT_SIGN_IN_COPY.identityProvider ? copy.identityProvider : reauth?.providerLabel
|
||||
|
||||
return copy.withProvider(provider ?? copy.identityProvider)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
import { $desktopOnboarding } from '@/store/onboarding'
|
||||
import { $gatewayState, setGatewayState } from '@/store/session'
|
||||
|
||||
import { BootFailureOverlay } from './boot-failure-overlay'
|
||||
import { GatewayConnectingOverlay } from './gateway-connecting-overlay'
|
||||
|
||||
// Repro for the "remote gateway → stuck on CONNECTING, no way to settings"
|
||||
// report. The connecting overlay (z-1200, full-screen, pointer-events on) is
|
||||
// shown whenever `gatewayState !== 'open' && !boot.error`. The ONLY escape
|
||||
// hatch — BootFailureOverlay, which has "Use local gateway" / "Sign in" /
|
||||
// "Retry" — only renders when `boot.error` is set.
|
||||
//
|
||||
// useGatewayBoot only calls failDesktopBoot() (which sets boot.error) when the
|
||||
// INITIAL boot() throws. After the first successful connect (bootCompleted),
|
||||
// any later socket drop goes through scheduleReconnect(), which loops FOREVER
|
||||
// against the dead remote and never sets boot.error. So gatewayState sits at
|
||||
// 'closed'/'error' with boot.error null → CONNECTING forever, recovery overlay
|
||||
// never appears, settings unreachable.
|
||||
|
||||
function resetStores() {
|
||||
setGatewayState('idle')
|
||||
$desktopBoot.set({
|
||||
error: null,
|
||||
fakeMode: false,
|
||||
message: 'ready',
|
||||
phase: 'renderer.ready',
|
||||
progress: 100,
|
||||
running: false,
|
||||
timestamp: Date.now(),
|
||||
visible: false
|
||||
})
|
||||
$desktopOnboarding.set({
|
||||
configured: true,
|
||||
flow: { status: 'idle' },
|
||||
mode: 'oauth',
|
||||
providers: null,
|
||||
reason: null,
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(resetStores)
|
||||
afterEach(cleanup)
|
||||
|
||||
// The connecting overlay renders "CONN" + a scrambled tail inside one
|
||||
// uppercase span; match that node specifically so the recovery overlay's
|
||||
// "Lost connection…" copy doesn't read as a false positive.
|
||||
const isConnectingShown = () =>
|
||||
screen.queryAllByText((_, el) => /^CONN[/\\|\-_=+<>~:*A-Z]*$/.test(el?.textContent?.trim() ?? '')).length > 0
|
||||
const isRecoveryShown = () =>
|
||||
Boolean(screen.queryByText(/use local gateway/i) || screen.queryByText(/retry/i) || screen.queryByText(/sign in/i))
|
||||
|
||||
describe('connecting overlay vs recovery surface', () => {
|
||||
it('hard initial-boot failure surfaces the recovery overlay (the working path)', () => {
|
||||
// failDesktopBoot() ran: error set, gateway never opened.
|
||||
$desktopBoot.set({ ...$desktopBoot.get(), error: 'Hermes backend did not become ready', running: false, visible: true })
|
||||
setGatewayState('error')
|
||||
|
||||
render(
|
||||
<>
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
</>
|
||||
)
|
||||
|
||||
expect(isRecoveryShown()).toBe(true)
|
||||
// Connecting overlay bows out when boot.error is set.
|
||||
expect(isConnectingShown()).toBe(false)
|
||||
})
|
||||
|
||||
it('REPRO: remote socket drops AFTER a successful boot → stuck on CONNECTING, no recovery, no settings', () => {
|
||||
// 1. Initial boot succeeded: gateway opened, boot completed (no error).
|
||||
setGatewayState('open')
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
</>
|
||||
)
|
||||
expect(isConnectingShown()).toBe(false)
|
||||
|
||||
// 2. The remote VPS socket drops (sleep/wake, remote restart, network).
|
||||
// bootCompleted is true, so useGatewayBoot routes this through
|
||||
// scheduleReconnect() — boot.error stays NULL.
|
||||
setGatewayState('closed')
|
||||
rerender(
|
||||
<>
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
</>
|
||||
)
|
||||
|
||||
// The connecting overlay reappears and latches...
|
||||
expect(isConnectingShown()).toBe(true)
|
||||
// ...with NO recovery surface, because boot.error was never set.
|
||||
expect(isRecoveryShown()).toBe(false)
|
||||
|
||||
// 3. Reconnect loops forever against the dead remote: gatewayState bounces
|
||||
// closed → error → closed, boot.error never gets set. The user is
|
||||
// pinned on CONNECTING with no path to Settings indefinitely.
|
||||
setGatewayState('error')
|
||||
rerender(
|
||||
<>
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
</>
|
||||
)
|
||||
expect($desktopBoot.get().error).toBeNull()
|
||||
expect(isConnectingShown()).toBe(true)
|
||||
expect(isRecoveryShown()).toBe(false)
|
||||
})
|
||||
|
||||
it('FIX: once the prolonged reconnect raises a recoverable boot error, the recovery overlay takes over', () => {
|
||||
// Mirrors what useGatewayBoot.scheduleReconnect() now does after ~45s of
|
||||
// failed post-boot reconnects: it calls failDesktopBoot(), flipping the UI
|
||||
// from the dead-end CONNECTING overlay to the recovery surface.
|
||||
setGatewayState('error')
|
||||
$desktopBoot.set({
|
||||
...$desktopBoot.get(),
|
||||
error: 'Lost connection to the Hermes gateway and could not reconnect.',
|
||||
running: false,
|
||||
visible: true
|
||||
})
|
||||
|
||||
render(
|
||||
<>
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
</>
|
||||
)
|
||||
|
||||
// Escape hatch is now reachable; the connecting overlay bows out.
|
||||
expect(isRecoveryShown()).toBe(true)
|
||||
expect(screen.getByText(/use local gateway/i)).toBeTruthy()
|
||||
expect(isConnectingShown()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { createPortal } from 'react-dom'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, type IconComponent, Info } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -30,8 +31,10 @@ const GHOST_BTN = 'bg-transparent text-muted-foreground hover:text-foreground'
|
||||
|
||||
export function NotificationStack() {
|
||||
const notifications = useStore($notifications)
|
||||
const { t } = useI18n()
|
||||
const lastNotificationIdRef = useRef<string | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const copy = t.notifications
|
||||
|
||||
useEffect(() => {
|
||||
if (notifications.length <= 1) {
|
||||
@@ -72,7 +75,7 @@ export function NotificationStack() {
|
||||
// scope, so fall back to its constant (34px) when mounted on <body>.
|
||||
return createPortal(
|
||||
<div
|
||||
aria-label="Notifications"
|
||||
aria-label={copy.region}
|
||||
className="pointer-events-none fixed left-1/2 top-[calc(var(--titlebar-height,34px)+0.75rem)] z-[200] flex w-[min(32rem,calc(100%-2rem))] -translate-x-1/2 flex-col gap-2"
|
||||
role="region"
|
||||
>
|
||||
@@ -81,10 +84,10 @@ export function NotificationStack() {
|
||||
{overflowCount > 0 && (
|
||||
<div className={cn(STACK_SURFACE, 'flex min-h-8 items-center justify-between rounded-lg px-3 text-xs')}>
|
||||
<button className={cn(GHOST_BTN, 'font-medium')} onClick={() => setExpanded(v => !v)} type="button">
|
||||
{expanded ? 'Hide' : 'Show'} {overflowCount} more {overflowCount === 1 ? 'notification' : 'notifications'}
|
||||
{expanded ? copy.hide : copy.show} {copy.more(overflowCount)}
|
||||
</button>
|
||||
<button className={GHOST_BTN} onClick={clearNotifications} type="button">
|
||||
Clear all
|
||||
{copy.clearAll}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -97,6 +100,8 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
|
||||
const styles = tone[notification.kind]
|
||||
const Icon = styles.icon
|
||||
const hasDetail = Boolean(notification.detail && notification.detail !== notification.message)
|
||||
const { t } = useI18n()
|
||||
const copy = t.notifications
|
||||
|
||||
return (
|
||||
<Alert
|
||||
@@ -126,7 +131,7 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Dismiss notification"
|
||||
aria-label={copy.dismiss}
|
||||
className="col-start-3 -mr-1 grid size-6 place-items-center rounded-md bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={() => dismissNotification(notification.id)}
|
||||
type="button"
|
||||
@@ -138,9 +143,12 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
|
||||
}
|
||||
|
||||
function NotificationDetail({ detail }: { detail: string }) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.notifications
|
||||
|
||||
return (
|
||||
<details className="mt-2 text-xs text-muted-foreground">
|
||||
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">Details</summary>
|
||||
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">{copy.details}</summary>
|
||||
<div className="mt-1 rounded-md border border-border/70 bg-background/65 p-2">
|
||||
<pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed">
|
||||
{detail}
|
||||
@@ -148,12 +156,12 @@ function NotificationDetail({ detail }: { detail: string }) {
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="mt-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[0.6875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
errorMessage="Could not copy notification detail"
|
||||
errorMessage={copy.copyDetailFailed}
|
||||
iconClassName="size-3"
|
||||
label="Copy detail"
|
||||
label={copy.copyDetail}
|
||||
text={detail}
|
||||
>
|
||||
Copy detail
|
||||
{copy.copyDetail}
|
||||
</CopyButton>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
Vendored
+10
-1
@@ -12,7 +12,7 @@ declare global {
|
||||
touchBackend: (profile?: string | null) => Promise<{ ok: boolean }>
|
||||
getGatewayWsUrl: (profile?: null | string) => Promise<string>
|
||||
getBootProgress: () => Promise<DesktopBootProgress>
|
||||
getConnectionConfig: () => Promise<DesktopConnectionConfig>
|
||||
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
|
||||
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
|
||||
@@ -190,6 +190,9 @@ export interface DesktopActiveProfile {
|
||||
export interface DesktopConnectionConfig {
|
||||
envOverride: boolean
|
||||
mode: 'local' | 'remote'
|
||||
// The profile this config describes, or null for the global/default
|
||||
// connection. Per-profile entries let a profile point at its own backend.
|
||||
profile: null | string
|
||||
remoteAuthMode: 'oauth' | 'token'
|
||||
remoteOauthConnected: boolean
|
||||
remoteTokenPreview: string | null
|
||||
@@ -199,6 +202,9 @@ export interface DesktopConnectionConfig {
|
||||
|
||||
export interface DesktopConnectionConfigInput {
|
||||
mode: 'local' | 'remote'
|
||||
// When set, the save/apply/test targets this profile's per-profile remote
|
||||
// override instead of the global connection.
|
||||
profile?: null | string
|
||||
remoteAuthMode?: 'oauth' | 'token'
|
||||
remoteToken?: string
|
||||
remoteUrl?: string
|
||||
@@ -366,6 +372,9 @@ export interface HermesReadDirEntry {
|
||||
export interface HermesReadDirResult {
|
||||
entries: HermesReadDirEntry[]
|
||||
error?: string
|
||||
// Absolute directory the entries were read from. Set by the gateway `fs.list`
|
||||
// RPC (remote backends); the local Electron readDir omits it.
|
||||
path?: string
|
||||
}
|
||||
|
||||
export interface HermesPreviewFileChanged {
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
OAuthSubmitResponse,
|
||||
PaginatedSessions,
|
||||
ProfileCreatePayload,
|
||||
ProfileSetupCommand,
|
||||
ProfileSoul,
|
||||
ProfilesResponse,
|
||||
SessionMessagesResponse,
|
||||
@@ -80,6 +81,7 @@ export type {
|
||||
PaginatedSessions,
|
||||
ProfileCreatePayload,
|
||||
ProfileInfo,
|
||||
ProfileSetupCommand,
|
||||
ProfileSoul,
|
||||
ProfilesResponse,
|
||||
RpcEvent,
|
||||
@@ -166,8 +168,13 @@ export async function listAllProfileSessions(
|
||||
}
|
||||
}
|
||||
|
||||
export function setSessionArchived(id: string, archived: boolean): Promise<{ ok: boolean }> {
|
||||
// Mutations take the owning `profile` so Electron routes them to that profile's
|
||||
// backend (remote pool or local primary) via request.profile — matching the
|
||||
// read path. A remote session's row lives only on its remote host, so a mutation
|
||||
// that hit the local primary would no-op or 404. Omit for the current/default.
|
||||
export function setSessionArchived(id: string, archived: boolean, profile?: string | null): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { archived }
|
||||
@@ -180,8 +187,10 @@ export function searchSessions(query: string): Promise<SessionSearchResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
// `profile` reads another profile's transcript straight off its state.db via the
|
||||
// primary backend (no spawn). Omit for the current/default profile.
|
||||
// Reads another profile's transcript. For a remote profile Electron reroutes
|
||||
// this GET to the remote backend (which serves its own state.db); for a local
|
||||
// profile the primary opens that profile's state.db via ?profile=. Omit for
|
||||
// the current/default profile.
|
||||
export function getSessionMessages(id: string, profile?: string | null): Promise<SessionMessagesResponse> {
|
||||
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
|
||||
|
||||
@@ -190,8 +199,9 @@ export function getSessionMessages(id: string, profile?: string | null): Promise
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteSession(id: string): Promise<{ ok: boolean }> {
|
||||
export function deleteSession(id: string, profile?: string | null): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
@@ -203,6 +213,7 @@ export function renameSession(
|
||||
profile?: string | null
|
||||
): Promise<{ ok: boolean; title: string }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; title: string }>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { title, ...(profile ? { profile } : {}) }
|
||||
@@ -554,6 +565,12 @@ export function updateProfileSoul(name: string, content: string): Promise<{ ok:
|
||||
})
|
||||
}
|
||||
|
||||
export function getProfileSetupCommand(name: string): Promise<ProfileSetupCommand> {
|
||||
return window.hermesDesktop.api<ProfileSetupCommand>({
|
||||
path: `/api/profiles/${encodeURIComponent(name)}/setup-command`
|
||||
})
|
||||
}
|
||||
|
||||
export function getUsageAnalytics(days = 30): Promise<AnalyticsResponse> {
|
||||
return window.hermesDesktop.api<AnalyticsResponse>({
|
||||
...profileScoped(),
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { en } from './en'
|
||||
import type { Locale, Translations } from './types'
|
||||
import { zh } from './zh'
|
||||
|
||||
export const TRANSLATIONS: Record<Locale, Translations> = {
|
||||
en,
|
||||
zh
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesConfigRecord } from '@/hermes'
|
||||
|
||||
import { type I18nConfigClient, I18nProvider, useI18n } from './context'
|
||||
import type { Locale } from './types'
|
||||
|
||||
function LanguageProbe({ target = 'zh' }: { target?: Locale }) {
|
||||
const { isLoadingConfig, isSavingLocale, locale, saveError, setLocale, t } = useI18n()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p data-testid="locale">{locale}</p>
|
||||
<p data-testid="label">{t.language.label}</p>
|
||||
<p data-testid="loading">{String(isLoadingConfig)}</p>
|
||||
<p data-testid="saving">{String(isSavingLocale)}</p>
|
||||
<p data-testid="save-error">{saveError?.message ?? ''}</p>
|
||||
<button onClick={() => void setLocale(target).catch(() => undefined)} type="button">
|
||||
switch
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('I18nProvider', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('defaults to English without a config client', () => {
|
||||
render(
|
||||
<I18nProvider configClient={null}>
|
||||
<LanguageProbe />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('locale').textContent).toBe('en')
|
||||
expect(screen.getByTestId('label').textContent).toBe('Language')
|
||||
})
|
||||
|
||||
it('normalizes an initial locale alias and switches translations', async () => {
|
||||
render(
|
||||
<I18nProvider configClient={null} initialLocale="zh-CN">
|
||||
<LanguageProbe target="en" />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('locale').textContent).toBe('zh')
|
||||
expect(screen.getByTestId('label').textContent).toBe('语言')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'switch' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('locale').textContent).toBe('en'))
|
||||
expect(screen.getByTestId('label').textContent).toBe('Language')
|
||||
})
|
||||
|
||||
it('loads the initial locale from display.language config', async () => {
|
||||
const configClient: I18nConfigClient = {
|
||||
getConfig: vi.fn().mockResolvedValue({ display: { language: 'zh-Hans' } }),
|
||||
saveConfig: vi.fn()
|
||||
}
|
||||
|
||||
render(
|
||||
<I18nProvider configClient={configClient}>
|
||||
<LanguageProbe />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
|
||||
|
||||
expect(screen.getByTestId('locale').textContent).toBe('zh')
|
||||
expect(screen.getByTestId('label').textContent).toBe('语言')
|
||||
expect(configClient.saveConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps English usable when config loading fails', async () => {
|
||||
const configClient: I18nConfigClient = {
|
||||
getConfig: vi.fn().mockRejectedValue(new Error('config unavailable')),
|
||||
saveConfig: vi.fn()
|
||||
}
|
||||
|
||||
render(
|
||||
<I18nProvider configClient={configClient} initialLocale="zh">
|
||||
<LanguageProbe />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
|
||||
|
||||
expect(screen.getByTestId('locale').textContent).toBe('en')
|
||||
expect(screen.getByTestId('label').textContent).toBe('Language')
|
||||
expect(configClient.saveConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not overwrite unsupported configured languages', async () => {
|
||||
const configClient: I18nConfigClient = {
|
||||
getConfig: vi.fn().mockResolvedValue({ display: { language: 'ja' } }),
|
||||
saveConfig: vi.fn()
|
||||
}
|
||||
|
||||
render(
|
||||
<I18nProvider configClient={configClient} initialLocale="zh">
|
||||
<LanguageProbe />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
|
||||
|
||||
expect(screen.getByTestId('locale').textContent).toBe('en')
|
||||
expect(screen.getByTestId('label').textContent).toBe('Language')
|
||||
expect(configClient.saveConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads latest config before saving language and preserves unrelated values', async () => {
|
||||
const saveConfig = vi.fn().mockResolvedValue({ ok: true })
|
||||
|
||||
const latestConfig: HermesConfigRecord = {
|
||||
display: { language: 'en', skin: 'slate' },
|
||||
terminal: { cwd: '/new' }
|
||||
}
|
||||
|
||||
const configClient: I18nConfigClient = {
|
||||
getConfig: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ display: { language: 'en', skin: 'mono' }, terminal: { cwd: '/old' } })
|
||||
.mockResolvedValueOnce(latestConfig),
|
||||
saveConfig
|
||||
}
|
||||
|
||||
render(
|
||||
<I18nProvider configClient={configClient}>
|
||||
<LanguageProbe />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'switch' }))
|
||||
|
||||
await waitFor(() => expect(saveConfig).toHaveBeenCalledTimes(1))
|
||||
expect(saveConfig).toHaveBeenCalledWith({
|
||||
display: { language: 'zh', skin: 'slate' },
|
||||
terminal: { cwd: '/new' }
|
||||
})
|
||||
})
|
||||
|
||||
it('rolls back the visible locale when saving fails', async () => {
|
||||
const configClient: I18nConfigClient = {
|
||||
getConfig: vi.fn().mockResolvedValue({ display: { language: 'en' } }),
|
||||
saveConfig: vi.fn().mockRejectedValue(new Error('save failed'))
|
||||
}
|
||||
|
||||
render(
|
||||
<I18nProvider configClient={configClient}>
|
||||
<LanguageProbe />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'switch' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('save-error').textContent).toBe('save failed'))
|
||||
|
||||
expect(screen.getByTestId('locale').textContent).toBe('en')
|
||||
expect(screen.getByTestId('label').textContent).toBe('Language')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { getHermesConfigRecord, type HermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
|
||||
import { TRANSLATIONS } from './catalog'
|
||||
import { DEFAULT_LOCALE, localeConfigValue, normalizeLocale } from './languages'
|
||||
import { setRuntimeI18nLocale } from './runtime'
|
||||
import type { Locale, Translations } from './types'
|
||||
|
||||
export { LOCALE_META } from './languages'
|
||||
|
||||
export interface I18nConfigClient {
|
||||
getConfig: () => Promise<HermesConfigRecord>
|
||||
saveConfig: (config: HermesConfigRecord) => Promise<{ ok: boolean }>
|
||||
}
|
||||
|
||||
const defaultConfigClient: I18nConfigClient = {
|
||||
getConfig: () => {
|
||||
if (typeof window === 'undefined' || !window.hermesDesktop?.api) {
|
||||
return Promise.resolve({})
|
||||
}
|
||||
|
||||
return getHermesConfigRecord()
|
||||
},
|
||||
saveConfig: config => {
|
||||
if (typeof window === 'undefined' || !window.hermesDesktop?.api) {
|
||||
return Promise.resolve({ ok: true })
|
||||
}
|
||||
|
||||
return saveHermesConfig(config)
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function getConfigDisplayLanguage(config: HermesConfigRecord): unknown {
|
||||
return isRecord(config.display) ? config.display.language : undefined
|
||||
}
|
||||
|
||||
export function withConfigDisplayLanguage(config: HermesConfigRecord, locale: Locale): HermesConfigRecord {
|
||||
const display = isRecord(config.display) ? config.display : {}
|
||||
|
||||
return {
|
||||
...config,
|
||||
display: {
|
||||
...display,
|
||||
language: localeConfigValue(locale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
|
||||
export interface I18nContextValue {
|
||||
configLoadError: Error | null
|
||||
isLoadingConfig: boolean
|
||||
isSavingLocale: boolean
|
||||
locale: Locale
|
||||
saveError: Error | null
|
||||
setLocale: (next: Locale) => Promise<void>
|
||||
t: Translations
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue>({
|
||||
configLoadError: null,
|
||||
isLoadingConfig: false,
|
||||
isSavingLocale: false,
|
||||
locale: DEFAULT_LOCALE,
|
||||
saveError: null,
|
||||
setLocale: async () => {},
|
||||
t: TRANSLATIONS[DEFAULT_LOCALE]
|
||||
})
|
||||
|
||||
export interface I18nProviderProps {
|
||||
children: ReactNode
|
||||
configClient?: I18nConfigClient | null
|
||||
initialLocale?: unknown
|
||||
}
|
||||
|
||||
export function I18nProvider({ children, configClient = defaultConfigClient, initialLocale }: I18nProviderProps) {
|
||||
const [locale, setLocaleState] = useState<Locale>(() => normalizeLocale(initialLocale))
|
||||
const [isLoadingConfig, setIsLoadingConfig] = useState(false)
|
||||
const [isSavingLocale, setIsSavingLocale] = useState(false)
|
||||
const [configLoadError, setConfigLoadError] = useState<Error | null>(null)
|
||||
const [saveError, setSaveError] = useState<Error | null>(null)
|
||||
const localeRef = useRef(locale)
|
||||
|
||||
useEffect(() => {
|
||||
localeRef.current = locale
|
||||
setRuntimeI18nLocale(locale)
|
||||
}, [locale])
|
||||
|
||||
useEffect(() => {
|
||||
if (!configClient) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
setIsLoadingConfig(true)
|
||||
setConfigLoadError(null)
|
||||
|
||||
configClient
|
||||
.getConfig()
|
||||
.then(config => {
|
||||
if (!cancelled) {
|
||||
setLocaleState(normalizeLocale(getConfigDisplayLanguage(config)))
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (!cancelled) {
|
||||
setConfigLoadError(toError(error))
|
||||
setLocaleState(DEFAULT_LOCALE)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoadingConfig(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [configClient, initialLocale])
|
||||
|
||||
const setLocale = useCallback(
|
||||
async (next: Locale) => {
|
||||
const previousLocale = localeRef.current
|
||||
|
||||
setSaveError(null)
|
||||
setLocaleState(next)
|
||||
|
||||
if (!configClient) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSavingLocale(true)
|
||||
|
||||
try {
|
||||
const latestConfig = await configClient.getConfig()
|
||||
const result = await configClient.saveConfig(withConfigDisplayLanguage(latestConfig, next))
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error('Failed to save language')
|
||||
}
|
||||
} catch (error) {
|
||||
const nextError = toError(error)
|
||||
|
||||
setLocaleState(previousLocale)
|
||||
setSaveError(nextError)
|
||||
|
||||
throw nextError
|
||||
} finally {
|
||||
setIsSavingLocale(false)
|
||||
}
|
||||
},
|
||||
[configClient]
|
||||
)
|
||||
|
||||
const value = useMemo<I18nContextValue>(
|
||||
() => ({
|
||||
configLoadError,
|
||||
isLoadingConfig,
|
||||
isSavingLocale,
|
||||
locale,
|
||||
saveError,
|
||||
setLocale,
|
||||
t: TRANSLATIONS[locale]
|
||||
}),
|
||||
[configLoadError, isLoadingConfig, isSavingLocale, locale, saveError, setLocale]
|
||||
)
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
||||
}
|
||||
|
||||
export function useI18n(): I18nContextValue {
|
||||
return useContext(I18nContext)
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
|
||||
|
||||
import type { Translations } from './types'
|
||||
|
||||
export const en: Translations = {
|
||||
common: {
|
||||
save: 'Save',
|
||||
saving: 'Saving…',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
confirm: 'Confirm',
|
||||
delete: 'Delete',
|
||||
refresh: 'Refresh',
|
||||
retry: 'Retry',
|
||||
on: 'On',
|
||||
off: 'Off'
|
||||
},
|
||||
|
||||
boot: {
|
||||
ready: 'Hermes Desktop is ready',
|
||||
desktopBootFailedWithMessage: message => `Desktop boot failed: ${message}`,
|
||||
steps: {
|
||||
connectingGateway: 'Connecting live desktop gateway',
|
||||
loadingSettings: 'Loading Hermes settings',
|
||||
loadingSessions: 'Loading recent sessions',
|
||||
startingDesktopConnection: 'Starting desktop connection',
|
||||
startingHermesDesktop: 'Starting Hermes Desktop…'
|
||||
},
|
||||
errors: {
|
||||
backgroundExited: 'Hermes background process exited.',
|
||||
backgroundExitedDuringStartup: 'Hermes background process exited during startup.',
|
||||
backendStopped: 'Backend stopped',
|
||||
desktopBootFailed: 'Desktop boot failed',
|
||||
gatewaySignInRequired: 'Gateway sign-in required',
|
||||
ipcBridgeUnavailable: 'Desktop IPC bridge is unavailable.'
|
||||
},
|
||||
failure: {
|
||||
title: "Hermes couldn't start",
|
||||
description:
|
||||
"The background gateway didn't come up. Try one of the recovery steps below. Nothing here deletes your chats or settings.",
|
||||
remoteTitle: 'Remote gateway sign-in required',
|
||||
remoteDescription:
|
||||
'Your remote gateway session has expired. Sign in again to reconnect. Nothing here deletes your chats or settings.',
|
||||
retry: 'Retry',
|
||||
repairInstall: 'Repair install',
|
||||
useLocalGateway: 'Use local gateway',
|
||||
openLogs: 'Open logs',
|
||||
repairHint: 'Repair re-runs the installer and can take a few minutes on a fresh machine.',
|
||||
remoteSignInHint: 'Opens the gateway login window. Use local gateway to switch to the bundled backend instead.',
|
||||
hideRecentLogs: 'Hide recent logs',
|
||||
showRecentLogs: 'Show recent logs',
|
||||
signedInTitle: 'Signed in',
|
||||
signedInMessage: 'Reconnecting to the remote gateway…',
|
||||
signInIncompleteTitle: 'Sign-in incomplete',
|
||||
signInIncompleteMessage: 'The login window closed before authentication finished.',
|
||||
signInFailed: 'Sign-in failed',
|
||||
signInToRemoteGateway: 'Sign in to remote gateway',
|
||||
signInWithProvider: provider => `Sign in with ${provider}`,
|
||||
identityProvider: 'your identity provider'
|
||||
}
|
||||
},
|
||||
|
||||
notifications: {
|
||||
region: 'Notifications',
|
||||
hide: 'Hide',
|
||||
show: 'Show',
|
||||
more: count => `${count} more ${count === 1 ? 'notification' : 'notifications'}`,
|
||||
clearAll: 'Clear all',
|
||||
dismiss: 'Dismiss notification',
|
||||
details: 'Details',
|
||||
copyDetail: 'Copy detail',
|
||||
copyDetailFailed: 'Could not copy notification detail',
|
||||
backendOutOfDateTitle: 'Backend out of date',
|
||||
backendOutOfDateMessage:
|
||||
'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
|
||||
updateHermes: 'Update Hermes',
|
||||
updateReadyTitle: 'Update ready',
|
||||
updateReadyMessage: count => `${count} new change${count === 1 ? '' : 's'} available.`,
|
||||
seeWhatsNew: "See what's new",
|
||||
errors: {
|
||||
elevenLabsNeedsKey: 'ElevenLabs STT needs ELEVENLABS_API_KEY.',
|
||||
elevenLabsRejectedKey: 'ElevenLabs rejected the API key (401).',
|
||||
methodNotAllowed:
|
||||
'The desktop backend rejected that request (405 Method Not Allowed). Try restarting Hermes Desktop.',
|
||||
microphonePermission: 'Microphone permission was denied.',
|
||||
openaiRejectedApiKey: 'OpenAI rejected the API key.',
|
||||
openaiRejectedApiKeyWithStatus: status => `OpenAI rejected the API key (${status} invalid_api_key).`,
|
||||
openaiTtsNeedsKey: 'OpenAI TTS needs VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY.'
|
||||
}
|
||||
},
|
||||
|
||||
titlebar: {
|
||||
hideSidebar: 'Hide sidebar',
|
||||
showSidebar: 'Show sidebar',
|
||||
search: 'Search',
|
||||
searchTitle: 'Search sessions, views, and actions',
|
||||
swapSidebarSides: 'Swap sidebar sides',
|
||||
swapSidebarSidesTitle: 'Swap the sessions and file browser sides',
|
||||
hideRightSidebar: 'Hide right sidebar',
|
||||
showRightSidebar: 'Show right sidebar',
|
||||
muteHaptics: 'Mute haptics',
|
||||
unmuteHaptics: 'Unmute haptics',
|
||||
openSettings: 'Open settings'
|
||||
},
|
||||
|
||||
language: {
|
||||
label: 'Language',
|
||||
description: 'Choose the language for the desktop interface.',
|
||||
saving: 'Saving language…',
|
||||
saveError: 'Language update failed'
|
||||
},
|
||||
|
||||
settings: {
|
||||
closeSettings: 'Close settings',
|
||||
exportConfig: 'Export config',
|
||||
importConfig: 'Import config',
|
||||
resetToDefaults: 'Reset to defaults',
|
||||
resetConfirm: 'Reset all settings to Hermes defaults?',
|
||||
exportFailed: 'Export failed',
|
||||
resetFailed: 'Reset failed',
|
||||
nav: {
|
||||
gateway: 'Gateway',
|
||||
apiKeys: 'Tools & Keys',
|
||||
mcp: 'MCP',
|
||||
archivedChats: 'Archived Chats',
|
||||
about: 'About'
|
||||
},
|
||||
sections: {
|
||||
model: 'Model',
|
||||
chat: 'Chat',
|
||||
appearance: 'Appearance',
|
||||
workspace: 'Workspace',
|
||||
safety: 'Safety',
|
||||
memory: 'Memory & Context',
|
||||
voice: 'Voice',
|
||||
advanced: 'Advanced'
|
||||
},
|
||||
searchPlaceholder: {
|
||||
about: 'About Hermes Desktop',
|
||||
config: 'Search settings...',
|
||||
gateway: 'Gateway connection...',
|
||||
keys: 'Search API keys...',
|
||||
mcp: 'Search MCP servers...',
|
||||
sessions: 'Search archived sessions...'
|
||||
},
|
||||
modeOptions: {
|
||||
light: { label: 'Light', description: 'Bright desktop surfaces' },
|
||||
dark: { label: 'Dark', description: 'Low-glare workspace' },
|
||||
system: { label: 'System', description: 'Follow OS appearance' }
|
||||
},
|
||||
appearance: {
|
||||
title: 'Appearance',
|
||||
intro:
|
||||
'These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and chat surface styling.',
|
||||
colorMode: 'Color Mode',
|
||||
colorModeDesc: 'Pick a fixed mode or let Hermes follow your system setting.',
|
||||
toolViewTitle: 'Tool Call Display',
|
||||
toolViewDesc: 'Product hides raw tool payloads; Technical shows full input/output.',
|
||||
product: 'Product',
|
||||
productDesc: 'Human-friendly tool activity with concise summaries.',
|
||||
technical: 'Technical',
|
||||
technicalDesc: 'Include raw tool args/results and low-level details.',
|
||||
themeTitle: 'Theme',
|
||||
themeDesc: 'Desktop palettes only. The selected mode is applied on top.'
|
||||
},
|
||||
fieldLabels: FIELD_LABELS,
|
||||
fieldDescriptions: FIELD_DESCRIPTIONS,
|
||||
about: {
|
||||
heading: 'Hermes Desktop',
|
||||
version: value => `Version ${value}`,
|
||||
versionUnavailable: 'Version unavailable',
|
||||
updates: 'Updates',
|
||||
checkNow: 'Check now',
|
||||
checking: 'Checking…',
|
||||
seeWhatsNew: "See what's new",
|
||||
releaseNotes: 'Release notes',
|
||||
onLatest: "You're on the latest version.",
|
||||
installing: 'An update is currently installing.',
|
||||
cantUpdate: "This build can't update itself from inside the app.",
|
||||
cantReach: "We couldn't reach the update server.",
|
||||
tapCheck: 'Tap "Check now" to look for updates.',
|
||||
updateReady: count => `A new update is ready (${count} change${count === 1 ? '' : 's'} included).`,
|
||||
lastChecked: age => `Last checked ${age}`,
|
||||
justNowSuffix: ' · just now',
|
||||
automaticUpdates: 'Automatic updates',
|
||||
automaticUpdatesDesc:
|
||||
'Hermes checks for updates automatically in the background and lets you know when one is ready.',
|
||||
branchCommit: (branch, commit) => `Branch ${branch} · Commit ${commit}`,
|
||||
never: 'never',
|
||||
justNow: 'just now',
|
||||
minAgo: count => `${count} min ago`,
|
||||
hoursAgo: count => `${count} hours ago`,
|
||||
daysAgo: count => `${count} days ago`
|
||||
}
|
||||
},
|
||||
|
||||
skills: {
|
||||
tabSkills: 'Skills',
|
||||
tabToolsets: 'Toolsets',
|
||||
all: 'All',
|
||||
searchSkills: 'Search skills...',
|
||||
searchToolsets: 'Search toolsets...',
|
||||
refresh: 'Refresh skills',
|
||||
refreshing: 'Refreshing skills',
|
||||
loading: 'Loading capabilities...',
|
||||
noSkillsTitle: 'No skills found',
|
||||
noSkillsDesc: 'Try a broader search or different category.',
|
||||
noToolsetsTitle: 'No toolsets found',
|
||||
noToolsetsDesc: 'Try a broader search query.',
|
||||
noDescription: 'No description.',
|
||||
configured: 'Configured',
|
||||
needsKeys: 'Needs keys',
|
||||
toolsetsEnabled: (enabled, total) => `${enabled}/${total} toolsets enabled`,
|
||||
configureToolset: label => `Configure ${label}`,
|
||||
toggleToolset: label => `Toggle ${label} toolset`,
|
||||
skillsLoadFailed: 'Skills failed to load',
|
||||
toolsetsRefreshFailed: 'Toolsets failed to refresh',
|
||||
skillEnabled: 'Skill enabled',
|
||||
skillDisabled: 'Skill disabled',
|
||||
toolsetEnabled: 'Toolset enabled',
|
||||
toolsetDisabled: 'Toolset disabled',
|
||||
appliesToNewSessions: name => `${name} applies to new sessions.`,
|
||||
failedToUpdate: name => `Failed to update ${name}`
|
||||
},
|
||||
|
||||
agents: {
|
||||
close: 'Close agents',
|
||||
title: 'Spawn tree',
|
||||
subtitle: 'Live subagent activity for the current turn.',
|
||||
emptyTitle: 'No live subagents',
|
||||
emptyDesc: 'When a turn delegates work, child agents stream their progress here.',
|
||||
running: 'Running',
|
||||
failed: 'Failed',
|
||||
done: 'Done',
|
||||
streaming: 'Streaming',
|
||||
files: 'Files',
|
||||
moreFiles: count => `+${count} more files`,
|
||||
delegation: index => `Delegation ${index}`,
|
||||
workers: count => `${count} workers`,
|
||||
workersActive: count => `${count} active`,
|
||||
agentsCount: count => `${count} ${count === 1 ? 'agent' : 'agents'}`,
|
||||
activeCount: count => `${count} active`,
|
||||
failedCount: count => `${count} failed`,
|
||||
toolsCount: count => `${count} tools`,
|
||||
filesCount: count => `${count} files`,
|
||||
updatedAgo: age => `updated ${age}`,
|
||||
ageNow: 'now',
|
||||
ageSeconds: seconds => `${seconds}s ago`,
|
||||
ageMinutes: minutes => `${minutes}m ago`,
|
||||
ageHours: hours => `${hours}h ago`,
|
||||
durationSeconds: seconds => `${seconds}s`,
|
||||
durationMinutes: (minutes, seconds) => `${minutes}m ${seconds}s`,
|
||||
tokensK: k => `${k}k tok`,
|
||||
tokens: value => `${value} tok`
|
||||
},
|
||||
|
||||
commandCenter: {
|
||||
close: 'Close command center',
|
||||
searchPlaceholder: 'Search sessions, views, and actions',
|
||||
sections: { sessions: 'Sessions', system: 'System', usage: 'Usage' },
|
||||
sectionDescriptions: {
|
||||
sessions: 'Search and manage sessions',
|
||||
system: 'Status, logs, and system actions',
|
||||
usage: 'Token, cost, and skill activity over time'
|
||||
},
|
||||
nav: {
|
||||
newChat: { title: 'New session', detail: 'Start a fresh session' },
|
||||
settings: { title: 'Settings', detail: 'Configure Hermes desktop' },
|
||||
skills: { title: 'Skills & Tools', detail: 'Enable skills, toolsets, and providers' },
|
||||
messaging: { title: 'Messaging', detail: 'Set up Telegram, Slack, Discord, and more' },
|
||||
artifacts: { title: 'Artifacts', detail: 'Browse generated outputs' }
|
||||
},
|
||||
sectionEntries: {
|
||||
sessions: { title: 'Sessions panel', detail: 'Search, pin, and manage sessions' },
|
||||
system: { title: 'System panel', detail: 'Gateway status, logs, restart/update' },
|
||||
usage: { title: 'Usage panel', detail: 'Token, cost, and skill activity' }
|
||||
},
|
||||
providerNavigate: 'Navigate',
|
||||
providerSessions: 'Sessions',
|
||||
refresh: 'Refresh',
|
||||
refreshing: 'Refreshing...',
|
||||
noResults: 'No matching results found.',
|
||||
pinSession: 'Pin session',
|
||||
unpinSession: 'Unpin session',
|
||||
exportSession: 'Export session',
|
||||
deleteSession: 'Delete session',
|
||||
noSessions: 'No sessions yet.',
|
||||
gatewayRunning: 'Messaging gateway running',
|
||||
gatewayStopped: 'Messaging gateway stopped',
|
||||
hermesActiveSessions: (version, count) => `Hermes ${version} · Active sessions ${count}`,
|
||||
restartMessaging: 'Restart messaging',
|
||||
updateHermes: 'Update Hermes',
|
||||
actionRunning: 'running',
|
||||
actionDone: 'done',
|
||||
actionFailed: 'failed',
|
||||
actionStartedWaiting: 'Action started, waiting for status...',
|
||||
loadingStatus: 'Loading status...',
|
||||
recentLogs: 'Recent logs',
|
||||
noLogs: 'No logs loaded yet.',
|
||||
days: count => `${count}d`,
|
||||
statSessions: 'Sessions',
|
||||
statApiCalls: 'API calls',
|
||||
statTokens: 'Tokens in/out',
|
||||
statCost: 'Est. cost',
|
||||
actualCost: cost => `actual ${cost}`,
|
||||
loadingUsage: 'Loading usage...',
|
||||
noUsage: period => `No usage in the last ${period} days.`,
|
||||
retry: 'Retry',
|
||||
dailyTokens: 'Daily tokens',
|
||||
input: 'input',
|
||||
output: 'output',
|
||||
noDailyActivity: 'No daily activity.',
|
||||
topModels: 'Top models',
|
||||
noModelUsage: 'No model usage yet.',
|
||||
topSkills: 'Top skills',
|
||||
noSkillActivity: 'No skill activity yet.',
|
||||
actions: count => `${count} actions`
|
||||
},
|
||||
|
||||
messaging: {
|
||||
search: 'Search messaging...',
|
||||
loading: 'Loading messaging platforms...',
|
||||
loadFailed: 'Messaging platforms failed to load',
|
||||
states: {
|
||||
connected: 'Connected',
|
||||
connecting: 'Connecting',
|
||||
disabled: 'Disabled',
|
||||
fatal: 'Error',
|
||||
gateway_stopped: 'Messaging gateway stopped',
|
||||
not_configured: 'Needs setup',
|
||||
pending_restart: 'Restart needed',
|
||||
retrying: 'Retrying',
|
||||
startup_failed: 'Startup failed'
|
||||
},
|
||||
unknown: 'Unknown',
|
||||
hintPendingRestart: 'Restart the gateway from the status bar to apply this change.',
|
||||
hintGatewayStopped: 'Start the gateway from the status bar to connect.',
|
||||
credentialsSet: 'Credentials set',
|
||||
needsSetup: 'Needs setup',
|
||||
gatewayStopped: 'Messaging gateway stopped',
|
||||
getCredentials: 'Get your credentials',
|
||||
openSetupGuide: 'Open setup guide',
|
||||
required: 'Required',
|
||||
recommended: 'Recommended',
|
||||
advanced: count => `Advanced (${count})`,
|
||||
noTokenNeeded: 'This platform does not need a token here. Use the setup guide above, then enable it below.',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
unsavedChanges: 'Unsaved changes',
|
||||
saving: 'Saving...',
|
||||
saveChanges: 'Save changes',
|
||||
saved: 'Saved',
|
||||
replaceValue: 'Replace current value',
|
||||
openDocs: 'Open docs',
|
||||
clearField: key => `Clear ${key}`,
|
||||
enableAria: name => `Enable ${name}`,
|
||||
disableAria: name => `Disable ${name}`,
|
||||
platformEnabled: name => `${name} enabled`,
|
||||
platformDisabled: name => `${name} disabled`,
|
||||
restartToApply: 'Restart the gateway for this change to take effect.',
|
||||
setupSaved: name => `${name} setup saved`,
|
||||
restartToReconnect: 'Restart the gateway to reconnect with the new credentials.',
|
||||
keyCleared: key => `${key} cleared`,
|
||||
setupUpdated: name => `${name} setup was updated.`,
|
||||
failedUpdate: name => `Failed to update ${name}`,
|
||||
failedSave: name => `Failed to save ${name}`,
|
||||
failedClear: key => `Failed to clear ${key}`,
|
||||
fieldCopy: {},
|
||||
platformIntro: {}
|
||||
},
|
||||
|
||||
profiles: {
|
||||
close: 'Close profiles',
|
||||
nameHint: 'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.',
|
||||
title: 'Profiles',
|
||||
count: count => `${count} ${count === 1 ? 'profile' : 'profiles'}`,
|
||||
loading: 'Loading profiles...',
|
||||
newProfile: 'New profile',
|
||||
noProfiles: 'No profiles yet.',
|
||||
selectPrompt: 'Select a profile to view its details.',
|
||||
refresh: 'Refresh profiles',
|
||||
refreshing: 'Refreshing profiles',
|
||||
default: 'default',
|
||||
skills: count => `${count} ${count === 1 ? 'skill' : 'skills'}`,
|
||||
env: 'env',
|
||||
defaultBadge: 'Default',
|
||||
rename: 'Rename',
|
||||
copySetup: 'Copy setup',
|
||||
copying: 'Copying...',
|
||||
modelLabel: 'Model',
|
||||
skillsLabel: 'Skills',
|
||||
notSet: 'Not set',
|
||||
soulDesc: 'The system prompt and persona instructions baked into this profile.',
|
||||
unsavedChanges: 'Unsaved changes',
|
||||
loadingSoul: 'Loading SOUL.md...',
|
||||
emptySoul: 'Empty SOUL.md — start writing the persona...',
|
||||
saving: 'Saving...',
|
||||
saveSoul: 'Save SOUL.md',
|
||||
deleteTitle: 'Delete profile?',
|
||||
deleteDescPrefix: 'This will delete ',
|
||||
deleteDescMid: ' and remove its ',
|
||||
deleteDescSuffix: ' directory. This cannot be undone.',
|
||||
deleting: 'Deleting...',
|
||||
createDesc: 'Profiles are independent Hermes environments: separate config, skills, and SOUL.md.',
|
||||
nameLabel: 'Name',
|
||||
cloneFromDefault: 'Clone from default',
|
||||
cloneFromDefaultDesc: 'Copy config, skills, and SOUL.md from your default profile.',
|
||||
invalidName: hint => `Invalid name. ${hint}`,
|
||||
nameRequired: 'Name is required.',
|
||||
creating: 'Creating...',
|
||||
createAction: 'Create profile',
|
||||
renameTitle: 'Rename profile',
|
||||
renameDescPrefix: 'Renaming updates the profile directory and any wrapper scripts in ',
|
||||
renameDescSuffix: '.',
|
||||
newNameLabel: 'New name',
|
||||
renaming: 'Renaming...',
|
||||
created: 'Profile created',
|
||||
renamed: 'Profile renamed',
|
||||
deleted: 'Profile deleted',
|
||||
setupCopied: 'Setup command copied',
|
||||
soulSaved: 'SOUL.md saved',
|
||||
failedLoad: 'Failed to load profiles',
|
||||
failedDelete: 'Failed to delete profile',
|
||||
failedCopy: 'Failed to copy setup command',
|
||||
failedLoadSoul: 'Failed to load SOUL.md',
|
||||
failedSaveSoul: 'Failed to save SOUL.md',
|
||||
failedCreate: 'Failed to create profile',
|
||||
failedRename: 'Failed to rename profile'
|
||||
},
|
||||
|
||||
cron: {
|
||||
close: 'Close cron',
|
||||
search: 'Search cron jobs...',
|
||||
refresh: 'Refresh cron jobs',
|
||||
refreshing: 'Refreshing cron jobs',
|
||||
loading: 'Loading cron jobs...',
|
||||
states: {
|
||||
enabled: 'enabled',
|
||||
scheduled: 'scheduled',
|
||||
running: 'running',
|
||||
paused: 'paused',
|
||||
disabled: 'disabled',
|
||||
error: 'error',
|
||||
completed: 'completed'
|
||||
},
|
||||
deliveryLabels: {
|
||||
local: 'This desktop',
|
||||
telegram: 'Telegram',
|
||||
discord: 'Discord',
|
||||
slack: 'Slack',
|
||||
email: 'Email'
|
||||
},
|
||||
scheduleLabels: {
|
||||
daily: 'Daily',
|
||||
weekdays: 'Weekdays',
|
||||
weekly: 'Weekly',
|
||||
monthly: 'Monthly',
|
||||
hourly: 'Hourly',
|
||||
'every-15-minutes': 'Every 15 minutes',
|
||||
custom: 'Custom'
|
||||
},
|
||||
scheduleHints: {
|
||||
daily: 'Every day at 9:00 AM',
|
||||
weekdays: 'Monday through Friday at 9:00 AM',
|
||||
weekly: 'Every Monday at 9:00 AM',
|
||||
monthly: 'The first day of each month at 9:00 AM',
|
||||
hourly: 'At the top of every hour',
|
||||
'every-15-minutes': 'Every 15 minutes',
|
||||
custom: 'Cron syntax or natural language'
|
||||
},
|
||||
days: {
|
||||
'0': 'Sunday',
|
||||
'1': 'Monday',
|
||||
'2': 'Tuesday',
|
||||
'3': 'Wednesday',
|
||||
'4': 'Thursday',
|
||||
'5': 'Friday',
|
||||
'6': 'Saturday',
|
||||
'7': 'Sunday'
|
||||
},
|
||||
dayFallback: value => `day ${value}`,
|
||||
everyDayAt: time => `Every day at ${time}`,
|
||||
weekdaysAt: time => `Weekdays at ${time}`,
|
||||
everyDayOfWeekAt: (day, time) => `Every ${day} at ${time}`,
|
||||
monthlyOnDayAt: (dayOfMonth, time) => `Monthly on day ${dayOfMonth} at ${time}`,
|
||||
topOfHour: 'At the top of every hour',
|
||||
everyHourAt: minute => `Every hour at :${minute}`,
|
||||
active: (enabled, total) => `${enabled}/${total} active`,
|
||||
newCron: 'New cron',
|
||||
createFirst: 'Create first cron',
|
||||
emptyDescNew:
|
||||
'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.',
|
||||
emptyDescSearch: 'Try a broader search query.',
|
||||
emptyTitleNew: 'No scheduled jobs yet',
|
||||
emptyTitleSearch: 'No matches',
|
||||
last: 'Last:',
|
||||
next: 'Next:',
|
||||
actionsFor: title => `Actions for ${title}`,
|
||||
actionsTitle: 'Cron job actions',
|
||||
resume: 'Resume cron',
|
||||
pause: 'Pause cron',
|
||||
resumeTitle: 'Resume',
|
||||
pauseTitle: 'Pause',
|
||||
triggerNow: 'Trigger now',
|
||||
edit: 'Edit cron',
|
||||
deleteTitle: 'Delete cron job?',
|
||||
deleteDescPrefix: 'This will remove ',
|
||||
deleteDescSuffix: ' permanently. It will stop firing immediately.',
|
||||
deleting: 'Deleting...',
|
||||
resumed: 'Cron resumed',
|
||||
paused: 'Cron paused',
|
||||
triggered: 'Cron triggered',
|
||||
deleted: 'Cron deleted',
|
||||
created: 'Cron created',
|
||||
updated: 'Cron updated',
|
||||
failedLoad: 'Failed to load cron jobs',
|
||||
failedUpdate: 'Failed to update cron job',
|
||||
failedTrigger: 'Failed to trigger cron job',
|
||||
failedDelete: 'Failed to delete cron job',
|
||||
failedSave: 'Failed to save cron job',
|
||||
editTitle: 'Edit cron job',
|
||||
createTitle: 'New cron job',
|
||||
editDesc: 'Update the schedule, prompt, or delivery target. Changes apply on next run.',
|
||||
createDesc:
|
||||
'Schedule a prompt to run automatically. Use cron syntax or a natural phrase like "every 15 minutes".',
|
||||
nameLabel: 'Name',
|
||||
namePlaceholder: 'Morning briefing',
|
||||
promptLabel: 'Prompt',
|
||||
promptPlaceholder: 'Summarize my unread Slack threads and email me the top 5...',
|
||||
frequencyLabel: 'Frequency',
|
||||
deliverLabel: 'Deliver to',
|
||||
customScheduleLabel: 'Custom schedule',
|
||||
customPlaceholder: '0 9 * * * or weekdays at 9am',
|
||||
customHint: 'Cron expression, or phrases like "every hour" or "weekdays at 9am".',
|
||||
optional: 'Optional',
|
||||
promptScheduleRequired: 'Prompt and schedule are required.',
|
||||
saveChanges: 'Save changes',
|
||||
createAction: 'Create cron'
|
||||
},
|
||||
|
||||
artifacts: {
|
||||
search: 'Search artifacts...',
|
||||
refresh: 'Refresh artifacts',
|
||||
refreshing: 'Refreshing artifacts',
|
||||
indexing: 'Indexing recent session artifacts',
|
||||
tabAll: 'All',
|
||||
tabImages: 'Images',
|
||||
tabFiles: 'Files',
|
||||
tabLinks: 'Links',
|
||||
noArtifactsTitle: 'No artifacts found',
|
||||
noArtifactsDesc: 'Generated images and file outputs will appear here as sessions produce them.',
|
||||
failedLoad: 'Artifacts failed to load',
|
||||
openFailed: 'Open failed',
|
||||
itemsImage: 'images',
|
||||
itemsLink: 'links',
|
||||
itemsFile: 'files',
|
||||
itemsGeneric: 'items',
|
||||
zero: '0',
|
||||
rangeOf: (start, end, total) => `${start}-${end} of ${total}`,
|
||||
goToPage: (itemLabel, page) => `Go to ${itemLabel} page ${page}`,
|
||||
colTitleLink: 'Link title',
|
||||
colTitleFile: 'Name',
|
||||
colTitleDefault: 'Title / name',
|
||||
colLocationLink: 'URL',
|
||||
colLocationFile: 'Path',
|
||||
colLocationDefault: 'Location',
|
||||
colSession: 'Session',
|
||||
kindImage: 'image',
|
||||
kindFile: 'file',
|
||||
kindLink: 'link',
|
||||
chat: 'Chat',
|
||||
copyUrl: 'Copy URL',
|
||||
copyPath: 'Copy path'
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
nav: {
|
||||
'new-session': 'New session',
|
||||
skills: 'Skills & Tools',
|
||||
messaging: 'Messaging',
|
||||
artifacts: 'Artifacts'
|
||||
},
|
||||
searchAria: 'Search sessions',
|
||||
searchPlaceholder: 'Search sessions…',
|
||||
clearSearch: 'Clear search',
|
||||
noMatch: query => `No sessions match “${query}”.`,
|
||||
results: 'Results',
|
||||
pinned: 'Pinned',
|
||||
sessions: 'Sessions',
|
||||
groupAriaGrouped: 'Show sessions as a single list',
|
||||
groupAriaUngrouped: 'Group sessions by workspace',
|
||||
groupTitleGrouped: 'Ungroup sessions',
|
||||
groupTitleUngrouped: 'Group by workspace',
|
||||
allPinned: 'Everything here is pinned. Unpin a chat to show it in recents.',
|
||||
shiftClickHint: 'Shift-click a chat to pin · drag to reorder',
|
||||
noWorkspace: 'No workspace',
|
||||
newSessionIn: label => `New session in ${label}`,
|
||||
reorderWorkspace: label => `Reorder workspace ${label}`,
|
||||
showMoreIn: (count, label) => `Show ${count} more in ${label}`,
|
||||
loading: 'Loading…',
|
||||
loadMore: 'Load more',
|
||||
loadCount: step => `Load ${step} more`,
|
||||
row: {
|
||||
pin: 'Pin',
|
||||
unpin: 'Unpin',
|
||||
copyId: 'Copy ID',
|
||||
export: 'Export',
|
||||
rename: 'Rename',
|
||||
archive: 'Archive',
|
||||
copyIdFailed: 'Could not copy session ID',
|
||||
actionsFor: title => `Actions for ${title}`,
|
||||
sessionActions: 'Session actions',
|
||||
sessionRunning: 'Session running',
|
||||
needsInput: 'Needs your input',
|
||||
waitingForAnswer: 'Waiting for your answer',
|
||||
renamed: 'Renamed',
|
||||
renameFailed: 'Rename failed',
|
||||
renameTitle: 'Rename session',
|
||||
renameDesc: 'Give this chat a memorable title. Leave empty to clear.',
|
||||
untitledPlaceholder: 'Untitled session',
|
||||
ageNow: 'now',
|
||||
ageDay: 'd',
|
||||
ageHour: 'h',
|
||||
ageMin: 'm'
|
||||
}
|
||||
},
|
||||
|
||||
composer: {
|
||||
message: 'Message',
|
||||
placeholderStarting: 'Starting Hermes...',
|
||||
placeholderReconnecting: 'Reconnecting to Hermes…',
|
||||
placeholderFollowUp: 'Send follow-up',
|
||||
newSessionPlaceholders: [
|
||||
'What are we building?',
|
||||
'Give Hermes a task',
|
||||
"What's on your mind?",
|
||||
'Describe what you need',
|
||||
'What should we tackle?',
|
||||
'Ask anything',
|
||||
'Start with a goal'
|
||||
],
|
||||
followUpPlaceholders: [
|
||||
'Send a follow-up',
|
||||
'Add more context',
|
||||
'Refine the request',
|
||||
"What's next?",
|
||||
'Keep it going',
|
||||
'Push it further',
|
||||
'Adjust or continue'
|
||||
],
|
||||
startVoice: 'Start voice conversation',
|
||||
queueMessage: 'Queue message',
|
||||
stop: 'Stop',
|
||||
send: 'Send',
|
||||
speaking: 'Speaking',
|
||||
transcribing: 'Transcribing',
|
||||
thinking: 'Thinking',
|
||||
muted: 'Muted',
|
||||
listening: 'Listening',
|
||||
muteMic: 'Mute microphone',
|
||||
unmuteMic: 'Unmute microphone',
|
||||
stopListening: 'Stop listening and send',
|
||||
stopShort: 'Stop',
|
||||
endConversation: 'End voice conversation',
|
||||
endShort: 'End',
|
||||
stopDictation: 'Stop dictation',
|
||||
transcribingDictation: 'Transcribing dictation',
|
||||
voiceDictation: 'Voice dictation',
|
||||
commonCommands: 'Common commands',
|
||||
hotkeys: 'Hotkeys',
|
||||
helpFooter: 'opens the full panel · backspace dismisses',
|
||||
commandDescs: {
|
||||
'/help': 'full list of commands + hotkeys',
|
||||
'/clear': 'start a new session',
|
||||
'/resume': 'resume a prior session',
|
||||
'/details': 'control transcript detail level',
|
||||
'/copy': 'copy selection or last assistant message',
|
||||
'/quit': 'exit hermes'
|
||||
},
|
||||
hotkeyDescs: {
|
||||
'@': 'reference files, folders, urls, git',
|
||||
'/': 'slash command palette',
|
||||
'?': 'this quick help (delete to dismiss)',
|
||||
Enter: 'send · Shift+Enter for newline',
|
||||
'Cmd/Ctrl+K': 'send next queued turn',
|
||||
'Cmd/Ctrl+L': 'redraw',
|
||||
Esc: 'close popover · cancel run',
|
||||
'↑ / ↓': 'cycle popover / history'
|
||||
},
|
||||
attachUrlTitle: 'Attach a URL',
|
||||
attachUrlDesc: 'Hermes will fetch the page and include it as context for this turn.',
|
||||
urlPlaceholder: 'https://example.com/post',
|
||||
urlHintPre: 'Include the full URL, e.g. ',
|
||||
attach: 'Attach',
|
||||
queued: count => `${count} Queued`,
|
||||
attachmentOnly: 'Attachment-only turn',
|
||||
emptyTurn: 'Empty turn',
|
||||
attachments: count => `${count} attachment${count === 1 ? '' : 's'}`,
|
||||
editingInComposer: 'Editing in composer',
|
||||
editQueued: 'Edit queued turn',
|
||||
sendQueuedNext: 'Send queued turn next',
|
||||
sendQueuedNow: 'Send queued turn now',
|
||||
deleteQueued: 'Delete queued turn',
|
||||
previewUnavailable: 'Preview unavailable',
|
||||
previewLabel: label => `Preview ${label}`,
|
||||
couldNotPreview: label => `Could not preview ${label}`,
|
||||
removeAttachment: label => `Remove ${label}`,
|
||||
dictating: 'Dictating',
|
||||
preparingAudio: 'Preparing audio',
|
||||
speakingResponse: 'Speaking response',
|
||||
readingAloud: 'Reading aloud',
|
||||
themeSuggestions: 'Desktop theme suggestions',
|
||||
noMatchingThemes: 'No matching themes.',
|
||||
themeTryPre: 'Try ',
|
||||
themeTryPost: '.',
|
||||
attachLabel: 'Attach',
|
||||
files: 'Files…',
|
||||
folder: 'Folder…',
|
||||
images: 'Images…',
|
||||
pasteImage: 'Paste image',
|
||||
url: 'URL…',
|
||||
promptSnippets: 'Prompt snippets…',
|
||||
tipPre: 'Tip: type ',
|
||||
tipPost: ' to reference files inline.',
|
||||
snippetsTitle: 'Prompt snippets',
|
||||
snippetsDesc: 'Pick a starter prompt to drop into the composer.',
|
||||
snippets: {
|
||||
codeReview: {
|
||||
label: 'Code review',
|
||||
description: 'Audit the current change for regressions, dropped edge cases, and missing tests.',
|
||||
text: 'Please review this for bugs, regressions, and missing tests.'
|
||||
},
|
||||
implementationPlan: {
|
||||
label: 'Implementation plan',
|
||||
description: 'Outline an approach before touching code so the diff stays focused.',
|
||||
text: 'Please make a concise implementation plan before changing code.'
|
||||
},
|
||||
explainThis: {
|
||||
label: 'Explain this',
|
||||
description: 'Walk through how the selected code works and link to the key files.',
|
||||
text: 'Please explain how this works and point me to the key files.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export { TRANSLATIONS } from './catalog'
|
||||
export {
|
||||
getConfigDisplayLanguage,
|
||||
type I18nConfigClient,
|
||||
type I18nContextValue,
|
||||
I18nProvider,
|
||||
LOCALE_META,
|
||||
useI18n,
|
||||
withConfigDisplayLanguage
|
||||
} from './context'
|
||||
export {
|
||||
DEFAULT_LOCALE,
|
||||
isLocale,
|
||||
isSupportedLocaleValue,
|
||||
LOCALE_OPTIONS,
|
||||
localeConfigValue,
|
||||
normalizeLocale
|
||||
} from './languages'
|
||||
export { setRuntimeI18nLocale, translateNow } from './runtime'
|
||||
export type { Locale, Translations } from './types'
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
isLocale,
|
||||
isSupportedLocaleValue,
|
||||
localeConfigValue,
|
||||
normalizeLocale
|
||||
} from './languages'
|
||||
|
||||
describe('desktop i18n languages', () => {
|
||||
it('normalizes supported locale aliases', () => {
|
||||
expect(normalizeLocale('en')).toBe('en')
|
||||
expect(normalizeLocale('EN-US')).toBe('en')
|
||||
expect(normalizeLocale('zh')).toBe('zh')
|
||||
expect(normalizeLocale('zh-CN')).toBe('zh')
|
||||
expect(normalizeLocale('zh-Hans')).toBe('zh')
|
||||
expect(normalizeLocale(' zh_hans_cn ')).toBe('zh')
|
||||
})
|
||||
|
||||
it('falls back to English for empty or unsupported values', () => {
|
||||
expect(normalizeLocale(null)).toBe(DEFAULT_LOCALE)
|
||||
expect(normalizeLocale('')).toBe(DEFAULT_LOCALE)
|
||||
expect(normalizeLocale('ja')).toBe(DEFAULT_LOCALE)
|
||||
})
|
||||
|
||||
it('distinguishes exact locale ids from supported config aliases', () => {
|
||||
expect(isSupportedLocaleValue('zh-CN')).toBe(true)
|
||||
expect(isSupportedLocaleValue('ja')).toBe(false)
|
||||
expect(isLocale('zh-CN')).toBe(false)
|
||||
expect(isLocale('zh')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns the persisted config value for supported locales', () => {
|
||||
expect(localeConfigValue('en')).toBe('en')
|
||||
expect(localeConfigValue('zh')).toBe('zh')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Locale } from './types'
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = 'en'
|
||||
|
||||
export const LOCALE_OPTIONS = [
|
||||
{
|
||||
id: 'en',
|
||||
name: 'English',
|
||||
configValue: 'en'
|
||||
},
|
||||
{
|
||||
id: 'zh',
|
||||
name: '简体中文',
|
||||
configValue: 'zh'
|
||||
}
|
||||
] as const satisfies readonly { configValue: string; id: Locale; name: string }[]
|
||||
|
||||
// Endonyms (native names) for the language picker so users recognize their
|
||||
// language regardless of the current UI language. No country flags:
|
||||
// languages are not countries.
|
||||
export const LOCALE_META: Record<Locale, { name: string }> = Object.fromEntries(
|
||||
LOCALE_OPTIONS.map(locale => [locale.id, { name: locale.name }])
|
||||
) as Record<Locale, { name: string }>
|
||||
|
||||
const LOCALE_ALIASES: Record<string, Locale> = {
|
||||
en: 'en',
|
||||
'en-us': 'en',
|
||||
en_us: 'en',
|
||||
zh: 'zh',
|
||||
'zh-cn': 'zh',
|
||||
zh_cn: 'zh',
|
||||
'zh-hans': 'zh',
|
||||
zh_hans: 'zh',
|
||||
'zh-hans-cn': 'zh',
|
||||
zh_hans_cn: 'zh'
|
||||
}
|
||||
|
||||
export function isLocale(value: unknown): value is Locale {
|
||||
return typeof value === 'string' && LOCALE_OPTIONS.some(locale => locale.id === value)
|
||||
}
|
||||
|
||||
export function normalizeLocale(value: unknown): Locale {
|
||||
if (typeof value !== 'string') {
|
||||
return DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
return LOCALE_ALIASES[value.trim().toLowerCase()] ?? DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
export function isSupportedLocaleValue(value: unknown): boolean {
|
||||
return typeof value === 'string' && LOCALE_ALIASES[value.trim().toLowerCase()] != null
|
||||
}
|
||||
|
||||
export function localeConfigValue(locale: Locale): string {
|
||||
return LOCALE_OPTIONS.find(item => item.id === locale)?.configValue ?? DEFAULT_LOCALE
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { setRuntimeI18nLocale, translateNow } from './runtime'
|
||||
|
||||
describe('desktop i18n runtime translator', () => {
|
||||
beforeEach(() => {
|
||||
setRuntimeI18nLocale('en')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setRuntimeI18nLocale('en')
|
||||
})
|
||||
|
||||
it('translates string paths for the active runtime locale', () => {
|
||||
setRuntimeI18nLocale('zh')
|
||||
|
||||
expect(translateNow('boot.ready')).toBe('Hermes Desktop 已就绪')
|
||||
})
|
||||
|
||||
it('passes arguments to function translations', () => {
|
||||
expect(translateNow('notifications.updateReadyMessage', 2)).toBe('2 new changes available.')
|
||||
})
|
||||
|
||||
it('returns the key when no locale can resolve a path', () => {
|
||||
setRuntimeI18nLocale('zh')
|
||||
|
||||
expect(translateNow('missing.path')).toBe('missing.path')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { TRANSLATIONS } from './catalog'
|
||||
import { DEFAULT_LOCALE } from './languages'
|
||||
import type { Locale, Translations } from './types'
|
||||
|
||||
let runtimeLocale: Locale = DEFAULT_LOCALE
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function resolvePath(catalog: Translations, key: string): unknown {
|
||||
return key.split('.').reduce<unknown>((current, part) => {
|
||||
if (!isRecord(current)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return current[part]
|
||||
}, catalog)
|
||||
}
|
||||
|
||||
function renderTranslation(value: unknown, args: unknown[]): string | null {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value === 'function') {
|
||||
return (value as (...args: unknown[]) => string)(...args)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function setRuntimeI18nLocale(locale: Locale) {
|
||||
runtimeLocale = locale
|
||||
}
|
||||
|
||||
export function translateNow(key: string, ...args: unknown[]): string {
|
||||
const active = renderTranslation(resolvePath(TRANSLATIONS[runtimeLocale], key), args)
|
||||
|
||||
if (active !== null) {
|
||||
return active
|
||||
}
|
||||
|
||||
if (runtimeLocale !== DEFAULT_LOCALE) {
|
||||
const fallback = renderTranslation(resolvePath(TRANSLATIONS[DEFAULT_LOCALE], key), args)
|
||||
|
||||
if (fallback !== null) {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
// Desktop i18n type contract.
|
||||
//
|
||||
// `Translations` is the single source of truth for every translatable string
|
||||
// surface. Each locale file (`en.ts`, `zh.ts`, …) must satisfy this interface,
|
||||
// so a missing key is a compile error — that's the completeness guard for
|
||||
// "full" coverage as more surfaces are migrated off hardcoded English.
|
||||
|
||||
export type Locale = 'en' | 'zh'
|
||||
|
||||
interface ModeOptionCopy {
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface Translations {
|
||||
common: {
|
||||
save: string
|
||||
saving: string
|
||||
cancel: string
|
||||
close: string
|
||||
confirm: string
|
||||
delete: string
|
||||
refresh: string
|
||||
retry: string
|
||||
on: string
|
||||
off: string
|
||||
}
|
||||
|
||||
boot: {
|
||||
ready: string
|
||||
desktopBootFailedWithMessage: (message: string) => string
|
||||
steps: {
|
||||
connectingGateway: string
|
||||
loadingSettings: string
|
||||
loadingSessions: string
|
||||
startingDesktopConnection: string
|
||||
startingHermesDesktop: string
|
||||
}
|
||||
errors: {
|
||||
backgroundExited: string
|
||||
backgroundExitedDuringStartup: string
|
||||
backendStopped: string
|
||||
desktopBootFailed: string
|
||||
gatewaySignInRequired: string
|
||||
ipcBridgeUnavailable: string
|
||||
}
|
||||
failure: {
|
||||
title: string
|
||||
description: string
|
||||
remoteTitle: string
|
||||
remoteDescription: string
|
||||
retry: string
|
||||
repairInstall: string
|
||||
useLocalGateway: string
|
||||
openLogs: string
|
||||
repairHint: string
|
||||
remoteSignInHint: string
|
||||
hideRecentLogs: string
|
||||
showRecentLogs: string
|
||||
signedInTitle: string
|
||||
signedInMessage: string
|
||||
signInIncompleteTitle: string
|
||||
signInIncompleteMessage: string
|
||||
signInFailed: string
|
||||
signInToRemoteGateway: string
|
||||
signInWithProvider: (provider: string) => string
|
||||
identityProvider: string
|
||||
}
|
||||
}
|
||||
|
||||
notifications: {
|
||||
region: string
|
||||
hide: string
|
||||
show: string
|
||||
more: (count: number) => string
|
||||
clearAll: string
|
||||
dismiss: string
|
||||
details: string
|
||||
copyDetail: string
|
||||
copyDetailFailed: string
|
||||
backendOutOfDateTitle: string
|
||||
backendOutOfDateMessage: string
|
||||
updateHermes: string
|
||||
updateReadyTitle: string
|
||||
updateReadyMessage: (count: number) => string
|
||||
seeWhatsNew: string
|
||||
errors: {
|
||||
elevenLabsNeedsKey: string
|
||||
elevenLabsRejectedKey: string
|
||||
methodNotAllowed: string
|
||||
microphonePermission: string
|
||||
openaiRejectedApiKey: string
|
||||
openaiRejectedApiKeyWithStatus: (status: string) => string
|
||||
openaiTtsNeedsKey: string
|
||||
}
|
||||
}
|
||||
|
||||
titlebar: {
|
||||
hideSidebar: string
|
||||
showSidebar: string
|
||||
search: string
|
||||
searchTitle: string
|
||||
swapSidebarSides: string
|
||||
swapSidebarSidesTitle: string
|
||||
hideRightSidebar: string
|
||||
showRightSidebar: string
|
||||
muteHaptics: string
|
||||
unmuteHaptics: string
|
||||
openSettings: string
|
||||
}
|
||||
|
||||
language: {
|
||||
label: string
|
||||
description: string
|
||||
saving: string
|
||||
saveError: string
|
||||
}
|
||||
|
||||
settings: {
|
||||
closeSettings: string
|
||||
exportConfig: string
|
||||
importConfig: string
|
||||
resetToDefaults: string
|
||||
resetConfirm: string
|
||||
exportFailed: string
|
||||
resetFailed: string
|
||||
nav: {
|
||||
gateway: string
|
||||
apiKeys: string
|
||||
mcp: string
|
||||
archivedChats: string
|
||||
about: string
|
||||
}
|
||||
sections: Record<string, string>
|
||||
searchPlaceholder: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string>
|
||||
modeOptions: Record<'light' | 'dark' | 'system', ModeOptionCopy>
|
||||
appearance: {
|
||||
title: string
|
||||
intro: string
|
||||
colorMode: string
|
||||
colorModeDesc: string
|
||||
toolViewTitle: string
|
||||
toolViewDesc: string
|
||||
product: string
|
||||
productDesc: string
|
||||
technical: string
|
||||
technicalDesc: string
|
||||
themeTitle: string
|
||||
themeDesc: string
|
||||
}
|
||||
fieldLabels: Record<string, string>
|
||||
fieldDescriptions: Record<string, string>
|
||||
about: {
|
||||
heading: string
|
||||
version: (value: string) => string
|
||||
versionUnavailable: string
|
||||
updates: string
|
||||
checkNow: string
|
||||
checking: string
|
||||
seeWhatsNew: string
|
||||
releaseNotes: string
|
||||
onLatest: string
|
||||
installing: string
|
||||
cantUpdate: string
|
||||
cantReach: string
|
||||
tapCheck: string
|
||||
updateReady: (count: number) => string
|
||||
lastChecked: (age: string) => string
|
||||
justNowSuffix: string
|
||||
automaticUpdates: string
|
||||
automaticUpdatesDesc: string
|
||||
branchCommit: (branch: string, commit: string) => string
|
||||
never: string
|
||||
justNow: string
|
||||
minAgo: (count: number) => string
|
||||
hoursAgo: (count: number) => string
|
||||
daysAgo: (count: number) => string
|
||||
}
|
||||
}
|
||||
|
||||
skills: {
|
||||
tabSkills: string
|
||||
tabToolsets: string
|
||||
all: string
|
||||
searchSkills: string
|
||||
searchToolsets: string
|
||||
refresh: string
|
||||
refreshing: string
|
||||
loading: string
|
||||
noSkillsTitle: string
|
||||
noSkillsDesc: string
|
||||
noToolsetsTitle: string
|
||||
noToolsetsDesc: string
|
||||
noDescription: string
|
||||
configured: string
|
||||
needsKeys: string
|
||||
toolsetsEnabled: (enabled: number, total: number) => string
|
||||
configureToolset: (label: string) => string
|
||||
toggleToolset: (label: string) => string
|
||||
skillsLoadFailed: string
|
||||
toolsetsRefreshFailed: string
|
||||
skillEnabled: string
|
||||
skillDisabled: string
|
||||
toolsetEnabled: string
|
||||
toolsetDisabled: string
|
||||
appliesToNewSessions: (name: string) => string
|
||||
failedToUpdate: (name: string) => string
|
||||
}
|
||||
|
||||
agents: {
|
||||
close: string
|
||||
title: string
|
||||
subtitle: string
|
||||
emptyTitle: string
|
||||
emptyDesc: string
|
||||
running: string
|
||||
failed: string
|
||||
done: string
|
||||
streaming: string
|
||||
files: string
|
||||
moreFiles: (count: number) => string
|
||||
delegation: (index: number) => string
|
||||
workers: (count: number) => string
|
||||
workersActive: (count: number) => string
|
||||
agentsCount: (count: number) => string
|
||||
activeCount: (count: number) => string
|
||||
failedCount: (count: number) => string
|
||||
toolsCount: (count: number) => string
|
||||
filesCount: (count: number) => string
|
||||
updatedAgo: (age: string) => string
|
||||
ageNow: string
|
||||
ageSeconds: (seconds: number) => string
|
||||
ageMinutes: (minutes: number) => string
|
||||
ageHours: (hours: number) => string
|
||||
durationSeconds: (seconds: string) => string
|
||||
durationMinutes: (minutes: number, seconds: number) => string
|
||||
tokensK: (k: string) => string
|
||||
tokens: (value: number) => string
|
||||
}
|
||||
|
||||
commandCenter: {
|
||||
close: string
|
||||
searchPlaceholder: string
|
||||
sections: Record<'sessions' | 'system' | 'usage', string>
|
||||
sectionDescriptions: Record<'sessions' | 'system' | 'usage', string>
|
||||
nav: Record<'newChat' | 'settings' | 'skills' | 'messaging' | 'artifacts', { title: string; detail: string }>
|
||||
sectionEntries: Record<'sessions' | 'system' | 'usage', { title: string; detail: string }>
|
||||
providerNavigate: string
|
||||
providerSessions: string
|
||||
refresh: string
|
||||
refreshing: string
|
||||
noResults: string
|
||||
pinSession: string
|
||||
unpinSession: string
|
||||
exportSession: string
|
||||
deleteSession: string
|
||||
noSessions: string
|
||||
gatewayRunning: string
|
||||
gatewayStopped: string
|
||||
hermesActiveSessions: (version: string, count: number) => string
|
||||
restartMessaging: string
|
||||
updateHermes: string
|
||||
actionRunning: string
|
||||
actionDone: string
|
||||
actionFailed: string
|
||||
actionStartedWaiting: string
|
||||
loadingStatus: string
|
||||
recentLogs: string
|
||||
noLogs: string
|
||||
days: (count: number) => string
|
||||
statSessions: string
|
||||
statApiCalls: string
|
||||
statTokens: string
|
||||
statCost: string
|
||||
actualCost: (cost: string) => string
|
||||
loadingUsage: string
|
||||
noUsage: (period: number) => string
|
||||
retry: string
|
||||
dailyTokens: string
|
||||
input: string
|
||||
output: string
|
||||
noDailyActivity: string
|
||||
topModels: string
|
||||
noModelUsage: string
|
||||
topSkills: string
|
||||
noSkillActivity: string
|
||||
actions: (count: string) => string
|
||||
}
|
||||
|
||||
messaging: {
|
||||
search: string
|
||||
loading: string
|
||||
loadFailed: string
|
||||
states: Record<string, string>
|
||||
unknown: string
|
||||
hintPendingRestart: string
|
||||
hintGatewayStopped: string
|
||||
credentialsSet: string
|
||||
needsSetup: string
|
||||
gatewayStopped: string
|
||||
getCredentials: string
|
||||
openSetupGuide: string
|
||||
required: string
|
||||
recommended: string
|
||||
advanced: (count: number) => string
|
||||
noTokenNeeded: string
|
||||
enabled: string
|
||||
disabled: string
|
||||
unsavedChanges: string
|
||||
saving: string
|
||||
saveChanges: string
|
||||
saved: string
|
||||
replaceValue: string
|
||||
openDocs: string
|
||||
clearField: (key: string) => string
|
||||
enableAria: (name: string) => string
|
||||
disableAria: (name: string) => string
|
||||
platformEnabled: (name: string) => string
|
||||
platformDisabled: (name: string) => string
|
||||
restartToApply: string
|
||||
setupSaved: (name: string) => string
|
||||
restartToReconnect: string
|
||||
keyCleared: (key: string) => string
|
||||
setupUpdated: (name: string) => string
|
||||
failedUpdate: (name: string) => string
|
||||
failedSave: (name: string) => string
|
||||
failedClear: (key: string) => string
|
||||
fieldCopy: Record<string, { label?: string; help?: string; placeholder?: string }>
|
||||
platformIntro: Record<string, string>
|
||||
}
|
||||
|
||||
profiles: {
|
||||
close: string
|
||||
nameHint: string
|
||||
title: string
|
||||
count: (count: number) => string
|
||||
loading: string
|
||||
newProfile: string
|
||||
noProfiles: string
|
||||
selectPrompt: string
|
||||
refresh: string
|
||||
refreshing: string
|
||||
default: string
|
||||
skills: (count: number) => string
|
||||
env: string
|
||||
defaultBadge: string
|
||||
rename: string
|
||||
copySetup: string
|
||||
copying: string
|
||||
modelLabel: string
|
||||
skillsLabel: string
|
||||
notSet: string
|
||||
soulDesc: string
|
||||
unsavedChanges: string
|
||||
loadingSoul: string
|
||||
emptySoul: string
|
||||
saving: string
|
||||
saveSoul: string
|
||||
deleteTitle: string
|
||||
deleteDescPrefix: string
|
||||
deleteDescMid: string
|
||||
deleteDescSuffix: string
|
||||
deleting: string
|
||||
createDesc: string
|
||||
nameLabel: string
|
||||
cloneFromDefault: string
|
||||
cloneFromDefaultDesc: string
|
||||
invalidName: (hint: string) => string
|
||||
nameRequired: string
|
||||
creating: string
|
||||
createAction: string
|
||||
renameTitle: string
|
||||
renameDescPrefix: string
|
||||
renameDescSuffix: string
|
||||
newNameLabel: string
|
||||
renaming: string
|
||||
created: string
|
||||
renamed: string
|
||||
deleted: string
|
||||
setupCopied: string
|
||||
soulSaved: string
|
||||
failedLoad: string
|
||||
failedDelete: string
|
||||
failedCopy: string
|
||||
failedLoadSoul: string
|
||||
failedSaveSoul: string
|
||||
failedCreate: string
|
||||
failedRename: string
|
||||
}
|
||||
|
||||
cron: {
|
||||
close: string
|
||||
search: string
|
||||
refresh: string
|
||||
refreshing: string
|
||||
loading: string
|
||||
states: Record<string, string>
|
||||
deliveryLabels: Record<string, string>
|
||||
scheduleLabels: Record<string, string>
|
||||
scheduleHints: Record<string, string>
|
||||
days: Record<string, string>
|
||||
dayFallback: (value: string) => string
|
||||
everyDayAt: (time: string) => string
|
||||
weekdaysAt: (time: string) => string
|
||||
everyDayOfWeekAt: (day: string, time: string) => string
|
||||
monthlyOnDayAt: (dayOfMonth: string, time: string) => string
|
||||
topOfHour: string
|
||||
everyHourAt: (minute: string) => string
|
||||
active: (enabled: number, total: number) => string
|
||||
newCron: string
|
||||
createFirst: string
|
||||
emptyDescNew: string
|
||||
emptyDescSearch: string
|
||||
emptyTitleNew: string
|
||||
emptyTitleSearch: string
|
||||
last: string
|
||||
next: string
|
||||
actionsFor: (title: string) => string
|
||||
actionsTitle: string
|
||||
resume: string
|
||||
pause: string
|
||||
resumeTitle: string
|
||||
pauseTitle: string
|
||||
triggerNow: string
|
||||
edit: string
|
||||
deleteTitle: string
|
||||
deleteDescPrefix: string
|
||||
deleteDescSuffix: string
|
||||
deleting: string
|
||||
resumed: string
|
||||
paused: string
|
||||
triggered: string
|
||||
deleted: string
|
||||
created: string
|
||||
updated: string
|
||||
failedLoad: string
|
||||
failedUpdate: string
|
||||
failedTrigger: string
|
||||
failedDelete: string
|
||||
failedSave: string
|
||||
editTitle: string
|
||||
createTitle: string
|
||||
editDesc: string
|
||||
createDesc: string
|
||||
nameLabel: string
|
||||
namePlaceholder: string
|
||||
promptLabel: string
|
||||
promptPlaceholder: string
|
||||
frequencyLabel: string
|
||||
deliverLabel: string
|
||||
customScheduleLabel: string
|
||||
customPlaceholder: string
|
||||
customHint: string
|
||||
optional: string
|
||||
promptScheduleRequired: string
|
||||
saveChanges: string
|
||||
createAction: string
|
||||
}
|
||||
|
||||
artifacts: {
|
||||
search: string
|
||||
refresh: string
|
||||
refreshing: string
|
||||
indexing: string
|
||||
tabAll: string
|
||||
tabImages: string
|
||||
tabFiles: string
|
||||
tabLinks: string
|
||||
noArtifactsTitle: string
|
||||
noArtifactsDesc: string
|
||||
failedLoad: string
|
||||
openFailed: string
|
||||
itemsImage: string
|
||||
itemsLink: string
|
||||
itemsFile: string
|
||||
itemsGeneric: string
|
||||
zero: string
|
||||
rangeOf: (start: number, end: number, total: number) => string
|
||||
goToPage: (itemLabel: string, page: number) => string
|
||||
colTitleLink: string
|
||||
colTitleFile: string
|
||||
colTitleDefault: string
|
||||
colLocationLink: string
|
||||
colLocationFile: string
|
||||
colLocationDefault: string
|
||||
colSession: string
|
||||
kindImage: string
|
||||
kindFile: string
|
||||
kindLink: string
|
||||
chat: string
|
||||
copyUrl: string
|
||||
copyPath: string
|
||||
}
|
||||
|
||||
sidebar: {
|
||||
nav: Record<string, string>
|
||||
searchAria: string
|
||||
searchPlaceholder: string
|
||||
clearSearch: string
|
||||
noMatch: (query: string) => string
|
||||
results: string
|
||||
pinned: string
|
||||
sessions: string
|
||||
groupAriaGrouped: string
|
||||
groupAriaUngrouped: string
|
||||
groupTitleGrouped: string
|
||||
groupTitleUngrouped: string
|
||||
allPinned: string
|
||||
shiftClickHint: string
|
||||
noWorkspace: string
|
||||
newSessionIn: (label: string) => string
|
||||
reorderWorkspace: (label: string) => string
|
||||
showMoreIn: (count: number, label: string) => string
|
||||
loading: string
|
||||
loadMore: string
|
||||
loadCount: (step: number) => string
|
||||
row: {
|
||||
pin: string
|
||||
unpin: string
|
||||
copyId: string
|
||||
export: string
|
||||
rename: string
|
||||
archive: string
|
||||
copyIdFailed: string
|
||||
actionsFor: (title: string) => string
|
||||
sessionActions: string
|
||||
sessionRunning: string
|
||||
needsInput: string
|
||||
waitingForAnswer: string
|
||||
renamed: string
|
||||
renameFailed: string
|
||||
renameTitle: string
|
||||
renameDesc: string
|
||||
untitledPlaceholder: string
|
||||
ageNow: string
|
||||
ageDay: string
|
||||
ageHour: string
|
||||
ageMin: string
|
||||
}
|
||||
}
|
||||
|
||||
composer: {
|
||||
message: string
|
||||
placeholderStarting: string
|
||||
placeholderReconnecting: string
|
||||
placeholderFollowUp: string
|
||||
newSessionPlaceholders: readonly string[]
|
||||
followUpPlaceholders: readonly string[]
|
||||
startVoice: string
|
||||
queueMessage: string
|
||||
stop: string
|
||||
send: string
|
||||
speaking: string
|
||||
transcribing: string
|
||||
thinking: string
|
||||
muted: string
|
||||
listening: string
|
||||
muteMic: string
|
||||
unmuteMic: string
|
||||
stopListening: string
|
||||
stopShort: string
|
||||
endConversation: string
|
||||
endShort: string
|
||||
stopDictation: string
|
||||
transcribingDictation: string
|
||||
voiceDictation: string
|
||||
commonCommands: string
|
||||
hotkeys: string
|
||||
helpFooter: string
|
||||
commandDescs: Record<string, string>
|
||||
hotkeyDescs: Record<string, string>
|
||||
attachUrlTitle: string
|
||||
attachUrlDesc: string
|
||||
urlPlaceholder: string
|
||||
urlHintPre: string
|
||||
attach: string
|
||||
queued: (count: number) => string
|
||||
attachmentOnly: string
|
||||
emptyTurn: string
|
||||
attachments: (count: number) => string
|
||||
editingInComposer: string
|
||||
editQueued: string
|
||||
sendQueuedNext: string
|
||||
sendQueuedNow: string
|
||||
deleteQueued: string
|
||||
previewUnavailable: string
|
||||
previewLabel: (label: string) => string
|
||||
couldNotPreview: (label: string) => string
|
||||
removeAttachment: (label: string) => string
|
||||
dictating: string
|
||||
preparingAudio: string
|
||||
speakingResponse: string
|
||||
readingAloud: string
|
||||
themeSuggestions: string
|
||||
noMatchingThemes: string
|
||||
themeTryPre: string
|
||||
themeTryPost: string
|
||||
attachLabel: string
|
||||
files: string
|
||||
folder: string
|
||||
images: string
|
||||
pasteImage: string
|
||||
url: string
|
||||
promptSnippets: string
|
||||
tipPre: string
|
||||
tipPost: string
|
||||
snippetsTitle: string
|
||||
snippetsDesc: string
|
||||
snippets: Record<string, { label: string; description: string; text: string }>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,874 @@
|
||||
import type { Translations } from './types'
|
||||
|
||||
export const zh: Translations = {
|
||||
common: {
|
||||
save: '保存',
|
||||
saving: '保存中…',
|
||||
cancel: '取消',
|
||||
close: '关闭',
|
||||
confirm: '确认',
|
||||
delete: '删除',
|
||||
refresh: '刷新',
|
||||
retry: '重试',
|
||||
on: '开',
|
||||
off: '关'
|
||||
},
|
||||
|
||||
boot: {
|
||||
ready: 'Hermes Desktop 已就绪',
|
||||
desktopBootFailedWithMessage: message => `桌面启动失败:${message}`,
|
||||
steps: {
|
||||
connectingGateway: '正在连接实时桌面网关',
|
||||
loadingSettings: '正在加载 Hermes 设置',
|
||||
loadingSessions: '正在加载最近会话',
|
||||
startingDesktopConnection: '正在启动桌面连接',
|
||||
startingHermesDesktop: '正在启动 Hermes Desktop…'
|
||||
},
|
||||
errors: {
|
||||
backgroundExited: 'Hermes 后台进程已退出。',
|
||||
backgroundExitedDuringStartup: 'Hermes 后台进程在启动期间退出。',
|
||||
backendStopped: '后端已停止',
|
||||
desktopBootFailed: '桌面启动失败',
|
||||
gatewaySignInRequired: '需要登录网关',
|
||||
ipcBridgeUnavailable: '桌面 IPC 桥不可用。'
|
||||
},
|
||||
failure: {
|
||||
title: 'Hermes 无法启动',
|
||||
description: '后台网关没有启动。请尝试下面的恢复步骤。这些操作不会删除你的对话或设置。',
|
||||
remoteTitle: '需要重新登录远程网关',
|
||||
remoteDescription: '你的远程网关会话已过期。请重新登录以恢复连接。这些操作不会删除你的对话或设置。',
|
||||
retry: '重试',
|
||||
repairInstall: '修复安装',
|
||||
useLocalGateway: '使用本地网关',
|
||||
openLogs: '打开日志',
|
||||
repairHint: '修复会重新运行安装器。在新机器上可能需要几分钟。',
|
||||
remoteSignInHint: '打开网关登录窗口。也可以使用本地网关切换到随应用提供的后端。',
|
||||
hideRecentLogs: '隐藏最近日志',
|
||||
showRecentLogs: '显示最近日志',
|
||||
signedInTitle: '已登录',
|
||||
signedInMessage: '正在重新连接远程网关…',
|
||||
signInIncompleteTitle: '登录未完成',
|
||||
signInIncompleteMessage: '登录窗口在认证完成前关闭。',
|
||||
signInFailed: '登录失败',
|
||||
signInToRemoteGateway: '登录远程网关',
|
||||
signInWithProvider: provider => `使用 ${provider} 登录`,
|
||||
identityProvider: '你的身份提供方'
|
||||
}
|
||||
},
|
||||
|
||||
notifications: {
|
||||
region: '通知',
|
||||
hide: '隐藏',
|
||||
show: '显示',
|
||||
more: count => `另外 ${count} 条通知`,
|
||||
clearAll: '全部清除',
|
||||
dismiss: '关闭通知',
|
||||
details: '详情',
|
||||
copyDetail: '复制详情',
|
||||
copyDetailFailed: '无法复制通知详情',
|
||||
backendOutOfDateTitle: '后端版本过旧',
|
||||
backendOutOfDateMessage: '你的 Hermes 后端早于当前桌面构建,可能无法正常工作。请更新以保持一致。',
|
||||
updateHermes: '更新 Hermes',
|
||||
updateReadyTitle: '有可用更新',
|
||||
updateReadyMessage: count => `有 ${count} 项新更改可用。`,
|
||||
seeWhatsNew: '查看更新内容',
|
||||
errors: {
|
||||
elevenLabsNeedsKey: 'ElevenLabs STT 需要 ELEVENLABS_API_KEY。',
|
||||
elevenLabsRejectedKey: 'ElevenLabs 拒绝了该 API key (401)。',
|
||||
methodNotAllowed: '桌面后端拒绝了该请求(405 Method Not Allowed)。请尝试重启 Hermes Desktop。',
|
||||
microphonePermission: '麦克风权限已被拒绝。',
|
||||
openaiRejectedApiKey: 'OpenAI 拒绝了该 API key。',
|
||||
openaiRejectedApiKeyWithStatus: status => `OpenAI 拒绝了该 API key (${status} invalid_api_key)。`,
|
||||
openaiTtsNeedsKey: 'OpenAI TTS 需要 VOICE_TOOLS_OPENAI_KEY 或 OPENAI_API_KEY。'
|
||||
}
|
||||
},
|
||||
|
||||
titlebar: {
|
||||
hideSidebar: '隐藏侧边栏',
|
||||
showSidebar: '显示侧边栏',
|
||||
search: '搜索',
|
||||
searchTitle: '搜索会话、视图与操作',
|
||||
swapSidebarSides: '交换侧边栏位置',
|
||||
swapSidebarSidesTitle: '交换会话栏和文件浏览器的位置',
|
||||
hideRightSidebar: '隐藏右侧栏',
|
||||
showRightSidebar: '显示右侧栏',
|
||||
muteHaptics: '关闭触感反馈',
|
||||
unmuteHaptics: '开启触感反馈',
|
||||
openSettings: '打开设置'
|
||||
},
|
||||
|
||||
language: {
|
||||
label: '语言',
|
||||
description: '选择桌面界面的语言。',
|
||||
saving: '正在保存语言…',
|
||||
saveError: '语言更新失败'
|
||||
},
|
||||
|
||||
settings: {
|
||||
closeSettings: '关闭设置',
|
||||
exportConfig: '导出配置',
|
||||
importConfig: '导入配置',
|
||||
resetToDefaults: '恢复默认',
|
||||
resetConfirm: '将所有设置恢复为 Hermes 默认值?',
|
||||
exportFailed: '导出失败',
|
||||
resetFailed: '重置失败',
|
||||
nav: {
|
||||
gateway: '网关',
|
||||
apiKeys: '工具与密钥',
|
||||
mcp: 'MCP',
|
||||
archivedChats: '已归档对话',
|
||||
about: '关于'
|
||||
},
|
||||
sections: {
|
||||
model: '模型',
|
||||
chat: '对话',
|
||||
appearance: '外观',
|
||||
workspace: '工作区',
|
||||
safety: '安全',
|
||||
memory: '记忆与上下文',
|
||||
voice: '语音',
|
||||
advanced: '高级'
|
||||
},
|
||||
searchPlaceholder: {
|
||||
about: '关于 Hermes Desktop',
|
||||
config: '搜索设置…',
|
||||
gateway: '网关连接…',
|
||||
keys: '搜索 API 密钥…',
|
||||
mcp: '搜索 MCP 服务器…',
|
||||
sessions: '搜索已归档会话…'
|
||||
},
|
||||
modeOptions: {
|
||||
light: { label: '明亮', description: '明亮的桌面界面' },
|
||||
dark: { label: '暗色', description: '低眩光工作区' },
|
||||
system: { label: '跟随系统', description: '跟随系统外观' }
|
||||
},
|
||||
appearance: {
|
||||
title: '外观',
|
||||
intro: '这些是仅桌面端的显示偏好。模式控制明暗;主题控制强调色与对话界面样式。',
|
||||
colorMode: '颜色模式',
|
||||
colorModeDesc: '选择固定模式,或让 Hermes 跟随系统设置。',
|
||||
toolViewTitle: '工具调用显示',
|
||||
toolViewDesc: '产品模式隐藏原始工具数据;技术模式显示完整输入/输出。',
|
||||
product: '产品',
|
||||
productDesc: '易读的工具活动与简洁摘要。',
|
||||
technical: '技术',
|
||||
technicalDesc: '包含原始工具参数/结果及底层细节。',
|
||||
themeTitle: '主题',
|
||||
themeDesc: '仅桌面端调色板。所选模式叠加其上。'
|
||||
},
|
||||
fieldLabels: {
|
||||
model: '默认模型',
|
||||
model_context_length: '上下文窗口',
|
||||
fallback_providers: '备用模型',
|
||||
toolsets: '启用的工具集',
|
||||
timezone: '时区',
|
||||
'display.personality': '人格',
|
||||
'display.show_reasoning': '推理过程块',
|
||||
'agent.max_turns': '最大智能体步数',
|
||||
'agent.image_input_mode': '图片附件',
|
||||
'terminal.cwd': '工作目录',
|
||||
'terminal.backend': '执行后端',
|
||||
'terminal.timeout': '命令超时',
|
||||
'terminal.persistent_shell': '持久化 Shell',
|
||||
'terminal.env_passthrough': '环境变量透传',
|
||||
file_read_max_chars: '文件读取上限',
|
||||
'tool_output.max_bytes': '终端输出上限',
|
||||
'tool_output.max_lines': '文件分页上限',
|
||||
'tool_output.max_line_length': '行长度上限',
|
||||
'code_execution.mode': '代码执行模式',
|
||||
'approvals.mode': '审批模式',
|
||||
'approvals.timeout': '审批超时',
|
||||
'approvals.mcp_reload_confirm': '确认 MCP 重载',
|
||||
command_allowlist: '命令白名单',
|
||||
'security.redact_secrets': '隐去密钥',
|
||||
'security.allow_private_urls': '允许私有 URL',
|
||||
'browser.allow_private_urls': '浏览器私有 URL',
|
||||
'browser.auto_local_for_private_urls': '私有 URL 使用本地浏览器',
|
||||
'checkpoints.enabled': '文件检查点',
|
||||
'checkpoints.max_snapshots': '检查点上限',
|
||||
'voice.record_key': '语音快捷键',
|
||||
'voice.max_recording_seconds': '最长录音时长',
|
||||
'voice.auto_tts': '朗读回复',
|
||||
'stt.enabled': '语音转文字',
|
||||
'stt.provider': '语音转文字提供方',
|
||||
'stt.local.model': '本地转写模型',
|
||||
'stt.local.language': '转写语言',
|
||||
'stt.elevenlabs.model_id': 'ElevenLabs STT 模型',
|
||||
'stt.elevenlabs.language_code': 'ElevenLabs 语言',
|
||||
'stt.elevenlabs.tag_audio_events': '标记音频事件',
|
||||
'stt.elevenlabs.diarize': '说话人区分',
|
||||
'tts.provider': '文字转语音提供方',
|
||||
'tts.edge.voice': 'Edge 语音',
|
||||
'tts.openai.model': 'OpenAI TTS 模型',
|
||||
'tts.openai.voice': 'OpenAI 语音',
|
||||
'tts.elevenlabs.voice_id': 'ElevenLabs 语音',
|
||||
'tts.elevenlabs.model_id': 'ElevenLabs 模型',
|
||||
'memory.memory_enabled': '持久记忆',
|
||||
'memory.user_profile_enabled': '用户画像',
|
||||
'memory.memory_char_limit': '记忆预算',
|
||||
'memory.user_char_limit': '画像预算',
|
||||
'memory.provider': '记忆提供方',
|
||||
'context.engine': '上下文引擎',
|
||||
'compression.enabled': '自动压缩',
|
||||
'compression.threshold': '压缩阈值',
|
||||
'compression.target_ratio': '压缩目标',
|
||||
'compression.protect_last_n': '保护最近消息',
|
||||
'agent.api_max_retries': 'API 重试次数',
|
||||
'agent.service_tier': '服务等级',
|
||||
'agent.tool_use_enforcement': '工具调用强制',
|
||||
'delegation.model': '子智能体模型',
|
||||
'delegation.provider': '子智能体提供方',
|
||||
'delegation.max_iterations': '子智能体轮次上限',
|
||||
'delegation.max_concurrent_children': '并行子智能体',
|
||||
'delegation.child_timeout_seconds': '子智能体超时',
|
||||
'delegation.reasoning_effort': '子智能体推理强度'
|
||||
},
|
||||
fieldDescriptions: {
|
||||
model: '用于新对话,除非你在输入框中选择其他模型。',
|
||||
model_context_length: '保持为 0 则使用所选模型检测到的上下文窗口。',
|
||||
fallback_providers: '默认模型失败时尝试的备用 provider:model 条目。',
|
||||
'display.personality': '新会话的默认助手风格。',
|
||||
timezone: '当 Hermes 需要本地时间上下文时使用。留空则使用系统时区。',
|
||||
'display.show_reasoning': '当后端提供推理内容时予以显示。',
|
||||
'agent.image_input_mode': '控制图片附件如何发送给模型。',
|
||||
'terminal.cwd': '工具与终端操作的默认项目目录。',
|
||||
'code_execution.mode': '代码执行被限定到当前项目的严格程度。',
|
||||
'terminal.persistent_shell': '当后端支持时,在命令之间保留 Shell 状态。',
|
||||
'terminal.env_passthrough': '传入工具执行的环境变量。',
|
||||
file_read_max_chars: 'Hermes 单次文件读取可读取的最大字符数。',
|
||||
'approvals.mode': 'Hermes 如何处理需要显式审批的命令。',
|
||||
'approvals.timeout': '审批提示在超时前等待的时长。',
|
||||
'security.redact_secrets': '尽可能从模型可见内容中隐藏检测到的密钥。',
|
||||
'checkpoints.enabled': '在文件编辑前创建可回滚的快照。',
|
||||
'memory.memory_enabled': '保存有助于未来会话的持久记忆。',
|
||||
'memory.user_profile_enabled': '维护一份精简的用户偏好画像。',
|
||||
'context.engine': '在接近上下文上限时管理长对话的策略。',
|
||||
'compression.enabled': '当对话变大时对较早的上下文进行摘要。',
|
||||
'voice.auto_tts': '自动朗读助手回复。',
|
||||
'stt.enabled': '启用本地或提供方支持的语音转写。',
|
||||
'stt.elevenlabs.language_code': '可选的 ISO-639-3 语言代码。留空让 ElevenLabs 自动检测。',
|
||||
'agent.max_turns': 'Hermes 停止一次运行前工具调用轮次的上限。'
|
||||
},
|
||||
about: {
|
||||
heading: 'Hermes Desktop',
|
||||
version: value => `版本 ${value}`,
|
||||
versionUnavailable: '版本不可用',
|
||||
updates: '更新',
|
||||
checkNow: '立即检查',
|
||||
checking: '检查中…',
|
||||
seeWhatsNew: '查看新增内容',
|
||||
releaseNotes: '发行说明',
|
||||
onLatest: '你已是最新版本。',
|
||||
installing: '正在安装更新。',
|
||||
cantUpdate: '此版本无法在应用内自我更新。',
|
||||
cantReach: '无法连接更新服务器。',
|
||||
tapCheck: '点击"立即检查"以查找更新。',
|
||||
updateReady: count => `已准备好新更新(包含 ${count} 项更改)。`,
|
||||
lastChecked: age => `上次检查:${age}`,
|
||||
justNowSuffix: ' · 刚刚',
|
||||
automaticUpdates: '自动更新',
|
||||
automaticUpdatesDesc: 'Hermes 会在后台自动检查更新,并在有可用更新时通知你。',
|
||||
branchCommit: (branch, commit) => `分支 ${branch} · 提交 ${commit}`,
|
||||
never: '从未',
|
||||
justNow: '刚刚',
|
||||
minAgo: count => `${count} 分钟前`,
|
||||
hoursAgo: count => `${count} 小时前`,
|
||||
daysAgo: count => `${count} 天前`
|
||||
}
|
||||
},
|
||||
|
||||
skills: {
|
||||
tabSkills: '技能',
|
||||
tabToolsets: '工具集',
|
||||
all: '全部',
|
||||
searchSkills: '搜索技能…',
|
||||
searchToolsets: '搜索工具集…',
|
||||
refresh: '刷新技能',
|
||||
refreshing: '正在刷新技能',
|
||||
loading: '正在加载能力…',
|
||||
noSkillsTitle: '未找到技能',
|
||||
noSkillsDesc: '尝试更宽泛的搜索或其他分类。',
|
||||
noToolsetsTitle: '未找到工具集',
|
||||
noToolsetsDesc: '尝试更宽泛的搜索词。',
|
||||
noDescription: '暂无描述。',
|
||||
configured: '已配置',
|
||||
needsKeys: '需要密钥',
|
||||
toolsetsEnabled: (enabled, total) => `已启用 ${enabled}/${total} 个工具集`,
|
||||
configureToolset: label => `配置 ${label}`,
|
||||
toggleToolset: label => `切换 ${label} 工具集`,
|
||||
skillsLoadFailed: '技能加载失败',
|
||||
toolsetsRefreshFailed: '工具集刷新失败',
|
||||
skillEnabled: '技能已启用',
|
||||
skillDisabled: '技能已禁用',
|
||||
toolsetEnabled: '工具集已启用',
|
||||
toolsetDisabled: '工具集已禁用',
|
||||
appliesToNewSessions: name => `${name} 将应用于新会话。`,
|
||||
failedToUpdate: name => `更新 ${name} 失败`
|
||||
},
|
||||
|
||||
agents: {
|
||||
close: '关闭代理',
|
||||
title: '派生树',
|
||||
subtitle: '当前回合的子代理实时活动。',
|
||||
emptyTitle: '暂无活跃子代理',
|
||||
emptyDesc: '当某个回合派发任务时,子代理会在此实时显示进度。',
|
||||
running: '运行中',
|
||||
failed: '失败',
|
||||
done: '完成',
|
||||
streaming: '流式传输',
|
||||
files: '文件',
|
||||
moreFiles: count => `还有 ${count} 个文件`,
|
||||
delegation: index => `派发 ${index}`,
|
||||
workers: count => `${count} 个工作单元`,
|
||||
workersActive: count => `${count} 个活跃`,
|
||||
agentsCount: count => `${count} 个代理`,
|
||||
activeCount: count => `${count} 个活跃`,
|
||||
failedCount: count => `${count} 个失败`,
|
||||
toolsCount: count => `${count} 个工具`,
|
||||
filesCount: count => `${count} 个文件`,
|
||||
updatedAgo: age => `更新于 ${age}`,
|
||||
ageNow: '刚刚',
|
||||
ageSeconds: seconds => `${seconds} 秒前`,
|
||||
ageMinutes: minutes => `${minutes} 分钟前`,
|
||||
ageHours: hours => `${hours} 小时前`,
|
||||
durationSeconds: seconds => `${seconds} 秒`,
|
||||
durationMinutes: (minutes, seconds) => `${minutes} 分 ${seconds} 秒`,
|
||||
tokensK: k => `${k}k 词元`,
|
||||
tokens: value => `${value} 词元`
|
||||
},
|
||||
|
||||
commandCenter: {
|
||||
close: '关闭命令中心',
|
||||
searchPlaceholder: '搜索会话、视图与操作',
|
||||
sections: { sessions: '会话', system: '系统', usage: '用量' },
|
||||
sectionDescriptions: {
|
||||
sessions: '搜索与管理会话',
|
||||
system: '状态、日志与系统操作',
|
||||
usage: '一段时间内的词元、成本与技能活动'
|
||||
},
|
||||
nav: {
|
||||
newChat: { title: '新建会话', detail: '开始一个新会话' },
|
||||
settings: { title: '设置', detail: '配置 Hermes 桌面端' },
|
||||
skills: { title: '技能与工具', detail: '启用技能、工具集与提供方' },
|
||||
messaging: { title: '消息平台', detail: '配置 Telegram、Slack、Discord 等' },
|
||||
artifacts: { title: '产物', detail: '浏览生成的输出' }
|
||||
},
|
||||
sectionEntries: {
|
||||
sessions: { title: '会话面板', detail: '搜索、置顶与管理会话' },
|
||||
system: { title: '系统面板', detail: '网关状态、日志、重启/更新' },
|
||||
usage: { title: '用量面板', detail: '词元、成本与技能活动' }
|
||||
},
|
||||
providerNavigate: '导航',
|
||||
providerSessions: '会话',
|
||||
refresh: '刷新',
|
||||
refreshing: '刷新中…',
|
||||
noResults: '未找到匹配结果。',
|
||||
pinSession: '置顶会话',
|
||||
unpinSession: '取消置顶',
|
||||
exportSession: '导出会话',
|
||||
deleteSession: '删除会话',
|
||||
noSessions: '暂无会话。',
|
||||
gatewayRunning: '消息网关运行中',
|
||||
gatewayStopped: '消息网关已停止',
|
||||
hermesActiveSessions: (version, count) => `Hermes ${version} · 活跃会话 ${count}`,
|
||||
restartMessaging: '重启消息服务',
|
||||
updateHermes: '更新 Hermes',
|
||||
actionRunning: '运行中',
|
||||
actionDone: '完成',
|
||||
actionFailed: '失败',
|
||||
actionStartedWaiting: '操作已启动,等待状态…',
|
||||
loadingStatus: '正在加载状态…',
|
||||
recentLogs: '最近日志',
|
||||
noLogs: '尚未加载日志。',
|
||||
days: count => `${count} 天`,
|
||||
statSessions: '会话',
|
||||
statApiCalls: 'API 调用',
|
||||
statTokens: '输入/输出词元',
|
||||
statCost: '预估成本',
|
||||
actualCost: cost => `实际 ${cost}`,
|
||||
loadingUsage: '正在加载用量…',
|
||||
noUsage: period => `最近 ${period} 天暂无用量。`,
|
||||
retry: '重试',
|
||||
dailyTokens: '每日词元',
|
||||
input: '输入',
|
||||
output: '输出',
|
||||
noDailyActivity: '暂无每日活动。',
|
||||
topModels: '常用模型',
|
||||
noModelUsage: '暂无模型用量。',
|
||||
topSkills: '常用技能',
|
||||
noSkillActivity: '暂无技能活动。',
|
||||
actions: count => `${count} 次操作`
|
||||
},
|
||||
|
||||
messaging: {
|
||||
search: '搜索消息平台…',
|
||||
loading: '正在加载消息平台…',
|
||||
loadFailed: '消息平台加载失败',
|
||||
states: {
|
||||
connected: '已连接',
|
||||
connecting: '连接中',
|
||||
disabled: '已禁用',
|
||||
fatal: '错误',
|
||||
gateway_stopped: '消息网关已停止',
|
||||
not_configured: '需要设置',
|
||||
pending_restart: '需要重启',
|
||||
retrying: '重试中',
|
||||
startup_failed: '启动失败'
|
||||
},
|
||||
unknown: '未知',
|
||||
hintPendingRestart: '在状态栏重启网关以应用此更改。',
|
||||
hintGatewayStopped: '在状态栏启动网关以建立连接。',
|
||||
credentialsSet: '凭据已设置',
|
||||
needsSetup: '需要设置',
|
||||
gatewayStopped: '消息网关已停止',
|
||||
getCredentials: '获取你的凭据',
|
||||
openSetupGuide: '打开设置指南',
|
||||
required: '必填',
|
||||
recommended: '推荐',
|
||||
advanced: count => `高级 (${count})`,
|
||||
noTokenNeeded: '此平台无需在此填写令牌。请按上方设置指南操作,然后在下方启用。',
|
||||
enabled: '已启用',
|
||||
disabled: '已禁用',
|
||||
unsavedChanges: '有未保存的更改',
|
||||
saving: '保存中…',
|
||||
saveChanges: '保存更改',
|
||||
saved: '已保存',
|
||||
replaceValue: '替换当前值',
|
||||
openDocs: '打开文档',
|
||||
clearField: key => `清除 ${key}`,
|
||||
enableAria: name => `启用 ${name}`,
|
||||
disableAria: name => `禁用 ${name}`,
|
||||
platformEnabled: name => `${name} 已启用`,
|
||||
platformDisabled: name => `${name} 已禁用`,
|
||||
restartToApply: '重启网关后此更改才会生效。',
|
||||
setupSaved: name => `${name} 设置已保存`,
|
||||
restartToReconnect: '重启网关以使用新凭据重新连接。',
|
||||
keyCleared: key => `${key} 已清除`,
|
||||
setupUpdated: name => `${name} 设置已更新。`,
|
||||
failedUpdate: name => `更新 ${name} 失败`,
|
||||
failedSave: name => `保存 ${name} 失败`,
|
||||
failedClear: key => `清除 ${key} 失败`,
|
||||
fieldCopy: {
|
||||
TELEGRAM_BOT_TOKEN: { label: 'Bot 令牌', help: '用 @BotFather 创建一个机器人,然后粘贴它给你的令牌。' },
|
||||
TELEGRAM_ALLOWED_USERS: {
|
||||
label: '允许的 Telegram 用户 ID',
|
||||
help: '推荐。来自 @userinfobot 的逗号分隔数字 ID。不设置则任何人都能私信你的机器人。'
|
||||
},
|
||||
TELEGRAM_PROXY: { label: '代理 URL', help: '仅在 Telegram 被屏蔽的网络中需要。' },
|
||||
DISCORD_BOT_TOKEN: { label: 'Bot 令牌', help: '在 Discord 开发者门户创建应用,添加机器人,然后粘贴其令牌。' },
|
||||
DISCORD_ALLOWED_USERS: { label: '允许的 Discord 用户 ID', help: '推荐。逗号分隔的 Discord 用户 ID。' },
|
||||
DISCORD_REPLY_TO_MODE: { label: '回复方式', help: 'first、all 或 off。' },
|
||||
SLACK_BOT_TOKEN: { label: 'Slack bot 令牌', help: '安装 Slack 应用后,在 OAuth & Permissions 中找到 bot 令牌。' },
|
||||
SLACK_APP_TOKEN: { label: 'Slack app 令牌', help: 'Socket Mode 需要 app 级令牌。' },
|
||||
SLACK_ALLOWED_USERS: { label: '允许的 Slack 用户 ID', help: '推荐。逗号分隔的 Slack 用户 ID。' },
|
||||
MATTERMOST_URL: { label: '服务器 URL' },
|
||||
MATTERMOST_TOKEN: { label: 'Bot 令牌' },
|
||||
MATTERMOST_ALLOWED_USERS: { label: '允许的用户 ID', help: '推荐。逗号分隔的 Mattermost 用户 ID。' },
|
||||
MATRIX_HOMESERVER: { label: 'Homeserver URL' },
|
||||
MATRIX_ACCESS_TOKEN: { label: '访问令牌' },
|
||||
MATRIX_USER_ID: { label: 'Bot 用户 ID' },
|
||||
MATRIX_ALLOWED_USERS: { label: '允许的 Matrix 用户 ID', help: '推荐。@user:server 格式的逗号分隔用户 ID。' },
|
||||
SIGNAL_HTTP_URL: { label: 'Signal 桥接 URL', help: '运行中的 signal-cli REST 桥接的 URL。' },
|
||||
SIGNAL_ACCOUNT: { label: '电话号码', help: '在 signal-cli 桥接中注册的号码。' },
|
||||
SIGNAL_ALLOWED_USERS: { label: '允许的 Signal 用户', help: '推荐。逗号分隔的 Signal 标识符。' },
|
||||
WHATSAPP_ENABLED: { label: '启用 WhatsApp 桥接', help: '由下方开关自动设置。除非确知需要,否则请勿改动。' },
|
||||
WHATSAPP_MODE: { label: '桥接模式' },
|
||||
WHATSAPP_ALLOWED_USERS: { label: '允许的 WhatsApp 用户', help: '推荐。逗号分隔的电话号码或 WhatsApp ID。' }
|
||||
},
|
||||
platformIntro: {
|
||||
telegram:
|
||||
'在 Telegram 中,与 @BotFather 对话,运行 /newbot,复制它给你的令牌。然后从 @userinfobot 获取你的数字用户 ID。',
|
||||
discord:
|
||||
'打开 Discord 开发者门户,创建应用,添加 Bot,然后复制其令牌。用正确的权限范围把机器人邀请到你的服务器。',
|
||||
slack: '创建 Slack 应用,启用 Socket Mode,安装到你的工作区,然后复制 bot 令牌和 app 级令牌。',
|
||||
mattermost: '在你的 Mattermost 服务器上,创建机器人账户或个人访问令牌,然后在此粘贴服务器 URL 和令牌。',
|
||||
matrix: '用机器人账户登录你的 homeserver,然后复制访问令牌、用户 ID 和 homeserver URL。',
|
||||
signal: '在可访问的位置运行 signal-cli REST 桥接,然后把 Hermes 指向该 URL 和已注册的电话号码。',
|
||||
whatsapp: '启动 Hermes 自带的 WhatsApp 桥接,首次运行时扫描二维码,然后启用该平台。',
|
||||
bluebubbles: '在装有 iMessage 的 Mac 上运行 BlueBubbles Server,暴露其 API,然后用服务器密码把 Hermes 指向该 URL。',
|
||||
homeassistant: '在 Home Assistant 中打开你的个人资料并创建长期访问令牌。把它连同你的 HA URL 一起粘贴到这里。',
|
||||
email: '使用专用邮箱。对于 Gmail/Workspace,创建应用专用密码并使用 imap.gmail.com / smtp.gmail.com。',
|
||||
sms: '从 Twilio 控制台获取你的 Account SID 和 Auth Token,以及一个可发送短信的电话号码。',
|
||||
dingtalk: '在开发者控制台创建钉钉应用,然后在此复制 Client ID(App key)和 Client Secret。',
|
||||
feishu: '创建飞书 / Lark 应用,配置机器人能力,复制 App ID、App secret 和事件加密密钥。',
|
||||
wecom: '在企业微信中添加群机器人,复制其 webhook key 作为 WECOM_BOT_ID。仅可发送——双向请用企业微信(应用)选项。',
|
||||
wecom_callback: '设置一个企业微信自建应用,暴露其回调 URL,并提供 corp ID、secret、agent ID 和 AES key。',
|
||||
weixin: '登录微信公众平台,复制 AppID 和 Token,并把消息回调 URL 指向 Hermes。',
|
||||
qqbot: '在 QQ 开放平台(q.qq.com)注册一个应用,复制 App ID 和 Client Secret。',
|
||||
api_server: '把 Hermes 暴露为兼容 OpenAI 的 API。设置一个鉴权密钥,然后把 Open WebUI / LobeChat 等指向 host:port。',
|
||||
webhook: '运行一个 HTTP 服务器,供其他工具(GitHub、GitLab、自定义应用)POST。用 secret 验证签名。'
|
||||
}
|
||||
},
|
||||
|
||||
profiles: {
|
||||
close: '关闭配置档案',
|
||||
nameHint: '小写字母、数字、连字符和下划线。必须以字母或数字开头。',
|
||||
title: '配置档案',
|
||||
count: count => `${count} 个配置档案`,
|
||||
loading: '正在加载配置档案…',
|
||||
newProfile: '新建配置档案',
|
||||
noProfiles: '暂无配置档案。',
|
||||
selectPrompt: '选择一个配置档案以查看其详情。',
|
||||
refresh: '刷新配置档案',
|
||||
refreshing: '正在刷新配置档案',
|
||||
default: '默认',
|
||||
skills: count => `${count} 个技能`,
|
||||
env: 'env',
|
||||
defaultBadge: '默认',
|
||||
rename: '重命名',
|
||||
copySetup: '复制安装命令',
|
||||
copying: '复制中…',
|
||||
modelLabel: '模型',
|
||||
skillsLabel: '技能',
|
||||
notSet: '未设置',
|
||||
soulDesc: '内置于此配置档案的系统提示词与人格指令。',
|
||||
unsavedChanges: '有未保存的更改',
|
||||
loadingSoul: '正在加载 SOUL.md…',
|
||||
emptySoul: '空的 SOUL.md —— 开始撰写人格设定…',
|
||||
saving: '保存中…',
|
||||
saveSoul: '保存 SOUL.md',
|
||||
deleteTitle: '删除配置档案?',
|
||||
deleteDescPrefix: '这将删除 ',
|
||||
deleteDescMid: ' 并移除其 ',
|
||||
deleteDescSuffix: ' 目录。此操作无法撤销。',
|
||||
deleting: '删除中…',
|
||||
createDesc: '配置档案是相互独立的 Hermes 环境:各自拥有独立的配置、技能和 SOUL.md。',
|
||||
nameLabel: '名称',
|
||||
cloneFromDefault: '从默认档案克隆',
|
||||
cloneFromDefaultDesc: '从你的默认配置档案复制配置、技能和 SOUL.md。',
|
||||
invalidName: hint => `名称无效。${hint}`,
|
||||
nameRequired: '名称为必填项。',
|
||||
creating: '创建中…',
|
||||
createAction: '创建配置档案',
|
||||
renameTitle: '重命名配置档案',
|
||||
renameDescPrefix: '重命名会更新配置档案目录以及 ',
|
||||
renameDescSuffix: ' 中的所有包装脚本。',
|
||||
newNameLabel: '新名称',
|
||||
renaming: '重命名中…',
|
||||
created: '配置档案已创建',
|
||||
renamed: '配置档案已重命名',
|
||||
deleted: '配置档案已删除',
|
||||
setupCopied: '安装命令已复制',
|
||||
soulSaved: 'SOUL.md 已保存',
|
||||
failedLoad: '加载配置档案失败',
|
||||
failedDelete: '删除配置档案失败',
|
||||
failedCopy: '复制安装命令失败',
|
||||
failedLoadSoul: '加载 SOUL.md 失败',
|
||||
failedSaveSoul: '保存 SOUL.md 失败',
|
||||
failedCreate: '创建配置档案失败',
|
||||
failedRename: '重命名配置档案失败'
|
||||
},
|
||||
|
||||
cron: {
|
||||
close: '关闭定时任务',
|
||||
search: '搜索定时任务…',
|
||||
refresh: '刷新定时任务',
|
||||
refreshing: '正在刷新定时任务',
|
||||
loading: '正在加载定时任务…',
|
||||
states: {
|
||||
enabled: '已启用',
|
||||
scheduled: '已排程',
|
||||
running: '运行中',
|
||||
paused: '已暂停',
|
||||
disabled: '已禁用',
|
||||
error: '错误',
|
||||
completed: '已完成'
|
||||
},
|
||||
deliveryLabels: {
|
||||
local: '此桌面',
|
||||
telegram: 'Telegram',
|
||||
discord: 'Discord',
|
||||
slack: 'Slack',
|
||||
email: '电子邮件'
|
||||
},
|
||||
scheduleLabels: {
|
||||
daily: '每天',
|
||||
weekdays: '工作日',
|
||||
weekly: '每周',
|
||||
monthly: '每月',
|
||||
hourly: '每小时',
|
||||
'every-15-minutes': '每 15 分钟',
|
||||
custom: '自定义'
|
||||
},
|
||||
scheduleHints: {
|
||||
daily: '每天上午 9:00',
|
||||
weekdays: '周一至周五上午 9:00',
|
||||
weekly: '每周一上午 9:00',
|
||||
monthly: '每月第一天上午 9:00',
|
||||
hourly: '每个整点',
|
||||
'every-15-minutes': '每 15 分钟',
|
||||
custom: 'Cron 语法或自然语言'
|
||||
},
|
||||
days: {
|
||||
'0': '周日',
|
||||
'1': '周一',
|
||||
'2': '周二',
|
||||
'3': '周三',
|
||||
'4': '周四',
|
||||
'5': '周五',
|
||||
'6': '周六',
|
||||
'7': '周日'
|
||||
},
|
||||
dayFallback: value => `第 ${value} 天`,
|
||||
everyDayAt: time => `每天 ${time}`,
|
||||
weekdaysAt: time => `工作日 ${time}`,
|
||||
everyDayOfWeekAt: (day, time) => `每${day} ${time}`,
|
||||
monthlyOnDayAt: (dayOfMonth, time) => `每月 ${dayOfMonth} 日 ${time}`,
|
||||
topOfHour: '每个整点',
|
||||
everyHourAt: minute => `每小时的 :${minute}`,
|
||||
active: (enabled, total) => `${enabled}/${total} 个启用`,
|
||||
newCron: '新建定时任务',
|
||||
createFirst: '创建第一个定时任务',
|
||||
emptyDescNew: '按 cron 表达式排程一个提示词。Hermes 会运行它,并把结果发送到你选择的目的地。',
|
||||
emptyDescSearch: '尝试更宽泛的搜索词。',
|
||||
emptyTitleNew: '暂无排程任务',
|
||||
emptyTitleSearch: '无匹配项',
|
||||
last: '上次:',
|
||||
next: '下次:',
|
||||
actionsFor: title => `${title} 的操作`,
|
||||
actionsTitle: '定时任务操作',
|
||||
resume: '恢复定时任务',
|
||||
pause: '暂停定时任务',
|
||||
resumeTitle: '恢复',
|
||||
pauseTitle: '暂停',
|
||||
triggerNow: '立即触发',
|
||||
edit: '编辑定时任务',
|
||||
deleteTitle: '删除定时任务?',
|
||||
deleteDescPrefix: '这将永久移除 ',
|
||||
deleteDescSuffix: '。它会立即停止触发。',
|
||||
deleting: '删除中…',
|
||||
resumed: '定时任务已恢复',
|
||||
paused: '定时任务已暂停',
|
||||
triggered: '定时任务已触发',
|
||||
deleted: '定时任务已删除',
|
||||
created: '定时任务已创建',
|
||||
updated: '定时任务已更新',
|
||||
failedLoad: '加载定时任务失败',
|
||||
failedUpdate: '更新定时任务失败',
|
||||
failedTrigger: '触发定时任务失败',
|
||||
failedDelete: '删除定时任务失败',
|
||||
failedSave: '保存定时任务失败',
|
||||
editTitle: '编辑定时任务',
|
||||
createTitle: '新建定时任务',
|
||||
editDesc: '更新排程、提示词或投递目标。更改将在下次运行时生效。',
|
||||
createDesc: '排程一个提示词以自动运行。使用 cron 语法或类似"每 15 分钟"的自然语言。',
|
||||
nameLabel: '名称',
|
||||
namePlaceholder: '晨间简报',
|
||||
promptLabel: '提示词',
|
||||
promptPlaceholder: '总结我未读的 Slack 话题,并把前 5 条邮件发给我…',
|
||||
frequencyLabel: '频率',
|
||||
deliverLabel: '投递至',
|
||||
customScheduleLabel: '自定义排程',
|
||||
customPlaceholder: '0 9 * * * 或 weekdays at 9am',
|
||||
customHint: 'Cron 表达式,或类似"每小时""工作日上午 9 点"的短语。',
|
||||
optional: '可选',
|
||||
promptScheduleRequired: '提示词和排程为必填项。',
|
||||
saveChanges: '保存更改',
|
||||
createAction: '创建定时任务'
|
||||
},
|
||||
|
||||
artifacts: {
|
||||
search: '搜索产物…',
|
||||
refresh: '刷新产物',
|
||||
refreshing: '正在刷新产物',
|
||||
indexing: '正在索引最近会话的产物',
|
||||
tabAll: '全部',
|
||||
tabImages: '图片',
|
||||
tabFiles: '文件',
|
||||
tabLinks: '链接',
|
||||
noArtifactsTitle: '未找到产物',
|
||||
noArtifactsDesc: '当会话生成图片和文件输出时,它们会显示在这里。',
|
||||
failedLoad: '产物加载失败',
|
||||
openFailed: '打开失败',
|
||||
itemsImage: '张图片',
|
||||
itemsLink: '个链接',
|
||||
itemsFile: '个文件',
|
||||
itemsGeneric: '项',
|
||||
zero: '0',
|
||||
rangeOf: (start, end, total) => `${start}-${end},共 ${total}`,
|
||||
goToPage: (itemLabel, page) => `前往${itemLabel}第 ${page} 页`,
|
||||
colTitleLink: '链接标题',
|
||||
colTitleFile: '名称',
|
||||
colTitleDefault: '标题 / 名称',
|
||||
colLocationLink: 'URL',
|
||||
colLocationFile: '路径',
|
||||
colLocationDefault: '位置',
|
||||
colSession: '会话',
|
||||
kindImage: '图片',
|
||||
kindFile: '文件',
|
||||
kindLink: '链接',
|
||||
chat: '对话',
|
||||
copyUrl: '复制 URL',
|
||||
copyPath: '复制路径'
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
nav: {
|
||||
'new-session': '新建会话',
|
||||
skills: '技能与工具',
|
||||
messaging: '消息平台',
|
||||
artifacts: '产物'
|
||||
},
|
||||
searchAria: '搜索会话',
|
||||
searchPlaceholder: '搜索会话…',
|
||||
clearSearch: '清除搜索',
|
||||
noMatch: query => `没有会话匹配"${query}"。`,
|
||||
results: '结果',
|
||||
pinned: '已置顶',
|
||||
sessions: '会话',
|
||||
groupAriaGrouped: '以单一列表显示会话',
|
||||
groupAriaUngrouped: '按工作区分组会话',
|
||||
groupTitleGrouped: '取消分组',
|
||||
groupTitleUngrouped: '按工作区分组',
|
||||
allPinned: '这里的全部已置顶。取消置顶某个对话即可在最近中显示。',
|
||||
shiftClickHint: 'Shift+单击对话以置顶 · 拖动以重新排序',
|
||||
noWorkspace: '无工作区',
|
||||
newSessionIn: label => `在 ${label} 中新建会话`,
|
||||
reorderWorkspace: label => `重新排序工作区 ${label}`,
|
||||
showMoreIn: (count, label) => `在 ${label} 中再显示 ${count} 个`,
|
||||
loading: '加载中…',
|
||||
loadMore: '加载更多',
|
||||
loadCount: step => `再加载 ${step} 个`,
|
||||
row: {
|
||||
pin: '置顶',
|
||||
unpin: '取消置顶',
|
||||
copyId: '复制 ID',
|
||||
export: '导出',
|
||||
rename: '重命名',
|
||||
archive: '归档',
|
||||
copyIdFailed: '无法复制会话 ID',
|
||||
actionsFor: title => `${title} 的操作`,
|
||||
sessionActions: '会话操作',
|
||||
sessionRunning: '会话运行中',
|
||||
needsInput: '需要你输入',
|
||||
waitingForAnswer: '正在等待你的回答',
|
||||
renamed: '已重命名',
|
||||
renameFailed: '重命名失败',
|
||||
renameTitle: '重命名会话',
|
||||
renameDesc: '给这个对话起一个好记的标题。留空则清除。',
|
||||
untitledPlaceholder: '无标题会话',
|
||||
ageNow: '刚刚',
|
||||
ageDay: '天',
|
||||
ageHour: '时',
|
||||
ageMin: '分'
|
||||
}
|
||||
},
|
||||
|
||||
composer: {
|
||||
message: '消息',
|
||||
placeholderStarting: '正在启动 Hermes…',
|
||||
placeholderReconnecting: '正在重新连接 Hermes…',
|
||||
placeholderFollowUp: '发送后续消息',
|
||||
newSessionPlaceholders: [
|
||||
'我们要构建什么?',
|
||||
'给 Hermes 一个任务',
|
||||
'你在想什么?',
|
||||
'描述你需要什么',
|
||||
'我们该处理什么?',
|
||||
'随便问点什么',
|
||||
'从一个目标开始'
|
||||
],
|
||||
followUpPlaceholders: [
|
||||
'发送后续消息',
|
||||
'补充更多上下文',
|
||||
'细化这个请求',
|
||||
'下一步是什么?',
|
||||
'继续推进',
|
||||
'再深入一点',
|
||||
'调整或继续'
|
||||
],
|
||||
startVoice: '开始语音对话',
|
||||
queueMessage: '排队消息',
|
||||
stop: '停止',
|
||||
send: '发送',
|
||||
speaking: '讲话中',
|
||||
transcribing: '转写中',
|
||||
thinking: '思考中',
|
||||
muted: '已静音',
|
||||
listening: '聆听中',
|
||||
muteMic: '麦克风静音',
|
||||
unmuteMic: '取消麦克风静音',
|
||||
stopListening: '停止聆听并发送',
|
||||
stopShort: '停止',
|
||||
endConversation: '结束语音对话',
|
||||
endShort: '结束',
|
||||
stopDictation: '停止听写',
|
||||
transcribingDictation: '正在转写听写',
|
||||
voiceDictation: '语音听写',
|
||||
commonCommands: '常用命令',
|
||||
hotkeys: '快捷键',
|
||||
helpFooter: '打开完整面板 · 退格键关闭',
|
||||
commandDescs: {
|
||||
'/help': '命令与快捷键的完整列表',
|
||||
'/clear': '开始新会话',
|
||||
'/resume': '恢复之前的会话',
|
||||
'/details': '控制对话记录的详细程度',
|
||||
'/copy': '复制所选内容或最后一条助手消息',
|
||||
'/quit': '退出 hermes'
|
||||
},
|
||||
hotkeyDescs: {
|
||||
'@': '引用文件、文件夹、URL、git',
|
||||
'/': '斜杠命令面板',
|
||||
'?': '此快速帮助(删除以关闭)',
|
||||
Enter: '发送 · Shift+Enter 换行',
|
||||
'Cmd/Ctrl+K': '发送下一条排队的回合',
|
||||
'Cmd/Ctrl+L': '重绘',
|
||||
Esc: '关闭弹窗 · 取消运行',
|
||||
'↑ / ↓': '循环弹窗 / 历史'
|
||||
},
|
||||
attachUrlTitle: '附加 URL',
|
||||
attachUrlDesc: 'Hermes 将抓取该页面并作为本回合的上下文。',
|
||||
urlPlaceholder: 'https://example.com/post',
|
||||
urlHintPre: '请包含完整 URL,例如 ',
|
||||
attach: '附加',
|
||||
queued: count => `${count} 条排队`,
|
||||
attachmentOnly: '仅附件回合',
|
||||
emptyTurn: '空回合',
|
||||
attachments: count => `${count} 个附件`,
|
||||
editingInComposer: '正在输入框中编辑',
|
||||
editQueued: '编辑排队回合',
|
||||
sendQueuedNext: '下一个发送排队回合',
|
||||
sendQueuedNow: '立即发送排队回合',
|
||||
deleteQueued: '删除排队回合',
|
||||
previewUnavailable: '预览不可用',
|
||||
previewLabel: label => `预览 ${label}`,
|
||||
couldNotPreview: label => `无法预览 ${label}`,
|
||||
removeAttachment: label => `移除 ${label}`,
|
||||
dictating: '听写中',
|
||||
preparingAudio: '正在准备音频',
|
||||
speakingResponse: '正在朗读回复',
|
||||
readingAloud: '朗读中',
|
||||
themeSuggestions: '桌面主题建议',
|
||||
noMatchingThemes: '没有匹配的主题。',
|
||||
themeTryPre: '试试 ',
|
||||
themeTryPost: '。',
|
||||
attachLabel: '附加',
|
||||
files: '文件…',
|
||||
folder: '文件夹…',
|
||||
images: '图片…',
|
||||
pasteImage: '粘贴图片',
|
||||
url: 'URL…',
|
||||
promptSnippets: '提示词片段…',
|
||||
tipPre: '提示:输入 ',
|
||||
tipPost: ' 以内联引用文件。',
|
||||
snippetsTitle: '提示词片段',
|
||||
snippetsDesc: '选择一个起始提示词放入输入框。',
|
||||
snippets: {
|
||||
codeReview: {
|
||||
label: '代码审查',
|
||||
description: '审查当前更改是否存在回归、遗漏的边界情况和缺失的测试。',
|
||||
text: '请审查这部分是否存在缺陷、回归和缺失的测试。'
|
||||
},
|
||||
implementationPlan: {
|
||||
label: '实现计划',
|
||||
description: '在动代码之前先勾勒方案,让 diff 保持聚焦。',
|
||||
text: '请在修改代码前制定一个简洁的实现计划。'
|
||||
},
|
||||
explainThis: {
|
||||
label: '解释这段',
|
||||
description: '讲解所选代码的工作方式,并链接到关键文件。',
|
||||
text: '请解释这是如何工作的,并指给我关键文件。'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { type ChatMessage, type ChatMessagePart, chatMessageText, textPart } fro
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import type { ModelOptionsResponse, SessionInfo } from '@/types/hermes'
|
||||
|
||||
export const INTERRUPTED_MARKER = '\n\n_[interrupted]_'
|
||||
export const SLASH_COMMAND_RE = /^\/[^\s/]*(?:\s|$)/
|
||||
export const BUILTIN_PERSONALITIES = [
|
||||
'helpful',
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { resolveRemotePathPicker } from '@/store/remote-path-picker'
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { fsGitRoot, fsReadDir, fsReadFileDataUrl, isRemoteBackend, selectPaths } from './desktop-fs'
|
||||
|
||||
const request = vi.fn()
|
||||
const readDir = vi.fn()
|
||||
const readFileDataUrl = vi.fn()
|
||||
const gitRoot = vi.fn()
|
||||
const desktopSelectPaths = vi.fn()
|
||||
|
||||
function setRemote(remote: boolean) {
|
||||
$connection.set(remote ? ({ mode: 'remote' } as never) : null)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
request.mockReset()
|
||||
readDir.mockReset()
|
||||
readFileDataUrl.mockReset()
|
||||
gitRoot.mockReset()
|
||||
desktopSelectPaths.mockReset()
|
||||
$gateway.set({ request } as unknown as HermesGateway)
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
|
||||
readDir,
|
||||
readFileDataUrl,
|
||||
gitRoot,
|
||||
selectPaths: desktopSelectPaths
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$connection.set(null)
|
||||
$gateway.set(null)
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
describe('desktop-fs facade', () => {
|
||||
it('routes reads to local IPC when not remote', async () => {
|
||||
setRemote(false)
|
||||
readDir.mockResolvedValue({ entries: [] })
|
||||
|
||||
await fsReadDir('/p')
|
||||
|
||||
expect(readDir).toHaveBeenCalledWith('/p')
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
expect(isRemoteBackend()).toBe(false)
|
||||
})
|
||||
|
||||
it('routes directory listing to fs.list when remote', async () => {
|
||||
setRemote(true)
|
||||
request.mockResolvedValue({ entries: [], path: '/srv' })
|
||||
|
||||
const result = await fsReadDir('/srv')
|
||||
|
||||
expect(request).toHaveBeenCalledWith('fs.list', { path: '/srv' })
|
||||
expect(result.path).toBe('/srv')
|
||||
expect(readDir).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unwraps the data url from fs.read_data_url when remote', async () => {
|
||||
setRemote(true)
|
||||
request.mockResolvedValue({ dataUrl: 'data:image/png;base64,AAAA' })
|
||||
|
||||
expect(await fsReadFileDataUrl('/srv/x.png')).toBe('data:image/png;base64,AAAA')
|
||||
expect(request).toHaveBeenCalledWith('fs.read_data_url', { path: '/srv/x.png' })
|
||||
})
|
||||
|
||||
it('returns gateway git root when remote', async () => {
|
||||
setRemote(true)
|
||||
request.mockResolvedValue({ root: '/srv/repo' })
|
||||
|
||||
expect(await fsGitRoot('/srv/repo/a')).toBe('/srv/repo')
|
||||
})
|
||||
|
||||
it('uses the native picker locally and the remote picker when remote', async () => {
|
||||
setRemote(false)
|
||||
desktopSelectPaths.mockResolvedValue(['/local/a.png'])
|
||||
expect(await selectPaths({ title: 'pick' })).toEqual(['/local/a.png'])
|
||||
|
||||
setRemote(true)
|
||||
const pending = selectPaths({ title: 'pick' })
|
||||
resolveRemotePathPicker(['/srv/a.png'])
|
||||
expect(await pending).toEqual(['/srv/a.png'])
|
||||
// Remote selection never touches the native dialog.
|
||||
expect(desktopSelectPaths).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { HermesReadDirResult, HermesReadFileTextResult, HermesSelectPathsOptions } from '@/global'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { openRemotePathPicker } from '@/store/remote-path-picker'
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
// On a remote gateway (e.g. a VPS over tailscale) the agent's filesystem lives
|
||||
// on the server, but the Electron IPC helpers only see the client machine. This
|
||||
// facade routes reads + path selection through gateway `fs.*` RPCs when remote,
|
||||
// and falls back to local Electron IPC against a locally-spawned backend.
|
||||
export const isRemoteBackend = (): boolean => $connection.get()?.mode === 'remote'
|
||||
|
||||
function gw<T>(method: string, params: Record<string, unknown>): Promise<T> {
|
||||
const gateway = $gateway.get()
|
||||
|
||||
if (!gateway) {
|
||||
throw new Error('Hermes gateway unavailable')
|
||||
}
|
||||
|
||||
return gateway.request<T>(method, params)
|
||||
}
|
||||
|
||||
const unavailable = (): never => {
|
||||
throw new Error('File reading is unavailable')
|
||||
}
|
||||
|
||||
export function fsReadDir(path: string): Promise<HermesReadDirResult> {
|
||||
if (isRemoteBackend()) {
|
||||
return gw('fs.list', { path })
|
||||
}
|
||||
|
||||
return window.hermesDesktop?.readDir?.(path) ?? Promise.resolve({ entries: [], error: 'no-bridge' })
|
||||
}
|
||||
|
||||
export function fsReadFileText(path: string): Promise<HermesReadFileTextResult> {
|
||||
if (isRemoteBackend()) {
|
||||
return gw('fs.read_text', { path })
|
||||
}
|
||||
|
||||
return window.hermesDesktop?.readFileText?.(path) ?? unavailable()
|
||||
}
|
||||
|
||||
export async function fsReadFileDataUrl(path: string): Promise<string> {
|
||||
if (isRemoteBackend()) {
|
||||
return (await gw<{ dataUrl?: string }>('fs.read_data_url', { path })).dataUrl ?? unavailable()
|
||||
}
|
||||
|
||||
return window.hermesDesktop?.readFileDataUrl?.(path) ?? unavailable()
|
||||
}
|
||||
|
||||
export async function fsGitRoot(path: string): Promise<string | null> {
|
||||
if (isRemoteBackend()) {
|
||||
return (await gw<{ root?: string | null }>('fs.git_root', { path })).root ?? null
|
||||
}
|
||||
|
||||
return window.hermesDesktop?.gitRoot?.(path) ?? null
|
||||
}
|
||||
|
||||
export async function selectPaths(options: HermesSelectPathsOptions = {}): Promise<string[]> {
|
||||
if (isRemoteBackend()) {
|
||||
return openRemotePathPicker(options)
|
||||
}
|
||||
|
||||
return (await window.hermesDesktop?.selectPaths?.(options)) ?? []
|
||||
}
|
||||
@@ -15,6 +15,7 @@ describe('desktop slash command curation', () => {
|
||||
expect(isDesktopSlashSuggestion('/branch')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/skin')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/usage')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/version')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/yolo')).toBe(true)
|
||||
expect(isDesktopSlashCommand('/yolo')).toBe(true)
|
||||
})
|
||||
|
||||
@@ -43,6 +43,7 @@ const DESKTOP_COMMAND_META = [
|
||||
['/title', 'Rename the current session'],
|
||||
['/undo', 'Remove the last user/assistant exchange'],
|
||||
['/usage', 'Show token usage for this session'],
|
||||
['/version', 'Show Hermes Agent version'],
|
||||
['/yolo', 'Toggle YOLO — auto-approve dangerous commands']
|
||||
] as const
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { HashRouter } from 'react-router-dom'
|
||||
import App from './app'
|
||||
import { ErrorBoundary } from './components/error-boundary'
|
||||
import { HapticsProvider } from './components/haptics-provider'
|
||||
import { I18nProvider } from './i18n'
|
||||
import { installClipboardShim } from './lib/clipboard'
|
||||
import { queryClient } from './lib/query-client'
|
||||
import { ThemeProvider } from './themes/context'
|
||||
@@ -27,13 +28,15 @@ createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary label="root">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<HapticsProvider>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</HapticsProvider>
|
||||
</ThemeProvider>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<HapticsProvider>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</HapticsProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import type { DesktopBootProgress } from '@/global'
|
||||
import { translateNow } from '@/i18n'
|
||||
|
||||
export interface DesktopBootState extends DesktopBootProgress {
|
||||
visible: boolean
|
||||
@@ -9,7 +10,7 @@ export interface DesktopBootState extends DesktopBootProgress {
|
||||
const INITIAL_BOOT_STATE: DesktopBootState = {
|
||||
error: null,
|
||||
fakeMode: false,
|
||||
message: 'Starting Hermes Desktop…',
|
||||
message: translateNow('boot.steps.startingHermesDesktop'),
|
||||
phase: 'renderer.init',
|
||||
progress: 2,
|
||||
running: true,
|
||||
@@ -61,7 +62,7 @@ export function setDesktopBootStep(step: {
|
||||
})
|
||||
}
|
||||
|
||||
export function completeDesktopBoot(message = 'Hermes Desktop is ready') {
|
||||
export function completeDesktopBoot(message = translateNow('boot.ready')) {
|
||||
const current = $desktopBoot.get()
|
||||
$desktopBoot.set({
|
||||
...current,
|
||||
@@ -80,7 +81,7 @@ export function failDesktopBoot(message: string) {
|
||||
$desktopBoot.set({
|
||||
...current,
|
||||
error: message,
|
||||
message: `Desktop boot failed: ${message}`,
|
||||
message: translateNow('boot.desktopBootFailedWithMessage', message),
|
||||
phase: 'renderer.error',
|
||||
progress: clampProgress(current.progress),
|
||||
running: false,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
$perSessionBrowse,
|
||||
browseBackward,
|
||||
browseForward,
|
||||
deriveUserHistory,
|
||||
isBrowsingHistory,
|
||||
resetBrowseState
|
||||
} from './composer-input-history'
|
||||
|
||||
const SESSION_A = 'session-a'
|
||||
const SESSION_B = 'session-b'
|
||||
|
||||
// Newest-first user text ring, what the caller passes to browse*.
|
||||
const HISTORY = ['third', 'second', 'first']
|
||||
|
||||
const MSG = (role: string, text: string) => ({ id: '', role, text })
|
||||
|
||||
beforeEach(() => {
|
||||
$perSessionBrowse.set({})
|
||||
})
|
||||
|
||||
describe('deriveUserHistory', () => {
|
||||
it('returns user messages newest-first with empty/whitespace skipped', () => {
|
||||
const messages = [
|
||||
MSG('user', ' '),
|
||||
MSG('assistant', 'hi'),
|
||||
MSG('user', 'first'),
|
||||
MSG('user', 'second')
|
||||
]
|
||||
|
||||
expect(deriveUserHistory(messages, m => m.text)).toEqual(['second', 'first'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('browseBackward', () => {
|
||||
it('returns null when history is empty', () => {
|
||||
expect(browseBackward(SESSION_A, '', [])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the most recent entry on first press and saves the draft', () => {
|
||||
const result = browseBackward(SESSION_A, 'unsent draft', HISTORY)
|
||||
|
||||
expect(result).toBe('third')
|
||||
expect($perSessionBrowse.get()[SESSION_A]!.draftSnapshot).toBe('unsent draft')
|
||||
})
|
||||
|
||||
it('moves to older entries on subsequent presses and stops at the oldest', () => {
|
||||
expect(browseBackward(SESSION_A, '', HISTORY)).toBe('third')
|
||||
expect(browseBackward(SESSION_A, '', HISTORY)).toBe('second')
|
||||
expect(browseBackward(SESSION_A, '', HISTORY)).toBe('first')
|
||||
expect(browseBackward(SESSION_A, '', HISTORY)).toBeNull()
|
||||
})
|
||||
|
||||
it('uses caller-provided history, not a mirrored ring', () => {
|
||||
// The store never owns the ring — the caller passes it every press.
|
||||
// If the ring changes between presses (e.g. a new message was sent),
|
||||
// the next press sees the updated ring and the cursor continues
|
||||
// from where it was within it.
|
||||
expect(browseBackward(SESSION_A, '', ['youngest', 'older'])).toBe('youngest')
|
||||
|
||||
// Caller added a new message; ring is now [brand-new, youngest, older].
|
||||
// Cursor was at 0, next press advances to 1 -> "youngest".
|
||||
expect(
|
||||
browseBackward(SESSION_A, '', ['brand-new', 'youngest', 'older'])
|
||||
).toBe('youngest')
|
||||
|
||||
// One more press -> "older".
|
||||
expect(
|
||||
browseBackward(SESSION_A, '', ['brand-new', 'youngest', 'older'])
|
||||
).toBe('older')
|
||||
})
|
||||
})
|
||||
|
||||
describe('browseForward', () => {
|
||||
it('returns null when not browsing', () => {
|
||||
expect(browseForward(SESSION_A, HISTORY)).toBeNull()
|
||||
})
|
||||
|
||||
it('moves toward the present', () => {
|
||||
browseBackward(SESSION_A, 'draft', HISTORY) // cursor 0 -> 'third'
|
||||
browseBackward(SESSION_A, '', HISTORY) // cursor 1 -> 'second'
|
||||
|
||||
expect(browseForward(SESSION_A, HISTORY)).toEqual({
|
||||
text: 'third',
|
||||
returnedToPresent: false
|
||||
})
|
||||
})
|
||||
|
||||
it('restores the saved draft and resets when reaching the present', () => {
|
||||
browseBackward(SESSION_A, 'my original draft', HISTORY)
|
||||
|
||||
const result = browseForward(SESSION_A, HISTORY)
|
||||
|
||||
expect(result).toEqual({ text: 'my original draft', returnedToPresent: true })
|
||||
expect(isBrowsingHistory(SESSION_A)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-session isolation', () => {
|
||||
it('tracks cursor and draft independently per session', () => {
|
||||
browseBackward(SESSION_A, 'draft-a', HISTORY)
|
||||
browseBackward(SESSION_A, '', HISTORY) // older
|
||||
|
||||
browseBackward(SESSION_B, 'draft-b', HISTORY)
|
||||
|
||||
const a = $perSessionBrowse.get()[SESSION_A]!
|
||||
const b = $perSessionBrowse.get()[SESSION_B]!
|
||||
|
||||
expect(a.cursor).toBe(1)
|
||||
expect(a.draftSnapshot).toBe('draft-a')
|
||||
expect(b.cursor).toBe(0)
|
||||
expect(b.draftSnapshot).toBe('draft-b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetBrowseState', () => {
|
||||
it('clears cursor and draft snapshot', () => {
|
||||
browseBackward(SESSION_A, 'draft', HISTORY)
|
||||
resetBrowseState(SESSION_A)
|
||||
|
||||
const s = $perSessionBrowse.get()[SESSION_A]!
|
||||
|
||||
expect(s.cursor).toBe(-1)
|
||||
expect(s.draftSnapshot).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('session switch behavior', () => {
|
||||
it('resets the previous session cursor and lets the new session derive its own ring', () => {
|
||||
// Session A: user browsed into the past
|
||||
browseBackward(SESSION_A, '', HISTORY)
|
||||
expect(isBrowsingHistory(SESSION_A)).toBe(true)
|
||||
|
||||
// Caller switches to session B; resets A's browse state
|
||||
resetBrowseState(SESSION_A)
|
||||
|
||||
// Session B's ring is derived from B's messages, not A's
|
||||
const sessionBMessages = [MSG('user', 'hello-b'), MSG('user', 'world-b')]
|
||||
const sessionBHistory = deriveUserHistory(sessionBMessages, m => m.text)
|
||||
|
||||
expect(browseBackward(SESSION_B, '', sessionBHistory)).toBe('world-b')
|
||||
expect(browseBackward(SESSION_B, '', sessionBHistory)).toBe('hello-b')
|
||||
expect(isBrowsingHistory(SESSION_A)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
/**
|
||||
* Per-session input history browse state.
|
||||
*
|
||||
* The user-text ring is **derived from the live session messages** on each
|
||||
* keypress — it is not mirrored anywhere. This keeps a single source of truth
|
||||
* and avoids the entire class of seeding/dedup bugs that come from trying to
|
||||
* keep a parallel ring in sync with submit/queue/voice paths.
|
||||
*
|
||||
* We only persist the cursor and the saved draft:
|
||||
* - `cursor` — index into the derived user-text ring (0 = newest, larger = older).
|
||||
* `-1` means "not browsing".
|
||||
* - `draftSnapshot` — the composer text at the moment the user started
|
||||
* browsing, so ArrowDown back to the "present" restores it.
|
||||
*/
|
||||
export interface SessionBrowseState {
|
||||
cursor: number
|
||||
draftSnapshot: string
|
||||
}
|
||||
|
||||
const $perSessionBrowse = atom<Record<string, SessionBrowseState>>({})
|
||||
|
||||
function ensure(sessionId: string): SessionBrowseState {
|
||||
const all = { ...$perSessionBrowse.get() }
|
||||
let s = all[sessionId]
|
||||
|
||||
if (!s) {
|
||||
s = { cursor: -1, draftSnapshot: '' }
|
||||
all[sessionId] = s
|
||||
$perSessionBrowse.set(all)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
function persist() {
|
||||
$perSessionBrowse.set({ ...$perSessionBrowse.get() })
|
||||
}
|
||||
|
||||
function valid(sessionId: string | null | undefined): sessionId is string {
|
||||
return typeof sessionId === 'string' && sessionId.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the user-text ring (newest first) from session messages.
|
||||
* The caller is responsible for providing already-session-scoped messages.
|
||||
*/
|
||||
export function deriveUserHistory<T extends { role: string }>(
|
||||
messages: readonly T[],
|
||||
getText: (m: T) => string
|
||||
): string[] {
|
||||
const out: string[] = []
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!
|
||||
|
||||
if (m.role !== 'user') {continue}
|
||||
|
||||
const t = getText(m).trim()
|
||||
|
||||
if (t) {out.push(t)}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Start browsing backward, or step to the next older entry.
|
||||
* Returns the text to place in the composer, or null if already at the oldest
|
||||
* entry (or the ring is empty).
|
||||
*/
|
||||
export function browseBackward(
|
||||
sessionId: string | null | undefined,
|
||||
currentDraft: string,
|
||||
history: readonly string[]
|
||||
): string | null {
|
||||
if (!valid(sessionId) || history.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const s = ensure(sessionId)
|
||||
|
||||
if (s.cursor === -1) {
|
||||
s.draftSnapshot = currentDraft
|
||||
s.cursor = 0
|
||||
} else if (s.cursor < history.length - 1) {
|
||||
s.cursor += 1
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
persist()
|
||||
|
||||
return history[s.cursor]!
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse forward toward the present. When reaching the "newest" entry the
|
||||
* saved draft is restored and the cursor resets.
|
||||
*/
|
||||
export function browseForward(
|
||||
sessionId: string | null | undefined,
|
||||
history: readonly string[]
|
||||
): { text: string; returnedToPresent: boolean } | null {
|
||||
if (!valid(sessionId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const s = ensure(sessionId)
|
||||
|
||||
if (s.cursor === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (s.cursor > 0) {
|
||||
s.cursor -= 1
|
||||
persist()
|
||||
|
||||
return { text: history[s.cursor]!, returnedToPresent: false }
|
||||
}
|
||||
|
||||
// At newest; moving forward restores the saved draft.
|
||||
const text = s.draftSnapshot
|
||||
s.cursor = -1
|
||||
s.draftSnapshot = ''
|
||||
persist()
|
||||
|
||||
return { text, returnedToPresent: true }
|
||||
}
|
||||
|
||||
/** Clear browse state for a session (e.g. on session switch or new submit). */
|
||||
export function resetBrowseState(sessionId: string | null | undefined) {
|
||||
if (!valid(sessionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const all = { ...$perSessionBrowse.get() }
|
||||
const existing = all[sessionId]
|
||||
|
||||
if (!existing) {return}
|
||||
|
||||
all[sessionId] = { cursor: -1, draftSnapshot: '' }
|
||||
$perSessionBrowse.set(all)
|
||||
}
|
||||
|
||||
/** True if the user is currently browsing history for this session. */
|
||||
export function isBrowsingHistory(sessionId: string | null | undefined): boolean {
|
||||
if (!valid(sessionId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const s = $perSessionBrowse.get()[sessionId]
|
||||
|
||||
return s ? s.cursor >= 0 : false
|
||||
}
|
||||
|
||||
export { $perSessionBrowse }
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
dequeueQueuedPrompt,
|
||||
enqueueQueuedPrompt,
|
||||
getQueuedPrompts,
|
||||
promoteQueuedPrompt,
|
||||
removeQueuedPrompt,
|
||||
shouldAutoDrainOnSettle,
|
||||
updateQueuedPrompt,
|
||||
@@ -63,6 +64,20 @@ describe('composer queue store', () => {
|
||||
expect(getQueuedPrompts(SESSION_KEY).map(entry => entry.text)).toEqual(['draft two'])
|
||||
})
|
||||
|
||||
it('promotes a queued entry to the front', () => {
|
||||
const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'first' })
|
||||
const second = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'second' })
|
||||
const third = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'third' })
|
||||
|
||||
expect(first).not.toBeNull()
|
||||
expect(second).not.toBeNull()
|
||||
expect(third).not.toBeNull()
|
||||
|
||||
expect(promoteQueuedPrompt(SESSION_KEY, third!.id)).toBe(true)
|
||||
expect(getQueuedPrompts(SESSION_KEY).map(entry => entry.text)).toEqual(['third', 'first', 'second'])
|
||||
expect(promoteQueuedPrompt(SESSION_KEY, third!.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('updates queued text and attachment snapshot', () => {
|
||||
const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [attachment('f-1')], text: 'draft one' })
|
||||
const editedAttachments = [attachment('f-2'), attachment('f-3', 'image')]
|
||||
@@ -103,26 +118,22 @@ describe('composer queue store', () => {
|
||||
})
|
||||
|
||||
describe('shouldAutoDrainOnSettle', () => {
|
||||
const base = { isBusy: false, queueLength: 1, userInterrupted: false, wasBusy: true }
|
||||
const base = { isBusy: false, queueLength: 1, wasBusy: true }
|
||||
|
||||
it('drains the next queued prompt when a turn completes naturally', () => {
|
||||
it('drains the next queued prompt when a turn settles', () => {
|
||||
expect(shouldAutoDrainOnSettle(base)).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT drain when the user explicitly interrupted (Stop button)', () => {
|
||||
// Regression: previously the Stop button "never worked" because cancelling
|
||||
// a turn flipped busy → false and the queue immediately re-fired its head.
|
||||
expect(shouldAutoDrainOnSettle({ ...base, userInterrupted: true })).toBe(false)
|
||||
it('drains after an interrupt — the settle edge is the same', () => {
|
||||
// Interrupting to reach a queued message is the point of the queue; the
|
||||
// gateway emits the same settle whether the turn finished or was stopped.
|
||||
expect(shouldAutoDrainOnSettle(base)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not drain when the queue is empty', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0 })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not drain when interrupted even if the queue is also empty', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0, userInterrupted: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores steady busy state (no true → false transition)', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, isBusy: true })).toBe(false)
|
||||
})
|
||||
|
||||
@@ -137,6 +137,26 @@ export const removeQueuedPrompt = (key: string | null | undefined, id: string):
|
||||
return true
|
||||
}
|
||||
|
||||
export const promoteQueuedPrompt = (key: string | null | undefined, id: string): boolean => {
|
||||
const sid = sidOf(key)
|
||||
|
||||
if (!sid) {
|
||||
return false
|
||||
}
|
||||
|
||||
const queue = queueFor(sid)
|
||||
const index = queue.findIndex(e => e.id === id)
|
||||
|
||||
if (index <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const entry = queue[index]!
|
||||
writeSession(sid, [entry, ...queue.slice(0, index), ...queue.slice(index + 1)])
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const updateQueuedPrompt = (
|
||||
key: string | null | undefined,
|
||||
id: string,
|
||||
@@ -194,33 +214,26 @@ export interface AutoDrainSettleInput {
|
||||
wasBusy: boolean
|
||||
isBusy: boolean
|
||||
queueLength: number
|
||||
userInterrupted: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the composer should auto-drain the next queued prompt when a
|
||||
* turn settles (busy transitions true → false).
|
||||
*
|
||||
* The queue auto-advances when a turn *completes naturally*, but must NOT
|
||||
* advance when the user *explicitly interrupted* the turn via the Stop button.
|
||||
* Conflating the two made the Stop button appear to "never work": cancelling a
|
||||
* turn flipped busy → false, the queue immediately re-fired its head, and the
|
||||
* agent kept running. An explicit interrupt means stop — the queued turns are
|
||||
* preserved and the user resumes them deliberately (Cmd/Ctrl+K, Enter, or the
|
||||
* per-row "send now" arrow).
|
||||
* Queued turns always advance once the session is idle again, whether the turn
|
||||
* finished naturally or the user interrupted it. Interrupting to reach a queued
|
||||
* message is the whole point of the queue, so we never suppress the drain. The
|
||||
* gateway guarantees a settle (message.complete + session.info running:false)
|
||||
* even after an interrupt, so this single edge reliably advances the queue. To
|
||||
* cancel queued turns the user deletes them from the panel.
|
||||
*/
|
||||
export const shouldAutoDrainOnSettle = (params: AutoDrainSettleInput): boolean => {
|
||||
const { isBusy, queueLength, userInterrupted, wasBusy } = params
|
||||
const { isBusy, queueLength, wasBusy } = params
|
||||
|
||||
// Only react to a true → false transition; ignore steady state and entry.
|
||||
if (isBusy || !wasBusy) {
|
||||
return false
|
||||
}
|
||||
|
||||
// An explicit Stop suppresses exactly one auto-drain.
|
||||
if (userInterrupted) {
|
||||
return false
|
||||
}
|
||||
|
||||
return queueLength > 0
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { translateNow } from '@/i18n'
|
||||
|
||||
export type NotificationKind = 'error' | 'warning' | 'info' | 'success'
|
||||
|
||||
export interface NotificationAction {
|
||||
@@ -52,28 +54,29 @@ const ERROR_SUMMARIES: { test: (msg: string) => boolean; summarize: (msg: string
|
||||
summarize: msg => {
|
||||
const status = msg.match(/(?:error code|status(?:Code)?)[^\d]*(\d{3})/i)?.[1]
|
||||
|
||||
return `OpenAI rejected the API key${status ? ` (${status} invalid_api_key)` : ''}.`
|
||||
return status
|
||||
? translateNow('notifications.errors.openaiRejectedApiKeyWithStatus', status)
|
||||
: translateNow('notifications.errors.openaiRejectedApiKey')
|
||||
}
|
||||
},
|
||||
{
|
||||
test: msg => /neither voice_tools_openai_key nor openai_api_key is set/i.test(msg),
|
||||
summarize: () => 'OpenAI TTS needs VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY.'
|
||||
summarize: () => translateNow('notifications.errors.openaiTtsNeedsKey')
|
||||
},
|
||||
{
|
||||
test: msg => /ELEVENLABS_API_KEY not set/i.test(msg) || /ElevenLabs STT API error \(HTTP 401\)/i.test(msg),
|
||||
summarize: msg =>
|
||||
/ELEVENLABS_API_KEY not set/i.test(msg)
|
||||
? 'ElevenLabs STT needs ELEVENLABS_API_KEY.'
|
||||
: 'ElevenLabs rejected the API key (401).'
|
||||
? translateNow('notifications.errors.elevenLabsNeedsKey')
|
||||
: translateNow('notifications.errors.elevenLabsRejectedKey')
|
||||
},
|
||||
{
|
||||
test: msg => /method not allowed/i.test(msg),
|
||||
summarize: () =>
|
||||
'The desktop backend rejected that request (405 Method Not Allowed). Try restarting Hermes Desktop.'
|
||||
summarize: () => translateNow('notifications.errors.methodNotAllowed')
|
||||
},
|
||||
{
|
||||
test: msg => /microphone permission/i.test(msg),
|
||||
summarize: () => 'Microphone permission was denied.'
|
||||
summarize: () => translateNow('notifications.errors.microphonePermission')
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import type { HermesSelectPathsOptions } from '@/global'
|
||||
|
||||
export interface RemotePathPickerRequest {
|
||||
id: number
|
||||
options: HermesSelectPathsOptions
|
||||
resolve: (paths: string[]) => void
|
||||
}
|
||||
|
||||
// Holds the currently open remote path-picker request, if any. The picker
|
||||
// modal subscribes and resolves the promise when the user confirms or cancels.
|
||||
// Used only when the desktop is connected to a remote gateway, where the native
|
||||
// OS dialog (which browses the client machine) is the wrong filesystem.
|
||||
export const $remotePathPicker = atom<RemotePathPickerRequest | null>(null)
|
||||
|
||||
let nextRequestId = 0
|
||||
|
||||
export function openRemotePathPicker(options: HermesSelectPathsOptions = {}): Promise<string[]> {
|
||||
// Only one picker at a time; cancel any prior request.
|
||||
const previous = $remotePathPicker.get()
|
||||
|
||||
if (previous) {
|
||||
previous.resolve([])
|
||||
}
|
||||
|
||||
return new Promise<string[]>(resolve => {
|
||||
$remotePathPicker.set({ id: (nextRequestId += 1), options, resolve })
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveRemotePathPicker(paths: string[]): void {
|
||||
const request = $remotePathPicker.get()
|
||||
|
||||
if (!request) {
|
||||
return
|
||||
}
|
||||
|
||||
$remotePathPicker.set(null)
|
||||
request.resolve(paths)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
DesktopUpdateStatus,
|
||||
DesktopVersionInfo
|
||||
} from '@/global'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
import { dismissNotification, notify } from '@/store/notifications'
|
||||
|
||||
@@ -85,12 +86,12 @@ export function reportBackendContract(contract: number | undefined): void {
|
||||
}
|
||||
|
||||
notify({
|
||||
action: { label: 'Update Hermes', onClick: () => void applyUpdates() },
|
||||
action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyUpdates() },
|
||||
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.',
|
||||
title: 'Backend out of date'
|
||||
message: translateNow('notifications.backendOutOfDateMessage'),
|
||||
title: translateNow('notifications.backendOutOfDateTitle')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -121,7 +122,7 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
|
||||
notify({
|
||||
action: {
|
||||
label: "See what's new",
|
||||
label: translateNow('notifications.seeWhatsNew'),
|
||||
onClick: () => {
|
||||
snoozeUpdateToast()
|
||||
openUpdatesWindow()
|
||||
@@ -130,9 +131,9 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
durationMs: 0,
|
||||
id: UPDATE_TOAST_ID,
|
||||
kind: 'info',
|
||||
message: `${behind} new change${behind === 1 ? '' : 's'} available.`,
|
||||
message: translateNow('notifications.updateReadyMessage', behind),
|
||||
onDismiss: () => snoozeUpdateToast(),
|
||||
title: 'Update ready'
|
||||
title: translateNow('notifications.updateReadyTitle')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -466,6 +466,10 @@ export interface ProfileInfo {
|
||||
skill_count: number
|
||||
}
|
||||
|
||||
export interface ProfileSetupCommand {
|
||||
command: string
|
||||
}
|
||||
|
||||
export interface ProfileSoul {
|
||||
content: string
|
||||
exists: boolean
|
||||
|
||||
@@ -871,7 +871,7 @@ delegation:
|
||||
max_iterations: 50 # Max tool-calling turns per child (default: 50)
|
||||
# max_concurrent_children: 3 # Max parallel child agents per batch (default: 3, floor: 1, no ceiling).
|
||||
# WARNING: values above 10 multiply API cost linearly.
|
||||
# max_spawn_depth: 1 # Delegation tree depth cap (range: 1-3, default: 1 = flat).
|
||||
# max_spawn_depth: 1 # Delegation tree depth (floor 1, no ceiling; default: 1 = flat).
|
||||
# Raise to 2 to allow workers to spawn their own subagents.
|
||||
# Requires role="orchestrator" on intermediate agents.
|
||||
# orchestrator_enabled: true # Kill switch for role="orchestrator" children (default: true).
|
||||
|
||||
@@ -3194,6 +3194,18 @@ class HermesCLI:
|
||||
_config_model = (_model_config.get("default") or _model_config.get("model") or "") if isinstance(_model_config, dict) else (_model_config or "")
|
||||
_DEFAULT_CONFIG_MODEL = ""
|
||||
self.model = model or _config_model or _DEFAULT_CONFIG_MODEL
|
||||
# Read max_tokens from config (env var override: HERMES_MAX_TOKENS)
|
||||
_env_mt = os.environ.get("HERMES_MAX_TOKENS")
|
||||
if _env_mt:
|
||||
try:
|
||||
self.max_tokens = int(_env_mt)
|
||||
except (ValueError, TypeError):
|
||||
self.max_tokens = None
|
||||
elif isinstance(_model_config, dict):
|
||||
_mt = _model_config.get("max_tokens")
|
||||
self.max_tokens = _mt if isinstance(_mt, int) else None
|
||||
else:
|
||||
self.max_tokens = None
|
||||
# Auto-detect model from local server if still on default
|
||||
if self.model == _DEFAULT_CONFIG_MODEL:
|
||||
_base_url = (_model_config.get("base_url") or "") if isinstance(_model_config, dict) else ""
|
||||
@@ -5097,9 +5109,9 @@ class HermesCLI:
|
||||
resolved_id = self.session_id
|
||||
if resolved_id and resolved_id != self.session_id:
|
||||
ChatConsole().print(
|
||||
f"[{_DIM}]Session {_escape(self.session_id)} was compressed into "
|
||||
f"[dim]Session {_escape(self.session_id)} was compressed into "
|
||||
f"{_escape(resolved_id)}; resuming the descendant with your "
|
||||
f"transcript.[/]"
|
||||
f"transcript.[/dim]"
|
||||
)
|
||||
self.session_id = resolved_id
|
||||
resolved_meta = self._session_db.get_session(self.session_id)
|
||||
@@ -5168,6 +5180,7 @@ class HermesCLI:
|
||||
acp_command=runtime.get("command"),
|
||||
acp_args=runtime.get("args"),
|
||||
credential_pool=runtime.get("credential_pool"),
|
||||
max_tokens=self.max_tokens,
|
||||
max_iterations=self.max_turns,
|
||||
enabled_toolsets=self.enabled_toolsets,
|
||||
disabled_toolsets=self.disabled_toolsets,
|
||||
@@ -5378,7 +5391,7 @@ class HermesCLI:
|
||||
if quiet:
|
||||
print(msg, file=sys.stderr)
|
||||
else:
|
||||
self._console_print(f"[{_DIM}]{_escape(msg)}[/]")
|
||||
self._console_print(f"[dim]{_escape(msg)}[/dim]")
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -5388,7 +5401,7 @@ class HermesCLI:
|
||||
if quiet:
|
||||
print(msg, file=sys.stderr)
|
||||
else:
|
||||
self._console_print(f"[{_DIM}]{_escape(msg)}[/]")
|
||||
self._console_print(f"[dim]{_escape(msg)}[/dim]")
|
||||
return
|
||||
|
||||
# Retarget the terminal/code-exec tools to match the process cwd.
|
||||
@@ -5398,7 +5411,7 @@ class HermesCLI:
|
||||
if quiet:
|
||||
print(msg, file=sys.stderr)
|
||||
else:
|
||||
self._console_print(f"[{_DIM}]{_escape(msg)}[/]")
|
||||
self._console_print(f"[dim]{_escape(msg)}[/dim]")
|
||||
|
||||
def _preload_resumed_session(self) -> bool:
|
||||
"""Load a resumed session's history from the DB early (before first chat).
|
||||
@@ -9002,6 +9015,10 @@ class HermesCLI:
|
||||
elif canonical == "update":
|
||||
if self._handle_update_command():
|
||||
return False
|
||||
elif canonical == "version":
|
||||
from hermes_cli.main import _print_version_info
|
||||
|
||||
_print_version_info(check_updates=True)
|
||||
elif canonical == "paste":
|
||||
self._handle_paste_command()
|
||||
elif canonical == "image":
|
||||
@@ -9284,6 +9301,7 @@ class HermesCLI:
|
||||
api_mode=turn_route["runtime"].get("api_mode"),
|
||||
acp_command=turn_route["runtime"].get("command"),
|
||||
acp_args=turn_route["runtime"].get("args"),
|
||||
max_tokens=turn_route["runtime"].get("max_tokens"),
|
||||
max_iterations=self.max_turns,
|
||||
enabled_toolsets=self.enabled_toolsets,
|
||||
quiet_mode=True,
|
||||
@@ -13094,6 +13112,16 @@ class HermesCLI:
|
||||
_welcome_color = "#FFF8DC"
|
||||
self._console_print(f"[{_welcome_color}]{_welcome_text}[/]")
|
||||
|
||||
# Warm the /model picker's provider-models cache off-thread during this
|
||||
# idle window (banner shown, user about to type). The no-args picker
|
||||
# otherwise blocks ~1-2s on serial /v1/models fetches the first time
|
||||
# it's opened in a session. Fire-and-forget, guarded once-per-process.
|
||||
try:
|
||||
from hermes_cli.model_switch import prewarm_picker_cache_async
|
||||
prewarm_picker_cache_async()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Redaction opt-out warning (#17691): ON by default, loud when off.
|
||||
# The redactor snapshots its state at import time so any toggle now
|
||||
# won't affect the running process — we just want the operator to
|
||||
|
||||
@@ -3016,6 +3016,17 @@ class BasePlatformAdapter(ABC):
|
||||
expanded = os.path.expanduser(raw)
|
||||
if os.path.isfile(expanded):
|
||||
found.append((raw, expanded))
|
||||
else:
|
||||
# The reply mentions a deliverable-looking path that does not
|
||||
# exist on disk, so it is silently dropped from native delivery.
|
||||
# This is the most common reason a promised file never arrives
|
||||
# (the model said "here's your file" but never wrote it, or
|
||||
# referenced the wrong path). Log it so the gap is visible in
|
||||
# gateway.log rather than vanishing without a trace.
|
||||
logger.info(
|
||||
"Skipping bare file path in reply (no file on disk): %s",
|
||||
_log_safe_path(raw),
|
||||
)
|
||||
|
||||
# Deduplicate by expanded path, preserving discovery order
|
||||
seen: set = set()
|
||||
|
||||
@@ -4073,7 +4073,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return SendResult(success=True, message_id=str(msg.message_id))
|
||||
except Exception as e:
|
||||
print(f"[{self.name}] Failed to send document: {e}")
|
||||
logger.warning("[%s] Failed to send document: %s", self.name, e, exc_info=True)
|
||||
return await super().send_document(chat_id, file_path, caption, file_name, reply_to, metadata=metadata)
|
||||
|
||||
async def send_video(
|
||||
@@ -4120,7 +4120,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return SendResult(success=True, message_id=str(msg.message_id))
|
||||
except Exception as e:
|
||||
print(f"[{self.name}] Failed to send video: {e}")
|
||||
logger.warning("[%s] Failed to send video: %s", self.name, e, exc_info=True)
|
||||
return await super().send_video(chat_id, video_path, caption, reply_to, metadata=metadata)
|
||||
|
||||
async def send_image(
|
||||
|
||||
+94
-2
@@ -1179,6 +1179,7 @@ def _resolve_runtime_agent_kwargs() -> dict:
|
||||
from hermes_cli.runtime_provider import (
|
||||
resolve_runtime_provider,
|
||||
format_runtime_provider_error,
|
||||
_get_model_config,
|
||||
)
|
||||
from hermes_cli.auth import AuthError, is_rate_limited_auth_error
|
||||
|
||||
@@ -1200,6 +1201,26 @@ def _resolve_runtime_agent_kwargs() -> dict:
|
||||
except Exception as exc:
|
||||
raise RuntimeError(format_runtime_provider_error(exc)) from exc
|
||||
|
||||
model_cfg = _get_model_config()
|
||||
max_tokens = None
|
||||
_env_mt = os.environ.get("HERMES_MAX_TOKENS")
|
||||
if _env_mt:
|
||||
try:
|
||||
max_tokens = int(_env_mt)
|
||||
except (ValueError, TypeError):
|
||||
max_tokens = None
|
||||
elif isinstance(model_cfg, dict):
|
||||
mt = model_cfg.get("max_tokens")
|
||||
if isinstance(mt, int):
|
||||
max_tokens = mt
|
||||
# Fall back to a per-provider output cap (custom_providers max_output_tokens)
|
||||
# only when the documented global model.max_tokens isn't set, so the global
|
||||
# key always wins.
|
||||
if max_tokens is None:
|
||||
_runtime_mot = runtime.get("max_output_tokens")
|
||||
if isinstance(_runtime_mot, int) and _runtime_mot > 0:
|
||||
max_tokens = _runtime_mot
|
||||
|
||||
return {
|
||||
"api_key": runtime.get("api_key"),
|
||||
"base_url": runtime.get("base_url"),
|
||||
@@ -1208,6 +1229,7 @@ def _resolve_runtime_agent_kwargs() -> dict:
|
||||
"command": runtime.get("command"),
|
||||
"args": list(runtime.get("args") or []),
|
||||
"credential_pool": runtime.get("credential_pool"),
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
|
||||
|
||||
@@ -2596,6 +2618,7 @@ class GatewayRunner:
|
||||
"api_key": override.get("api_key"),
|
||||
"base_url": override.get("base_url"),
|
||||
"api_mode": override.get("api_mode"),
|
||||
"max_tokens": override.get("max_tokens"),
|
||||
}
|
||||
if override_runtime.get("api_key"):
|
||||
logger.debug(
|
||||
@@ -2693,6 +2716,7 @@ class GatewayRunner:
|
||||
"command": runtime_kwargs.get("command"),
|
||||
"args": list(runtime_kwargs.get("args") or []),
|
||||
"credential_pool": runtime_kwargs.get("credential_pool"),
|
||||
"max_tokens": runtime_kwargs.get("max_tokens"),
|
||||
}
|
||||
route = {
|
||||
"model": model,
|
||||
@@ -7908,6 +7932,8 @@ class GatewayRunner:
|
||||
return await self._handle_profile_command(event)
|
||||
if _cmd_def_inner.name == "update":
|
||||
return await self._handle_update_command(event)
|
||||
if _cmd_def_inner.name == "version":
|
||||
return await self._handle_version_command(event)
|
||||
|
||||
# Catch-all: any other recognized slash command reached the
|
||||
# running-agent guard. Reject gracefully rather than falling
|
||||
@@ -8264,6 +8290,9 @@ class GatewayRunner:
|
||||
if canonical == "update":
|
||||
return await self._handle_update_command(event)
|
||||
|
||||
if canonical == "version":
|
||||
return await self._handle_version_command(event)
|
||||
|
||||
if canonical == "debug":
|
||||
return await self._handle_debug_command(event)
|
||||
|
||||
@@ -10889,6 +10918,12 @@ class GatewayRunner:
|
||||
return event.platform_update_id <= recorded_uid
|
||||
|
||||
|
||||
async def _handle_version_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /version — show the running Hermes Agent version."""
|
||||
from hermes_cli.banner import format_banner_version_label
|
||||
|
||||
return format_banner_version_label()
|
||||
|
||||
async def _handle_help_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /help command - list available commands."""
|
||||
from hermes_cli.commands import gateway_help_lines
|
||||
@@ -11998,13 +12033,24 @@ class GatewayRunner:
|
||||
self._save_voice_modes()
|
||||
if adapter:
|
||||
self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True)
|
||||
return t("gateway.voice.enabled_short")
|
||||
toggle_line = t("gateway.voice.enabled_short")
|
||||
else:
|
||||
self._voice_mode[voice_key] = "off"
|
||||
self._save_voice_modes()
|
||||
if adapter:
|
||||
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
|
||||
return t("gateway.voice.disabled_short")
|
||||
toggle_line = t("gateway.voice.disabled_short")
|
||||
# Bare /voice still toggles, but append an explainer so users
|
||||
# discover the on/off/tts/status subcommands (and, on Discord,
|
||||
# live voice-channel join/leave). The toggle result is shown
|
||||
# first via the {toggle} placeholder.
|
||||
supports_voice_channels = adapter is not None and hasattr(
|
||||
adapter, "join_voice_channel"
|
||||
)
|
||||
channels = (
|
||||
t("gateway.voice.help_channels") if supports_voice_channels else ""
|
||||
)
|
||||
return t("gateway.voice.help", toggle=toggle_line, channels=channels)
|
||||
|
||||
async def _handle_voice_channel_join(self, event: MessageEvent) -> str:
|
||||
"""Join the user's current Discord voice channel."""
|
||||
@@ -17024,6 +17070,47 @@ class GatewayRunner:
|
||||
last_progress_msg = [None] # Track last message for dedup
|
||||
repeat_count = [0] # How many times the same message repeated
|
||||
|
||||
# ── Discord voice "verbal ack before tool calls" ────────────────
|
||||
# When the bot is in a voice channel with the continuous mixer
|
||||
# installed (discord.voice_fx.enabled), speak a short phrase ("let me
|
||||
# look into that") over the ambient idle bed on the FIRST tool call of
|
||||
# the turn. Fires from tool_start_callback (independent of the
|
||||
# tool-progress text gate), at most once per turn. No-op on every
|
||||
# other platform / when not in a voice channel.
|
||||
_voice_ack_fired = [False]
|
||||
_voice_ack_guild: List[Optional[int]] = [None]
|
||||
if source.platform == Platform.DISCORD:
|
||||
_va = self.adapters.get(Platform.DISCORD)
|
||||
# source.chat_id is the linked text channel; resolve the guild whose
|
||||
# voice connection is bound to it (mirrors DiscordAdapter.play_tts).
|
||||
_vtc = getattr(_va, "_voice_text_channels", None)
|
||||
if isinstance(_vtc, dict) and hasattr(_va, "voice_mixer_active"):
|
||||
for _gid, _tc in _vtc.items():
|
||||
if str(_tc) == str(source.chat_id) and _va.voice_mixer_active(_gid):
|
||||
_voice_ack_guild[0] = _gid
|
||||
break
|
||||
_voice_ack_loop = asyncio.get_running_loop()
|
||||
|
||||
def voice_ack_callback(call_id, tool_name, args):
|
||||
"""tool_start_callback: speak a one-time ack in the voice channel."""
|
||||
if _voice_ack_fired[0] or _voice_ack_guild[0] is None:
|
||||
return
|
||||
if not _run_still_current():
|
||||
return
|
||||
_voice_ack_fired[0] = True
|
||||
_adapter = self.adapters.get(Platform.DISCORD)
|
||||
if _adapter is None or not hasattr(_adapter, "play_ack_in_voice"):
|
||||
return
|
||||
try:
|
||||
safe_schedule_threadsafe(
|
||||
_adapter.play_ack_in_voice(_voice_ack_guild[0]),
|
||||
_voice_ack_loop,
|
||||
logger=logger,
|
||||
log_message="voice ack scheduling error",
|
||||
)
|
||||
except Exception as _ack_err:
|
||||
logger.debug("voice ack schedule failed: %s", _ack_err)
|
||||
|
||||
# Auto-cleanup of temporary progress bubbles (Telegram + any adapter
|
||||
# that implements ``delete_message``). When enabled via
|
||||
# ``display.platforms.<platform>.cleanup_progress: true``, message IDs
|
||||
@@ -17834,6 +17921,11 @@ class GatewayRunner:
|
||||
# Per-message state — callbacks and reasoning config change every
|
||||
# turn and must not be baked into the cached agent constructor.
|
||||
agent.tool_progress_callback = progress_callback if tool_progress_enabled else None
|
||||
# Discord voice verbal-ack hook (fires once per turn on first tool
|
||||
# call; armed only when in a voice channel with the mixer running).
|
||||
agent.tool_start_callback = (
|
||||
voice_ack_callback if _voice_ack_guild[0] is not None else None
|
||||
)
|
||||
agent.step_callback = _step_callback_sync if _hooks_ref.loaded_hooks else None
|
||||
agent.stream_delta_callback = _stream_delta_cb
|
||||
agent.interim_assistant_callback = _interim_assistant_cb if _want_interim_messages else None
|
||||
|
||||
+7
-3
@@ -227,7 +227,10 @@ def _read_json_file(path: Path) -> Optional[dict[str, Any]]:
|
||||
return None
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
except (OSError, UnicodeDecodeError):
|
||||
# OSError: file vanished or permission flipped between exists() and
|
||||
# read. UnicodeDecodeError: file holds non-UTF-8 / binary garbage
|
||||
# (a truncated or clobbered status file). Either way it's unusable.
|
||||
return None
|
||||
if not raw:
|
||||
return None
|
||||
@@ -249,8 +252,9 @@ def _read_pid_record(pid_path: Optional[Path] = None) -> Optional[dict]:
|
||||
|
||||
try:
|
||||
raw = pid_path.read_text().strip()
|
||||
except OSError:
|
||||
# File was deleted between exists() and read_text(), or permission flipped.
|
||||
except (OSError, UnicodeDecodeError):
|
||||
# File was deleted between exists() and read_text(), permission
|
||||
# flipped, or it holds non-UTF-8 / binary garbage.
|
||||
return None
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
@@ -14,8 +14,8 @@ Provides subcommands for:
|
||||
import os
|
||||
import sys
|
||||
|
||||
__version__ = "0.15.1"
|
||||
__release_date__ = "2026.5.29"
|
||||
__version__ = "0.16.0"
|
||||
__release_date__ = "2026.6.5"
|
||||
|
||||
|
||||
def _ensure_utf8():
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user