mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-13 13:49:15 +08:00
Compare commits
104 Commits
ethie/nix-
...
bb/version
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
237807ad3a | ||
|
|
d95c76aa37 | ||
|
|
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 | ||
|
|
7f016f5f33 | ||
|
|
ab706a3346 | ||
|
|
4eca569bf4 | ||
|
|
7c00ffd92c | ||
|
|
fb853a1783 | ||
|
|
96cd37e212 | ||
|
|
bcb024ad48 | ||
|
|
500cf537b7 | ||
|
|
10c78bf625 | ||
|
|
9cc47b20cb | ||
|
|
5bcb63e400 | ||
|
|
2069e78b88 | ||
|
|
1bcfe9c58a | ||
|
|
e9529578d5 | ||
|
|
25742372eb | ||
|
|
facd011b63 | ||
|
|
338f0b2234 | ||
|
|
391b594752 | ||
|
|
ff5652d0f6 | ||
|
|
7b4acadfe7 | ||
|
|
4891f9ae78 | ||
|
|
89baf02919 | ||
|
|
1b01fa3acf | ||
|
|
86371e6cd8 | ||
|
|
80672754a8 | ||
|
|
dfe6fbb0b3 | ||
|
|
46abf04012 | ||
|
|
ea44011d15 | ||
|
|
93b5df3189 | ||
|
|
c60952ba94 | ||
|
|
46b2afc56b | ||
|
|
76c7512dbf | ||
|
|
19db9cd076 | ||
|
|
d33d23c852 | ||
|
|
f736d2be86 | ||
|
|
f764b0400a | ||
|
|
9dbd3c57d7 | ||
|
|
a40e20e136 | ||
|
|
cf9dc366dd | ||
|
|
48d8d80771 | ||
|
|
0c7def31aa | ||
|
|
76b98f43ca | ||
|
|
fb18bde897 | ||
|
|
9915665e4c | ||
|
|
3e4fa8ca9c | ||
|
|
cfbc47d893 | ||
|
|
e0121c59d3 | ||
|
|
5df732a355 | ||
|
|
b94b3622b5 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -33,7 +33,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
|
||||
### Linux, macOS, WSL2, Termux
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
```
|
||||
|
||||
### Windows (native, PowerShell)
|
||||
@@ -43,7 +43,7 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri
|
||||
Run this in PowerShell:
|
||||
|
||||
```powershell
|
||||
iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1)
|
||||
iex (irm https://hermes-agent.nousresearch.com/install.ps1)
|
||||
```
|
||||
|
||||
The installer handles everything: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **and a portable Git Bash** (MinGit, unpacked to `%LOCALAPPDATA%\hermes\git` — no admin required, completely isolated from any system Git install). Hermes uses this bundled Git Bash to run shell commands.
|
||||
@@ -52,7 +52,7 @@ If you already have Git installed, the installer detects it and uses that instea
|
||||
|
||||
> **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies.
|
||||
>
|
||||
> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively).
|
||||
> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively).
|
||||
|
||||
After installation:
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
## 快速安装
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
```
|
||||
|
||||
支持 Linux、macOS、WSL2 和 Android (Termux)。安装程序会自动处理平台特定的配置。
|
||||
|
||||
@@ -457,12 +457,7 @@ class SessionManager:
|
||||
else:
|
||||
# Update model_config (contains cwd) if changed.
|
||||
try:
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?",
|
||||
(cwd_json, model_str, state.session_id),
|
||||
)
|
||||
db._conn.commit()
|
||||
db.update_session_meta(state.session_id, cwd_json, model_str)
|
||||
except Exception:
|
||||
logger.debug("Failed to update ACP session metadata", exc_info=True)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --include-desktop
|
||||
```
|
||||
|
||||
Already have the Hermes CLI? Just run:
|
||||
|
||||
```bash
|
||||
@@ -40,7 +34,7 @@ It builds and launches the GUI against your existing install — same config, ke
|
||||
|
||||
### Prebuilt installers
|
||||
|
||||
When a release ships desktop installers they're attached to its [releases page](https://github.com/NousResearch/hermes-agent/releases) — `.dmg` (macOS), `.exe` / `.msi` (Windows), `.AppImage` / `.deb` / `.rpm` (Linux). These are published manually, so the install-with-Hermes path above is the most reliable way to get the latest.
|
||||
Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/desktop).
|
||||
|
||||
---
|
||||
|
||||
@@ -56,10 +50,7 @@ hermes update
|
||||
|
||||
## Requirements
|
||||
|
||||
The installer handles everything for you (Python 3.11+, a portable Git, ripgrep). The only thing worth knowing:
|
||||
|
||||
- **Windows** — the installer bundles its own Git and Python; no admin rights or system changes required.
|
||||
- **macOS / Linux** — uses your system Python 3.11+ (installed automatically if missing).
|
||||
The installer handles everything for you (Python 3.11+, a portable Git, ripgrep).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -65,6 +78,94 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
|
||||
return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the WS URL the renderer would connect with, so the connection test can
|
||||
* exercise the same transport the app actually uses.
|
||||
*
|
||||
* The OAuth ticket-minter is injected (`mintTicket(baseUrl) -> Promise<ticket>`)
|
||||
* so this stays electron-free and unit-testable; main.cjs passes the real
|
||||
* `mintGatewayWsTicket`.
|
||||
*
|
||||
* Return semantics:
|
||||
* - token mode + token → ws(s)://…/api/ws?token=…
|
||||
* - token mode, no token → null (genuine skip; nothing to authenticate with)
|
||||
* - oauth, mint ok → ws(s)://…/api/ws?ticket=…
|
||||
* - oauth, mint fails → THROWS (NOT a skip)
|
||||
*
|
||||
* The oauth-mint-failure throw is the important case: the real boot path
|
||||
* (resolveRemoteBackend in main.cjs) treats a mint failure as a hard
|
||||
* "session expired" auth error and refuses to connect. Swallowing it here
|
||||
* would re-introduce the exact false-positive this test exists to catch —
|
||||
* HTTP /api/status passes, the test reports "reachable", then the renderer
|
||||
* can't authenticate /api/ws and boot dies with "Could not connect".
|
||||
*
|
||||
* @param {string} baseUrl
|
||||
* @param {'token'|'oauth'} authMode
|
||||
* @param {string|null} token
|
||||
* @param {{ mintTicket: (baseUrl: string) => Promise<string> }} deps
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
|
||||
if (authMode === 'oauth') {
|
||||
const mintTicket = deps.mintTicket
|
||||
if (typeof mintTicket !== 'function') {
|
||||
throw new Error('resolveTestWsUrl: a mintTicket function is required in OAuth mode.')
|
||||
}
|
||||
let ticket
|
||||
try {
|
||||
ticket = await mintTicket(baseUrl)
|
||||
} catch (error) {
|
||||
const err = new Error(
|
||||
'Reached the gateway over HTTP, but could not mint a WebSocket ticket for the OAuth session ' +
|
||||
'(it may have expired). Open Settings → Gateway and sign in again.'
|
||||
)
|
||||
err.needsOauthLogin = true
|
||||
err.cause = error
|
||||
throw err
|
||||
}
|
||||
return buildGatewayWsUrlWithTicket(baseUrl, ticket)
|
||||
}
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
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 || '')
|
||||
|
||||
@@ -97,22 +198,57 @@ 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,15 +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', () => {
|
||||
@@ -130,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)
|
||||
})
|
||||
@@ -145,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', () => {
|
||||
@@ -159,3 +278,52 @@ test('tokenPreview returns set for short tokens', () => {
|
||||
test('tokenPreview returns a masked suffix for long tokens', () => {
|
||||
assert.equal(tokenPreview('abcdefghijklmnop'), '...klmnop')
|
||||
})
|
||||
|
||||
// --- resolveTestWsUrl ---
|
||||
//
|
||||
// The "Test remote" button must exercise the same WS transport the app uses,
|
||||
// and must FAIL (not skip) when an OAuth session can't mint a ws-ticket — that
|
||||
// is the exact false-positive PR #39098 set out to eliminate.
|
||||
|
||||
test('resolveTestWsUrl (token mode) builds a ?token= URL the WS probe can use', async () => {
|
||||
const url = await resolveTestWsUrl('https://gw.example.com', 'token', 'tok123')
|
||||
assert.equal(url, 'wss://gw.example.com/api/ws?token=tok123')
|
||||
})
|
||||
|
||||
test('resolveTestWsUrl (token mode, no token) returns null — genuine skip', async () => {
|
||||
assert.equal(await resolveTestWsUrl('https://gw.example.com', 'token', null), null)
|
||||
})
|
||||
|
||||
test('resolveTestWsUrl (oauth, mint ok) builds a ?ticket= URL', async () => {
|
||||
const url = await resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
|
||||
mintTicket: async () => 'tkt-9'
|
||||
})
|
||||
assert.equal(url, 'wss://gw.example.com/api/ws?ticket=tkt-9')
|
||||
})
|
||||
|
||||
test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validation', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
|
||||
mintTicket: async () => {
|
||||
throw new Error('401 ticket mint failed')
|
||||
}
|
||||
}),
|
||||
err => {
|
||||
// Actionable, points the user at re-auth, and preserves the cause + flag
|
||||
// the boot overlay uses to offer a sign-in prompt.
|
||||
assert.match(err.message, /WebSocket ticket/i)
|
||||
assert.match(err.message, /sign in again/i)
|
||||
assert.equal(err.needsOauthLogin, true)
|
||||
assert.ok(err.cause instanceof Error)
|
||||
return true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveTestWsUrl (oauth) requires a mintTicket function', async () => {
|
||||
await assert.rejects(
|
||||
() => resolveTestWsUrl('https://gw.example.com', 'oauth', null),
|
||||
/mintTicket function is required/
|
||||
)
|
||||
})
|
||||
|
||||
188
apps/desktop/electron/gateway-ws-probe.cjs
Normal file
188
apps/desktop/electron/gateway-ws-probe.cjs
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Live WebSocket validation for the remote-gateway "Test remote" button.
|
||||
*
|
||||
* Background: the desktop boot does two independent things to a remote gateway:
|
||||
*
|
||||
* 1. The MAIN process hits ``GET /api/status`` over HTTP (token in a header)
|
||||
* to confirm the backend is up. This is what "Test remote" historically
|
||||
* checked, and what the boot logs print as "Remote Hermes backend is
|
||||
* ready".
|
||||
* 2. The RENDERER then opens a live WebSocket to ``/api/ws`` (credential in a
|
||||
* query param) via ``gateway.connect()``. The chat surface only works once
|
||||
* THIS succeeds.
|
||||
*
|
||||
* Those two paths use different processes, transports, and credentials, and the
|
||||
* server applies extra guards to the WS upgrade that the HTTP status route never
|
||||
* sees (Host/Origin checks, ws-ticket/token auth, peer-IP checks). So a gateway
|
||||
* can pass the HTTP status check yet reject the WebSocket — which surfaces to
|
||||
* the user as a green "Test remote" followed by an opaque "Could not connect to
|
||||
* Hermes gateway" on the boot overlay.
|
||||
*
|
||||
* This module performs the second half of the check: it actually opens the WS
|
||||
* URL and confirms the upgrade is accepted (and isn't immediately torn down by
|
||||
* a post-upgrade auth rejection). The ``WebSocketImpl`` is injectable so the
|
||||
* unit tests can drive the handshake without a real socket; in production the
|
||||
* caller passes the Node/Electron global ``WebSocket``.
|
||||
*/
|
||||
|
||||
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
|
||||
// After the upgrade is accepted, a gateway that rejects the credential
|
||||
// post-handshake closes the socket almost immediately. Wait a short grace
|
||||
// window: a frame (gateway.ready) or a still-open socket means success; an
|
||||
// early close means the upgrade was accepted but the session was refused.
|
||||
const DEFAULT_READY_GRACE_MS = 750
|
||||
|
||||
/**
|
||||
* Attempt a live WebSocket connection and classify the outcome.
|
||||
*
|
||||
* @param {string} wsUrl - Fully-formed ws(s):// URL including the credential.
|
||||
* @param {object} [options]
|
||||
* @param {new (url: string) => any} [options.WebSocketImpl] - WebSocket ctor.
|
||||
* @param {number} [options.connectTimeoutMs]
|
||||
* @param {number} [options.readyGraceMs]
|
||||
* @returns {Promise<{ ok: boolean, reason?: string }>}
|
||||
*/
|
||||
function probeGatewayWebSocket(wsUrl, options = {}) {
|
||||
const WebSocketImpl = options.WebSocketImpl
|
||||
const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
|
||||
const readyGraceMs = options.readyGraceMs ?? DEFAULT_READY_GRACE_MS
|
||||
|
||||
if (typeof WebSocketImpl !== 'function') {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
reason: 'WebSocket is not available in this runtime.'
|
||||
})
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let settled = false
|
||||
let opened = false
|
||||
let connectTimer = null
|
||||
let graceTimer = null
|
||||
let socket
|
||||
|
||||
const clearTimers = () => {
|
||||
if (connectTimer !== null) {
|
||||
clearTimeout(connectTimer)
|
||||
connectTimer = null
|
||||
}
|
||||
if (graceTimer !== null) {
|
||||
clearTimeout(graceTimer)
|
||||
graceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const finish = result => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimers()
|
||||
try {
|
||||
socket?.close?.()
|
||||
} catch {
|
||||
// ignore — best effort teardown
|
||||
}
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
try {
|
||||
socket = new WebSocketImpl(wsUrl)
|
||||
} catch (error) {
|
||||
finish({
|
||||
ok: false,
|
||||
reason: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const onOpen = () => {
|
||||
if (settled) return
|
||||
opened = true
|
||||
// Upgrade accepted. Give the server a brief window to reject the
|
||||
// credential post-handshake (early close) before declaring success.
|
||||
graceTimer = setTimeout(() => {
|
||||
finish({ ok: true })
|
||||
}, readyGraceMs)
|
||||
}
|
||||
|
||||
const onMessage = () => {
|
||||
// Any frame means the gateway accepted us and is talking — unambiguous
|
||||
// success, no need to wait out the grace window.
|
||||
finish({ ok: true })
|
||||
}
|
||||
|
||||
const onError = event => {
|
||||
finish({
|
||||
ok: false,
|
||||
reason: extractErrorReason(event) || 'WebSocket connection failed.'
|
||||
})
|
||||
}
|
||||
|
||||
const onClose = event => {
|
||||
if (settled) return
|
||||
if (opened) {
|
||||
// Opened, then closed inside the grace window: the upgrade was accepted
|
||||
// but the session was refused (e.g. ws-ticket/token rejected, or a
|
||||
// server-side Host/Origin guard tripped after accept).
|
||||
finish({
|
||||
ok: false,
|
||||
reason: closeReason(event, 'The gateway accepted the connection then closed it (credential rejected?).')
|
||||
})
|
||||
return
|
||||
}
|
||||
finish({
|
||||
ok: false,
|
||||
reason: closeReason(event, 'The gateway closed the WebSocket before it opened.')
|
||||
})
|
||||
}
|
||||
|
||||
addListener(socket, 'open', onOpen)
|
||||
addListener(socket, 'message', onMessage)
|
||||
addListener(socket, 'error', onError)
|
||||
addListener(socket, 'close', onClose)
|
||||
|
||||
if (connectTimeoutMs > 0) {
|
||||
connectTimer = setTimeout(() => {
|
||||
finish({
|
||||
ok: false,
|
||||
reason: `Timed out after ${connectTimeoutMs}ms waiting for the WebSocket to open.`
|
||||
})
|
||||
}, connectTimeoutMs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function addListener(socket, type, handler) {
|
||||
if (typeof socket.addEventListener === 'function') {
|
||||
socket.addEventListener(type, handler)
|
||||
return
|
||||
}
|
||||
// Node's global WebSocket implements addEventListener; this fallback keeps the
|
||||
// helper usable with the `ws` package's EventEmitter shape too.
|
||||
if (typeof socket.on === 'function') {
|
||||
socket.on(type, handler)
|
||||
}
|
||||
}
|
||||
|
||||
function extractErrorReason(event) {
|
||||
if (!event) return ''
|
||||
if (event instanceof Error) return event.message
|
||||
const err = event.error || event.message
|
||||
if (err instanceof Error) return err.message
|
||||
if (typeof err === 'string') return err
|
||||
return ''
|
||||
}
|
||||
|
||||
function closeReason(event, fallback) {
|
||||
const code = event && typeof event.code === 'number' ? event.code : null
|
||||
const reason = event && typeof event.reason === 'string' ? event.reason.trim() : ''
|
||||
if (code && reason) return `${fallback} (code ${code}: ${reason})`
|
||||
if (code) return `${fallback} (code ${code})`
|
||||
if (reason) return `${fallback} (${reason})`
|
||||
return fallback
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
DEFAULT_READY_GRACE_MS,
|
||||
probeGatewayWebSocket
|
||||
}
|
||||
122
apps/desktop/electron/gateway-ws-probe.test.cjs
Normal file
122
apps/desktop/electron/gateway-ws-probe.test.cjs
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Tests for electron/gateway-ws-probe.cjs.
|
||||
*
|
||||
* Run with: node --test electron/gateway-ws-probe.test.cjs
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* The probe drives a real WebSocket handshake for the "Test remote" button.
|
||||
* Here we inject a fake socket so we can deterministically replay each handshake
|
||||
* outcome (open, frame, error, early close, never-opens) without a network.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
|
||||
// Minimal WebSocket double: records listeners synchronously (the probe attaches
|
||||
// them in its executor) and exposes emit() so the test can replay events.
|
||||
function makeFakeWs() {
|
||||
const instances = []
|
||||
class FakeWs {
|
||||
constructor(url) {
|
||||
this.url = url
|
||||
this.listeners = {}
|
||||
this.closed = false
|
||||
instances.push(this)
|
||||
}
|
||||
addEventListener(type, fn) {
|
||||
;(this.listeners[type] ||= []).push(fn)
|
||||
}
|
||||
close() {
|
||||
this.closed = true
|
||||
}
|
||||
emit(type, event) {
|
||||
for (const fn of this.listeners[type] || []) fn(event)
|
||||
}
|
||||
}
|
||||
return { FakeWs, instances }
|
||||
}
|
||||
|
||||
const FAST = { connectTimeoutMs: 1_000, readyGraceMs: 10 }
|
||||
|
||||
test('probe resolves ok when the socket opens and stays open', async () => {
|
||||
const { FakeWs, instances } = makeFakeWs()
|
||||
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST })
|
||||
instances[0].emit('open')
|
||||
const result = await promise
|
||||
assert.deepEqual(result, { ok: true })
|
||||
assert.equal(instances[0].closed, true)
|
||||
})
|
||||
|
||||
test('probe resolves ok immediately when a frame arrives', async () => {
|
||||
const { FakeWs, instances } = makeFakeWs()
|
||||
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', {
|
||||
WebSocketImpl: FakeWs,
|
||||
connectTimeoutMs: 1_000,
|
||||
readyGraceMs: 10_000 // long grace: success must come from the frame, not the timer
|
||||
})
|
||||
instances[0].emit('open')
|
||||
instances[0].emit('message', { data: '{"jsonrpc":"2.0"}' })
|
||||
const result = await promise
|
||||
assert.deepEqual(result, { ok: true })
|
||||
})
|
||||
|
||||
test('probe fails when the socket errors before opening', async () => {
|
||||
const { FakeWs, instances } = makeFakeWs()
|
||||
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST })
|
||||
instances[0].emit('error', { message: 'ECONNREFUSED' })
|
||||
const result = await promise
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.reason, /ECONNREFUSED/)
|
||||
})
|
||||
|
||||
test('probe fails when the gateway closes before opening', async () => {
|
||||
const { FakeWs, instances } = makeFakeWs()
|
||||
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST })
|
||||
instances[0].emit('close', { code: 1006 })
|
||||
const result = await promise
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.reason, /before it opened/)
|
||||
assert.match(result.reason, /1006/)
|
||||
})
|
||||
|
||||
test('probe fails when the gateway accepts then immediately closes (auth rejected)', async () => {
|
||||
const { FakeWs, instances } = makeFakeWs()
|
||||
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST })
|
||||
instances[0].emit('open')
|
||||
instances[0].emit('close', { code: 4403, reason: 'forbidden' })
|
||||
const result = await promise
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.reason, /credential rejected/)
|
||||
assert.match(result.reason, /4403/)
|
||||
assert.match(result.reason, /forbidden/)
|
||||
})
|
||||
|
||||
test('probe times out when the socket never opens', async () => {
|
||||
const { FakeWs } = makeFakeWs()
|
||||
const result = await probeGatewayWebSocket('ws://host/api/ws?token=t', {
|
||||
WebSocketImpl: FakeWs,
|
||||
connectTimeoutMs: 20,
|
||||
readyGraceMs: 10
|
||||
})
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.reason, /Timed out/)
|
||||
})
|
||||
|
||||
test('probe fails gracefully when the constructor throws', async () => {
|
||||
class ThrowingWs {
|
||||
constructor() {
|
||||
throw new Error('bad url')
|
||||
}
|
||||
}
|
||||
const result = await probeGatewayWebSocket('ws://host/api/ws', { WebSocketImpl: ThrowingWs, ...FAST })
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.reason, /bad url/)
|
||||
})
|
||||
|
||||
test('probe reports unavailable when no WebSocket implementation is provided', async () => {
|
||||
const result = await probeGatewayWebSocket('ws://host/api/ws', { WebSocketImpl: undefined })
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.reason, /not available/)
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,21 @@
|
||||
const { contextBridge, ipcRenderer, webUtils } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getConnection: () => ipcRenderer.invoke('hermes:connection'),
|
||||
getGatewayWsUrl: () => ipcRenderer.invoke('hermes:gateway:ws-url'),
|
||||
getConnection: profile => ipcRenderer.invoke('hermes:connection', profile),
|
||||
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
|
||||
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
|
||||
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
||||
getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'),
|
||||
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),
|
||||
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
|
||||
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
|
||||
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),
|
||||
profile: {
|
||||
get: () => ipcRenderer.invoke('hermes:profile:get'),
|
||||
set: name => ipcRenderer.invoke('hermes:profile:set', name)
|
||||
},
|
||||
api: request => ipcRenderer.invoke('hermes:api', request),
|
||||
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
|
||||
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),
|
||||
|
||||
@@ -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",
|
||||
"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",
|
||||
"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,
|
||||
@@ -16,7 +17,9 @@ import {
|
||||
PaginationPrevious
|
||||
} from '@/components/ui/pagination'
|
||||
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getSessionMessages, listSessions } from '@/hermes'
|
||||
import { 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'
|
||||
@@ -310,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'> {
|
||||
@@ -355,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')
|
||||
|
||||
@@ -378,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)))
|
||||
@@ -392,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)
|
||||
|
||||
@@ -478,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 => {
|
||||
@@ -502,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>
|
||||
) : (
|
||||
@@ -546,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}
|
||||
@@ -578,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}
|
||||
@@ -607,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">
|
||||
@@ -626,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)}
|
||||
>
|
||||
@@ -656,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
|
||||
@@ -682,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}
|
||||
@@ -697,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>
|
||||
@@ -736,7 +765,6 @@ function ArtifactCellAction({
|
||||
<button
|
||||
className="flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) font-normal text-(--ui-text-secondary) no-underline underline-offset-4 decoration-current/20 transition-colors hover:text-foreground hover:underline"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
@@ -768,21 +796,23 @@ 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">
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)',
|
||||
isLink ? 'font-normal' : 'font-mono'
|
||||
)}
|
||||
title={artifact.value}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
<Tip label={artifact.value}>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)',
|
||||
isLink ? 'font-normal' : 'font-mono'
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</Tip>
|
||||
<CopyButton
|
||||
appearance="icon"
|
||||
buttonSize="icon-xs"
|
||||
@@ -813,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%]')
|
||||
}
|
||||
@@ -842,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>
|
||||
|
||||
@@ -1,26 +1,43 @@
|
||||
import { useRef } from 'react'
|
||||
|
||||
import type { DragKind } from '@/app/chat/hooks/use-file-drop-zone'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const COPY: Record<'files' | 'session', { icon: string; label: string }> = {
|
||||
files: { icon: 'cloud-upload', label: 'Drop files to attach' },
|
||||
session: { icon: 'comment-discussion', label: 'Drop to link this chat' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-bleed affordance shown while files are dragged over the chat area. Always
|
||||
* `pointer-events-none` so the drop lands on the real element underneath and the
|
||||
* drop-zone handler claims it — the overlay is purely visual. Mirrors the
|
||||
* composer surface so the two read as one family.
|
||||
* Full-bleed affordance shown while files or a session are dragged over the chat
|
||||
* area. Always `pointer-events-none` so the drop lands on the real element
|
||||
* underneath and the drop-zone handler claims it — the overlay is purely visual.
|
||||
* Copy adapts to whatever is being dragged; the last kind is held through the
|
||||
* fade-out so the label doesn't blank.
|
||||
*/
|
||||
export function ChatDropOverlay({ active }: { active: boolean }) {
|
||||
export function ChatDropOverlay({ kind }: { kind: DragKind }) {
|
||||
const lastKind = useRef<'files' | 'session'>('files')
|
||||
|
||||
if (kind) {
|
||||
lastKind.current = kind
|
||||
}
|
||||
|
||||
const { icon, label } = COPY[kind ?? lastKind.current]
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center p-4 transition-opacity duration-150 ease-out',
|
||||
active ? 'opacity-100' : 'opacity-0'
|
||||
kind ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
data-slot="chat-drop-overlay"
|
||||
>
|
||||
<div className="absolute inset-2 rounded-2xl border-2 border-dashed border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)] backdrop-blur-[2px] [-webkit-backdrop-filter:blur(2px)]" />
|
||||
<div className="relative flex items-center gap-2 rounded-full border border-[color-mix(in_srgb,var(--dt-composer-ring)_45%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 text-[0.8125rem] font-medium text-foreground shadow-composer">
|
||||
<Codicon className="text-(--ui-accent)" name="cloud-upload" size="1rem" />
|
||||
Drop files to attach
|
||||
<Codicon className="text-(--ui-accent)" name={icon} size="1rem" />
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
45
apps/desktop/src/app/chat/chat-swap-overlay.tsx
Normal file
45
apps/desktop/src/app/chat/chat-swap-overlay.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Braille spinner frames — reads as a tiny ASCII loader in monospace.
|
||||
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
|
||||
// Shown over the conversation while the live gateway swaps to another profile's
|
||||
// backend (lazily spawned). Keeps the last profile name through the fade-out so
|
||||
// the label doesn't blank. Purely visual — pointer-events-none.
|
||||
export function ChatSwapOverlay({ profile }: { profile: string | null }) {
|
||||
const [frame, setFrame] = useState(0)
|
||||
const [label, setLabel] = useState<null | string>(profile)
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setLabel(profile)
|
||||
}
|
||||
}, [profile])
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = window.setInterval(() => setFrame(value => (value + 1) % FRAMES.length), 80)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [profile])
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 z-50 flex items-center justify-center transition-opacity duration-150 ease-out',
|
||||
profile ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 font-mono text-[0.8125rem] text-foreground shadow-composer">
|
||||
<span className="w-3 text-(--ui-accent)">{FRAMES[frame]}</span>
|
||||
Waking up {label}…
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
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'
|
||||
@@ -25,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'
|
||||
@@ -52,59 +56,59 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group/attachment relative min-w-0 shrink-0"
|
||||
title={attachment.path || attachment.detail || attachment.label}
|
||||
>
|
||||
<button
|
||||
aria-label={canPreview ? `Preview ${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()}
|
||||
title={canPreview ? `Preview ${attachment.label}` : attachment.label}
|
||||
type="button"
|
||||
>
|
||||
{attachment.previewUrl && attachment.kind === 'image' ? (
|
||||
<img
|
||||
alt={attachment.label}
|
||||
className="size-8 shrink-0 border border-border/70 object-cover"
|
||||
draggable={false}
|
||||
src={attachment.previewUrl}
|
||||
/>
|
||||
) : (
|
||||
<span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground">
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
|
||||
{attachment.label}
|
||||
</span>
|
||||
{detail && (
|
||||
<span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">{detail}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{onRemove && (
|
||||
<Tip label={attachment.path || attachment.detail || attachment.label}>
|
||||
<div className="group/attachment relative min-w-0 shrink-0">
|
||||
<button
|
||||
aria-label={`Remove ${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)}
|
||||
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()}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.625rem" />
|
||||
{attachment.previewUrl && attachment.kind === 'image' ? (
|
||||
<img
|
||||
alt={attachment.label}
|
||||
className="size-8 shrink-0 border border-border/70 object-cover"
|
||||
draggable={false}
|
||||
src={attachment.previewUrl}
|
||||
/>
|
||||
) : (
|
||||
<span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground">
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
|
||||
{attachment.label}
|
||||
</span>
|
||||
{detail && (
|
||||
<span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">
|
||||
{detail}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onRemove && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Codicon name="close" size="0.625rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,5 +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'
|
||||
@@ -54,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} />
|
||||
}
|
||||
@@ -64,38 +69,40 @@ 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 ? (
|
||||
<Button
|
||||
aria-label="Start voice conversation"
|
||||
className={PRIMARY_ICON_BTN}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic('open')
|
||||
conversation.onStart()
|
||||
}}
|
||||
size="icon"
|
||||
title="Start voice conversation"
|
||||
type="button"
|
||||
>
|
||||
<AudioLines size={17} />
|
||||
</Button>
|
||||
<Tip label={c.startVoice}>
|
||||
<Button
|
||||
aria-label={c.startVoice}
|
||||
className={PRIMARY_ICON_BTN}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic('open')
|
||||
conversation.onStart()
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
>
|
||||
<AudioLines size={17} />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : (
|
||||
<Button
|
||||
aria-label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
|
||||
className={PRIMARY_ICON_BTN}
|
||||
disabled={disabled || !canSubmit}
|
||||
title={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
|
||||
type="submit"
|
||||
>
|
||||
{busy ? (
|
||||
busyAction === 'queue' ? (
|
||||
<Layers3 size={16} />
|
||||
<Tip label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}>
|
||||
<Button
|
||||
aria-label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}
|
||||
className={PRIMARY_ICON_BTN}
|
||||
disabled={disabled || !canSubmit}
|
||||
type="submit"
|
||||
>
|
||||
{busy ? (
|
||||
busyAction === 'queue' ? (
|
||||
<Layers3 size={16} />
|
||||
) : (
|
||||
<span className="block size-3 rounded-[0.1875rem] bg-current" />
|
||||
)
|
||||
) : (
|
||||
<span className="block size-3 rounded-[0.1875rem] bg-current" />
|
||||
)
|
||||
) : (
|
||||
<Codicon name="arrow-up" size="1rem" />
|
||||
)}
|
||||
</Button>
|
||||
<Codicon name="arrow-up" size="1rem" />
|
||||
)}
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -110,68 +117,71 @@ 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)">
|
||||
<Button
|
||||
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'}
|
||||
aria-pressed={muted}
|
||||
className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
onToggleMute()
|
||||
}}
|
||||
size="icon"
|
||||
title={muted ? 'Unmute microphone' : 'Mute microphone'}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={muted ? 'mic-off' : 'mic'} size="1rem" />
|
||||
</Button>
|
||||
<Tip label={muted ? c.unmuteMic : c.muteMic}>
|
||||
<Button
|
||||
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}
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
onToggleMute()
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={muted ? 'mic-off' : 'mic'} size="1rem" />
|
||||
</Button>
|
||||
</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="Stop listening and send"
|
||||
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="End voice conversation"
|
||||
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}
|
||||
@@ -218,40 +228,43 @@ 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 (
|
||||
<Button
|
||||
aria-label={aria}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'p-0',
|
||||
'data-[active=true]:bg-accent data-[active=true]:text-foreground',
|
||||
status === 'recording' && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary',
|
||||
status === 'transcribing' && 'bg-primary/10 text-primary'
|
||||
)}
|
||||
data-active={active}
|
||||
disabled={disabled || !state.enabled || status === 'transcribing'}
|
||||
onClick={() => {
|
||||
triggerHaptic(active ? 'close' : 'open')
|
||||
onToggle()
|
||||
}}
|
||||
size="icon"
|
||||
title={aria}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{status === 'recording' ? (
|
||||
<Square className="fill-current" size={12} />
|
||||
) : status === 'transcribing' ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : (
|
||||
<Codicon name="mic" size="1rem" />
|
||||
)}
|
||||
</Button>
|
||||
<Tip label={aria}>
|
||||
<Button
|
||||
aria-label={aria}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'p-0',
|
||||
'data-[active=true]:bg-accent data-[active=true]:text-foreground',
|
||||
status === 'recording' && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary',
|
||||
status === 'transcribing' && 'bg-primary/10 text-primary'
|
||||
)}
|
||||
data-active={active}
|
||||
disabled={disabled || !state.enabled || status === 'transcribing'}
|
||||
onClick={() => {
|
||||
triggerHaptic(active ? 'close' : 'open')
|
||||
onToggle()
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{status === 'recording' ? (
|
||||
<Square className="fill-current" size={12} />
|
||||
) : status === 'transcribing' ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : (
|
||||
<Codicon name="mic" size="1rem" />
|
||||
)}
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* steal focus from the composer effect.
|
||||
*/
|
||||
|
||||
import type { InlineRefInput } from './inline-refs'
|
||||
|
||||
export type ComposerTarget = 'edit' | 'main'
|
||||
export type ComposerInsertMode = 'block' | 'inline'
|
||||
|
||||
@@ -23,8 +25,14 @@ interface InsertDetail {
|
||||
text: string
|
||||
}
|
||||
|
||||
interface InsertRefsDetail {
|
||||
refs: InlineRefInput[]
|
||||
target: ComposerTarget
|
||||
}
|
||||
|
||||
const FOCUS_EVENT = 'hermes:composer-focus'
|
||||
const INSERT_EVENT = 'hermes:composer-insert'
|
||||
const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
|
||||
|
||||
let activeTarget: ComposerTarget = 'main'
|
||||
|
||||
@@ -82,6 +90,20 @@ export const onComposerFocusRequest = (handler: (target: ComposerTarget) => void
|
||||
export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) =>
|
||||
subscribe<InsertDetail>(INSERT_EVENT, handler)
|
||||
|
||||
/** Insert typed ref chips (carrying a display label) into a composer — the
|
||||
* structured cousin of {@link requestComposerInsert}, used for session links. */
|
||||
export const requestComposerInsertRefs = (
|
||||
refs: InlineRefInput[],
|
||||
{ target = 'active' }: { target?: ComposerTarget | 'active' } = {}
|
||||
) => {
|
||||
if (refs.length) {
|
||||
dispatch<InsertRefsDetail>(INSERT_REFS_EVENT, { refs, target: resolve(target) })
|
||||
}
|
||||
}
|
||||
|
||||
export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) =>
|
||||
subscribe<InsertRefsDetail>(INSERT_REFS_EVENT, handler)
|
||||
|
||||
/**
|
||||
* Focus a composer input across React commit + browser focus restore.
|
||||
*
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ 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'
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
focusComposerInput,
|
||||
markActiveComposer,
|
||||
onComposerFocusRequest,
|
||||
onComposerInsertRefsRequest,
|
||||
onComposerInsertRequest
|
||||
} from './focus'
|
||||
import { HelpHint } from './help-hint'
|
||||
@@ -52,7 +54,12 @@ import { useAtCompletions } from './hooks/use-at-completions'
|
||||
import { useSlashCompletions } from './hooks/use-slash-completions'
|
||||
import { useVoiceConversation } from './hooks/use-voice-conversation'
|
||||
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
||||
import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from './inline-refs'
|
||||
import {
|
||||
dragHasAttachments,
|
||||
droppedFileInlineRef,
|
||||
type InlineRefInput,
|
||||
insertInlineRefsIntoEditor
|
||||
} from './inline-refs'
|
||||
import { QueuePanel } from './queue-panel'
|
||||
import {
|
||||
composerPlainText,
|
||||
@@ -78,29 +85,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 {
|
||||
@@ -184,7 +168,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
|
||||
@@ -192,7 +179,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)
|
||||
@@ -211,16 +198,16 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
setRestingPlaceholder(pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS))
|
||||
}, [sessionId])
|
||||
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(() => {
|
||||
@@ -432,7 +419,7 @@ export function ChatBar({
|
||||
requestMainFocus()
|
||||
}
|
||||
|
||||
const insertInlineRefs = (refs: string[]) => {
|
||||
const insertInlineRefs = (refs: InlineRefInput[]) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor) {
|
||||
@@ -452,6 +439,19 @@ export function ChatBar({
|
||||
return true
|
||||
}
|
||||
|
||||
// Latest-closure ref so the (once-only) subscription always calls the current
|
||||
// insertInlineRefs without re-subscribing every render.
|
||||
const insertInlineRefsRef = useRef(insertInlineRefs)
|
||||
insertInlineRefsRef.current = insertInlineRefs
|
||||
|
||||
useEffect(() => {
|
||||
return onComposerInsertRefsRequest(({ refs, target }) => {
|
||||
if (target === 'main') {
|
||||
insertInlineRefsRef.current(refs)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const selectSkinSlashCommand = (command: string) => {
|
||||
draftRef.current = command
|
||||
aui.composer().setText(command)
|
||||
@@ -1194,7 +1194,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(
|
||||
|
||||
@@ -5,6 +5,49 @@ import type { DroppedFile } from '../hooks/use-composer-actions'
|
||||
|
||||
import { composerPlainText, escapeHtml, placeCaretEnd, refChipHtml } from './rich-editor'
|
||||
|
||||
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
|
||||
export type InlineRefInput = string | { kind: string; label?: string; value: string }
|
||||
|
||||
/** MIME for an in-app session drag (sidebar row → composer). */
|
||||
export const HERMES_SESSION_MIME = 'application/x-hermes-session'
|
||||
|
||||
export interface SessionDragPayload {
|
||||
id: string
|
||||
profile: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export function writeSessionDrag(transfer: DataTransfer, payload: SessionDragPayload) {
|
||||
transfer.setData(HERMES_SESSION_MIME, JSON.stringify(payload))
|
||||
transfer.effectAllowed = 'copy'
|
||||
}
|
||||
|
||||
export function dragHasSession(transfer: DataTransfer | null) {
|
||||
return Boolean(transfer) && Array.from(transfer!.types || []).includes(HERMES_SESSION_MIME)
|
||||
}
|
||||
|
||||
export function readSessionDrag(transfer: DataTransfer | null): null | SessionDragPayload {
|
||||
const raw = transfer?.getData(HERMES_SESSION_MIME)
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<SessionDragPayload>
|
||||
|
||||
return parsed.id ? { id: parsed.id, profile: parsed.profile || 'default', title: parsed.title || '' } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a `@session:<profile>/<id>` chip. Value carries the metadata the agent
|
||||
* needs to resolve the link (session_search); label shows the friendly title. */
|
||||
export function sessionInlineRef({ id, profile, title }: SessionDragPayload): InlineRefInput {
|
||||
return { kind: 'session', label: title || `chat ${id.slice(0, 8)}`, value: `${profile || 'default'}/${id}` }
|
||||
}
|
||||
|
||||
export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) {
|
||||
if (!transfer) {
|
||||
return false
|
||||
@@ -40,13 +83,17 @@ export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null
|
||||
return `@${kind}:${formatRefValue(rel)}`
|
||||
}
|
||||
|
||||
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly string[]) {
|
||||
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
|
||||
if (!refs.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const refsHtml = refs
|
||||
.map(ref => {
|
||||
if (typeof ref !== 'string') {
|
||||
return refChipHtml(ref.kind, ref.value, ref.label)
|
||||
}
|
||||
|
||||
const match = ref.match(/^@([^:]+):(.+)$/)
|
||||
|
||||
return match ? refChipHtml(match[1], match[2]) : escapeHtml(ref)
|
||||
|
||||
@@ -2,6 +2,8 @@ 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'
|
||||
@@ -15,10 +17,12 @@ 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 { t } = useI18n()
|
||||
const c = t.composer
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
if (entries.length === 0) {
|
||||
@@ -33,7 +37,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
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 && (
|
||||
@@ -56,17 +60,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>
|
||||
@@ -80,41 +84,44 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
: 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100'
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
aria-label="Edit queued turn"
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={Boolean(editingId) && !isEditing}
|
||||
onClick={() => onEdit(entry)}
|
||||
size="icon-xs"
|
||||
title="Edit queued turn"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Send queued turn now"
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={busy || isEditing}
|
||||
onClick={() => onSendNow(entry.id)}
|
||||
size="icon-xs"
|
||||
title="Send queued turn now"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowUp size={11} />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Delete queued turn"
|
||||
className="h-5 w-5 rounded-md"
|
||||
onClick={() => onDelete(entry.id)}
|
||||
size="icon-xs"
|
||||
title="Delete queued turn"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</Button>
|
||||
<Tip label={c.editQueued}>
|
||||
<Button
|
||||
aria-label={c.editQueued}
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={Boolean(editingId) && !isEditing}
|
||||
onClick={() => onEdit(entry)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={c.sendQueuedNow}>
|
||||
<Button
|
||||
aria-label={c.sendQueuedNow}
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={busy || isEditing}
|
||||
onClick={() => onSendNow(entry.id)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowUp size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={c.deleteQueued}>
|
||||
<Button
|
||||
aria-label={c.deleteQueued}
|
||||
className="h-5 w-5 rounded-md"
|
||||
onClick={() => onDelete(entry.id)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
|
||||
export const RICH_INPUT_SLOT = 'composer-rich-input'
|
||||
|
||||
export const REF_RE = /@(file|folder|url|image|tool|line|terminal):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
|
||||
export const REF_RE = /@(file|folder|url|image|tool|line|terminal|session):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
|
||||
|
||||
const ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
||||
|
||||
@@ -52,14 +52,14 @@ export function quoteRefValue(value: string) {
|
||||
return formatRefValue(value)
|
||||
}
|
||||
|
||||
export function refChipHtml(kind: string, rawValue: string) {
|
||||
export function refChipHtml(kind: string, rawValue: string, displayLabel?: string) {
|
||||
const id = unquoteRef(rawValue)
|
||||
const text = `@${kind}:${quoteRefValue(id)}`
|
||||
|
||||
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(refLabel(id))}</span></span>`
|
||||
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(displayLabel || refLabel(id))}</span></span>`
|
||||
}
|
||||
|
||||
export function refChipElement(kind: string, rawValue: string) {
|
||||
export function refChipElement(kind: string, rawValue: string, displayLabel?: string) {
|
||||
const id = unquoteRef(rawValue)
|
||||
const text = `@${kind}:${quoteRefValue(id)}`
|
||||
const chip = document.createElement('span')
|
||||
@@ -71,7 +71,7 @@ export function refChipElement(kind: string, rawValue: string) {
|
||||
chip.dataset.refKind = kind
|
||||
chip.className = DIRECTIVE_CHIP_CLASS
|
||||
label.className = 'truncate'
|
||||
label.textContent = refLabel(id)
|
||||
label.textContent = displayLabel || refLabel(id)
|
||||
chip.append(directiveIconElement(kind), label)
|
||||
|
||||
return chip
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,50 +1,71 @@
|
||||
import { type DragEvent as ReactDragEvent, useCallback, useRef, useState } from 'react'
|
||||
|
||||
import { dragHasAttachments } from '@/app/chat/composer/inline-refs'
|
||||
import {
|
||||
dragHasAttachments,
|
||||
dragHasSession,
|
||||
readSessionDrag,
|
||||
type SessionDragPayload
|
||||
} from '@/app/chat/composer/inline-refs'
|
||||
|
||||
import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from './use-composer-actions'
|
||||
|
||||
const hasFiles = (event: ReactDragEvent) => dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)
|
||||
export type DragKind = 'files' | 'session' | null
|
||||
|
||||
const dragKindOf = (event: ReactDragEvent): DragKind => {
|
||||
if (dragHasSession(event.dataTransfer)) {
|
||||
return 'session'
|
||||
}
|
||||
|
||||
if (dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
|
||||
return 'files'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
interface FileDropZoneOptions {
|
||||
/** When false the zone ignores drags entirely. */
|
||||
enabled?: boolean
|
||||
onDropFiles: (files: DroppedFile[]) => void
|
||||
onDropSession?: (session: SessionDragPayload) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* "Drop files anywhere in this region" affordance. An enter/leave depth counter
|
||||
* keeps nested children from flickering the active state; `onDropCapture` clears
|
||||
* it even when a nested target (the composer) handles the drop and stops
|
||||
* propagation before our bubble-phase `onDrop` would fire.
|
||||
* "Drop anywhere in this region" affordance for files *and* in-app session
|
||||
* links. An enter/leave depth counter keeps nested children from flickering the
|
||||
* active state; `onDropCapture` clears it even when a nested target (the
|
||||
* composer) handles the drop and stops propagation before our bubble-phase
|
||||
* `onDrop` would fire.
|
||||
*
|
||||
* Spread `dropHandlers` onto the container; render an overlay off `dragActive`.
|
||||
* Spread `dropHandlers` onto the container; render an overlay off `dragKind`.
|
||||
*/
|
||||
export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOptions) {
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }: FileDropZoneOptions) {
|
||||
const [dragKind, setDragKind] = useState<DragKind>(null)
|
||||
const depth = useRef(0)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
depth.current = 0
|
||||
setDragActive(false)
|
||||
setDragKind(null)
|
||||
}, [])
|
||||
|
||||
const onDragEnter = useCallback(
|
||||
(event: ReactDragEvent) => {
|
||||
if (!enabled || !hasFiles(event)) {
|
||||
const kind = enabled ? dragKindOf(event) : null
|
||||
|
||||
if (!kind) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
depth.current += 1
|
||||
setDragActive(true)
|
||||
setDragKind(kind)
|
||||
},
|
||||
[enabled]
|
||||
)
|
||||
|
||||
const onDragOver = useCallback(
|
||||
(event: ReactDragEvent) => {
|
||||
if (!enabled || !hasFiles(event)) {
|
||||
if (!enabled || !dragKindOf(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,21 +83,36 @@ export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOpt
|
||||
|
||||
const onDrop = useCallback(
|
||||
(event: ReactDragEvent) => {
|
||||
if (!enabled || !hasFiles(event)) {
|
||||
const kind = enabled ? dragKindOf(event) : null
|
||||
|
||||
if (!kind) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
reset()
|
||||
|
||||
if (kind === 'session') {
|
||||
const session = readSessionDrag(event.dataTransfer)
|
||||
|
||||
if (session) {
|
||||
onDropSession?.(session)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const files = extractDroppedFiles(event.dataTransfer)
|
||||
|
||||
if (files.length) {
|
||||
onDropFiles(files)
|
||||
}
|
||||
},
|
||||
[enabled, onDropFiles, reset]
|
||||
[enabled, onDropFiles, onDropSession, reset]
|
||||
)
|
||||
|
||||
return { dragActive, dropHandlers: { onDragEnter, onDragLeave, onDragOver, onDrop, onDropCapture: reset } }
|
||||
return {
|
||||
dragKind,
|
||||
dropHandlers: { onDragEnter, onDragLeave, onDragOver, onDrop, onDropCapture: reset }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { Thread } from '@/components/assistant-ui/thread'
|
||||
import { Backdrop } from '@/components/Backdrop'
|
||||
import { NotificationStack } from '@/components/notifications'
|
||||
import { PromptOverlays } from '@/components/prompt-overlays'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
@@ -23,6 +22,7 @@ import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-s
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { $gatewaySwapTarget } from '@/store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$awaitingResponse,
|
||||
@@ -46,9 +46,10 @@ import { routeSessionId } from '../routes'
|
||||
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
|
||||
|
||||
import { ChatDropOverlay } from './chat-drop-overlay'
|
||||
import { ChatSwapOverlay } from './chat-swap-overlay'
|
||||
import { ChatBar, ChatBarFallback } from './composer'
|
||||
import { requestComposerInsert } from './composer/focus'
|
||||
import { droppedFileInlineRef } from './composer/inline-refs'
|
||||
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
|
||||
import { droppedFileInlineRef, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
|
||||
import type { ChatBarState } from './composer/types'
|
||||
import type { DroppedFile } from './hooks/use-composer-actions'
|
||||
import { useFileDropZone } from './hooks/use-file-drop-zone'
|
||||
@@ -179,6 +180,7 @@ export function ChatView({
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const freshDraftReady = useStore($freshDraftReady)
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const gatewaySwapTarget = useStore($gatewaySwapTarget)
|
||||
const gatewayOpen = gatewayState === 'open'
|
||||
const introPersonality = useStore($introPersonality)
|
||||
const introSeed = useStore($introSeed)
|
||||
@@ -307,7 +309,13 @@ export function ChatView({
|
||||
[currentCwd]
|
||||
)
|
||||
|
||||
const { dragActive, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles })
|
||||
// Dropping a sidebar session inserts an @session link the agent can resolve
|
||||
// via session_search (carries the source profile, so cross-profile works).
|
||||
const onDropSession = useCallback((session: SessionDragPayload) => {
|
||||
requestComposerInsertRefs([sessionInlineRef(session)], { target: 'main' })
|
||||
}, [])
|
||||
|
||||
const { dragKind, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles, onDropSession })
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -325,7 +333,6 @@ export function ChatView({
|
||||
selectedSessionId={selectedSessionId}
|
||||
/>
|
||||
|
||||
<NotificationStack />
|
||||
<PromptOverlays />
|
||||
|
||||
<div
|
||||
@@ -372,7 +379,8 @@ export function ChatView({
|
||||
</Suspense>
|
||||
)}
|
||||
</AssistantRuntimeProvider>
|
||||
<ChatDropOverlay active={dragActive} />
|
||||
<ChatDropOverlay kind={dragKind} />
|
||||
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { requestComposerInsert } from '@/app/chat/composer/focus'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { PanelBottom, Send, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify } from '@/store/notifications'
|
||||
@@ -80,17 +81,18 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console
|
||||
selected && 'border-border/60 bg-accent/40'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
'mt-0.5 text-left uppercase opacity-70 transition-colors hover:opacity-100',
|
||||
consoleLevelClass[log.level] ?? consoleLevelClass[0]
|
||||
)}
|
||||
onClick={onToggleSelect}
|
||||
title={selected ? 'Deselect entry' : 'Select entry'}
|
||||
type="button"
|
||||
>
|
||||
{consoleLevelLabel[log.level] || 'log'}
|
||||
</button>
|
||||
<Tip label={selected ? 'Deselect entry' : 'Select entry'}>
|
||||
<button
|
||||
className={cn(
|
||||
'mt-0.5 text-left uppercase opacity-70 transition-colors hover:opacity-100',
|
||||
consoleLevelClass[log.level] ?? consoleLevelClass[0]
|
||||
)}
|
||||
onClick={onToggleSelect}
|
||||
type="button"
|
||||
>
|
||||
{consoleLevelLabel[log.level] || 'log'}
|
||||
</button>
|
||||
</Tip>
|
||||
<div className="min-w-0" data-selectable-text="true">
|
||||
<span className={cn('block wrap-break-word', consoleLevelClass[log.level] ?? consoleLevelClass[0])}>
|
||||
{log.message}
|
||||
@@ -112,14 +114,15 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console
|
||||
showLabel={false}
|
||||
text={copyText}
|
||||
/>
|
||||
<button
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onSend}
|
||||
title="Send this entry to chat"
|
||||
type="button"
|
||||
>
|
||||
<Send className="size-3" />
|
||||
</button>
|
||||
<Tip label="Send this entry to chat">
|
||||
<button
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onSend}
|
||||
type="button"
|
||||
>
|
||||
<Send className="size-3" />
|
||||
</button>
|
||||
</Tip>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
@@ -225,11 +228,6 @@ export function PreviewConsolePanel({
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
disabled={sendableLogs.length === 0}
|
||||
onClick={() => sendLogsToComposer(sendableLogs)}
|
||||
title={
|
||||
visibleSelection.length > 0
|
||||
? `Send ${visibleSelection.length} selected to chat`
|
||||
: 'Send all log entries to chat'
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Send className="size-3" />
|
||||
@@ -250,7 +248,6 @@ export function PreviewConsolePanel({
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
disabled={logs.length === 0}
|
||||
onClick={consoleState.clear}
|
||||
title="Clear console"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { Bug } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
@@ -607,15 +608,16 @@ export function PreviewPane({
|
||||
{!embedded && (
|
||||
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<a
|
||||
className="pointer-events-auto inline max-w-full truncate text-left text-xs font-medium text-foreground underline-offset-4 decoration-current/20 transition-colors hover:text-primary hover:underline"
|
||||
href={currentUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={`Open ${currentUrl}`}
|
||||
>
|
||||
{previewLabel || 'Preview'}
|
||||
</a>
|
||||
<Tip label={`Open ${currentUrl}`}>
|
||||
<a
|
||||
className="pointer-events-auto inline max-w-full truncate text-left text-xs font-medium text-foreground underline-offset-4 decoration-current/20 transition-colors hover:text-primary hover:underline"
|
||||
href={currentUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{previewLabel || 'Preview'}
|
||||
</a>
|
||||
</Tip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useMemo } from 'react'
|
||||
|
||||
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$rightRailActiveTabId,
|
||||
@@ -117,16 +118,17 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
{active && (
|
||||
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
|
||||
)}
|
||||
<button
|
||||
aria-selected={active}
|
||||
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
|
||||
onClick={() => selectRightRailTab(tab.id)}
|
||||
role="tab"
|
||||
title={tab.label}
|
||||
type="button"
|
||||
>
|
||||
<span className="block min-w-0 truncate">{tab.label}</span>
|
||||
</button>
|
||||
<Tip label={tab.label}>
|
||||
<button
|
||||
aria-selected={active}
|
||||
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
|
||||
onClick={() => selectRightRailTab(tab.id)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
<span className="block min-w-0 truncate">{tab.label}</span>
|
||||
</button>
|
||||
</Tip>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
|
||||
@@ -135,7 +137,6 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
aria-label={`Close ${tab.label}`}
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
|
||||
onClick={() => closeRightRailTab(tab.id)}
|
||||
title={`Close ${tab.label}`}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
@@ -148,7 +149,6 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
aria-label="Close preview pane"
|
||||
className="mr-1.5 grid size-6 shrink-0 self-center place-items-center rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring group-hover/rail-tabs:opacity-100 [-webkit-app-region:no-drag]"
|
||||
onClick={closeRightRail}
|
||||
title="Close preview pane"
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
@@ -34,7 +34,10 @@ import {
|
||||
SidebarMenuItem
|
||||
} from '@/components/ui/sidebar'
|
||||
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'
|
||||
import {
|
||||
@@ -52,8 +55,17 @@ import {
|
||||
SIDEBAR_SESSIONS_PAGE_SIZE,
|
||||
unpinSession
|
||||
} from '@/store/layout'
|
||||
import {
|
||||
$newChatProfile,
|
||||
$profiles,
|
||||
$profileScope,
|
||||
ALL_PROFILES,
|
||||
newSessionInProfile,
|
||||
normalizeProfileKey
|
||||
} from '@/store/profile'
|
||||
import {
|
||||
$selectedStoredSessionId,
|
||||
$sessionProfileTotals,
|
||||
$sessions,
|
||||
$sessionsLoading,
|
||||
$sessionsTotal,
|
||||
@@ -65,6 +77,7 @@ import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '..
|
||||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||
import type { SidebarNavItem } from '../../types'
|
||||
|
||||
import { ProfileRail } from './profile-switcher'
|
||||
import { SidebarSessionRow } from './session-row'
|
||||
import { VirtualSessionList } from './virtual-session-list'
|
||||
|
||||
@@ -94,6 +107,9 @@ const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
]
|
||||
|
||||
const WORKSPACE_PAGE = 5
|
||||
// ALL-profiles view: show only the latest N per profile up front to keep the
|
||||
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
|
||||
const PROFILE_INITIAL_PAGE = 5
|
||||
const WS_ID_PREFIX = 'workspace:'
|
||||
|
||||
const wsId = (id: string) => `${WS_ID_PREFIX}${id}`
|
||||
@@ -161,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)
|
||||
@@ -201,6 +217,7 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
currentView: AppView
|
||||
onNavigate: (item: SidebarNavItem) => void
|
||||
onLoadMoreSessions: () => void
|
||||
onLoadMoreProfileSessions?: (profile: string) => Promise<void> | void
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
onArchiveSession: (sessionId: string) => void
|
||||
@@ -211,11 +228,14 @@ export function ChatSidebar({
|
||||
currentView,
|
||||
onNavigate,
|
||||
onLoadMoreSessions,
|
||||
onLoadMoreProfileSessions,
|
||||
onResumeSession,
|
||||
onDeleteSession,
|
||||
onArchiveSession,
|
||||
onNewSessionInWorkspace
|
||||
}: ChatSidebarProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const agentsGrouped = useStore($sidebarAgentsGrouped)
|
||||
@@ -226,12 +246,23 @@ export function ChatSidebar({
|
||||
const sessions = useStore($sessions)
|
||||
const sessionsLoading = useStore($sessionsLoading)
|
||||
const sessionsTotal = useStore($sessionsTotal)
|
||||
const sessionProfileTotals = useStore($sessionProfileTotals)
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
const profiles = useStore($profiles)
|
||||
const profileScope = useStore($profileScope)
|
||||
// Only surface the profile switcher when more than one profile exists, so
|
||||
// single-profile users see the unchanged sidebar.
|
||||
const multiProfile = profiles.length > 1
|
||||
// Gate ALL-profiles grouping on multiProfile too: if a user drops back to one
|
||||
// profile while scope is still ALL (persisted), the rail is hidden and they'd
|
||||
// otherwise be stuck in the grouped view with no way out.
|
||||
const showAllProfiles = multiProfile && profileScope === ALL_PROFILES
|
||||
const [agentOrderIds, setAgentOrderIds] = useState<string[]>([])
|
||||
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
|
||||
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
|
||||
const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})
|
||||
const trimmedQuery = searchQuery.trim()
|
||||
|
||||
// Flash the ⌘N hint full-opacity (no transition) for the press, so hitting
|
||||
@@ -260,7 +291,19 @@ export function ChatSidebar({
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
)
|
||||
|
||||
const sortedSessions = useMemo(() => [...sessions].sort((a, b) => sessionTime(b) - sessionTime(a)), [sessions])
|
||||
// Profile scope = the "workspace switcher" context. Concrete scope shows only
|
||||
// that profile's sessions (clean rows, no per-row tags); ALL fans every
|
||||
// profile in, grouped by profile below. Single-profile users land here with
|
||||
// scope === their only profile, so nothing is filtered out.
|
||||
const visibleSessions = useMemo(
|
||||
() => (showAllProfiles ? sessions : sessions.filter(s => normalizeProfileKey(s.profile) === profileScope)),
|
||||
[sessions, showAllProfiles, profileScope]
|
||||
)
|
||||
|
||||
const sortedSessions = useMemo(
|
||||
() => [...visibleSessions].sort((a, b) => sessionTime(b) - sessionTime(a)),
|
||||
[visibleSessions]
|
||||
)
|
||||
|
||||
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])
|
||||
|
||||
@@ -269,7 +312,7 @@ export function ChatSidebar({
|
||||
const sessionByAnyId = useMemo(() => {
|
||||
const map = new Map<string, SessionInfo>()
|
||||
|
||||
for (const s of sessions) {
|
||||
for (const s of visibleSessions) {
|
||||
map.set(s.id, s)
|
||||
|
||||
if (s._lineage_root_id && !map.has(s._lineage_root_id)) {
|
||||
@@ -278,7 +321,7 @@ export function ChatSidebar({
|
||||
}
|
||||
|
||||
return map
|
||||
}, [sessions])
|
||||
}, [visibleSessions])
|
||||
|
||||
const pinnedSessions = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
@@ -362,15 +405,91 @@ 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(
|
||||
(profile: string) => {
|
||||
if (!onLoadMoreProfileSessions) {
|
||||
return
|
||||
}
|
||||
|
||||
setProfileLoadMorePending(prev => ({ ...prev, [profile]: true }))
|
||||
|
||||
void Promise.resolve(onLoadMoreProfileSessions(profile))
|
||||
.catch(() => undefined)
|
||||
.finally(() =>
|
||||
setProfileLoadMorePending(({ [profile]: _done, ...rest }) => rest)
|
||||
)
|
||||
},
|
||||
[onLoadMoreProfileSessions]
|
||||
)
|
||||
|
||||
// ALL-profiles view: one collapsible group per profile, color on the header
|
||||
// (not on every row). Default profile floats to the top, the rest alpha.
|
||||
const profileGroups = useMemo<SidebarSessionGroup[] | undefined>(() => {
|
||||
if (!showAllProfiles) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const groups = new Map<string, SidebarSessionGroup>()
|
||||
|
||||
for (const session of agentSessions) {
|
||||
const key = normalizeProfileKey(session.profile)
|
||||
|
||||
const group = groups.get(key) ?? {
|
||||
color: profileColor(key),
|
||||
id: key,
|
||||
label: key,
|
||||
mode: 'profile',
|
||||
path: null,
|
||||
sessions: []
|
||||
}
|
||||
|
||||
group.sessions.push(session)
|
||||
|
||||
groups.set(key, group)
|
||||
}
|
||||
|
||||
return [...groups.values()]
|
||||
.map(group => ({
|
||||
...group,
|
||||
loadingMore: Boolean(profileLoadMorePending[group.id]),
|
||||
onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined,
|
||||
totalCount: Math.max(group.sessions.length, sessionProfileTotals[group.id] ?? 0)
|
||||
}))
|
||||
// default (root) first, then the rest alphabetically.
|
||||
.sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label)))
|
||||
}, [
|
||||
showAllProfiles,
|
||||
agentSessions,
|
||||
loadMoreForProfileGroup,
|
||||
onLoadMoreProfileSessions,
|
||||
profileLoadMorePending,
|
||||
sessionProfileTotals
|
||||
])
|
||||
|
||||
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
|
||||
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
|
||||
const knownSessionTotal = Math.max(sessionsTotal, sortedSessions.length)
|
||||
const hasMoreSessions = knownSessionTotal > sortedSessions.length
|
||||
const remainingSessionCount = Math.max(0, knownSessionTotal - sortedSessions.length)
|
||||
// Pagination is scope-aware. In "All profiles" mode it tracks the global
|
||||
// unified set. When scoped to one profile it must compare that profile's own
|
||||
// loaded rows against that profile's total — otherwise a huge default profile
|
||||
// keeps "Load more" stuck on while you browse a small one (the aggregator's
|
||||
// total sums every profile). Per-profile totals come from the aggregator
|
||||
// (children excluded); fall back to the global total / loaded count.
|
||||
const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length
|
||||
const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]
|
||||
|
||||
const knownSessionTotal = Math.max(
|
||||
showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
|
||||
loadedSessionCount
|
||||
)
|
||||
|
||||
const hasMoreSessions = knownSessionTotal > loadedSessionCount
|
||||
const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)
|
||||
|
||||
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
|
||||
|
||||
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
if (!over || active.id === over.id) {
|
||||
@@ -449,6 +568,8 @@ export function ChatSidebar({
|
||||
(item.id === 'messaging' && currentView === 'messaging') ||
|
||||
(item.id === 'artifacts' && currentView === 'artifacts')
|
||||
|
||||
const isNewSession = item.id === 'new-session'
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={item.id}>
|
||||
<SidebarMenuButton
|
||||
@@ -460,15 +581,27 @@ export function ChatSidebar({
|
||||
!isInteractive &&
|
||||
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
|
||||
)}
|
||||
onClick={() => onNavigate(item)}
|
||||
tooltip={item.label}
|
||||
onClick={() => {
|
||||
// A plain new session lands in whatever profile the live
|
||||
// gateway is on (= the active switcher context). null →
|
||||
// no swap. The switcher header is the single place to
|
||||
// change which profile that is.
|
||||
if (isNewSession) {
|
||||
$newChatProfile.set(null)
|
||||
}
|
||||
|
||||
onNavigate(item)
|
||||
}}
|
||||
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>
|
||||
{item.id === 'new-session' && (
|
||||
<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!')}
|
||||
keys={[...NEW_SESSION_KBD]}
|
||||
@@ -487,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>
|
||||
@@ -501,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}
|
||||
@@ -525,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}
|
||||
@@ -544,11 +677,19 @@ export function ChatSidebar({
|
||||
{sidebarOpen && showSessionSections && !trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
|
||||
contentClassName={cn(
|
||||
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75',
|
||||
// Separate profile sections clearly in the ALL view; rows inside
|
||||
// each group keep their own tight gap-px rhythm.
|
||||
showAllProfiles ? 'gap-3' : 'gap-px'
|
||||
)}
|
||||
dndSensors={dndSensors}
|
||||
emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />}
|
||||
footer={
|
||||
!agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
|
||||
// Hide "load more" only when workspace-grouped (those groups page
|
||||
// themselves). ALL-profiles now pages per-profile from each profile
|
||||
// header; the global footer only applies to non-ALL views.
|
||||
!showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
|
||||
<SidebarLoadMoreRow
|
||||
loading={sessionsLoading}
|
||||
onClick={onLoadMoreSessions}
|
||||
@@ -557,37 +698,43 @@ export function ChatSidebar({
|
||||
) : null
|
||||
}
|
||||
forceEmptyState={showSessionSkeletons}
|
||||
groups={agentsGrouped ? agentGroups : undefined}
|
||||
groups={showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined}
|
||||
headerAction={
|
||||
// Grouping operates on unpinned recents; if everything is
|
||||
// pinned the toggle does nothing visible, so hide it to avoid
|
||||
// a phantom click target.
|
||||
agentSessions.length > 0 ? (
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
|
||||
className={cn(
|
||||
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setSidebarRecentsOpen(true)
|
||||
setSidebarAgentsGrouped(!agentsGrouped)
|
||||
}}
|
||||
size="icon-xs"
|
||||
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
||||
</Button>
|
||||
) : null
|
||||
// Always reserve the icon-xs (size-6) slot so the header keeps the
|
||||
// same height whether or not the toggle renders — otherwise the
|
||||
// "Sessions" label jumps when switching to the ALL-profiles view.
|
||||
// Grouping operates on unpinned recents; if everything is pinned
|
||||
// the toggle does nothing, and it's irrelevant in the ALL-profiles
|
||||
// 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 ? s.groupTitleGrouped : s.groupTitleUngrouped}>
|
||||
<Button
|
||||
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'
|
||||
)}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setSidebarRecentsOpen(true)
|
||||
setSidebarAgentsGrouped(!agentsGrouped)
|
||||
}}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
label="Sessions"
|
||||
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
|
||||
label={s.sessions}
|
||||
labelMeta={recentsMeta}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onNewSessionInWorkspace={onNewSessionInWorkspace}
|
||||
onReorder={handleAgentDragEnd}
|
||||
onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
|
||||
onReorder={showAllProfiles ? undefined : handleAgentDragEnd}
|
||||
onResumeSession={onResumeSession}
|
||||
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
|
||||
onTogglePin={pinSession}
|
||||
@@ -595,10 +742,18 @@ export function ChatSidebar({
|
||||
pinned={false}
|
||||
rootClassName="min-h-0 flex-1 p-0"
|
||||
sessions={agentSessions}
|
||||
sortable={agentSessions.length > 1}
|
||||
sortable={!showAllProfiles && agentSessions.length > 1}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sidebarOpen && !showSessionSections && <div className="min-h-0 flex-1" />}
|
||||
|
||||
{sidebarOpen && (
|
||||
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
|
||||
<ProfileRail />
|
||||
</div>
|
||||
)}
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
)
|
||||
@@ -645,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>
|
||||
)
|
||||
}
|
||||
@@ -667,6 +828,12 @@ interface SidebarSessionGroup {
|
||||
label: string
|
||||
path: null | string
|
||||
sessions: SessionInfo[]
|
||||
// Profile color for the ALL-profiles view; absent for workspace groups.
|
||||
color?: null | string
|
||||
loadingMore?: boolean
|
||||
mode?: 'profile' | 'workspace'
|
||||
onLoadMore?: () => void
|
||||
totalCount?: number
|
||||
}
|
||||
|
||||
interface SidebarSessionsSectionProps {
|
||||
@@ -850,43 +1017,72 @@ 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)
|
||||
const [visibleCount, setVisibleCount] = useState(WORKSPACE_PAGE)
|
||||
const [visibleCount, setVisibleCount] = useState(pageStep)
|
||||
|
||||
const loadedCount = group.sessions.length
|
||||
// Profile groups know their on-disk total (children excluded); workspace
|
||||
// groups only ever page within what's already loaded.
|
||||
const totalCount = isProfileGroup ? Math.max(group.totalCount ?? loadedCount, loadedCount) : loadedCount
|
||||
const visibleSessions = group.sessions.slice(0, visibleCount)
|
||||
const hiddenCount = Math.max(0, group.sessions.length - visibleSessions.length)
|
||||
const nextCount = Math.min(WORKSPACE_PAGE, hiddenCount)
|
||||
const hiddenCount = Math.max(0, totalCount - visibleSessions.length)
|
||||
const nextCount = Math.min(pageStep, hiddenCount)
|
||||
|
||||
// Reveal already-loaded rows first; only hit the backend when the next page
|
||||
// crosses what's been fetched for this profile.
|
||||
const handleProfileLoadMore = () => {
|
||||
const target = visibleCount + pageStep
|
||||
|
||||
setVisibleCount(target)
|
||||
|
||||
if (target > loadedCount && loadedCount < totalCount) {
|
||||
group.onLoadMore?.()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
|
||||
<div className="group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
|
||||
<button
|
||||
className="flex min-w-0 items-center gap-1 bg-transparent text-left hover:text-(--ui-text-secondary)"
|
||||
className="flex min-w-0 items-center gap-1.5 bg-transparent text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => setOpen(value => !value)}
|
||||
title={group.path ?? undefined}
|
||||
type="button"
|
||||
>
|
||||
{group.color ? (
|
||||
<span aria-hidden="true" className="size-2 shrink-0 rounded-full" style={{ backgroundColor: group.color }} />
|
||||
) : null}
|
||||
<span className="truncate">{group.label}</span>
|
||||
<SidebarCount>{group.sessions.length}</SidebarCount>
|
||||
<SidebarCount>
|
||||
{isProfileGroup ? countLabel(visibleSessions.length, totalCount) : group.sessions.length}
|
||||
</SidebarCount>
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
</button>
|
||||
{onNewSession && (
|
||||
<button
|
||||
aria-label={`New session in ${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"
|
||||
onClick={() => onNewSession(group.path)}
|
||||
title={`New session in ${group.label}`}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="add" size="0.75rem" />
|
||||
</button>
|
||||
{(onNewSession || isProfileGroup) && (
|
||||
<Tip label={s.newSessionIn(group.label)}>
|
||||
<button
|
||||
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
|
||||
// alone); workspace groups seed the new session's cwd from the path.
|
||||
onClick={() => (isProfileGroup ? newSessionInProfile(group.id) : onNewSession?.(group.path))}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="add" size="0.75rem" />
|
||||
</button>
|
||||
</Tip>
|
||||
)}
|
||||
{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()}
|
||||
>
|
||||
@@ -904,17 +1100,21 @@ function SidebarWorkspaceGroup({
|
||||
{open && (
|
||||
<>
|
||||
{renderRows(visibleSessions)}
|
||||
{hiddenCount > 0 && (
|
||||
<button
|
||||
aria-label={`Show ${nextCount} more in ${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)}
|
||||
title={`Show ${nextCount} more in ${group.label}`}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.75rem" />
|
||||
</button>
|
||||
)}
|
||||
{hiddenCount > 0 &&
|
||||
(isProfileGroup ? (
|
||||
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
|
||||
) : (
|
||||
<Tip label={s.showMoreIn(nextCount, group.label)}>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.75rem" />
|
||||
</button>
|
||||
</Tip>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -957,16 +1157,21 @@ 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
|
||||
className="flex min-h-5 items-center gap-1 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
|
||||
className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
|
||||
disabled={loading}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
|
||||
{/* Seat the icon in the same w-3.5 column session rows use for their dot
|
||||
so the chevron + label line up with the rows above. */}
|
||||
<span className="grid w-3.5 shrink-0 place-items-center">
|
||||
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
491
apps/desktop/src/app/chat/sidebar/profile-switcher.tsx
Normal file
491
apps/desktop/src/app/chat/sidebar/profile-switcher.tsx
Normal file
@@ -0,0 +1,491 @@
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
type DragOverEvent,
|
||||
type DragStartEvent,
|
||||
KeyboardSensor,
|
||||
type Modifier,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
$profileColors,
|
||||
$profileOrder,
|
||||
$profiles,
|
||||
$profileScope,
|
||||
ALL_PROFILES,
|
||||
normalizeProfileKey,
|
||||
refreshActiveProfile,
|
||||
selectProfile,
|
||||
setProfileColor,
|
||||
setProfileOrder,
|
||||
setShowAllProfiles,
|
||||
sortByProfileOrder
|
||||
} from '@/store/profile'
|
||||
import type { ProfileInfo } from '@/types/hermes'
|
||||
|
||||
import { CreateProfileDialog } from '../../profiles/create-profile-dialog'
|
||||
import { DeleteProfileDialog } from '../../profiles/delete-profile-dialog'
|
||||
import { RenameProfileDialog } from '../../profiles/rename-profile-dialog'
|
||||
import { PROFILES_ROUTE } from '../../routes'
|
||||
|
||||
const RAIL_GAP = 4 // px — matches gap-1 between squares.
|
||||
|
||||
// easeOutBack — a little overshoot so squares spring into their new slot rather
|
||||
// than sliding in flat. Neighbors reflow on RAIL_TRANSITION; the dragged square
|
||||
// glides between snapped cells on the snappier DRAG_TRANSITION.
|
||||
const SPRING = 'cubic-bezier(0.34, 1.56, 0.64, 1)'
|
||||
const RAIL_TRANSITION = { duration: 300, easing: SPRING }
|
||||
const DRAG_TRANSITION = `transform 200ms ${SPRING}`
|
||||
|
||||
// The rail is a single horizontal strip of fixed cells. Pin drags to the x-axis
|
||||
// (no cross-axis scrollbar), snap to whole cells so a square steps slot-to-slot
|
||||
// instead of gliding, and clamp to the occupied strip so it can't float past the
|
||||
// last profile onto the "+".
|
||||
const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, transform }) => {
|
||||
if (!draggingNodeRect || !containerNodeRect) {
|
||||
return { ...transform, y: 0 }
|
||||
}
|
||||
|
||||
const pitch = draggingNodeRect.width + RAIL_GAP
|
||||
const minX = containerNodeRect.left - draggingNodeRect.left
|
||||
const maxX = containerNodeRect.right - draggingNodeRect.right
|
||||
const snapped = Math.round(transform.x / pitch) * pitch
|
||||
|
||||
return { ...transform, x: Math.min(maxX, Math.max(minX, snapped)), y: 0 }
|
||||
}
|
||||
|
||||
// Arc-Spaces-style profile rail at the sidebar foot: a default↔all toggle pinned
|
||||
// left, the colored named profiles scrolling between, and Manage pinned right.
|
||||
// The active profile pops in its own color — the "where am I" cue. Single-
|
||||
// profile users see only the "+" (create their first profile); everything else
|
||||
// appears once a second profile exists.
|
||||
export function ProfileRail() {
|
||||
const profiles = useStore($profiles)
|
||||
const scope = useStore($profileScope)
|
||||
const gatewayProfile = useStore($activeGatewayProfile)
|
||||
const order = useStore($profileOrder)
|
||||
const colors = useStore($profileColors)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [pendingRename, setPendingRename] = useState<null | ProfileInfo>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// A plain mouse wheel only emits deltaY; map it to horizontal scroll so the
|
||||
// rail is navigable without a trackpad. Trackpad x-scroll (deltaX) passes
|
||||
// through. Native + non-passive so we can preventDefault and not bleed the
|
||||
// gesture into the sessions list above.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (el.scrollWidth <= el.clientWidth || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) {
|
||||
return
|
||||
}
|
||||
|
||||
el.scrollLeft += event.deltaY
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
|
||||
return () => el.removeEventListener('wheel', onWheel)
|
||||
}, [])
|
||||
|
||||
const isAll = scope === ALL_PROFILES
|
||||
const activeKey = normalizeProfileKey(gatewayProfile)
|
||||
const defaultProfile = profiles.find(profile => profile.is_default)
|
||||
const onDefault = !isAll && activeKey === 'default'
|
||||
|
||||
const named = sortByProfileOrder(profiles.filter(profile => !profile.is_default), order)
|
||||
const multiProfile = profiles.length > 1
|
||||
|
||||
// distance constraint: a small drag reorders, a tap still selects the profile.
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
)
|
||||
|
||||
// Tick a haptic each time the drag crosses into a new cell, and a satisfying
|
||||
// confirm on a committed reorder.
|
||||
const lastOverRef = useRef<string | null>(null)
|
||||
|
||||
const handleDragStart = ({ active }: DragStartEvent) => {
|
||||
lastOverRef.current = String(active.id)
|
||||
}
|
||||
|
||||
const handleDragOver = ({ over }: DragOverEvent) => {
|
||||
const id = over ? String(over.id) : null
|
||||
|
||||
if (id && id !== lastOverRef.current) {
|
||||
lastOverRef.current = id
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
lastOverRef.current = null
|
||||
|
||||
if (!over || active.id === over.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const ids = named.map(profile => profile.name)
|
||||
const from = ids.indexOf(String(active.id))
|
||||
const to = ids.indexOf(String(over.id))
|
||||
|
||||
if (from >= 0 && to >= 0) {
|
||||
setProfileOrder(arrayMove(ids, from, to))
|
||||
triggerHaptic('success')
|
||||
}
|
||||
}
|
||||
|
||||
// Re-pull the running profile + list on mount so a profile created elsewhere
|
||||
// shows up; cheap and best-effort.
|
||||
useEffect(() => {
|
||||
void refreshActiveProfile()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div aria-label="Profiles" className="flex items-center gap-0.5" role="tablist">
|
||||
{/* One button toggles default ↔ all: home face when scoped to a profile,
|
||||
layers face when showing everything. Pinned left like Manage is right.
|
||||
Hidden until a second profile exists. */}
|
||||
{multiProfile &&
|
||||
(defaultProfile ? (
|
||||
// On default → toggle to all. Anywhere else (all view or a named
|
||||
// profile) → return to default. So leaving a profile never lands on all.
|
||||
<ProfilePill
|
||||
active={isAll || onDefault}
|
||||
glyph={isAll ? 'layers' : 'home'}
|
||||
label={onDefault ? 'Show all profiles' : `Switch to ${defaultProfile.name}`}
|
||||
onSelect={() => (onDefault ? setShowAllProfiles(true) : selectProfile(defaultProfile.name))}
|
||||
/>
|
||||
) : (
|
||||
<ProfilePill active={isAll} glyph="layers" label="All profiles" onSelect={() => setShowAllProfiles(true)} />
|
||||
))}
|
||||
|
||||
{/* Single-profile: the active default's home icon next to the create +. */}
|
||||
{!multiProfile && defaultProfile && (
|
||||
<ProfilePill active glyph="home" label={defaultProfile.name} onSelect={() => selectProfile(defaultProfile.name)} />
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
ref={scrollRef}
|
||||
>
|
||||
{multiProfile && (
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[stepThroughCells]}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragStart={handleDragStart}
|
||||
sensors={sensors}
|
||||
>
|
||||
<SortableContext items={named.map(profile => profile.name)} strategy={horizontalListSortingStrategy}>
|
||||
{/* relative → the strip is the dragged square's offsetParent, so the
|
||||
clamp modifier bounds drags to the occupied cells (not the +). */}
|
||||
<div className="relative flex items-center gap-1">
|
||||
{named.map(profile => (
|
||||
<ProfileSquare
|
||||
active={!isAll && normalizeProfileKey(profile.name) === activeKey}
|
||||
color={resolveProfileColor(profile.name, colors)}
|
||||
key={profile.name}
|
||||
label={profile.name}
|
||||
onDelete={() => setPendingDelete(profile)}
|
||||
onRecolor={color => setProfileColor(profile.name, color)}
|
||||
onRename={() => setPendingRename(profile)}
|
||||
onSelect={() => selectProfile(profile.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
<Tip label="New profile">
|
||||
<button
|
||||
aria-label="New profile"
|
||||
className="grid size-5 shrink-0 place-items-center rounded-[3px] text-(--ui-text-tertiary) opacity-55 transition hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="add" size="0.75rem" />
|
||||
</button>
|
||||
</Tip>
|
||||
</div>
|
||||
|
||||
{multiProfile && (
|
||||
<ProfilePill active={false} glyph="ellipsis" label="Manage profiles…" onSelect={() => navigate(PROFILES_ROUTE)} />
|
||||
)}
|
||||
|
||||
{/* Land in the new profile on a fresh chat (selectProfile triggers the
|
||||
new-session reset), not stuck on the session you were just in. */}
|
||||
<CreateProfileDialog
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={async name => {
|
||||
await refreshActiveProfile()
|
||||
selectProfile(name)
|
||||
}}
|
||||
open={createOpen}
|
||||
/>
|
||||
|
||||
<RenameProfileDialog
|
||||
currentName={pendingRename?.name ?? ''}
|
||||
onClose={() => setPendingRename(null)}
|
||||
onRenamed={refreshActiveProfile}
|
||||
open={pendingRename !== null}
|
||||
/>
|
||||
|
||||
<DeleteProfileDialog
|
||||
onClose={() => setPendingDelete(null)}
|
||||
onDeleted={refreshActiveProfile}
|
||||
open={pendingDelete !== null}
|
||||
profile={pendingDelete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProfilePillProps {
|
||||
active: boolean
|
||||
// home / All / Manage are glyph action buttons (navigation, not identity).
|
||||
glyph: string
|
||||
label: string
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
function ProfilePill({ active, glyph, label, onSelect }: ProfilePillProps) {
|
||||
return (
|
||||
<Tip label={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'bg-transparent text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
|
||||
active && 'bg-(--ui-control-active-background) text-foreground'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={glyph} size="0.875rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProfileSquareProps {
|
||||
active: boolean
|
||||
color: null | string
|
||||
label: string
|
||||
onSelect: () => void
|
||||
onRecolor: (color: null | string) => void
|
||||
onRename: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
// Hold this long without moving (a drag would have started first) to open the
|
||||
// color picker — the "hard press" gesture, distinct from tap-to-select.
|
||||
const LONG_PRESS_MS = 450
|
||||
|
||||
// A profile *is* its colored square — no icon-button chrome. Soft profile-tint
|
||||
// fill + the initial in the full color; the active one pops to full opacity with
|
||||
// a color ring. These pack tightly so the rail reads as a strip of profiles,
|
||||
// drag-sort to reorder (a tap below the drag threshold still selects), and
|
||||
// right-click to rename/delete. The button carries both the tooltip and
|
||||
// context-menu triggers via nested asChild Slots, so a single element keeps the
|
||||
// dnd listeners, hover tip, and right-click menu.
|
||||
function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, onSelect }: ProfileSquareProps) {
|
||||
const hue = color ?? 'var(--ui-text-quaternary)'
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const pressTimer = useRef<null | number>(null)
|
||||
const suppressClick = useRef(false)
|
||||
|
||||
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({
|
||||
id: label,
|
||||
transition: RAIL_TRANSITION
|
||||
})
|
||||
|
||||
const clearPress = () => {
|
||||
if (pressTimer.current != null) {
|
||||
clearTimeout(pressTimer.current)
|
||||
pressTimer.current = null
|
||||
}
|
||||
}
|
||||
|
||||
// A real drag (movement past the dnd threshold) cancels the pending hold, so a
|
||||
// reorder never doubles as a color pick. Also tidy up on unmount.
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
clearPress()
|
||||
}
|
||||
}, [isDragging])
|
||||
useEffect(() => clearPress, [])
|
||||
|
||||
const base = CSS.Transform.toString(transform)
|
||||
const ring = active ? `inset 0 0 0 1.5px ${hue}` : ''
|
||||
const lift = isDragging ? '0 6px 16px -4px rgb(0 0 0 / 0.4)' : ''
|
||||
|
||||
const pickColor = (next: null | string) => {
|
||||
onRecolor(next)
|
||||
setPickerOpen(false)
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setPickerOpen} open={pickerOpen}>
|
||||
<ContextMenu>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<PopoverAnchor asChild>
|
||||
<ContextMenuTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'grid size-5 shrink-0 cursor-grab touch-none select-none place-items-center rounded-[3px] text-[0.5625rem] font-semibold uppercase leading-none transition-opacity hover:opacity-100',
|
||||
active ? 'opacity-100' : 'opacity-55',
|
||||
isDragging && 'z-10 cursor-grabbing opacity-100'
|
||||
)}
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
backgroundColor: profileColorSoft(hue, active ? 30 : 22),
|
||||
boxShadow: [ring, lift].filter(Boolean).join(', ') || undefined,
|
||||
color: color ?? undefined,
|
||||
// Glide the dragged square between snapped cells with a little
|
||||
// overshoot (no scale — the overflow-x strip would clip it).
|
||||
transform: base,
|
||||
transition: isDragging ? DRAG_TRANSITION : transition
|
||||
}}
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
// Hold-to-recolor rides alongside the dnd pointer listener (call
|
||||
// it first so drag tracking still arms), then a timer opens the
|
||||
// picker and flags the trailing click so it doesn't also select.
|
||||
onClick={() => {
|
||||
if (suppressClick.current) {
|
||||
suppressClick.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onSelect()
|
||||
}}
|
||||
onPointerCancel={clearPress}
|
||||
onPointerDown={event => {
|
||||
listeners?.onPointerDown?.(event)
|
||||
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
suppressClick.current = false
|
||||
clearPress()
|
||||
pressTimer.current = window.setTimeout(() => {
|
||||
suppressClick.current = true
|
||||
triggerHaptic('success')
|
||||
setPickerOpen(true)
|
||||
}, LONG_PRESS_MS)
|
||||
}}
|
||||
onPointerLeave={clearPress}
|
||||
onPointerUp={clearPress}
|
||||
>
|
||||
{label.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
</ContextMenuTrigger>
|
||||
</PopoverAnchor>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* The rail sits at the very bottom, so pad off the chrome (esp. the
|
||||
statusbar) — Radix then flips the menu up instead of squishing it. */}
|
||||
<ContextMenuContent
|
||||
aria-label={`Actions for ${label}`}
|
||||
className="w-40"
|
||||
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
|
||||
>
|
||||
<ContextMenuItem onSelect={() => setPickerOpen(true)}>
|
||||
<Codicon name="symbol-color" size="0.875rem" />
|
||||
<span>Color…</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={onRename}>
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
<span>Rename</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive focus:text-destructive" onSelect={onDelete} variant="destructive">
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
<span>Delete</span>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<PopoverContent
|
||||
aria-label={`Color for ${label}`}
|
||||
className="w-auto p-2"
|
||||
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
|
||||
side="top"
|
||||
>
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{PROFILE_SWATCHES.map(swatch => (
|
||||
<button
|
||||
aria-label={`Set color ${swatch}`}
|
||||
className="size-5 rounded-full transition-transform hover:scale-110"
|
||||
key={swatch}
|
||||
onClick={() => pickColor(swatch)}
|
||||
style={{
|
||||
backgroundColor: swatch,
|
||||
boxShadow: swatch === color ? '0 0 0 2px var(--ui-bg-elevated), 0 0 0 3.5px currentColor' : undefined,
|
||||
color: swatch
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="mt-2 flex w-full items-center justify-center gap-1.5 rounded-md py-1 text-xs text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
onClick={() => pickColor(null)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="sync" size="0.75rem" />
|
||||
Auto
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -25,6 +26,7 @@ interface SessionActions {
|
||||
sessionId: string
|
||||
title: string
|
||||
pinned?: boolean
|
||||
profile?: string
|
||||
onPin?: () => void
|
||||
onArchive?: () => void
|
||||
onDelete?: () => void
|
||||
@@ -41,14 +43,16 @@ interface ItemSpec {
|
||||
variant?: 'destructive'
|
||||
}
|
||||
|
||||
function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive, onDelete }: SessionActions) {
|
||||
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?.()
|
||||
@@ -57,17 +61,17 @@ function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive,
|
||||
{
|
||||
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 })
|
||||
@@ -76,7 +80,7 @@ function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive,
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'edit',
|
||||
label: 'Rename',
|
||||
label: r.rename,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
setRenameOpen(true)
|
||||
@@ -85,7 +89,7 @@ function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive,
|
||||
{
|
||||
disabled: !onArchive,
|
||||
icon: 'archive',
|
||||
label: 'Archive',
|
||||
label: r.archive,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onArchive?.()
|
||||
@@ -95,7 +99,7 @@ function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive,
|
||||
className: 'text-destructive focus:text-destructive',
|
||||
disabled: !onDelete,
|
||||
icon: 'trash',
|
||||
label: 'Delete',
|
||||
label: t.common.delete,
|
||||
onSelect: () => {
|
||||
triggerHaptic('warning')
|
||||
onDelete?.()
|
||||
@@ -113,7 +117,13 @@ function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive,
|
||||
))
|
||||
|
||||
const renameDialog = (
|
||||
<RenameSessionDialog currentTitle={title} onOpenChange={setRenameOpen} open={renameOpen} sessionId={sessionId} />
|
||||
<RenameSessionDialog
|
||||
currentTitle={title}
|
||||
onOpenChange={setRenameOpen}
|
||||
open={renameOpen}
|
||||
profile={profile}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
)
|
||||
|
||||
return { renameDialog, renderItems }
|
||||
@@ -125,6 +135,7 @@ interface SessionActionsMenuProps
|
||||
}
|
||||
|
||||
export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const { renameDialog, renderItems } = useSessionActions(actions)
|
||||
|
||||
return (
|
||||
@@ -133,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}
|
||||
>
|
||||
@@ -150,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>
|
||||
@@ -170,9 +182,12 @@ interface RenameSessionDialogProps {
|
||||
onOpenChange: (open: boolean) => void
|
||||
sessionId: string
|
||||
currentTitle: string
|
||||
profile?: string
|
||||
}
|
||||
|
||||
function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle }: 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)
|
||||
@@ -200,13 +215,13 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle }: Re
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
const result = await renameSession(sessionId, next)
|
||||
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)
|
||||
}
|
||||
@@ -216,8 +231,8 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle }: Re
|
||||
<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
|
||||
@@ -231,16 +246,16 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle }: Re
|
||||
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>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
|
||||
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'
|
||||
@@ -25,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({
|
||||
@@ -60,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
|
||||
@@ -74,6 +78,7 @@ export function SidebarSessionRow({
|
||||
onDelete={onDelete}
|
||||
onPin={onPin}
|
||||
pinned={isPinned}
|
||||
profile={session.profile}
|
||||
sessionId={session.id}
|
||||
title={title}
|
||||
>
|
||||
@@ -86,6 +91,22 @@ export function SidebarSessionRow({
|
||||
className
|
||||
)}
|
||||
data-working={isWorking ? 'true' : undefined}
|
||||
draggable
|
||||
onDragStart={event => {
|
||||
// Reorder drags belong to dnd-kit (the grab handle) — cancel the
|
||||
// native drag so the two DnD systems don't fight.
|
||||
if ((event.target as HTMLElement).closest('[data-reorder-handle]')) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
writeSessionDrag(event.dataTransfer, {
|
||||
id: session.id,
|
||||
profile: session.profile || 'default',
|
||||
title
|
||||
})
|
||||
}}
|
||||
ref={ref}
|
||||
style={style}
|
||||
{...rest}
|
||||
@@ -123,12 +144,15 @@ export function SidebarSessionRow({
|
||||
className={cn(
|
||||
// Scope the dot↔grabber swap to a local group so the grabber
|
||||
// only reveals when hovering/focusing the handle itself, not
|
||||
// anywhere on the row.
|
||||
'group/handle relative -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing',
|
||||
// anywhere on the row. Width MUST match the non-reorderable dot
|
||||
// column (w-3.5) so rows don't shift horizontally when reorder is
|
||||
// toggled (e.g. scoped → ALL-profiles view).
|
||||
'group/handle relative -my-0.5 grid w-3.5 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing',
|
||||
// The quest-glow box-shadow extends past the dot; let it bleed
|
||||
// out instead of being clipped by this handle's overflow-hidden.
|
||||
needsInput && 'overflow-visible'
|
||||
)}
|
||||
data-reorder-handle
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<SidebarRowDot
|
||||
@@ -152,10 +176,10 @@ export function SidebarSessionRow({
|
||||
needsInput ? 'overflow-visible' : 'overflow-hidden'
|
||||
)}
|
||||
>
|
||||
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
|
||||
</span>
|
||||
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
|
||||
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
@@ -170,14 +194,15 @@ export function SidebarSessionRow({
|
||||
onDelete={onDelete}
|
||||
onPin={onPin}
|
||||
pinned={isPinned}
|
||||
profile={session.profile}
|
||||
sessionId={session.id}
|
||||
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" />
|
||||
@@ -198,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
|
||||
@@ -205,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}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 {
|
||||
@@ -13,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 {
|
||||
@@ -26,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)
|
||||
@@ -154,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 {
|
||||
@@ -242,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 {
|
||||
@@ -300,13 +259,17 @@ 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' })
|
||||
@@ -314,13 +277,17 @@ export function CronView({ onClose }: CronViewProps) {
|
||||
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,9 +331,9 @@ 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)
|
||||
}
|
||||
@@ -382,10 +349,10 @@ export function CronView({ onClose }: CronViewProps) {
|
||||
try {
|
||||
await deleteCronJob(pendingDelete.id)
|
||||
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
|
||||
notify({ kind: 'success', title: 'Cron deleted', message: truncate(jobTitle(pendingDelete), 60) })
|
||||
notify({ kind: 'success', title: c.deleted, message: truncate(jobTitle(pendingDelete), 60) })
|
||||
setPendingDelete(null)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to delete cron job')
|
||||
notifyError(err, c.failedDelete)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
@@ -401,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,
|
||||
@@ -411,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)}
|
||||
@@ -477,39 +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} />
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete cron job?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pendingDelete ? (
|
||||
<>
|
||||
This will remove{' '}
|
||||
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span>{' '}
|
||||
permanently. It will stop firing immediately.
|
||||
</>
|
||||
) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<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,
|
||||
@@ -517,6 +491,7 @@ function CronJobRow({
|
||||
onTrigger
|
||||
}: {
|
||||
busy: boolean
|
||||
c: Translations['cron']
|
||||
job: CronJob
|
||||
onDelete: () => void
|
||||
onEdit: () => void
|
||||
@@ -532,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>}
|
||||
@@ -553,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">
|
||||
@@ -585,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,
|
||||
@@ -621,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
|
||||
@@ -663,7 +650,7 @@ function CronEditorDialog({
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleHint = scheduleSummary(selectedScheduleOption, schedule)
|
||||
const scheduleHint = scheduleSummary(selectedScheduleOption, schedule, c)
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
@@ -671,7 +658,7 @@ function CronEditorDialog({
|
||||
const trimmedSchedule = schedule.trim()
|
||||
|
||||
if (!trimmedPrompt || !trimmedSchedule) {
|
||||
setError('Prompt and schedule are required.')
|
||||
setError(c.promptScheduleRequired)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -687,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)
|
||||
}
|
||||
@@ -697,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>
|
||||
@@ -759,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">
|
||||
@@ -787,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>
|
||||
@@ -803,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>
|
||||
@@ -836,7 +821,5 @@ interface EditorValues {
|
||||
|
||||
interface ScheduleOption {
|
||||
expr?: string
|
||||
hint: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Pane, PaneMain } from '@/components/pane-shell'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||
import { getSessionMessages, listSessions } from '../hermes'
|
||||
import { getSessionMessages, listAllProfileSessions, type SessionInfo } from '../hermes'
|
||||
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
|
||||
import { toggleCommandPalette } from '../store/command-palette'
|
||||
import {
|
||||
@@ -25,9 +25,11 @@ import {
|
||||
pinSession,
|
||||
SIDEBAR_DEFAULT_WIDTH,
|
||||
SIDEBAR_MAX_WIDTH,
|
||||
SIDEBAR_SESSIONS_PAGE_SIZE,
|
||||
unpinSession
|
||||
} from '../store/layout'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
|
||||
import { $activeGatewayProfile, $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentCwd,
|
||||
@@ -45,6 +47,7 @@ import {
|
||||
setCurrentModel,
|
||||
setCurrentProvider,
|
||||
setMessages,
|
||||
setSessionProfileTotals,
|
||||
setSessions,
|
||||
setSessionsLoading,
|
||||
setSessionsTotal
|
||||
@@ -98,6 +101,26 @@ const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).P
|
||||
const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView }))
|
||||
const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView }))
|
||||
|
||||
// Rows a session refresh must preserve even if the aggregator omits them:
|
||||
// in-flight first turns (message_count 0), pinned rows aged off the page, and
|
||||
// the actively-viewed chat (its "working" flag clears a beat before the
|
||||
// aggregator sees the persisted row). Pass `scope` to only keep the active row
|
||||
// when it belongs to the profile being paged.
|
||||
function sessionsToKeep(scope?: string): Set<string> {
|
||||
const keep = new Set<string>([...$workingSessionIds.get(), ...$pinnedSessionIds.get()])
|
||||
const active = $selectedStoredSessionId.get()
|
||||
|
||||
if (active) {
|
||||
const session = scope ? $sessions.get().find(s => s.id === active) : null
|
||||
|
||||
if (!scope || !session || normalizeProfileKey(session.profile) === scope) {
|
||||
keep.add(active)
|
||||
}
|
||||
}
|
||||
|
||||
return keep
|
||||
}
|
||||
|
||||
export function DesktopController() {
|
||||
const queryClient = useQueryClient()
|
||||
const location = useLocation()
|
||||
@@ -201,9 +224,9 @@ export function DesktopController() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Global chrome shortcuts (plain Cmd/Ctrl, no alt/shift): Cmd+K → command
|
||||
// palette (the composer's "drain next queued" moved to Cmd+Shift+K), Cmd+. →
|
||||
// command center (sessions / system / usage).
|
||||
// Global chrome shortcuts (plain Cmd/Ctrl, no alt/shift): Cmd+K / Cmd+P →
|
||||
// command palette (the composer's "drain next queued" moved to Cmd+Shift+K),
|
||||
// Cmd+. → command center (sessions / system / usage).
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) {
|
||||
@@ -212,7 +235,7 @@ export function DesktopController() {
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
|
||||
if (key === 'k') {
|
||||
if (key === 'k' || key === 'p') {
|
||||
event.preventDefault()
|
||||
toggleCommandPalette()
|
||||
} else if (key === '.') {
|
||||
@@ -236,17 +259,15 @@ export function DesktopController() {
|
||||
// Require at least one message so abandoned/empty "Untitled" drafts (one
|
||||
// was created per TUI/desktop launch before the lazy-create fix) don't
|
||||
// clutter the sidebar.
|
||||
const result = await listSessions(limit, 1)
|
||||
// Unified cross-profile list (served read-only off each profile's
|
||||
// state.db; no per-profile backend is spawned). Single-profile users get
|
||||
// the same rows tagged profile="default".
|
||||
const result = await listAllProfileSessions(limit, 1)
|
||||
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
// Don't hard-replace. Two kinds of rows must survive a refresh the
|
||||
// server didn't return: (1) sessions whose first turn is still in
|
||||
// flight (message_count 0, so min_messages=1 omits them) and (2)
|
||||
// pinned sessions that have aged off the most-recent page — otherwise
|
||||
// the pin "disappears until you refresh". mergeSessionPage keeps both.
|
||||
const keepIds = new Set<string>([...$workingSessionIds.get(), ...$pinnedSessionIds.get()])
|
||||
setSessions(prev => mergeSessionPage(prev, result.sessions, keepIds))
|
||||
setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep()))
|
||||
setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
|
||||
setSessionProfileTotals(result.profile_totals ?? {})
|
||||
}
|
||||
} finally {
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
@@ -260,6 +281,21 @@ export function DesktopController() {
|
||||
void refreshSessions()
|
||||
}, [refreshSessions])
|
||||
|
||||
// ALL-profiles view pages one profile at a time: fetch that profile's next
|
||||
// page and merge it in place, leaving every other profile's rows untouched.
|
||||
const loadMoreSessionsForProfile = useCallback(async (profile: string) => {
|
||||
const key = normalizeProfileKey(profile)
|
||||
const inKey = (s: SessionInfo) => normalizeProfileKey(s.profile) === key
|
||||
const loaded = $sessions.get().filter(inKey).length
|
||||
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key)
|
||||
const keep = sessionsToKeep(key)
|
||||
|
||||
setSessions(prev => [...prev.filter(s => !inKey(s)), ...mergeSessionPage(prev.filter(inKey), result.sessions, keep)])
|
||||
|
||||
const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length
|
||||
setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) }))
|
||||
}, [])
|
||||
|
||||
const toggleSelectedPin = useCallback(() => {
|
||||
const sessionId = $selectedStoredSessionId.get()
|
||||
|
||||
@@ -349,9 +385,11 @@ export function DesktopController() {
|
||||
return
|
||||
}
|
||||
|
||||
const storedProfile = $sessions.get().find(session => session.id === storedSessionId)?.profile
|
||||
|
||||
for (let index = 0; index < Math.max(1, attempts); index += 1) {
|
||||
try {
|
||||
const latest = await getSessionMessages(storedSessionId)
|
||||
const latest = await getSessionMessages(storedSessionId, storedProfile)
|
||||
updateSessionState(
|
||||
runtimeSessionId,
|
||||
state => ({
|
||||
@@ -454,6 +492,39 @@ export function DesktopController() {
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [startFreshSessionDraft])
|
||||
|
||||
// A profile switch/create drops to a fresh new-session draft so the previously
|
||||
// open session doesn't bleed across contexts. Skip the initial value.
|
||||
const freshSessionRequest = useStore($freshSessionRequest)
|
||||
const lastFreshRef = useRef(freshSessionRequest)
|
||||
|
||||
useEffect(() => {
|
||||
if (freshSessionRequest === lastFreshRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastFreshRef.current = freshSessionRequest
|
||||
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,
|
||||
@@ -529,6 +600,7 @@ export function DesktopController() {
|
||||
useEffect(() => {
|
||||
if (gatewayState === 'open') {
|
||||
void refreshCurrentModel()
|
||||
void refreshActiveProfile()
|
||||
void refreshSessions().catch(() => undefined)
|
||||
}
|
||||
}, [gatewayState, refreshCurrentModel, refreshSessions])
|
||||
@@ -571,6 +643,7 @@ export function DesktopController() {
|
||||
currentView={currentView}
|
||||
onArchiveSession={sessionId => void archiveSession(sessionId)}
|
||||
onDeleteSession={sessionId => void removeSession(sessionId)}
|
||||
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
|
||||
onLoadMoreSessions={loadMoreSessions}
|
||||
onNavigate={selectSidebarItem}
|
||||
onNewSessionInWorkspace={startSessionInWorkspace}
|
||||
@@ -627,6 +700,7 @@ export function DesktopController() {
|
||||
initialSection={commandCenterInitialSection}
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onDeleteSession={removeSession}
|
||||
onNavigateRoute={path => navigate(path)}
|
||||
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -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,
|
||||
@@ -10,9 +11,27 @@ import {
|
||||
failDesktopBoot,
|
||||
setDesktopBootStep
|
||||
} from '@/store/boot'
|
||||
import { setGateway } from '@/store/gateway'
|
||||
import {
|
||||
$gateway,
|
||||
closeSecondaryGateways,
|
||||
configureGatewayRegistry,
|
||||
ensureGatewayForProfile,
|
||||
pruneSecondaryGateways,
|
||||
reconnectSecondaryGateways,
|
||||
reportPrimaryGatewayState,
|
||||
setPrimaryGateway,
|
||||
touchSecondaryGateways
|
||||
} from '@/store/gateway'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $connection, setConnection, setGatewayState, setSessionsLoading } from '@/store/session'
|
||||
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
|
||||
import {
|
||||
$attentionSessionIds,
|
||||
$connection,
|
||||
$sessions,
|
||||
$workingSessionIds,
|
||||
setConnection,
|
||||
setSessionsLoading
|
||||
} from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
interface GatewayBootOptions {
|
||||
@@ -76,6 +95,10 @@ export function useGatewayBoot({
|
||||
let reconnecting = false
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let reconnectAttempt = 0
|
||||
// Surface "sign in again" once per disconnect episode, not on every backoff
|
||||
// tick — a stale OAuth ticket fails every attempt and would otherwise stack
|
||||
// identical error toasts (and their haptics). Reset on the next clean open.
|
||||
let reauthNotified = false
|
||||
|
||||
// Wrap the live getter in a call so TS control-flow analysis doesn't narrow
|
||||
// `connectionState` to a constant across the early-return guards (the state
|
||||
@@ -97,7 +120,7 @@ export function useGatewayBoot({
|
||||
reconnecting = true
|
||||
|
||||
try {
|
||||
const conn = await desktop.getConnection()
|
||||
const conn = await desktop.getConnection($activeGatewayProfile.get())
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
@@ -127,8 +150,9 @@ export function useGatewayBoot({
|
||||
// again" message once instead of silently looping the backoff against a
|
||||
// ticket that can never succeed. Transport failures fall through to the
|
||||
// backoff in the finally block below.
|
||||
if (!cancelled && isGatewayReauthRequired(err)) {
|
||||
notifyError(err, 'Gateway sign-in required')
|
||||
if (!cancelled && isGatewayReauthRequired(err) && !reauthNotified) {
|
||||
reauthNotified = true
|
||||
notifyError(err, translateNow('boot.errors.gatewaySignInRequired'))
|
||||
}
|
||||
} finally {
|
||||
reconnecting = false
|
||||
@@ -160,6 +184,7 @@ export function useGatewayBoot({
|
||||
|
||||
clearReconnectTimer()
|
||||
reconnectAttempt = 0
|
||||
reconnectSecondaryGateways()
|
||||
|
||||
if (!gatewayOpen()) {
|
||||
void attemptReconnect()
|
||||
@@ -180,13 +205,18 @@ export function useGatewayBoot({
|
||||
|
||||
const gateway = new HermesGateway()
|
||||
callbacksRef.current.onGatewayReady(gateway)
|
||||
setGateway(gateway)
|
||||
setPrimaryGateway(gateway, normalizeProfileKey($activeGatewayProfile.get()))
|
||||
// Secondary (background-profile) sockets funnel into the same handler.
|
||||
configureGatewayRegistry({ onEvent: event => callbacksRef.current.handleGatewayEvent(event) })
|
||||
|
||||
const offState = gateway.onState(st => {
|
||||
setGatewayState(st)
|
||||
// Mirror to the composer only while the primary is the active profile —
|
||||
// a background secondary reconnect mustn't flip the foreground state.
|
||||
reportPrimaryGatewayState(st)
|
||||
|
||||
if (st === 'open') {
|
||||
reconnectAttempt = 0
|
||||
reauthNotified = false
|
||||
clearReconnectTimer()
|
||||
} else if (bootCompleted && (st === 'closed' || st === 'error')) {
|
||||
// The socket dropped after a healthy boot (typically sleep/wake). Try
|
||||
@@ -212,6 +242,34 @@ export function useGatewayBoot({
|
||||
window.addEventListener('online', onOnline)
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
|
||||
// Keep live pool backends alive while this window is open (the main process
|
||||
// can't observe the direct renderer↔backend WS). No-op for the primary.
|
||||
const keepaliveTimer = setInterval(() => {
|
||||
touchActiveGatewayBackend()
|
||||
touchSecondaryGateways()
|
||||
}, 60_000)
|
||||
|
||||
// Bound concurrency cost to live work: keep a background socket only while
|
||||
// its profile has a running (working) or blocked (needs-input) session.
|
||||
// Once that profile goes idle its socket is dropped and its backend is free
|
||||
// to idle-reap. The active profile is always spared.
|
||||
const recomputeKeptGateways = () => {
|
||||
const live = new Set([...$workingSessionIds.get(), ...$attentionSessionIds.get()])
|
||||
const keep = new Set<string>()
|
||||
|
||||
for (const session of $sessions.get()) {
|
||||
if (live.has(session.id)) {
|
||||
keep.add(normalizeProfileKey(session.profile))
|
||||
}
|
||||
}
|
||||
|
||||
pruneSecondaryGateways(keep)
|
||||
}
|
||||
|
||||
const offWorking = $workingSessionIds.subscribe(() => recomputeKeptGateways())
|
||||
const offAttention = $attentionSessionIds.subscribe(() => recomputeKeptGateways())
|
||||
const offActiveProfile = $activeGatewayProfile.subscribe(() => recomputeKeptGateways())
|
||||
|
||||
const offWindowState = desktop.onWindowStateChanged?.(payload => {
|
||||
const current = $connection.get()
|
||||
|
||||
@@ -259,6 +317,19 @@ export function useGatewayBoot({
|
||||
return
|
||||
}
|
||||
|
||||
// Record which profile the primary (window) backend booted as, so
|
||||
// same-profile resumes are no-op swaps and any reconnect targets the
|
||||
// right backend. Best-effort: a missing preference means "default".
|
||||
try {
|
||||
const pref = await desktop.profile?.get?.()
|
||||
const profileKey = (pref?.profile ?? '').trim() || 'default'
|
||||
$activeGatewayProfile.set(profileKey)
|
||||
setPrimaryGateway(gateway, profileKey)
|
||||
void ensureGatewayForProfile(profileKey)
|
||||
} catch {
|
||||
$activeGatewayProfile.set('default')
|
||||
}
|
||||
|
||||
setDesktopBootStep({
|
||||
phase: 'renderer.config',
|
||||
message: 'Loading Hermes settings',
|
||||
@@ -293,6 +364,10 @@ export function useGatewayBoot({
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearReconnectTimer()
|
||||
clearInterval(keepaliveTimer)
|
||||
offWorking()
|
||||
offAttention()
|
||||
offActiveProfile()
|
||||
window.removeEventListener('online', onOnline)
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
offPowerResume?.()
|
||||
@@ -301,10 +376,12 @@ export function useGatewayBoot({
|
||||
offExit()
|
||||
offWindowState?.()
|
||||
offBootProgress()
|
||||
closeSecondaryGateways()
|
||||
gateway.close()
|
||||
publish(null)
|
||||
callbacksRef.current.onGatewayReady(null)
|
||||
setGateway(null)
|
||||
setPrimaryGateway(null)
|
||||
$gateway.set(null)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import { $gateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gateway'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import { $gatewayState, setConnection } from '@/store/session'
|
||||
|
||||
export function useGatewayRequest() {
|
||||
@@ -24,6 +26,16 @@ export function useGatewayRequest() {
|
||||
gatewayStateRef.current = gatewayState
|
||||
}, [gatewayState])
|
||||
|
||||
// Track the active gateway (primary or a background profile's socket) so
|
||||
// outbound requests and overlay props always target the focused profile.
|
||||
useEffect(
|
||||
() =>
|
||||
$gateway.subscribe(gateway => {
|
||||
gatewayRef.current = gateway as HermesGateway | null
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const ensureGatewayOpen = useCallback(async () => {
|
||||
const existing = gatewayRef.current
|
||||
|
||||
@@ -49,7 +61,10 @@ export function useGatewayRequest() {
|
||||
reauthErrorRef.current = null
|
||||
|
||||
try {
|
||||
const conn = await desktop.getConnection()
|
||||
// Reconnect to whichever profile the gateway is currently routed to (not
|
||||
// always the primary), so a sleep/wake reconnect keeps the user on the
|
||||
// profile they were chatting in.
|
||||
const conn = await desktop.getConnection($activeGatewayProfile.get())
|
||||
connectionRef.current = conn
|
||||
setConnection(conn)
|
||||
// Re-mint the WS URL before reconnecting. OAuth tickets are single-use
|
||||
@@ -95,7 +110,10 @@ export function useGatewayRequest() {
|
||||
throw error
|
||||
}
|
||||
|
||||
const recovered = await ensureGatewayOpen()
|
||||
// Primary keeps the OAuth-aware reconnect (remote gateways re-mint a
|
||||
// single-use ticket); background profiles are always local pool
|
||||
// backends, so the registry handles their reconnect with no reauth.
|
||||
const recovered = isActivePrimary() ? await ensureGatewayOpen() : await ensureActiveGatewayOpen()
|
||||
|
||||
if (!recovered) {
|
||||
// Prefer the reauth error from the failed reconnect (OAuth session
|
||||
|
||||
@@ -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,15 +13,16 @@ 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'
|
||||
|
||||
import { CREDENTIAL_CONTROL_CLASS } from '../settings/credential-key-ui'
|
||||
import { ListRow } from '../settings/primitives'
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import { CREDENTIAL_CONTROL_CLASS } from '../settings/credential-key-ui'
|
||||
import { ListRow } from '../settings/primitives'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
import { PlatformAvatar } from './platform-icon'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
37
apps/desktop/src/app/overlays/overlay-search-input.tsx
Normal file
37
apps/desktop/src/app/overlays/overlay-search-input.tsx
Normal file
@@ -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>
|
||||
|
||||
158
apps/desktop/src/app/profiles/create-profile-dialog.tsx
Normal file
158
apps/desktop/src/app/profiles/create-profile-dialog.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { ActionStatus } from '@/components/ui/action-status'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { createProfile, updateProfileSoul } from '@/hermes'
|
||||
import { AlertTriangle } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
||||
|
||||
export const PROFILE_NAME_HINT =
|
||||
'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.'
|
||||
|
||||
export function isValidProfileName(name: string): boolean {
|
||||
return PROFILE_NAME_RE.test(name.trim())
|
||||
}
|
||||
|
||||
// Self-contained create flow (name + clone toggle + optional SOUL.md). Owns the
|
||||
// createProfile/updateProfileSoul calls so every caller just refreshes/selects
|
||||
// via onCreated. SOUL left blank keeps the cloned/blank persona untouched.
|
||||
export function CreateProfileDialog({
|
||||
onClose,
|
||||
onCreated,
|
||||
open
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreated?: (name: string) => Promise<void> | void
|
||||
open: boolean
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [cloneFromDefault, setCloneFromDefault] = useState(true)
|
||||
const [soul, setSoul] = useState('')
|
||||
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
setName('')
|
||||
setCloneFromDefault(true)
|
||||
setSoul('')
|
||||
setError(null)
|
||||
setStatus('idle')
|
||||
}, [open])
|
||||
|
||||
const trimmed = name.trim()
|
||||
const invalid = trimmed !== '' && !isValidProfileName(trimmed)
|
||||
const busy = status === 'saving' || status === 'done'
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!trimmed || invalid) {
|
||||
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('saving')
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
|
||||
|
||||
if (soul.trim()) {
|
||||
await updateProfileSoul(trimmed, soul)
|
||||
}
|
||||
|
||||
await onCreated?.(trimmed)
|
||||
setStatus('done')
|
||||
window.setTimeout(onClose, 800)
|
||||
} catch (err) {
|
||||
setStatus('idle')
|
||||
setError(err instanceof Error ? err.message : 'Failed to create profile')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
Profiles are independent Hermes environments: separate config, skills, and SOUL.md.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
aria-invalid={invalid}
|
||||
autoFocus
|
||||
id="new-profile-name"
|
||||
onChange={event => setName(event.target.value)}
|
||||
placeholder="my-profile"
|
||||
value={name}
|
||||
/>
|
||||
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
|
||||
{PROFILE_NAME_HINT}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer select-none items-start gap-2.5 px-0.5 py-1">
|
||||
<Checkbox
|
||||
checked={cloneFromDefault}
|
||||
className="mt-0.5 shrink-0"
|
||||
onCheckedChange={checked => setCloneFromDefault(checked === true)}
|
||||
/>
|
||||
<span className="grid gap-0.5 leading-snug">
|
||||
<span className="text-sm font-medium">Clone from default</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Copy config, skills, and SOUL.md from your default profile.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-soul">
|
||||
SOUL.md <span className="font-normal text-muted-foreground">— optional</span>
|
||||
</label>
|
||||
<Textarea
|
||||
className="min-h-28 font-mono text-xs leading-5"
|
||||
id="new-profile-soul"
|
||||
onChange={event => setSoul(event.target.value)}
|
||||
placeholder={`The system prompt / persona for this profile.\nLeave blank to keep the ${cloneFromDefault ? 'cloned' : 'empty'} default.`}
|
||||
value={soul}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={busy || !trimmed || invalid} type="submit">
|
||||
<ActionStatus busy="Creating…" done="Created" idle="Create profile" state={status} />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
58
apps/desktop/src/app/profiles/delete-profile-dialog.tsx
Normal file
58
apps/desktop/src/app/profiles/delete-profile-dialog.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { deleteProfile } from '@/hermes'
|
||||
import { $activeGatewayProfile, normalizeProfileKey, selectProfile, setActiveProfile } from '@/store/profile'
|
||||
|
||||
// Thin wrapper over ConfirmDialog: owns the deleteProfile call, inherits
|
||||
// Enter-to-confirm + busy/done/error from the shared dialog. The single choke
|
||||
// point for every delete entry point (rail + Profiles view).
|
||||
export function DeleteProfileDialog({
|
||||
profile,
|
||||
onClose,
|
||||
onDeleted,
|
||||
open
|
||||
}: {
|
||||
profile: { name: string; path: string } | null
|
||||
onClose: () => void
|
||||
onDeleted?: () => Promise<void> | void
|
||||
open: boolean
|
||||
}) {
|
||||
return (
|
||||
<ConfirmDialog
|
||||
busyLabel="Deleting…"
|
||||
confirmLabel="Delete"
|
||||
description={
|
||||
profile ? (
|
||||
<>
|
||||
This will delete <span className="font-medium text-foreground">{profile.name}</span> and remove its{' '}
|
||||
<span className="font-mono text-xs">{profile.path}</span> directory. This cannot be undone.
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
destructive
|
||||
doneLabel="Deleted"
|
||||
onClose={onClose}
|
||||
onConfirm={async () => {
|
||||
if (!profile) {
|
||||
return
|
||||
}
|
||||
|
||||
// Deleting the profile the live gateway is on strands it on a dead
|
||||
// backend. Capture that before the delete; reset *after* the host's
|
||||
// onDeleted refresh so our reset is the last write — a refreshActiveProfile
|
||||
// racing the (still-dying) backend can't clobber the pill back to it.
|
||||
const wasActive = normalizeProfileKey(profile.name) === normalizeProfileKey($activeGatewayProfile.get())
|
||||
await deleteProfile(profile.name)
|
||||
await onDeleted?.()
|
||||
|
||||
if (wasActive) {
|
||||
// Swap gateway/sidebar to default and set the pill now — the primary
|
||||
// backend is always default, so this is correct, not just optimistic.
|
||||
selectProfile('default')
|
||||
setActiveProfile('default')
|
||||
}
|
||||
}}
|
||||
open={open}
|
||||
title="Delete profile?"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, 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 {
|
||||
@@ -24,34 +24,47 @@ import {
|
||||
renameProfile,
|
||||
updateProfileSoul
|
||||
} from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { OverlayMain, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
import { titlebarHeaderBaseClass } from '../shell/titlebar'
|
||||
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
|
||||
|
||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
||||
|
||||
const PROFILE_NAME_HINT = 'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.'
|
||||
|
||||
function isValidProfileName(name: string): boolean {
|
||||
return PROFILE_NAME_RE.test(name.trim())
|
||||
}
|
||||
|
||||
interface ProfilesViewProps {
|
||||
interface ProfilesViewProps extends React.ComponentProps<'section'> {
|
||||
onClose: () => void
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}
|
||||
|
||||
export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
export function ProfilesView({
|
||||
onClose,
|
||||
setStatusbarItemGroup: _setStatusbarItemGroup,
|
||||
setTitlebarToolGroup,
|
||||
...props
|
||||
}: ProfilesViewProps) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const [profiles, setProfiles] = useState<null | ProfileInfo[]>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [selectedName, setSelectedName] = useState<null | string>(null)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const { profiles: list } = await getProfiles()
|
||||
setProfiles(list)
|
||||
@@ -63,9 +76,11 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
return list.find(p => p.is_default)?.name ?? list[0]?.name ?? null
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to load profiles')
|
||||
notifyError(err, p.failedLoad)
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
}, [p])
|
||||
|
||||
useRefreshHotkey(refresh)
|
||||
|
||||
@@ -73,6 +88,24 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
useEffect(() => {
|
||||
if (!setTitlebarToolGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
setTitlebarToolGroup('profiles', [
|
||||
{
|
||||
disabled: refreshing,
|
||||
icon: <Codicon name="refresh" spinning={refreshing} />,
|
||||
id: 'refresh-profiles',
|
||||
label: refreshing ? p.refreshing : p.refresh,
|
||||
onSelect: () => void refresh()
|
||||
}
|
||||
])
|
||||
|
||||
return () => setTitlebarToolGroup('profiles', [])
|
||||
}, [p, refresh, refreshing, setTitlebarToolGroup])
|
||||
|
||||
const selected = useMemo(() => {
|
||||
if (!profiles) {
|
||||
return null
|
||||
@@ -86,15 +119,15 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
const trimmed = name.trim()
|
||||
|
||||
if (!isValidProfileName(trimmed)) {
|
||||
throw new Error(PROFILE_NAME_HINT)
|
||||
throw new Error(p.nameHint)
|
||||
}
|
||||
|
||||
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
|
||||
notify({ kind: 'success', title: 'Profile created', message: trimmed })
|
||||
notify({ kind: 'success', title: p.created, message: trimmed })
|
||||
setSelectedName(trimmed)
|
||||
await refresh()
|
||||
},
|
||||
[refresh]
|
||||
[p, refresh]
|
||||
)
|
||||
|
||||
const handleRename = useCallback(
|
||||
@@ -106,15 +139,15 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
}
|
||||
|
||||
if (!isValidProfileName(target)) {
|
||||
throw new Error(PROFILE_NAME_HINT)
|
||||
throw new Error(p.nameHint)
|
||||
}
|
||||
|
||||
await renameProfile(from, target)
|
||||
notify({ kind: 'success', title: 'Profile renamed', message: `${from} → ${target}` })
|
||||
notify({ kind: 'success', title: p.renamed, message: `${from} → ${target}` })
|
||||
setSelectedName(target)
|
||||
await refresh()
|
||||
},
|
||||
[refresh]
|
||||
[p, refresh]
|
||||
)
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
@@ -126,121 +159,133 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
|
||||
try {
|
||||
await deleteProfile(pendingDelete.name)
|
||||
notify({ kind: 'success', title: 'Profile deleted', message: pendingDelete.name })
|
||||
notify({ kind: 'success', title: p.deleted, message: pendingDelete.name })
|
||||
setPendingDelete(null)
|
||||
setSelectedName(null)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to delete profile')
|
||||
notifyError(err, p.failedDelete)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}, [pendingDelete, refresh])
|
||||
}, [p, pendingDelete, refresh])
|
||||
|
||||
return (
|
||||
<OverlayView closeLabel="Close profiles" onClose={onClose}>
|
||||
{!profiles ? (
|
||||
<PageLoader label="Loading profiles..." />
|
||||
) : (
|
||||
<OverlaySplitLayout>
|
||||
<OverlaySidebar>
|
||||
<div className="mb-1 flex items-center justify-between gap-2 pl-1.5 pr-0.5">
|
||||
<span className="text-[0.7rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)">
|
||||
Profiles
|
||||
</span>
|
||||
<Button
|
||||
aria-label="New profile"
|
||||
className="text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="0.875rem" />
|
||||
</Button>
|
||||
</div>
|
||||
{profiles.map(profile => (
|
||||
<ProfileRow
|
||||
active={selected?.name === profile.name}
|
||||
key={profile.name}
|
||||
onSelect={() => setSelectedName(profile.name)}
|
||||
profile={profile}
|
||||
/>
|
||||
))}
|
||||
{profiles.length === 0 && <p className="px-1.5 py-3 text-xs text-muted-foreground">No profiles yet.</p>}
|
||||
</OverlaySidebar>
|
||||
<OverlayView closeLabel={p.close} onClose={onClose}>
|
||||
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background">
|
||||
<header className={titlebarHeaderBaseClass}>
|
||||
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">{p.title}</h2>
|
||||
<span className="pointer-events-auto text-xs text-muted-foreground">
|
||||
{profiles ? p.count(profiles.length) : ''}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<OverlayMain className="px-0">
|
||||
{selected ? (
|
||||
<ProfileDetail
|
||||
key={selected.name}
|
||||
onDelete={() => setPendingDelete(selected)}
|
||||
onRename={newName => handleRename(selected.name, newName)}
|
||||
profile={selected}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
|
||||
<div>
|
||||
<Users className="mx-auto size-6 text-muted-foreground/60" />
|
||||
<p className="mt-3">Select a profile to view its details.</p>
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85">
|
||||
{!profiles ? (
|
||||
<PageLoader label={p.loading} />
|
||||
) : (
|
||||
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[16rem_minmax(0,1fr)]">
|
||||
<aside className="flex min-h-0 flex-col overflow-hidden border-b border-border/50 lg:border-b-0 lg:border-r">
|
||||
<div className="border-b border-border/40 p-2">
|
||||
<Button className="w-full" onClick={() => setCreateOpen(true)} size="sm">
|
||||
<Codicon name="add" />
|
||||
{p.newProfile}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
)}
|
||||
<ul className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
|
||||
{profiles.map(profile => (
|
||||
<li key={profile.name}>
|
||||
<ProfileRow
|
||||
active={selected?.name === profile.name}
|
||||
onSelect={() => setSelectedName(profile.name)}
|
||||
profile={profile}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{profiles.length === 0 && (
|
||||
<li className="px-2 py-4 text-center text-xs text-muted-foreground">{p.noProfiles}</li>
|
||||
)}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<CreateProfileDialog
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)}
|
||||
open={createOpen}
|
||||
/>
|
||||
<main className="min-h-0 overflow-hidden">
|
||||
{selected ? (
|
||||
<ProfileDetail
|
||||
key={selected.name}
|
||||
onDelete={() => setPendingDelete(selected)}
|
||||
onRename={newName => handleRename(selected.name, newName)}
|
||||
profile={selected}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
|
||||
<div>
|
||||
<Users className="mx-auto size-6 text-muted-foreground/60" />
|
||||
<p className="mt-3">{p.selectPrompt}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete profile?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pendingDelete ? (
|
||||
<>
|
||||
This will delete <span className="font-medium text-foreground">{pendingDelete.name}</span> and remove
|
||||
its <span className="font-mono text-xs">{pendingDelete.path}</span> directory. This cannot be undone.
|
||||
</>
|
||||
) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CreateProfileDialog
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)}
|
||||
open={createOpen}
|
||||
/>
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{p.deleteTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pendingDelete ? (
|
||||
<>
|
||||
{p.deleteDescPrefix}
|
||||
<span className="font-medium text-foreground">{pendingDelete.name}</span>
|
||||
{p.deleteDescMid}
|
||||
<span className="font-mono text-xs">{pendingDelete.path}</span>
|
||||
{p.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 ? p.deleting : t.common.delete}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileRow({ active, onSelect, profile }: { active: boolean; onSelect: () => void; profile: ProfileInfo }) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors',
|
||||
active
|
||||
? 'bg-(--ui-row-active-background) text-foreground'
|
||||
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground'
|
||||
'flex w-full flex-col items-start gap-1 rounded-lg px-2.5 py-2 text-left transition-colors',
|
||||
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex w-full items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium">{profile.name}</span>
|
||||
{profile.is_default && <span className="text-[0.6rem] text-primary">default</span>}
|
||||
{profile.is_default && <span className="text-[0.6rem] text-primary">{p.default}</span>}
|
||||
</span>
|
||||
<span className="text-[0.66rem] text-muted-foreground">
|
||||
{profile.skill_count} {profile.skill_count === 1 ? 'skill' : 'skills'}
|
||||
{profile.has_env ? ' · env' : ''}
|
||||
{p.skills(profile.skill_count)}
|
||||
{profile.has_env ? ` · ${p.env}` : ''}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
@@ -255,6 +300,8 @@ function ProfileDetail({
|
||||
onRename: (newName: string) => Promise<void>
|
||||
profile: ProfileInfo
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
const [copying, setCopying] = useState(false)
|
||||
|
||||
@@ -264,13 +311,13 @@ function ProfileDetail({
|
||||
try {
|
||||
const { command } = await getProfileSetupCommand(profile.name)
|
||||
await navigator.clipboard.writeText(command)
|
||||
notify({ kind: 'success', title: 'Setup command copied', message: command })
|
||||
notify({ kind: 'success', title: p.setupCopied, message: command })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to copy setup command')
|
||||
notifyError(err, p.failedCopy)
|
||||
} finally {
|
||||
setCopying(false)
|
||||
}
|
||||
}, [profile.name])
|
||||
}, [p, profile.name])
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
@@ -281,50 +328,58 @@ function ProfileDetail({
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-xl font-semibold tracking-tight">{profile.name}</h3>
|
||||
{profile.is_default && <Badge>Default</Badge>}
|
||||
{profile.has_env && <Badge variant="muted">.env</Badge>}
|
||||
{profile.is_default && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[0.65rem] font-medium text-primary">
|
||||
{p.defaultBadge}
|
||||
</span>
|
||||
)}
|
||||
{profile.has_env && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[0.65rem] font-medium text-muted-foreground">
|
||||
.env
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-[0.7rem] text-muted-foreground" title={profile.path}>
|
||||
{profile.path}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{!profile.is_default && (
|
||||
<Button onClick={() => setRenameOpen(true)} size="sm" variant="text">
|
||||
<Button onClick={() => setRenameOpen(true)} size="sm" variant="outline">
|
||||
<Pencil />
|
||||
Rename
|
||||
{p.rename}
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={copying} onClick={() => void handleCopySetup()} size="sm" variant="text">
|
||||
<Button disabled={copying} onClick={() => void handleCopySetup()} size="sm" variant="outline">
|
||||
<Terminal />
|
||||
{copying ? 'Copying...' : 'Copy setup'}
|
||||
{copying ? p.copying : p.copySetup}
|
||||
</Button>
|
||||
{!profile.is_default && (
|
||||
<Button
|
||||
className="hover:text-destructive hover:no-underline"
|
||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
size="sm"
|
||||
variant="text"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
{t.common.delete}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="grid gap-2 text-xs sm:grid-cols-2">
|
||||
<DetailRow label="Model">
|
||||
<dl className="grid gap-2 rounded-lg border border-border/40 bg-background/70 px-3 py-3 text-xs sm:grid-cols-2">
|
||||
<DetailRow label={p.modelLabel}>
|
||||
{profile.model ? (
|
||||
<>
|
||||
<span className="font-mono">{profile.model}</span>
|
||||
{profile.provider && <span className="text-muted-foreground"> · {profile.provider}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Not set</span>
|
||||
<span className="text-muted-foreground">{p.notSet}</span>
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Skills">{profile.skill_count}</DetailRow>
|
||||
<DetailRow label={p.skillsLabel}>{profile.skill_count}</DetailRow>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
@@ -349,12 +404,14 @@ function DetailRow({ children, label }: { children: React.ReactNode; label: stri
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<dt className="text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">{label}</dt>
|
||||
<dd className="text-xs text-foreground">{children}</dd>
|
||||
<dd className="text-sm text-foreground">{children}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SoulEditor({ profileName }: { profileName: string }) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const [content, setContent] = useState('')
|
||||
const [original, setOriginal] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -379,7 +436,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestRef.current === profileName) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load SOUL.md')
|
||||
setError(err instanceof Error ? err.message : p.failedLoadSoul)
|
||||
}
|
||||
} finally {
|
||||
if (requestRef.current === profileName) {
|
||||
@@ -387,7 +444,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, [profileName])
|
||||
}, [p, profileName])
|
||||
|
||||
const dirty = content !== original
|
||||
const isEmpty = !content.trim()
|
||||
@@ -399,9 +456,9 @@ function SoulEditor({ profileName }: { profileName: string }) {
|
||||
try {
|
||||
await updateProfileSoul(profileName, content)
|
||||
setOriginal(content)
|
||||
notify({ kind: 'success', title: 'SOUL.md saved', message: profileName })
|
||||
notify({ kind: 'success', title: p.soulSaved, message: profileName })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save SOUL.md')
|
||||
setError(err instanceof Error ? err.message : p.failedSaveSoul)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -412,20 +469,20 @@ function SoulEditor({ profileName }: { profileName: string }) {
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<h4 className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground">SOUL.md</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The system prompt and persona instructions baked into this profile.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{p.soulDesc}</p>
|
||||
</div>
|
||||
{dirty && <span className="text-[0.65rem] text-muted-foreground">Unsaved changes</span>}
|
||||
{dirty && <span className="text-[0.65rem] text-muted-foreground">{p.unsavedChanges}</span>}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<PageLoader className="min-h-44" label="Loading SOUL.md" />
|
||||
<div className="grid h-44 place-items-center rounded-md border border-border/40 bg-background/60 text-xs text-muted-foreground">
|
||||
{p.loadingSoul}
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
className="min-h-72 font-mono text-xs leading-5"
|
||||
onChange={event => setContent(event.target.value)}
|
||||
placeholder={isEmpty ? 'Empty SOUL.md — start writing the persona...' : undefined}
|
||||
placeholder={isEmpty ? p.emptySoul : undefined}
|
||||
value={content}
|
||||
/>
|
||||
)}
|
||||
@@ -440,7 +497,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
|
||||
<div className="flex justify-end">
|
||||
<Button disabled={!dirty || saving || loading} onClick={() => void handleSave()} size="sm">
|
||||
<Save />
|
||||
{saving ? 'Saving...' : 'Save SOUL.md'}
|
||||
{saving ? p.saving : p.saveSoul}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -456,6 +513,8 @@ function CreateProfileDialog({
|
||||
onCreate: (name: string, cloneFromDefault: boolean) => Promise<void>
|
||||
open: boolean
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const [name, setName] = useState('')
|
||||
const [cloneFromDefault, setCloneFromDefault] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -479,7 +538,7 @@ function CreateProfileDialog({
|
||||
event.preventDefault()
|
||||
|
||||
if (!trimmed || invalid) {
|
||||
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
|
||||
setError(invalid ? p.invalidName(p.nameHint) : p.nameRequired)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -491,7 +550,7 @@ function CreateProfileDialog({
|
||||
await onCreate(trimmed, cloneFromDefault)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create profile')
|
||||
setError(err instanceof Error ? err.message : p.failedCreate)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -501,16 +560,14 @@ function CreateProfileDialog({
|
||||
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
Profiles are independent Hermes environments: separate config, skills, and SOUL.md.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{p.newProfile}</DialogTitle>
|
||||
<DialogDescription>{p.createDesc}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-name">
|
||||
Name
|
||||
{p.nameLabel}
|
||||
</label>
|
||||
<Input
|
||||
aria-invalid={invalid}
|
||||
@@ -521,7 +578,7 @@ function CreateProfileDialog({
|
||||
value={name}
|
||||
/>
|
||||
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
|
||||
{PROFILE_NAME_HINT}
|
||||
{p.nameHint}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -533,10 +590,8 @@ function CreateProfileDialog({
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">Clone from default</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
Copy config, skills, and SOUL.md from your default profile.
|
||||
</span>
|
||||
<span className="font-medium">{p.cloneFromDefault}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{p.cloneFromDefaultDesc}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -549,10 +604,10 @@ function CreateProfileDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
|
||||
Cancel
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={saving || !trimmed || invalid} type="submit">
|
||||
{saving ? 'Creating...' : 'Create profile'}
|
||||
{saving ? p.creating : p.createAction}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -572,6 +627,8 @@ function RenameProfileDialog({
|
||||
onRename: (newName: string) => Promise<void>
|
||||
open: boolean
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const [name, setName] = useState(currentName)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
@@ -600,7 +657,7 @@ function RenameProfileDialog({
|
||||
}
|
||||
|
||||
if (!trimmed || invalid) {
|
||||
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
|
||||
setError(invalid ? p.invalidName(p.nameHint) : p.nameRequired)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -611,7 +668,7 @@ function RenameProfileDialog({
|
||||
try {
|
||||
await onRename(trimmed)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to rename profile')
|
||||
setError(err instanceof Error ? err.message : p.failedRename)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -621,17 +678,18 @@ function RenameProfileDialog({
|
||||
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename profile</DialogTitle>
|
||||
<DialogTitle>{p.renameTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Renaming updates the profile directory and any wrapper scripts in{' '}
|
||||
<span className="font-mono">~/.local/bin</span>.
|
||||
{p.renameDescPrefix}
|
||||
<span className="font-mono">~/.local/bin</span>
|
||||
{p.renameDescSuffix}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="grid gap-3" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="rename-profile-name">
|
||||
New name
|
||||
{p.newNameLabel}
|
||||
</label>
|
||||
<Input
|
||||
aria-invalid={invalid}
|
||||
@@ -641,7 +699,7 @@ function RenameProfileDialog({
|
||||
value={name}
|
||||
/>
|
||||
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
|
||||
{PROFILE_NAME_HINT}
|
||||
{p.nameHint}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -654,10 +712,10 @@ function RenameProfileDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
|
||||
Cancel
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={saving || invalid || unchanged} type="submit">
|
||||
{saving ? 'Renaming...' : 'Rename'}
|
||||
{saving ? p.renaming : p.rename}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
121
apps/desktop/src/app/profiles/rename-profile-dialog.tsx
Normal file
121
apps/desktop/src/app/profiles/rename-profile-dialog.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { ActionStatus } from '@/components/ui/action-status'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { renameProfile } from '@/hermes'
|
||||
import { AlertTriangle } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { isValidProfileName, PROFILE_NAME_HINT } from './create-profile-dialog'
|
||||
|
||||
// Self-contained rename (owns the renameProfile call) so every caller just
|
||||
// reacts via onRenamed. Unchanged name is a no-op close.
|
||||
export function RenameProfileDialog({
|
||||
currentName,
|
||||
onClose,
|
||||
onRenamed,
|
||||
open
|
||||
}: {
|
||||
currentName: string
|
||||
onClose: () => void
|
||||
onRenamed?: (name: string) => Promise<void> | void
|
||||
open: boolean
|
||||
}) {
|
||||
const [name, setName] = useState(currentName)
|
||||
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
setName(currentName)
|
||||
setError(null)
|
||||
setStatus('idle')
|
||||
}, [currentName, open])
|
||||
|
||||
const trimmed = name.trim()
|
||||
const unchanged = trimmed === currentName
|
||||
const invalid = trimmed !== '' && !unchanged && !isValidProfileName(trimmed)
|
||||
const busy = status === 'saving' || status === 'done'
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
if (unchanged) {
|
||||
onClose()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!trimmed || invalid) {
|
||||
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('saving')
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await renameProfile(currentName, trimmed)
|
||||
await onRenamed?.(trimmed)
|
||||
setStatus('done')
|
||||
window.setTimeout(onClose, 800)
|
||||
} catch (err) {
|
||||
setStatus('idle')
|
||||
setError(err instanceof Error ? err.message : 'Failed to rename profile')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
Renaming updates the profile directory and any wrapper scripts in{' '}
|
||||
<span className="font-mono">~/.local/bin</span>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="grid gap-3" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="rename-profile-name">
|
||||
New name
|
||||
</label>
|
||||
<Input
|
||||
aria-invalid={invalid}
|
||||
autoFocus
|
||||
id="rename-profile-name"
|
||||
onChange={event => setName(event.target.value)}
|
||||
value={name}
|
||||
/>
|
||||
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
|
||||
{PROFILE_NAME_HINT}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={busy || invalid || unchanged} type="submit">
|
||||
<ActionStatus busy="Renaming…" done="Renamed" idle="Rename" state={status} />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ErrorBoundary } from '@/components/error-boundary'
|
||||
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 { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $panesFlipped } from '@/store/layout'
|
||||
@@ -148,21 +149,21 @@ function RightSidebarChrome({
|
||||
<div className="flex items-center gap-2 px-2.5 py-1">
|
||||
<nav aria-label="Right sidebar panels" className="flex min-w-0 items-center gap-1">
|
||||
{tabs.map(tab => (
|
||||
<Button
|
||||
aria-label={tab.label}
|
||||
aria-pressed={tab.id === activeTab}
|
||||
className={cn(
|
||||
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
|
||||
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
|
||||
)}
|
||||
key={tab.id}
|
||||
onClick={() => setRightSidebarTab(tab.id)}
|
||||
size="icon-xs"
|
||||
title={tab.label}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={tab.icon} size="0.875rem" />
|
||||
</Button>
|
||||
<Tip key={tab.id} label={tab.label}>
|
||||
<Button
|
||||
aria-label={tab.label}
|
||||
aria-pressed={tab.id === activeTab}
|
||||
className={cn(
|
||||
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
|
||||
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
|
||||
)}
|
||||
onClick={() => setRightSidebarTab(tab.id)}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={tab.icon} size="0.875rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -216,21 +217,21 @@ function FilesystemTab({
|
||||
return (
|
||||
<div className="group/project-header flex min-h-0 flex-1 flex-col">
|
||||
<RightSidebarSectionHeader>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => void onChangeFolder()}
|
||||
title={hasCwd ? `${cwd} — click to change folder` : 'Open a folder'}
|
||||
type="button"
|
||||
>
|
||||
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
|
||||
</button>
|
||||
<Tip label={hasCwd ? `${cwd} — click to change folder` : 'Open a folder'}>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => void onChangeFolder()}
|
||||
type="button"
|
||||
>
|
||||
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
|
||||
</button>
|
||||
</Tip>
|
||||
<Button
|
||||
aria-label="Refresh tree"
|
||||
className={HEADER_ACTION_CLASS}
|
||||
disabled={!hasCwd || loading}
|
||||
onClick={onRefresh}
|
||||
size="icon-xs"
|
||||
title="Refresh tree"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
|
||||
@@ -240,7 +241,6 @@ function FilesystemTab({
|
||||
className={HEADER_ACTION_CLASS}
|
||||
onClick={() => void onChangeFolder()}
|
||||
size="icon-xs"
|
||||
title={hasCwd ? 'Open a different folder' : 'Open a folder'}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="folder-opened" size="0.8125rem" />
|
||||
@@ -251,7 +251,6 @@ function FilesystemTab({
|
||||
disabled={!hasCwd || !canCollapse}
|
||||
onClick={onCollapseAll}
|
||||
size="icon-xs"
|
||||
title="Collapse all folders"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="collapse-all" size="0.8125rem" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useStore } from '@nanostores/react'
|
||||
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 { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||
import { $terminalTakeover, setRightSidebarTab, setTerminalTakeover } from '../store'
|
||||
@@ -39,17 +40,18 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div className="flex h-8 shrink-0 items-center gap-2 px-2.5">
|
||||
<SidebarPanelLabel className="text-white!">{shellName}</SidebarPanelLabel>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="ml-auto size-6 rounded-md text-white!"
|
||||
onClick={toggleTakeover}
|
||||
size="icon"
|
||||
title={label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={takeover ? 'screen-normal' : 'screen-full'} size="0.875rem" />
|
||||
</Button>
|
||||
<Tip label={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="ml-auto size-6 rounded-md text-white!"
|
||||
onClick={toggleTakeover}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={takeover ? 'screen-normal' : 'screen-full'} size="0.875rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1 bg-[#002b36] p-2">
|
||||
{status === 'starting' && (
|
||||
|
||||
@@ -752,12 +752,11 @@ export function useMessageStream({
|
||||
return
|
||||
}
|
||||
|
||||
// Turn ended — drop any blocking prompt that's still open (e.g. the
|
||||
// agent was interrupted, or the approval already resolved). Prevents a
|
||||
// stale overlay from outliving the turn that raised it.
|
||||
if (isActiveEvent) {
|
||||
clearAllPrompts()
|
||||
}
|
||||
// Turn ended — drop any blocking prompt still open for THIS session
|
||||
// (e.g. interrupted, or the approval already resolved). Scoped to the
|
||||
// session so a background turn finishing can't wipe the active chat's
|
||||
// prompt, and vice versa.
|
||||
clearAllPrompts(sessionId)
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
@@ -842,37 +841,34 @@ export function useMessageStream({
|
||||
}
|
||||
}
|
||||
} else if (event.type === 'approval.request') {
|
||||
if (!isActiveEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
// Dangerous-command / execute_code approval. The Python side is
|
||||
// blocked in _await_gateway_decision() until approval.respond lands;
|
||||
// without this the agent stalls until its 5-min timeout and the tool
|
||||
// is BLOCKED. Approval is session-keyed (no request_id) — the overlay
|
||||
// sends back {choice, session_id}.
|
||||
// Dangerous-command / execute_code approval. The Python side is blocked
|
||||
// in _await_gateway_decision() until approval.respond lands; without
|
||||
// this the agent stalls until its 5-min timeout and the tool is BLOCKED.
|
||||
// Park it per-session (like clarify) so a *background* profile's turn can
|
||||
// raise it and wait — the sidebar flags "needs input" and the inline bar
|
||||
// surfaces once the user focuses that chat.
|
||||
setApprovalRequest({
|
||||
command: typeof payload?.command === 'string' ? payload.command : '',
|
||||
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
|
||||
sessionId: sessionId ?? null
|
||||
})
|
||||
} else if (event.type === 'sudo.request') {
|
||||
if (!isActiveEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
} else if (event.type === 'sudo.request') {
|
||||
// Sudo password capture (tools/terminal_tool.py). Blocked on
|
||||
// sudo.respond {request_id, password}.
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
|
||||
if (requestId) {
|
||||
setSudoRequest({ requestId })
|
||||
setSudoRequest({ requestId, sessionId: sessionId ?? null })
|
||||
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
}
|
||||
} else if (event.type === 'secret.request') {
|
||||
if (!isActiveEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skill credential capture (tools/skills_tool.py). Blocked on
|
||||
// secret.respond {request_id, value}.
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
@@ -881,18 +877,23 @@ export function useMessageStream({
|
||||
setSecretRequest({
|
||||
requestId,
|
||||
envVar: typeof payload?.env_var === 'string' ? payload.env_var : '',
|
||||
prompt: typeof payload?.prompt === 'string' ? payload.prompt : ''
|
||||
prompt: typeof payload?.prompt === 'string' ? payload.prompt : '',
|
||||
sessionId: sessionId ?? null
|
||||
})
|
||||
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
const errorMessage = payload?.message || 'Hermes reported an error'
|
||||
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
|
||||
|
||||
// A turn that errors out has also ended — drop any open blocking
|
||||
// prompt so an approval/sudo/secret overlay can't linger past the
|
||||
// failed turn (same intent as the message.complete clear).
|
||||
if (isActiveEvent) {
|
||||
clearAllPrompts()
|
||||
// A turn that errors out has also ended — drop any open blocking prompt
|
||||
// for this session so an approval/sudo/secret overlay can't linger past
|
||||
// the failed turn (same intent as the message.complete clear).
|
||||
if (sessionId) {
|
||||
clearAllPrompts(sessionId)
|
||||
}
|
||||
|
||||
if (looksLikeProviderSetup) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
|
||||
import { type MutableRefObject, useCallback } from 'react'
|
||||
|
||||
import { transcribeAudio } from '@/hermes'
|
||||
import { getProfiles, transcribeAudio } from '@/hermes'
|
||||
import { appendTextPart, branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||
import {
|
||||
attachmentDisplayText,
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from '@/store/composer'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$busy,
|
||||
$messages,
|
||||
@@ -443,6 +444,51 @@ export function usePromptActions({
|
||||
return
|
||||
}
|
||||
|
||||
// /profile selects which profile new chats open in — no app relaunch.
|
||||
// A profile is per-session now, so an existing thread can't change its
|
||||
// profile mid-stream; `/profile <name>` instead points the next new chat
|
||||
// (and the current empty draft) at that profile's backend.
|
||||
if (normalizedName === 'profile') {
|
||||
const target = arg.trim()
|
||||
const current = normalizeProfileKey($activeGatewayProfile.get())
|
||||
|
||||
if (!target) {
|
||||
notify({
|
||||
kind: 'success',
|
||||
message: `Profile: ${current}. Use /profile <name> or the "New session" picker to start a chat in another profile.`
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { profiles } = await getProfiles()
|
||||
const match = profiles.find(profile => profile.name === target)
|
||||
|
||||
if (!match) {
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: 'Unknown profile',
|
||||
message: `No profile named "${target}". Available: ${profiles.map(profile => profile.name).join(', ')}`
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const key = normalizeProfileKey(match.name)
|
||||
|
||||
$newChatProfile.set(key)
|
||||
// Swap the live gateway now so an empty draft sends into this
|
||||
// profile immediately; an existing thread keeps its own profile.
|
||||
await ensureGatewayProfile(key)
|
||||
notify({ kind: 'success', message: `New chats will use profile ${match.name}.` })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to set profile')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
|
||||
|
||||
if (!sessionId) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { clearQueuedPrompts } from '@/store/composer-queue'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$currentCwd,
|
||||
$messages,
|
||||
@@ -173,6 +174,10 @@ function upsertOptimisticSession(
|
||||
preview: string | null = null
|
||||
) {
|
||||
const now = Date.now() / 1000
|
||||
// Stamp the profile the session was just created on (= the live gateway's
|
||||
// profile) so the scoped sidebar shows the new row immediately instead of
|
||||
// filtering it out as "default" until the aggregator re-fetches.
|
||||
const profileKey = normalizeProfileKey($activeGatewayProfile.get())
|
||||
|
||||
const session: SessionInfo = {
|
||||
cwd: created.info?.cwd ?? null,
|
||||
@@ -180,11 +185,13 @@ function upsertOptimisticSession(
|
||||
id,
|
||||
input_tokens: 0,
|
||||
is_active: true,
|
||||
is_default_profile: profileKey === 'default',
|
||||
last_active: now,
|
||||
message_count: created.message_count ?? created.messages?.length ?? 0,
|
||||
model: created.info?.model ?? null,
|
||||
output_tokens: 0,
|
||||
preview,
|
||||
profile: profileKey,
|
||||
source: 'tui',
|
||||
started_at: now,
|
||||
title,
|
||||
@@ -320,8 +327,18 @@ export function useSessionActions({
|
||||
creatingSessionRef.current = true
|
||||
|
||||
try {
|
||||
// Route the new chat to the chosen profile's backend (null = primary,
|
||||
// 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 (
|
||||
@@ -420,6 +437,12 @@ export function useSessionActions({
|
||||
const isCurrentResume = () =>
|
||||
resumeRequestRef.current === requestId && selectedStoredSessionIdRef.current === storedSessionId
|
||||
|
||||
// Swap the single live gateway to this session's profile before any
|
||||
// gateway call (no-op when it's already on that profile / single-profile).
|
||||
const storedForProfile = $sessions.get().find(session => session.id === storedSessionId)
|
||||
const sessionProfile = storedForProfile?.profile
|
||||
await ensureGatewayProfile(sessionProfile)
|
||||
|
||||
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId)
|
||||
|
||||
@@ -437,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)
|
||||
@@ -482,7 +521,7 @@ export function useSessionActions({
|
||||
let localSnapshot = $messages.get()
|
||||
|
||||
try {
|
||||
const storedMessages = await getSessionMessages(storedSessionId)
|
||||
const storedMessages = await getSessionMessages(storedSessionId, sessionProfile)
|
||||
|
||||
if (isCurrentResume()) {
|
||||
localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get())
|
||||
@@ -497,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()) {
|
||||
@@ -552,7 +595,7 @@ export function useSessionActions({
|
||||
return
|
||||
}
|
||||
|
||||
const fallback = await getSessionMessages(storedSessionId)
|
||||
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
|
||||
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
@@ -731,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) {
|
||||
@@ -807,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" />
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { IconDownload, IconRefresh, IconUpload } from '@tabler/icons-react'
|
||||
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'
|
||||
@@ -33,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.
|
||||
@@ -63,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
|
||||
}
|
||||
|
||||
@@ -77,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 => {
|
||||
@@ -93,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)}
|
||||
/>
|
||||
)
|
||||
@@ -126,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' && (
|
||||
@@ -156,45 +159,49 @@ 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">
|
||||
<OverlayIconButton onClick={() => void exportConfig()} title="Export config">
|
||||
<IconDownload className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
<OverlayIconButton
|
||||
onClick={() => {
|
||||
triggerHaptic('open')
|
||||
importInputRef.current?.click()
|
||||
}}
|
||||
title="Import config"
|
||||
>
|
||||
<IconUpload className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
<OverlayIconButton
|
||||
className="hover:text-destructive"
|
||||
onClick={() => {
|
||||
triggerHaptic('warning')
|
||||
void resetConfig()
|
||||
}}
|
||||
title="Reset to defaults"
|
||||
>
|
||||
<IconRefresh className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
<Tip label={t.settings.exportConfig}>
|
||||
<OverlayIconButton onClick={() => void exportConfig()}>
|
||||
<IconDownload className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
</Tip>
|
||||
<Tip label={t.settings.importConfig}>
|
||||
<OverlayIconButton
|
||||
onClick={() => {
|
||||
triggerHaptic('open')
|
||||
importInputRef.current?.click()
|
||||
}}
|
||||
>
|
||||
<IconUpload className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
</Tip>
|
||||
<Tip label={t.settings.resetToDefaults}>
|
||||
<OverlayIconButton
|
||||
className="hover:text-destructive"
|
||||
onClick={() => {
|
||||
triggerHaptic('warning')
|
||||
void resetConfig()
|
||||
}}
|
||||
>
|
||||
<IconRefresh className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
</Tip>
|
||||
</div>
|
||||
</OverlaySidebar>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
@@ -56,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)])
|
||||
@@ -77,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) {
|
||||
@@ -134,18 +135,19 @@ export function SessionsSettings() {
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />}
|
||||
<span>Unarchive</span>
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Delete permanently"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(session)}
|
||||
size="icon"
|
||||
title="Delete permanently"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<Tip label="Delete permanently">
|
||||
<Button
|
||||
aria-label="Delete permanently"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(session)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
}
|
||||
description={session.preview || undefined}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
import { NotificationStack } from '@/components/notifications'
|
||||
import { PaneShell } from '@/components/pane-shell'
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import {
|
||||
@@ -153,6 +154,10 @@ export function AppShell({
|
||||
</main>
|
||||
|
||||
{overlays}
|
||||
|
||||
{/* Mounted at the shell root (after overlays) so success/error toasts
|
||||
surface above every route and overlay — not just the chat view. */}
|
||||
<NotificationStack />
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { IconLayoutDashboard } from '@tabler/icons-react'
|
||||
|
||||
import { StatusDot, type StatusTone } from '@/components/status-dot'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { Activity, AlertCircle } from '@/lib/icons'
|
||||
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -76,16 +77,17 @@ export function GatewayMenuPanel({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
aria-label="Open system panel"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenSystem}
|
||||
size="icon-sm"
|
||||
title="Open system panel"
|
||||
variant="ghost"
|
||||
>
|
||||
<IconLayoutDashboard />
|
||||
</Button>
|
||||
<Tip label="Open system panel">
|
||||
<Button
|
||||
aria-label="Open system panel"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenSystem}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<IconLayoutDashboard />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -99,13 +101,11 @@ export function GatewayMenuPanel({
|
||||
<SectionLabel>Recent activity</SectionLabel>
|
||||
<ul className="mt-1.5 space-y-0.5">
|
||||
{recentLogs.map((line, index) => (
|
||||
<li
|
||||
className="truncate font-mono text-[0.68rem] text-muted-foreground/85"
|
||||
key={`${index}:${line}`}
|
||||
title={line.trim()}
|
||||
>
|
||||
{trimLogLine(line) || '\u00A0'}
|
||||
</li>
|
||||
<Tip key={`${index}:${line}`} label={line.trim()}>
|
||||
<li className="truncate font-mono text-[0.68rem] text-muted-foreground/85">
|
||||
{trimLogLine(line) || '\u00A0'}
|
||||
</li>
|
||||
</Tip>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
|
||||
@@ -91,18 +91,11 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
</>
|
||||
)
|
||||
|
||||
const title = item.title ?? (typeof item.label === 'string' ? item.label : undefined)
|
||||
|
||||
if (item.variant === 'menu' && (item.menuContent || (item.menuItems && item.menuItems.length > 0))) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
|
||||
disabled={item.disabled}
|
||||
title={title}
|
||||
type="button"
|
||||
>
|
||||
<button className={cn(STATUSBAR_ACTION_CLASS, item.className)} disabled={item.disabled} type="button">
|
||||
{content}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -135,7 +128,6 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
href={menuItem.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={menuItem.title ?? menuItem.label}
|
||||
>
|
||||
{menuItem.icon}
|
||||
<span className="truncate">{menuItem.label}</span>
|
||||
@@ -168,13 +160,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
|
||||
if (item.href || item.variant === 'link') {
|
||||
return (
|
||||
<a
|
||||
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
|
||||
href={item.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={title}
|
||||
>
|
||||
<a className={cn(STATUSBAR_ACTION_CLASS, item.className)} href={item.href} rel="noreferrer" target="_blank">
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
@@ -191,7 +177,6 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
|
||||
item.onSelect?.()
|
||||
}}
|
||||
title={title}
|
||||
type="button"
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -4,14 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
|
||||
@@ -24,7 +17,7 @@ import {
|
||||
toggleSidebarOpen
|
||||
} from '@/store/layout'
|
||||
|
||||
import { appViewForPath, isOverlayView, PROFILES_ROUTE } from '../routes'
|
||||
import { appViewForPath, isOverlayView } from '../routes'
|
||||
|
||||
import { titlebarButtonClass } from './titlebar'
|
||||
|
||||
@@ -52,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)
|
||||
@@ -84,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()
|
||||
@@ -93,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
|
||||
]
|
||||
@@ -106,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()
|
||||
@@ -119,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()
|
||||
@@ -185,7 +179,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
{visibleSystemToolsBeforeSettings.map(tool => (
|
||||
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
||||
))}
|
||||
<ProfilesMenuButton navigate={navigate} />
|
||||
{settingsTool && <TitlebarToolButton navigate={navigate} tool={settingsTool} />}
|
||||
<TitlebarToolButton navigate={navigate} tool={rightSidebarTool} />
|
||||
</div>
|
||||
@@ -193,47 +186,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
)
|
||||
}
|
||||
|
||||
function ProfilesMenuButton({ navigate }: { navigate: ReturnType<typeof useNavigate> }) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Profiles"
|
||||
className={cn(titlebarButtonClass, 'bg-transparent select-none')}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
size="icon-titlebar"
|
||||
title="Profiles"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{/* Optical bump: the `account` glyph has more internal padding than
|
||||
`search`/`settings-gear`, so at the shared 0.875rem it reads small.
|
||||
Nudge just this glyph to visually match its neighbours. */}
|
||||
<Codicon name="account" size="1rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64" sideOffset={8}>
|
||||
<DropdownMenuLabel>
|
||||
<div className="text-sm font-medium text-foreground">Profiles</div>
|
||||
<div className="mt-1 text-xs font-normal leading-4 text-muted-foreground">
|
||||
Advanced Hermes environments for separate personas, config, skills, and SOUL.md.
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
triggerHaptic('open')
|
||||
navigate(PROFILES_ROUTE)
|
||||
}}
|
||||
>
|
||||
<Codicon name="account" size="1rem" />
|
||||
<span>Manage profiles</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof useNavigate>; tool: TitlebarTool }) {
|
||||
// Titlebar actions never show an active background — state reads from the
|
||||
// icon itself (e.g. the mute/unmute glyph). aria-pressed still carries it
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { extractEmbeddedImages } from '@/lib/embedded-images'
|
||||
|
||||
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal'] as const
|
||||
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal', 'session'] as const
|
||||
type HermesRefType = (typeof HERMES_REF_TYPES)[number]
|
||||
|
||||
/** Single source of truth for chip icon glyphs (Tabler outline @ 24×24).
|
||||
@@ -38,7 +38,12 @@ const ICON_PATHS: Record<HermesRefType, string[]> = {
|
||||
],
|
||||
tool: ['M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5'],
|
||||
line: ['M5 9l14 0', 'M5 15l14 0', 'M11 4l-4 16', 'M17 4l-4 16'],
|
||||
terminal: ['M5 7l5 5l-5 5', 'M12 19l7 0']
|
||||
terminal: ['M5 7l5 5l-5 5', 'M12 19l7 0'],
|
||||
session: [
|
||||
'M8 9h8',
|
||||
'M8 13h6',
|
||||
'M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3z'
|
||||
]
|
||||
}
|
||||
|
||||
const ICON_FALLBACK = ['M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', 'M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28']
|
||||
@@ -98,7 +103,7 @@ const DirectiveIcon: FC<{ type: string }> = ({ type }) => (
|
||||
* raw HTML composer chips in `rich-editor.ts`. Neutral subtle wash + plain
|
||||
* muted-foreground text so chips read as quiet tags on any bubble color. */
|
||||
export const DIRECTIVE_CHIP_CLASS =
|
||||
'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded px-1.5 py-0.5 align-[0.02em] text-[0.86em] font-normal leading-none bg-[color-mix(in_srgb,currentColor_8%,transparent)] text-muted-foreground'
|
||||
'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded px-1.5 py-0.5 align-middle text-[0.86em] font-normal leading-none bg-[color-mix(in_srgb,currentColor_8%,transparent)] text-muted-foreground'
|
||||
|
||||
/**
|
||||
* Parses our composer's `@type:value` references into directive segments
|
||||
@@ -113,7 +118,7 @@ export const DIRECTIVE_CHIP_CLASS =
|
||||
const CANONICAL_DIRECTIVE_RE = /:([\w-]{1,64})\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/g
|
||||
|
||||
const HERMES_DIRECTIVE_RE = new RegExp(
|
||||
'@(file|folder|url|image|tool|line|terminal):(' + '`[^`\\n]+`' + '|"[^"\\n]+"' + "|'[^'\\n]+'" + '|\\S+' + ')',
|
||||
'@(file|folder|url|image|tool|line|terminal|session):(' + '`[^`\\n]+`' + '|"[^"\\n]+"' + "|'[^'\\n]+'" + '|\\S+' + ')',
|
||||
'g'
|
||||
)
|
||||
|
||||
@@ -263,6 +268,14 @@ function shortLabel(type: HermesRefType, id: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// `@session:<profile>/<id>` — show a short id; the composer chip carries the
|
||||
// friendly title, but once sent the wire form only has the id.
|
||||
if (type === 'session') {
|
||||
const sid = id.split('/').filter(Boolean).pop() || id
|
||||
|
||||
return sid.length > 10 ? `${sid.slice(0, 8)}…` : sid
|
||||
}
|
||||
|
||||
const tail = id.split(/[\\/]/).filter(Boolean).pop()
|
||||
|
||||
return tail || id
|
||||
|
||||
@@ -438,7 +438,7 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
|
||||
s.thread.isRunning &&
|
||||
s.message.status?.type === 'running' &&
|
||||
s.message.parts
|
||||
.slice(Math.max(0, startIndex), Math.min(s.message.parts.length, endIndex))
|
||||
.slice(Math.max(0, startIndex))
|
||||
.some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime }
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $approvalRequest } from '@/store/prompts'
|
||||
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
import { $toolDisclosureStates } from '@/store/tool-view'
|
||||
|
||||
import { Thread } from './thread'
|
||||
@@ -120,13 +121,15 @@ function GroupHarness({ message }: { message: ThreadMessage }) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
$approvalRequest.set(null)
|
||||
clearAllPrompts()
|
||||
$activeSessionId.set('sess-1')
|
||||
$toolDisclosureStates.set({})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$approvalRequest.set(null)
|
||||
clearAllPrompts()
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
describe('ToolGroupSlot approval surfacing', () => {
|
||||
@@ -143,7 +146,7 @@ describe('ToolGroupSlot approval surfacing', () => {
|
||||
})
|
||||
|
||||
it('force-opens the group body so the approval surfaces without expanding', async () => {
|
||||
$approvalRequest.set({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
|
||||
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
|
||||
|
||||
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { $approvalRequest } from '@/store/prompts'
|
||||
import { $approvalRequest, clearAllPrompts, setApprovalRequest } from '@/store/prompts'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
|
||||
import { PendingToolApproval } from './tool-approval'
|
||||
import type { ToolPart } from './tool-fallback-model'
|
||||
@@ -13,7 +14,8 @@ function part(toolName: string): ToolPart {
|
||||
}
|
||||
|
||||
function setRequest(command = 'rm -rf /tmp/x') {
|
||||
$approvalRequest.set({ command, description: 'dangerous command', sessionId: 'sess-1' })
|
||||
$activeSessionId.set('sess-1')
|
||||
setApprovalRequest({ command, description: 'dangerous command', sessionId: 'sess-1' })
|
||||
}
|
||||
|
||||
function mockGateway() {
|
||||
@@ -25,7 +27,8 @@ function mockGateway() {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$approvalRequest.set(null)
|
||||
clearAllPrompts()
|
||||
$activeSessionId.set(null)
|
||||
$gateway.set(null)
|
||||
})
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
session_id: request.sessionId ?? undefined
|
||||
})
|
||||
triggerHaptic(choice === 'deny' ? 'cancel' : 'submit')
|
||||
clearApprovalRequest()
|
||||
clearApprovalRequest(request.sessionId)
|
||||
} catch (error) {
|
||||
notifyError(error, 'Could not send approval response')
|
||||
setSubmitting(null)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { type ComponentPropsWithRef, forwardRef } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface TooltipIconButtonProps extends ComponentPropsWithRef<typeof Button> {
|
||||
@@ -11,19 +12,20 @@ export interface TooltipIconButtonProps extends ComponentPropsWithRef<typeof But
|
||||
}
|
||||
|
||||
export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButtonProps>(
|
||||
({ children, tooltip, side: _side = 'bottom', className, ...rest }, ref) => {
|
||||
({ children, tooltip, side = 'bottom', className, ...rest }, ref) => {
|
||||
return (
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
{...rest}
|
||||
aria-label={tooltip}
|
||||
className={cn('aui-button-icon', className)}
|
||||
ref={ref}
|
||||
title={tooltip}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
<Tip label={tooltip} side={side}>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
{...rest}
|
||||
aria-label={tooltip}
|
||||
className={cn('aui-button-icon', className)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ function setProviders(providers: OAuthProvider[]) {
|
||||
providers,
|
||||
reason: null,
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false
|
||||
} satisfies DesktopOnboardingState)
|
||||
}
|
||||
@@ -33,6 +34,13 @@ const ctx: OnboardingContext = { requestGateway: async () => undefined as never
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
|
||||
try {
|
||||
window.localStorage.clear()
|
||||
} catch {
|
||||
// jsdom localStorage should always be present; ignore if not.
|
||||
}
|
||||
|
||||
$desktopOnboarding.set({
|
||||
configured: null,
|
||||
flow: { status: 'idle' },
|
||||
@@ -40,6 +48,7 @@ afterEach(() => {
|
||||
providers: null,
|
||||
reason: null,
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false
|
||||
})
|
||||
})
|
||||
@@ -68,4 +77,24 @@ describe('onboarding Picker', () => {
|
||||
expect(screen.queryByText('Other sign-in options')).toBeNull()
|
||||
expect(screen.queryByText('Recommended')).toBeNull()
|
||||
})
|
||||
|
||||
it('offers "choose later" on first run and persists the skip', () => {
|
||||
setProviders([provider('nous', 'Nous Portal')])
|
||||
render(<Picker ctx={ctx} />)
|
||||
|
||||
const skip = screen.getByRole('button', { name: "I'll choose a provider later" })
|
||||
|
||||
fireEvent.click(skip)
|
||||
|
||||
expect($desktopOnboarding.get().firstRunSkipped).toBe(true)
|
||||
expect(window.localStorage.getItem('hermes-onboarding-skipped-v1')).toBe('1')
|
||||
})
|
||||
|
||||
it('hides "choose later" in manual (add-provider) mode', () => {
|
||||
setProviders([provider('nous', 'Nous Portal')])
|
||||
$desktopOnboarding.set({ ...$desktopOnboarding.get(), manual: true })
|
||||
render(<Picker ctx={ctx} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: "I'll choose a provider later" })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
confirmOnboardingModel,
|
||||
copyDeviceCode,
|
||||
copyExternalCommand,
|
||||
dismissFirstRunOnboarding,
|
||||
type OnboardingContext,
|
||||
type OnboardingFlow,
|
||||
peekPendingProviderOAuth,
|
||||
@@ -189,6 +190,13 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
|
||||
return null
|
||||
}
|
||||
|
||||
// The user chose "I'll choose a provider later" on first run. Stay out of the
|
||||
// way on every subsequent launch — they re-enter via Settings → Providers
|
||||
// (manual mode), which sets manual=true and bypasses this gate.
|
||||
if (onboarding.firstRunSkipped && !onboarding.manual) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { flow } = onboarding
|
||||
const rawReason = onboarding.reason?.trim() || null
|
||||
const reason = rawReason && !isProviderSetupErrorMessage(rawReason) ? rawReason : null
|
||||
@@ -304,18 +312,25 @@ const persistShowAll = (value: boolean) => {
|
||||
}
|
||||
|
||||
export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
||||
const { mode, providers } = useStore($desktopOnboarding)
|
||||
const { manual, mode, providers } = useStore($desktopOnboarding)
|
||||
const [showAll, setShowAll] = useState(readShowAll)
|
||||
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
|
||||
const hasOauth = ordered.length > 0
|
||||
|
||||
if (mode === 'apikey' || !hasOauth) {
|
||||
return (
|
||||
<ApiKeyForm
|
||||
canGoBack={hasOauth}
|
||||
onBack={() => setOnboardingMode('oauth')}
|
||||
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
|
||||
/>
|
||||
<div className="grid gap-3">
|
||||
<ApiKeyForm
|
||||
canGoBack={hasOauth}
|
||||
onBack={() => setOnboardingMode('oauth')}
|
||||
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
|
||||
/>
|
||||
{manual ? null : (
|
||||
<div className="flex justify-center border-t border-(--ui-stroke-tertiary) pt-3">
|
||||
<ChooseLaterLink />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -352,7 +367,11 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
||||
<ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
|
||||
</button>
|
||||
) : null}
|
||||
<div className="flex justify-end pt-1">
|
||||
<div className="flex items-center justify-between gap-3 pt-1">
|
||||
{/* First run only: let the user defer the choice and land in the app.
|
||||
In manual mode the overlay already has a close affordance, so the
|
||||
"choose later" escape would be redundant — hide it. */}
|
||||
{manual ? <span /> : <ChooseLaterLink />}
|
||||
<button
|
||||
className="text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setOnboardingMode('apikey')}
|
||||
@@ -365,6 +384,21 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
||||
)
|
||||
}
|
||||
|
||||
// "I'll choose a provider later" — dismisses the first-run picker and persists
|
||||
// the skip so it never re-nags. The user connects a provider any time from
|
||||
// Settings → Providers. Rendered only on the unconfigured first-run flow.
|
||||
function ChooseLaterLink() {
|
||||
return (
|
||||
<button
|
||||
className="text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
onClick={() => dismissFirstRunOnboarding()}
|
||||
type="button"
|
||||
>
|
||||
I'll choose a provider later
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function FeaturedProviderRow({
|
||||
onSelect,
|
||||
provider
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
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'
|
||||
@@ -29,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) {
|
||||
@@ -63,10 +67,16 @@ export function NotificationStack() {
|
||||
const [latest, ...olderNotifications] = notifications
|
||||
const overflowCount = olderNotifications.length
|
||||
|
||||
return (
|
||||
// Portaled to <body> with a z above the Radix dialog layer (overlay z-[120],
|
||||
// content z-[130]). Without the portal the stack lives inside the React root
|
||||
// subtree, which any body-level dialog/overlay portal paints over — so a
|
||||
// success toast fired while a dialog is open (or over an OverlayView page)
|
||||
// was invisible. The titlebar-height var only exists inside the app shell
|
||||
// scope, so fall back to its constant (34px) when mounted on <body>.
|
||||
return createPortal(
|
||||
<div
|
||||
aria-label="Notifications"
|
||||
className="pointer-events-none absolute left-1/2 top-[calc(var(--titlebar-height)+0.75rem)] z-1050 flex w-[min(32rem,calc(100%-2rem))] -translate-x-1/2 flex-col gap-2"
|
||||
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"
|
||||
>
|
||||
<NotificationItem notification={latest} />
|
||||
@@ -74,14 +84,15 @@ 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>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,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
|
||||
@@ -118,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"
|
||||
@@ -130,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}
|
||||
@@ -140,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>
|
||||
|
||||
@@ -64,7 +64,7 @@ function SudoDialog() {
|
||||
request_id: request.requestId
|
||||
})
|
||||
triggerHaptic('submit')
|
||||
clearSudoRequest(request.requestId)
|
||||
clearSudoRequest(request.sessionId, request.requestId)
|
||||
} catch (error) {
|
||||
notifyError(error, 'Could not send sudo password')
|
||||
setSubmitting(false)
|
||||
@@ -163,7 +163,7 @@ function SecretDialog() {
|
||||
value: secret
|
||||
})
|
||||
triggerHaptic('submit')
|
||||
clearSecretRequest(request.requestId)
|
||||
clearSecretRequest(request.sessionId, request.requestId)
|
||||
} catch (error) {
|
||||
notifyError(error, 'Could not send secret')
|
||||
setSubmitting(false)
|
||||
|
||||
25
apps/desktop/src/components/ui/action-status.tsx
Normal file
25
apps/desktop/src/components/ui/action-status.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Check, Loader2 } from '@/lib/icons'
|
||||
|
||||
// idle → saving → done label+icon for action buttons (create / rename / delete…).
|
||||
export function ActionStatus({
|
||||
state,
|
||||
idle,
|
||||
busy,
|
||||
done,
|
||||
idleIcon = null
|
||||
}: {
|
||||
state: 'done' | 'idle' | 'saving'
|
||||
idle: string
|
||||
busy: string
|
||||
done: string
|
||||
idleIcon?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{state === 'saving' ? <Loader2 className="size-4 animate-spin" /> : state === 'done' ? <Check /> : idleIcon}
|
||||
{state === 'saving' ? busy : state === 'done' ? done : idle}
|
||||
</>
|
||||
)
|
||||
}
|
||||
103
apps/desktop/src/components/ui/confirm-dialog.tsx
Normal file
103
apps/desktop/src/components/ui/confirm-dialog.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { ActionStatus } from '@/components/ui/action-status'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { AlertTriangle } from '@/lib/icons'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
// Does the work. Throw to surface an inline error and keep the dialog open.
|
||||
onConfirm: () => Promise<void> | void
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
confirmLabel?: string
|
||||
busyLabel?: string
|
||||
doneLabel?: string
|
||||
cancelLabel?: string
|
||||
destructive?: boolean
|
||||
}
|
||||
|
||||
// Shared confirmation dialog: Enter confirms (from anywhere in the dialog),
|
||||
// Esc/Cancel/backdrop dismiss. Owns the pending → done → close beat and inline
|
||||
// error, so callers pass only an async onConfirm that does the work.
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Confirm',
|
||||
busyLabel = 'Working…',
|
||||
doneLabel = 'Done',
|
||||
cancelLabel = 'Cancel',
|
||||
destructive = false
|
||||
}: ConfirmDialogProps) {
|
||||
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
const busy = status === 'saving' || status === 'done'
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStatus('idle')
|
||||
setError(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
async function run() {
|
||||
if (busy) {
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('saving')
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onConfirm()
|
||||
setStatus('done')
|
||||
window.setTimeout(onClose, 600)
|
||||
} catch (err) {
|
||||
setStatus('idle')
|
||||
setError(err instanceof Error ? err.message : 'Something went wrong')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
onKeyDown={event => {
|
||||
// Enter/Space confirm regardless of which button holds focus
|
||||
// (preventDefault stops a focused Cancel from swallowing it).
|
||||
if ((event.key === 'Enter' || event.key === ' ') && !busy) {
|
||||
event.preventDefault()
|
||||
void run()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
||||
</DialogHeader>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={() => void run()} variant={destructive ? 'destructive' : 'default'}>
|
||||
<ActionStatus busy={busyLabel} done={doneLabel} idle={confirmLabel} state={status} />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Copy, X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -178,7 +179,6 @@ export function CopyButton({
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={event => void copy(event)}
|
||||
title={feedbackLabel}
|
||||
type="button"
|
||||
>
|
||||
{content}
|
||||
@@ -188,34 +188,37 @@ export function CopyButton({
|
||||
|
||||
if (appearance === 'tool-row') {
|
||||
return (
|
||||
<button
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'grid size-6 place-items-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent/55 hover:text-foreground focus-visible:opacity-100 group-hover/tool-row:opacity-100 disabled:opacity-40',
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={event => void copy(event)}
|
||||
title={feedbackLabel}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
<Tip label={feedbackLabel}>
|
||||
<button
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'grid size-6 place-items-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent/55 hover:text-foreground focus-visible:opacity-100 group-hover/tool-row:opacity-100 disabled:opacity-40',
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={event => void copy(event)}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
const button = (
|
||||
<Button
|
||||
aria-label={ariaLabel}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
onClick={event => void copy(event)}
|
||||
size={buttonSize ?? (appearance === 'icon' ? 'icon' : 'default')}
|
||||
title={feedbackLabel}
|
||||
type="button"
|
||||
variant={buttonVariant}
|
||||
>
|
||||
{content}
|
||||
</Button>
|
||||
)
|
||||
|
||||
// Only icon-only buttons need a tooltip; the text variant already shows its label.
|
||||
return appearance === 'icon' ? <Tip label={feedbackLabel}>{button}</Tip> : button
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -57,12 +58,16 @@ function DialogContent({
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
className="absolute right-2.5 top-2.5 rounded-md p-1 text-(--ui-text-tertiary) opacity-70 transition-opacity hover:bg-(--chrome-action-hover) hover:text-foreground hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none"
|
||||
data-slot="dialog-close-button"
|
||||
>
|
||||
<Codicon name="close" size="1rem" />
|
||||
<span className="sr-only">Close</span>
|
||||
<DialogPrimitive.Close asChild data-slot="dialog-close-button">
|
||||
<Button
|
||||
aria-label="Close"
|
||||
className="absolute right-2.5 top-2.5 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="1rem" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
44
apps/desktop/src/components/ui/popover.tsx
Normal file
44
apps/desktop/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Popover as PopoverPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
align = 'center',
|
||||
className,
|
||||
collisionPadding = 8,
|
||||
sideOffset = 6,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
align={align}
|
||||
// Mirrors DropdownMenuContent: themed elevated surface, viewport-aware
|
||||
// (Radix flips/shifts off edges), with the standard open/close motion.
|
||||
className={cn(
|
||||
'z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-2 text-popover-foreground shadow-md backdrop-blur-md outline-hidden data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
collisionPadding={collisionPadding}
|
||||
data-slot="popover-content"
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }
|
||||
@@ -17,15 +17,18 @@ function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimiti
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
sideOffset = 6,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
// Instant, no transition (the Provider's delayDuration=0 + no animate-*
|
||||
// classes). bg-foreground/text-background auto-inverts per theme: white
|
||||
// on near-black in light mode, black on white in dark.
|
||||
className={cn(
|
||||
'z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
'z-[200] w-fit bg-foreground px-1.5 py-1 text-[11px] font-bold leading-none text-background select-none [font-family:Arial,sans-serif]',
|
||||
className
|
||||
)}
|
||||
data-slot="tooltip-content"
|
||||
@@ -33,10 +36,34 @@ function TooltipContent({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_0.125rem)] rotate-45 rounded-[0.125rem] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
interface TipProps extends Omit<React.ComponentProps<typeof TooltipPrimitive.Content>, 'content'> {
|
||||
label: React.ReactNode
|
||||
children: React.ReactNode
|
||||
delayDuration?: number
|
||||
}
|
||||
|
||||
// Drop-in replacement for native `title=`: wrap any single element. Instant,
|
||||
// position-aware, themed. Self-contained (carries its own Provider) so it works
|
||||
// anywhere without a provider ancestor. Renders the child untouched when label
|
||||
// is falsy.
|
||||
function Tip({ label, children, delayDuration = 0, ...props }: TipProps) {
|
||||
if (!label) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={delayDuration}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent {...props}>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
|
||||
38
apps/desktop/src/global.d.ts
vendored
38
apps/desktop/src/global.d.ts
vendored
@@ -3,16 +3,29 @@ export {}
|
||||
declare global {
|
||||
interface Window {
|
||||
hermesDesktop: {
|
||||
getConnection: () => Promise<HermesConnection>
|
||||
getGatewayWsUrl: () => Promise<string>
|
||||
// Resolve a backend connection. Omit `profile` (or pass the primary) for
|
||||
// the window's backend; pass a named profile to lazily spawn/reuse that
|
||||
// profile's backend from the pool.
|
||||
getConnection: (profile?: string | null) => Promise<HermesConnection>
|
||||
// Keepalive: mark a pool profile backend as recently used so the idle
|
||||
// reaper spares it while its chat is active.
|
||||
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>
|
||||
probeConnectionConfig: (remoteUrl: string) => Promise<DesktopConnectionProbeResult>
|
||||
oauthLoginConnectionConfig: (remoteUrl: string) => Promise<DesktopOauthLoginResult>
|
||||
oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise<DesktopOauthLogoutResult>
|
||||
profile: {
|
||||
get: () => Promise<DesktopActiveProfile>
|
||||
// Persists the desktop's profile choice and relaunches the local
|
||||
// backend under the new HERMES_HOME (reloads the window). Pass null to
|
||||
// clear the preference.
|
||||
set: (name: string | null) => Promise<DesktopActiveProfile>
|
||||
}
|
||||
api: <T>(request: HermesApiRequest) => Promise<T>
|
||||
notify: (payload: HermesNotification) => Promise<boolean>
|
||||
requestMicrophoneAccess: () => Promise<boolean>
|
||||
@@ -151,6 +164,9 @@ export interface HermesConnection {
|
||||
token: string
|
||||
wsUrl: string
|
||||
logs: string[]
|
||||
// Set for pool (non-primary) backends so the renderer knows which profile a
|
||||
// connection belongs to.
|
||||
profile?: string
|
||||
windowButtonPosition: { x: number; y: number } | null
|
||||
}
|
||||
|
||||
@@ -165,9 +181,18 @@ export interface HermesWindowState {
|
||||
windowButtonPosition: { x: number; y: number } | null
|
||||
}
|
||||
|
||||
export interface DesktopActiveProfile {
|
||||
// The desktop's stored profile preference, or null when unset (legacy launch
|
||||
// that defers to the sticky active_profile / default).
|
||||
profile: string | null
|
||||
}
|
||||
|
||||
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
|
||||
@@ -177,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
|
||||
@@ -293,6 +321,10 @@ export interface HermesApiRequest {
|
||||
method?: string
|
||||
body?: unknown
|
||||
timeoutMs?: number
|
||||
// Route this REST call to a specific profile's backend. Omit for the primary
|
||||
// (window) backend. Read-only cross-profile data is served by the primary, so
|
||||
// this is only needed for profile-scoped live/settings calls.
|
||||
profile?: string | null
|
||||
}
|
||||
|
||||
export interface HermesNotification {
|
||||
|
||||
@@ -111,6 +111,22 @@ export class HermesGateway extends JsonRpcGatewayClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Profile that profile-scoped REST settings (config/env/skills/tools/model/…)
|
||||
// should target. Mirrors $activeGatewayProfile, pushed in from the store via
|
||||
// setApiRequestProfile so this module needs no store import (avoids a cycle).
|
||||
// Electron main consumes request.profile to pick which backend *process* serves
|
||||
// the call; each pooled backend already has its own HERMES_HOME, so no backend
|
||||
// change is needed. Null → primary, so single-profile users are unaffected.
|
||||
let _apiProfile: null | string = null
|
||||
|
||||
export function setApiRequestProfile(profile: null | string): void {
|
||||
_apiProfile = profile || null
|
||||
}
|
||||
|
||||
function profileScoped(): { profile?: string } {
|
||||
return _apiProfile ? { profile: _apiProfile } : {}
|
||||
}
|
||||
|
||||
export async function listSessions(
|
||||
limit = 40,
|
||||
minMessages = 0,
|
||||
@@ -128,8 +144,37 @@ export async function listSessions(
|
||||
}
|
||||
}
|
||||
|
||||
export function setSessionArchived(id: string, archived: boolean): Promise<{ ok: boolean }> {
|
||||
// Unified, read-only session list aggregated across ALL profiles. Served by the
|
||||
// primary backend straight off each profile's state.db — no per-profile backend
|
||||
// is spawned. Single-profile users get the same rows as listSessions(), tagged
|
||||
// profile="default".
|
||||
export async function listAllProfileSessions(
|
||||
limit = 40,
|
||||
minMessages = 0,
|
||||
archived: 'exclude' | 'include' | 'only' = 'exclude',
|
||||
order: 'created' | 'recent' = 'recent',
|
||||
profile: 'all' | (string & {}) = 'all'
|
||||
): Promise<PaginatedSessions> {
|
||||
const result = await window.hermesDesktop.api<PaginatedSessions>({
|
||||
path:
|
||||
`/api/profiles/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
|
||||
`&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}`
|
||||
})
|
||||
|
||||
return {
|
||||
...result,
|
||||
sessions: result.sessions.slice(0, limit),
|
||||
offset: 0
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }
|
||||
@@ -142,29 +187,42 @@ export function searchSessions(query: string): Promise<SessionSearchResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
export function getSessionMessages(id: string): Promise<SessionMessagesResponse> {
|
||||
// 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)}` : ''
|
||||
|
||||
return window.hermesDesktop.api<SessionMessagesResponse>({
|
||||
path: `/api/sessions/${encodeURIComponent(id)}/messages`
|
||||
path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}`
|
||||
})
|
||||
}
|
||||
|
||||
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'
|
||||
})
|
||||
}
|
||||
|
||||
export function renameSession(id: string, title: string): Promise<{ ok: boolean; title: string }> {
|
||||
export function renameSession(
|
||||
id: string,
|
||||
title: string,
|
||||
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 }
|
||||
body: { title, ...(profile ? { profile } : {}) }
|
||||
})
|
||||
}
|
||||
|
||||
export function getGlobalModelInfo(): Promise<ModelInfoResponse> {
|
||||
return window.hermesDesktop.api<ModelInfoResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/model/info'
|
||||
})
|
||||
}
|
||||
@@ -202,36 +260,42 @@ export function getLogs(params: {
|
||||
const suffix = query.toString()
|
||||
|
||||
return window.hermesDesktop.api<LogsResponse>({
|
||||
...profileScoped(),
|
||||
path: suffix ? `/api/logs?${suffix}` : '/api/logs'
|
||||
})
|
||||
}
|
||||
|
||||
export function getHermesConfig(): Promise<HermesConfig> {
|
||||
return window.hermesDesktop.api<HermesConfig>({
|
||||
...profileScoped(),
|
||||
path: '/api/config'
|
||||
})
|
||||
}
|
||||
|
||||
export function getHermesConfigRecord(): Promise<HermesConfigRecord> {
|
||||
return window.hermesDesktop.api<HermesConfigRecord>({
|
||||
...profileScoped(),
|
||||
path: '/api/config'
|
||||
})
|
||||
}
|
||||
|
||||
export function getHermesConfigDefaults(): Promise<HermesConfigRecord> {
|
||||
return window.hermesDesktop.api<HermesConfigRecord>({
|
||||
...profileScoped(),
|
||||
path: '/api/config/defaults'
|
||||
})
|
||||
}
|
||||
|
||||
export function getHermesConfigSchema(): Promise<ConfigSchemaResponse> {
|
||||
return window.hermesDesktop.api<ConfigSchemaResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/config/schema'
|
||||
})
|
||||
}
|
||||
|
||||
export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...profileScoped(),
|
||||
path: '/api/config',
|
||||
method: 'PUT',
|
||||
body: { config }
|
||||
@@ -240,12 +304,14 @@ export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: bool
|
||||
|
||||
export function getEnvVars(): Promise<Record<string, EnvVarInfo>> {
|
||||
return window.hermesDesktop.api<Record<string, EnvVarInfo>>({
|
||||
...profileScoped(),
|
||||
path: '/api/env'
|
||||
})
|
||||
}
|
||||
|
||||
export function setEnvVar(key: string, value: string): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...profileScoped(),
|
||||
path: '/api/env',
|
||||
method: 'PUT',
|
||||
body: { key, value }
|
||||
@@ -257,6 +323,7 @@ export function validateProviderCredential(
|
||||
value: string
|
||||
): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({
|
||||
...profileScoped(),
|
||||
path: '/api/providers/validate',
|
||||
method: 'POST',
|
||||
body: { key, value }
|
||||
@@ -265,6 +332,7 @@ export function validateProviderCredential(
|
||||
|
||||
export function deleteEnvVar(key: string): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...profileScoped(),
|
||||
path: '/api/env',
|
||||
method: 'DELETE',
|
||||
body: { key }
|
||||
@@ -273,6 +341,7 @@ export function deleteEnvVar(key: string): Promise<{ ok: boolean }> {
|
||||
|
||||
export function revealEnvVar(key: string): Promise<{ key: string; value: string }> {
|
||||
return window.hermesDesktop.api<{ key: string; value: string }>({
|
||||
...profileScoped(),
|
||||
path: '/api/env/reveal',
|
||||
method: 'POST',
|
||||
body: { key }
|
||||
@@ -281,12 +350,14 @@ export function revealEnvVar(key: string): Promise<{ key: string; value: string
|
||||
|
||||
export function listOAuthProviders(): Promise<OAuthProvidersResponse> {
|
||||
return window.hermesDesktop.api<OAuthProvidersResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/providers/oauth'
|
||||
})
|
||||
}
|
||||
|
||||
export function startOAuthLogin(providerId: string): Promise<OAuthStartResponse> {
|
||||
return window.hermesDesktop.api<OAuthStartResponse>({
|
||||
...profileScoped(),
|
||||
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
|
||||
method: 'POST',
|
||||
body: {}
|
||||
@@ -295,6 +366,7 @@ export function startOAuthLogin(providerId: string): Promise<OAuthStartResponse>
|
||||
|
||||
export function submitOAuthCode(providerId: string, sessionId: string, code: string): Promise<OAuthSubmitResponse> {
|
||||
return window.hermesDesktop.api<OAuthSubmitResponse>({
|
||||
...profileScoped(),
|
||||
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
|
||||
method: 'POST',
|
||||
body: { session_id: sessionId, code }
|
||||
@@ -303,12 +375,14 @@ export function submitOAuthCode(providerId: string, sessionId: string, code: str
|
||||
|
||||
export function pollOAuthSession(providerId: string, sessionId: string): Promise<OAuthPollResponse> {
|
||||
return window.hermesDesktop.api<OAuthPollResponse>({
|
||||
...profileScoped(),
|
||||
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`
|
||||
})
|
||||
}
|
||||
|
||||
export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...profileScoped(),
|
||||
path: `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
@@ -316,12 +390,14 @@ export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }>
|
||||
|
||||
export function getSkills(): Promise<SkillInfo[]> {
|
||||
return window.hermesDesktop.api<SkillInfo[]>({
|
||||
...profileScoped(),
|
||||
path: '/api/skills'
|
||||
})
|
||||
}
|
||||
|
||||
export function toggleSkill(name: string, enabled: boolean): Promise<{ ok: boolean; name: string; enabled: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({
|
||||
...profileScoped(),
|
||||
path: '/api/skills/toggle',
|
||||
method: 'PUT',
|
||||
body: { name, enabled }
|
||||
@@ -330,6 +406,7 @@ export function toggleSkill(name: string, enabled: boolean): Promise<{ ok: boole
|
||||
|
||||
export function getToolsets(): Promise<ToolsetInfo[]> {
|
||||
return window.hermesDesktop.api<ToolsetInfo[]>({
|
||||
...profileScoped(),
|
||||
path: '/api/tools/toolsets'
|
||||
})
|
||||
}
|
||||
@@ -339,6 +416,7 @@ export function toggleToolset(
|
||||
enabled: boolean
|
||||
): Promise<{ ok: boolean; name: string; enabled: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({
|
||||
...profileScoped(),
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}`,
|
||||
method: 'PUT',
|
||||
body: { enabled }
|
||||
@@ -347,6 +425,7 @@ export function toggleToolset(
|
||||
|
||||
export function getToolsetConfig(name: string): Promise<ToolsetConfig> {
|
||||
return window.hermesDesktop.api<ToolsetConfig>({
|
||||
...profileScoped(),
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}/config`
|
||||
})
|
||||
}
|
||||
@@ -356,6 +435,7 @@ export function selectToolsetProvider(
|
||||
provider: string
|
||||
): Promise<{ ok: boolean; name: string; provider: string }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; name: string; provider: string }>({
|
||||
...profileScoped(),
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`,
|
||||
method: 'PUT',
|
||||
body: { provider }
|
||||
@@ -493,12 +573,14 @@ export function getProfileSetupCommand(name: string): Promise<ProfileSetupComman
|
||||
|
||||
export function getUsageAnalytics(days = 30): Promise<AnalyticsResponse> {
|
||||
return window.hermesDesktop.api<AnalyticsResponse>({
|
||||
...profileScoped(),
|
||||
path: `/api/analytics/usage?days=${Math.max(1, Math.floor(days))}`
|
||||
})
|
||||
}
|
||||
|
||||
export function getGlobalModelOptions(): Promise<ModelOptionsResponse> {
|
||||
return window.hermesDesktop.api<ModelOptionsResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/model/options'
|
||||
})
|
||||
}
|
||||
@@ -515,6 +597,7 @@ export interface RecommendedDefaultModel {
|
||||
// free user gets a free model instead of a paid default.
|
||||
export function getRecommendedDefaultModel(provider: string): Promise<RecommendedDefaultModel> {
|
||||
return window.hermesDesktop.api<RecommendedDefaultModel>({
|
||||
...profileScoped(),
|
||||
path: `/api/model/recommended-default?provider=${encodeURIComponent(provider)}`
|
||||
})
|
||||
}
|
||||
@@ -524,6 +607,7 @@ export function setGlobalModel(
|
||||
model: string
|
||||
): Promise<{ ok: boolean; provider: string; model: string }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; provider: string; model: string }>({
|
||||
...profileScoped(),
|
||||
path: '/api/model/set',
|
||||
method: 'POST',
|
||||
body: {
|
||||
@@ -536,12 +620,14 @@ export function setGlobalModel(
|
||||
|
||||
export function getAuxiliaryModels(): Promise<AuxiliaryModelsResponse> {
|
||||
return window.hermesDesktop.api<AuxiliaryModelsResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/model/auxiliary'
|
||||
})
|
||||
}
|
||||
|
||||
export function setModelAssignment(body: ModelAssignmentRequest): Promise<ModelAssignmentResponse> {
|
||||
return window.hermesDesktop.api<ModelAssignmentResponse>({
|
||||
...profileScoped(),
|
||||
path: '/api/model/set',
|
||||
method: 'POST',
|
||||
body
|
||||
|
||||
8
apps/desktop/src/i18n/catalog.ts
Normal file
8
apps/desktop/src/i18n/catalog.ts
Normal file
@@ -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
|
||||
}
|
||||
168
apps/desktop/src/i18n/context.test.tsx
Normal file
168
apps/desktop/src/i18n/context.test.tsx
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
183
apps/desktop/src/i18n/context.tsx
Normal file
183
apps/desktop/src/i18n/context.tsx
Normal file
@@ -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)
|
||||
}
|
||||
744
apps/desktop/src/i18n/en.ts
Normal file
744
apps/desktop/src/i18n/en.ts
Normal file
@@ -0,0 +1,744 @@
|
||||
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',
|
||||
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.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
apps/desktop/src/i18n/index.ts
Normal file
20
apps/desktop/src/i18n/index.ts
Normal file
@@ -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'
|
||||
38
apps/desktop/src/i18n/languages.test.ts
Normal file
38
apps/desktop/src/i18n/languages.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
56
apps/desktop/src/i18n/languages.ts
Normal file
56
apps/desktop/src/i18n/languages.ts
Normal file
@@ -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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user