Compare commits

..

1 Commits

Author SHA1 Message Date
teknium1
5288341fd7 fix(desktop): remote-ready boot no longer latched by local helper exit
The desktop's onBackendExit handler failed the boot whenever the local
helper process exited, with no mode check. In Remote Gateway mode the
local process is not the desktop's backend: it crashes on the 9120 bind
and is then SIGTERM'd when the app switches to remote. Each exit latched
a boot failure that the subsequent remote-ready progress could not clear,
stranding the user in the failure/repair overlay despite a healthy remote.

- main.cjs: track activeConnectionMode; mark deliberate teardowns in
  resetHermesConnection; tag backend-exit payload with {mode, deliberate};
  skip the fatal boot-progress update on deliberate exits.
- use-gateway-boot: a local exit is only fatal when attached in local mode
  and not a deliberate teardown (isFatalBackendExit).
- Extract the decision into src/lib/backend-exit.ts + vitest coverage.

Fixes #37869
2026-06-03 14:42:23 -07:00
232 changed files with 3701 additions and 10706 deletions

View File

@@ -171,11 +171,6 @@ jobs:
source .venv/bin/activate
uv pip install -e ".[all,dev]"
- name: Packaged-wheel i18n smoke test
run: |
source .venv/bin/activate
python -m pytest -m integration tests/test_wheel_locales_e2e.py -v
- name: Run e2e tests
run: |
source .venv/bin/activate

View File

@@ -283,21 +283,6 @@ The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes
**Structured React UI around the TUI is allowed when it is not a second chat surface.** Sidebar widgets, inspectors, summaries, status panels, and similar supporting views (e.g. `ChatSidebar`, `ModelPickerDialog`, `ToolCall`) are fine when they complement the embedded TUI rather than replacing the transcript / composer / terminal. Keep their state independent of the PTY child's session and surface their failures non-destructively so the terminal pane keeps working unimpaired.
### Electron Desktop Chat App (`apps/desktop/`)
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`.
**Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline:
- **Backend already provides everything.** `tui_gateway/server.py` `commands.catalog` (empty-query list) and `complete.slash` (typed-query completions) both include built-in commands, user `quick_commands`, AND skill-derived commands (`scan_skill_commands()` / `get_skill_commands()`). The desktop app does not need a new RPC to see skills.
- **The renderer curates via `apps/desktop/src/lib/desktop-slash-commands.ts`.** This is the load-bearing file. It holds `DESKTOP_COMMANDS` (the ~19 built-ins shown in the palette) plus block-lists for terminal-only / messaging-only / picker-owned / settings-owned / advanced commands that should NOT clutter the desktop popover.
- `isDesktopSlashCommand(name)` — gates **execution**. Returns true for built-ins AND for any non-built-in (skill / quick command), so typed extension commands run.
- `isDesktopSlashSuggestion(name)` — gates **discovery/completion**. Used by BOTH completion paths in `app/chat/composer/hooks/use-slash-completions.ts` (empty-query catalog filter + typed-query `complete.slash` filter) and by `filterDesktopCommandsCatalog`.
- `isDesktopSlashExtensionCommand(name)` — true when the command is NOT a known Hermes built-in (i.e. a skill or user quick command). Both suggestion and catalog-filter paths allow extensions through so skill commands surface in the palette. (Added when fixing "skill commands missing from the desktop slash palette" — the curated allow-list was silently dropping every skill/quick command from completions even though they executed fine when typed.)
- **Dispatch** lives in `app/session/hooks/use-prompt-actions.ts` (`runSlash`): built-ins that the desktop owns (`/skin`, `/help`, `/new`, …) are handled locally or via `commands.catalog`; everything else goes to `slash.exec`, falling back to `command.dispatch` (which the gateway resolves into skill / alias / exec directives). A skill command resolves to `{type: "skill", message}` and is submitted as a normal prompt.
**Rule:** the desktop slash palette's curation is about hiding noise (terminal-only / messaging-only built-ins), NOT about hiding user-activated extensions. Skill commands and `quick_commands` are extensions the backend surfaces — they belong in completions. If you tighten `desktop-slash-commands.ts`, keep `isDesktopSlashExtensionCommand` flowing into both the suggestion and catalog-filter paths. Tests: `apps/desktop/src/lib/desktop-slash-commands.test.ts` (run via the repo-root `vitest`, since `apps/desktop` resolves deps from the root workspace install).
---
## Adding New Tools

View File

@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
# hermes process, the dashboard, and per-profile gateways.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev procps git openssh-client docker-cli xz-utils && \
rm -rf /var/lib/apt/lists/*
# ---------- s6-overlay install ----------
@@ -157,17 +157,10 @@ RUN npm install --prefer-offline --no-audit && \
# so Docker users can use these providers without requiring runtime
# lazy-install access to PyPI (often blocked in containerized envs).
#
# The hindsight memory provider's client (hindsight-client) is baked in
# for the same reason: it lazy-installs into /opt/hermes/.venv at first
# use, which lives inside the (immutable) image layer rather than the
# mounted /opt/data volume, so it is lost on every container recreate /
# image update and recall/retain then fails with
# `ModuleNotFoundError: No module named 'hindsight_client'` (#38128).
#
# The editable link is created after the source copy below.
COPY pyproject.toml uv.lock ./
RUN touch ./README.md
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity
# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
@@ -185,16 +178,13 @@ RUN cd web && npm run build && \
# hermes_cli/main.py succeeds (see #18800). /opt/hermes/web is build-time
# only (HERMES_WEB_DIST points at hermes_cli/web_dist) and is intentionally
# not chowned here.
# /opt/hermes/gateway is runtime-writable: Python may create __pycache__ and
# gateway state artifacts beneath the package after services drop privileges,
# especially when the hermes UID is remapped at boot (#27221).
# The .venv MUST remain hermes-writable so lazy_deps.py can install
# remaining optional platform packages and future pin bumps at first use.
# Without this, `uv pip install` fails with EACCES and adapters silently
# fail to load. See tools/lazy_deps.py.
USER root
RUN chmod -R a+rX /opt/hermes && \
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/gateway /opt/hermes/node_modules
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/node_modules
# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
# the data volume. Each supervised service then drops to the hermes user via
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services

View File

@@ -1,6 +1,5 @@
graft skills
graft optional-skills
graft locales
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs
# built from the sdist (e.g. Homebrew, downstream packagers). package-data

View File

@@ -94,7 +94,7 @@ One command from a fresh install:
hermes setup --portal
```
That logs you in via OAuth, sets Nous as your provider, and turns on the Tool Gateway. Check what's wired up any time with `hermes portal info`. Full details on the [Tool Gateway docs page](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway).
That logs you in via OAuth, sets Nous as your provider, and turns on the Tool Gateway. Check what's wired up any time with `hermes portal status`. Full details on the [Tool Gateway docs page](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway).
You can still bring your own keys per-tool whenever you want — the gateway is per-backend, not all-or-nothing.

View File

@@ -80,7 +80,7 @@ Hermes 始终允许你使用任意服务商,这点不会改变。但如果你
hermes setup --portal
```
它会通过 OAuth 登录、把 Nous 设为推理服务商,并启用 Tool Gateway。随时用 `hermes portal info` 查看路由状态。完整说明见 [Tool Gateway 文档](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway)。
它会通过 OAuth 登录、把 Nous 设为推理服务商,并启用 Tool Gateway。随时用 `hermes portal status` 查看路由状态。完整说明见 [Tool Gateway 文档](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway)。
你随时可以按工具单独切回自己的 API Key — Gateway 是按工具粒度生效的,不是一刀切。

View File

@@ -1296,7 +1296,7 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
for internal_key in [k for k in api_msg if isinstance(k, str) and k.startswith("_")]:
api_msg.pop(internal_key, None)
if _needs_sanitize:
agent._sanitize_tool_calls_for_strict_api(api_msg, model=agent.model)
agent._sanitize_tool_calls_for_strict_api(api_msg)
api_messages.append(api_msg)
effective_system = agent._cached_system_prompt or ""

View File

@@ -982,7 +982,7 @@ def run_conversation(
# Uses new dicts so the internal messages list retains the fields
# for Codex Responses compatibility.
if agent._should_sanitize_tool_calls():
agent._sanitize_tool_calls_for_strict_api(api_msg, model=agent.model)
agent._sanitize_tool_calls_for_strict_api(api_msg)
# Keep 'reasoning_details' - OpenRouter uses this for multi-turn reasoning context
# The signature field helps maintain reasoning continuity
api_messages.append(api_msg)
@@ -3255,7 +3255,7 @@ def run_conversation(
else: # nous
agent._vprint(f"{agent.log_prefix} 💡 Nous Portal OAuth token was rejected (HTTP 401). Your token may be", force=True)
agent._vprint(f"{agent.log_prefix} expired, revoked, or your account may be out of credits. To fix:", force=True)
agent._vprint(f"{agent.log_prefix} 1. Re-authenticate: hermes portal", force=True)
agent._vprint(f"{agent.log_prefix} 1. Re-authenticate: hermes auth add nous --type oauth", force=True)
agent._vprint(f"{agent.log_prefix} 2. Check your portal account: https://portal.nousresearch.com", force=True)
# ``:free`` is OpenRouter slug syntax; Nous Portal will reject
# the model name even after a successful re-auth.

View File

@@ -32,7 +32,6 @@ from __future__ import annotations
import logging
import os
import sysconfig
import threading
from functools import lru_cache
from pathlib import Path
@@ -88,54 +87,11 @@ _catalog_lock = threading.Lock()
def _locales_dir() -> Path:
"""Return the directory containing locale YAML files.
Resolution order, first existing wins:
1. ``HERMES_BUNDLED_LOCALES`` env var -- set by the Nix wrapper (or any
sealed-packaging system) to point at the installed catalog directory.
2. ``<repo-root>/locales`` -- source checkouts and ``pip install -e .``,
where the working tree sits next to ``agent/``.
3. ``<sysconfig data|purelib|platlib>/locales`` -- pip wheel installs.
setuptools ``data-files`` extracts ``locales/*.yaml`` under the
interpreter's ``data`` scheme; the other schemes are checked as a
safety net for nonstandard layouts.
Falling through to the source-style path (even when missing) keeps
``_load_catalog`` error messages informative -- it logs the path it
looked at -- rather than raising.
Lives next to the repo root so both the bundled install and editable
checkouts find it without PYTHONPATH gymnastics.
"""
override = os.getenv("HERMES_BUNDLED_LOCALES", "").strip()
if override:
candidate = Path(override)
if candidate.is_dir():
return candidate
logger.warning(
"HERMES_BUNDLED_LOCALES points to a non-directory path (%s); "
"falling back to bundled/source locale resolution",
override,
)
# agent/i18n.py -> agent/ -> repo root (source checkout, editable install)
source_dir = Path(__file__).resolve().parent.parent / "locales"
if source_dir.is_dir():
return source_dir
# pip wheel install: data-files lands under the interpreter data scheme.
# ``data`` (== sys.prefix in a venv) is where setuptools data-files extract
# and is checked first. ``purelib``/``platlib`` (site-packages) are a safety
# net for nonstandard layouts. NOTE: this does NOT cover ``pip install
# --user`` (user scheme, ~/.local/locales) or ``pip install --target`` --
# both are out of scope; see the plan header.
for scheme in ("data", "purelib", "platlib"):
raw = sysconfig.get_path(scheme)
if not raw:
continue
candidate = Path(raw) / "locales"
if candidate.is_dir():
return candidate
# Last resort: return the source-style path so _load_catalog's catalog-missing
# log (logger.debug "i18n catalog missing for %s at %s") stays informative.
return source_dir
# agent/i18n.py -> agent/ -> repo root
return Path(__file__).resolve().parent.parent / "locales"
def _normalize_lang(value: Any) -> str:

View File

@@ -129,14 +129,9 @@ DEFAULT_AGENT_IDENTITY = (
)
HERMES_AGENT_HELP_GUIDANCE = (
"You run on Hermes Agent (by Nous Research). When the user needs help with "
"Hermes itself — configuring, setting up, using, extending, or troubleshooting "
"it — or when you need to understand your own features, tools, or capabilities, "
"the documentation at https://hermes-agent.nousresearch.com/docs is your "
"authoritative reference and always holds the latest, most up-to-date "
"information. Load the `hermes-agent` skill with skill_view(name='hermes-agent') "
"for additional guidance and proven workflows, but treat the docs as the source "
"of truth when the two differ."
"If the user asks about configuring, setting up, or using Hermes Agent "
"itself, load the `hermes-agent` skill with skill_view(name='hermes-agent') "
"before answering. Docs: https://hermes-agent.nousresearch.com/docs"
)
MEMORY_GUIDANCE = (

View File

@@ -99,22 +99,6 @@ def _is_gemini_openai_compat_base_url(base_url: Any) -> bool:
return normalized.endswith("/openai")
def _model_consumes_thought_signature(model: Any) -> bool:
"""True when the outgoing model is a Gemini family model that requires
``extra_content`` (thought_signature) to be replayed on tool calls.
Gemini 3 thinking models attach ``extra_content`` to each tool call and
reject subsequent requests with HTTP 400 if it is missing. Every other
strict OpenAI-compatible provider (Fireworks, Mistral, ...) rejects the
request with 400 if ``extra_content`` *is* present. So the field must be
kept only when the target model is itself Gemini-family, and stripped
otherwise — including when a non-Gemini model inherits stale Gemini
``extra_content`` from earlier in a mixed-provider session.
"""
m = str(model or "").lower()
return "gemini" in m or "gemma" in m
class ChatCompletionsTransport(ProviderTransport):
"""Transport for api_mode='chat_completions'.
@@ -135,14 +119,6 @@ class ChatCompletionsTransport(ProviderTransport):
- Codex Responses API fields: ``codex_reasoning_items`` /
``codex_message_items`` on the message, ``call_id`` /
``response_item_id`` on ``tool_calls`` entries.
- ``extra_content`` on ``tool_calls`` (Gemini thought_signature) —
stripped unless the outgoing ``model`` is itself Gemini-family.
Gemini 3 thinking models attach it for replay, but strict providers
(Fireworks, Mistral) reject any payload containing it with
``Extra inputs are not permitted, field: 'messages[N].tool_calls[M].extra_content'``.
It must be kept for Gemini targets (replay required) and dropped for
everyone else, including non-Gemini models that inherited stale
Gemini ``extra_content`` earlier in a mixed-provider session.
- ``tool_name`` on tool-result messages — written by
``make_tool_result_message()`` for the SQLite FTS index, but not
part of the Chat Completions schema. Strict providers (Fireworks,
@@ -161,9 +137,6 @@ class ChatCompletionsTransport(ProviderTransport):
``Extra inputs are not permitted, field: 'messages[N]._empty_recovery_synthetic'``,
which then poisons every subsequent request in the session.
"""
strip_extra_content = not _model_consumes_thought_signature(
kwargs.get("model")
)
needs_sanitize = False
for msg in messages:
if not isinstance(msg, dict):
@@ -182,9 +155,7 @@ class ChatCompletionsTransport(ProviderTransport):
if isinstance(tool_calls, list):
for tc in tool_calls:
if isinstance(tc, dict) and (
"call_id" in tc
or "response_item_id" in tc
or (strip_extra_content and "extra_content" in tc)
"call_id" in tc or "response_item_id" in tc
):
needs_sanitize = True
break
@@ -212,8 +183,6 @@ class ChatCompletionsTransport(ProviderTransport):
if isinstance(tc, dict):
tc.pop("call_id", None)
tc.pop("response_item_id", None)
if strip_extra_content:
tc.pop("extra_content", None)
return sanitized
def convert_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -271,10 +240,8 @@ class ChatCompletionsTransport(ProviderTransport):
anthropic_max_output: int | None
extra_body_additions: dict | None
"""
# Codex sanitization: drop reasoning_items / call_id / response_item_id.
# Pass model so the Gemini thought_signature (extra_content) is kept for
# Gemini targets and stripped for strict non-Gemini providers.
sanitized = self.convert_messages(messages, model=model)
# Codex sanitization: drop reasoning_items / call_id / response_item_id
sanitized = self.convert_messages(messages)
# ── Provider profile: single-path when present ──────────────────
_profile = params.get("provider_profile")

View File

@@ -21,7 +21,7 @@ use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use tokio::sync::{mpsc, Mutex};
use crate::events::{BootstrapEvent, LogStream, Manifest, StageState};
use crate::events::{BootstrapEvent, Manifest, StageState};
use crate::install_script::{self, Pin, ScriptKind, ScriptSource};
use crate::powershell::{self, StreamSink};
use crate::AppState;
@@ -179,11 +179,9 @@ pub async fn launch_hermes_desktop(
tracing::info!(?exe_path, "launching Hermes desktop");
// Detach from us — the installer is about to exit. On macOS launch the
// bundle through LaunchServices instead of exec'ing Contents/MacOS/Hermes
// directly; this matches user double-click/open behavior and avoids cwd /
// quarantine oddities after a self-update rebuild.
let mut cmd = desktop_launch_command(&exe_path, &install_root);
// Detach from us — the installer is about to exit.
let mut cmd = tokio::process::Command::new(&exe_path);
cmd.current_dir(exe_path.parent().unwrap_or(&install_root));
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
@@ -234,24 +232,6 @@ pub(crate) fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Opti
None
}
pub(crate) fn resolve_hermes_desktop_app(install_root: &std::path::Path) -> Option<PathBuf> {
let exe = resolve_hermes_desktop_exe(install_root)?;
#[cfg(target_os = "macos")]
{
// .../Hermes.app/Contents/MacOS/Hermes -> .../Hermes.app
let app = exe.parent()?.parent()?.parent()?.to_path_buf();
if app.extension().and_then(|e| e.to_str()) == Some("app") && app.is_dir() {
return Some(app);
}
}
#[cfg(not(target_os = "macos"))]
{
return Some(exe);
}
#[allow(unreachable_code)]
None
}
/// True when a prior install completed (bootstrap-complete marker present) AND a
/// launchable desktop app exists on disk. Used by the installer's launcher fast
/// path so a bare re-open just opens Hermes instead of re-running setup.
@@ -267,7 +247,8 @@ pub(crate) fn spawn_installed_desktop(install_root: &std::path::Path) -> std::io
let exe = resolve_hermes_desktop_exe(install_root).ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, "no built Hermes desktop app")
})?;
let mut cmd = desktop_launch_command_std(&exe, install_root);
let mut cmd = std::process::Command::new(&exe);
cmd.current_dir(exe.parent().unwrap_or(install_root));
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
@@ -280,62 +261,6 @@ pub(crate) fn spawn_installed_desktop(install_root: &std::path::Path) -> std::io
cmd.spawn().map(|_child| ())
}
#[cfg(target_os = "macos")]
pub(crate) fn open_macos_app_detached(app_bundle: &std::path::Path) -> std::io::Result<()> {
let mut cmd = std::process::Command::new("/usr/bin/open");
cmd.arg(app_bundle);
cmd.current_dir(crate::paths::hermes_home());
cmd.spawn().map(|_child| ())
}
#[cfg(target_os = "macos")]
fn app_bundle_for_exe(exe: &std::path::Path) -> Option<PathBuf> {
let app = exe.parent()?.parent()?.parent()?.to_path_buf();
if app.extension().and_then(|e| e.to_str()) == Some("app") && app.is_dir() {
Some(app)
} else {
None
}
}
fn desktop_launch_command(
exe_path: &std::path::Path,
install_root: &std::path::Path,
) -> tokio::process::Command {
#[cfg(target_os = "macos")]
{
if let Some(app_bundle) = app_bundle_for_exe(exe_path) {
let mut cmd = tokio::process::Command::new("/usr/bin/open");
cmd.arg(app_bundle);
cmd.current_dir(crate::paths::hermes_home());
return cmd;
}
}
let mut cmd = tokio::process::Command::new(exe_path);
cmd.current_dir(exe_path.parent().unwrap_or(install_root));
cmd
}
fn desktop_launch_command_std(
exe_path: &std::path::Path,
install_root: &std::path::Path,
) -> std::process::Command {
#[cfg(target_os = "macos")]
{
if let Some(app_bundle) = app_bundle_for_exe(exe_path) {
let mut cmd = std::process::Command::new("/usr/bin/open");
cmd.arg(app_bundle);
cmd.current_dir(crate::paths::hermes_home());
return cmd;
}
}
let mut cmd = std::process::Command::new(exe_path);
cmd.current_dir(exe_path.parent().unwrap_or(install_root));
cmd
}
// ---------------------------------------------------------------------------
// Bootstrap implementation
// ---------------------------------------------------------------------------
@@ -366,7 +291,6 @@ async fn run_bootstrap(
BootstrapEvent::Log {
stage: None,
line: line.to_string(),
stream: LogStream::Stdout,
},
);
// Bump to info-level so the line shows in bootstrap-installer.log
@@ -701,7 +625,6 @@ async fn run_install_script(
BootstrapEvent::Log {
stage: stage_for_stdout.clone(),
line: line.to_string(),
stream: LogStream::Stdout,
},
);
// Tee to the rolling installer log so we have a persistent
@@ -720,8 +643,7 @@ async fn run_install_script(
&app_for_stderr,
BootstrapEvent::Log {
stage: stage_for_stderr.clone(),
line: line.to_string(),
stream: LogStream::Stderr,
line: format!("stderr: {line}"),
},
);
// stderr-level lines get warn! so they're visually distinct
@@ -817,90 +739,3 @@ fn truncate(s: &str, max: usize) -> String {
format!("{}...", &s[..max])
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::path::Path;
fn unique_tmp_dir(tag: &str) -> PathBuf {
let base = std::env::temp_dir().join(format!(
"hermes-bootstrap-test-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&base).unwrap();
base
}
// Build a fake built-desktop release tree at the platform's expected path
// and return (install_root, expected_app_bundle_or_exe).
fn make_release_tree(install_root: &Path) -> PathBuf {
let release = install_root.join("apps").join("desktop").join("release");
if cfg!(target_os = "macos") {
let macos_dir = release
.join("mac-arm64")
.join("Hermes.app")
.join("Contents")
.join("MacOS");
std::fs::create_dir_all(&macos_dir).unwrap();
std::fs::write(macos_dir.join("Hermes"), b"#!/bin/sh\n").unwrap();
macos_dir.parent().unwrap().parent().unwrap().to_path_buf() // .../Hermes.app
} else if cfg!(target_os = "windows") {
let dir = release.join("win-unpacked");
std::fs::create_dir_all(&dir).unwrap();
let exe = dir.join("Hermes.exe");
std::fs::write(&exe, b"stub").unwrap();
exe
} else {
let dir = release.join("linux-unpacked");
std::fs::create_dir_all(&dir).unwrap();
let exe = dir.join("hermes");
std::fs::write(&exe, b"stub").unwrap();
exe
}
}
// The relaunch / install target is derived from the rebuilt desktop app.
// On macOS this MUST resolve to the .app bundle (what `open` relaunches and
// what the updater ditto's over /Applications/Hermes.app). A regression in
// this derivation breaks the post-update auto-relaunch, so guard it.
#[test]
fn resolve_hermes_desktop_app_finds_built_bundle() {
let root = unique_tmp_dir("app-ok");
let expected = make_release_tree(&root);
let resolved = resolve_hermes_desktop_app(&root)
.expect("should resolve the freshly-built desktop app");
#[cfg(target_os = "macos")]
{
assert_eq!(resolved, expected, "must resolve to the .app bundle");
assert_eq!(
resolved.extension().and_then(|e| e.to_str()),
Some("app"),
"relaunch target must be a .app bundle on macOS"
);
}
#[cfg(not(target_os = "macos"))]
{
assert_eq!(resolved, expected);
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn resolve_hermes_desktop_app_is_none_without_a_build() {
let root = unique_tmp_dir("app-none");
// No release tree created.
assert!(
resolve_hermes_desktop_app(&root).is_none(),
"no resolved app when nothing has been built"
);
let _ = std::fs::remove_dir_all(&root);
}
}

View File

@@ -51,16 +51,6 @@ pub enum StageState {
Failed,
}
/// Which pipe a raw log line came from. Reported as structured metadata so
/// the UI can style stderr subtly rather than mislabeling it as an error:
/// uv/pip/git/npm write normal progress to stderr by design.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum LogStream {
Stdout,
Stderr,
}
/// The single event channel `bootstrap` emits these. `type` discriminates.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "lowercase")]
@@ -82,14 +72,11 @@ pub enum BootstrapEvent {
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
/// Raw stdout/stderr line from install.ps1 (or our wrapper). `stream`
/// tells the UI which pipe it came from so stderr can be styled subtly
/// instead of being mislabeled as an error.
/// Raw stdout/stderr line from install.ps1 (or our wrapper).
Log {
#[serde(skip_serializing_if = "Option::is_none")]
stage: Option<String>,
line: String,
stream: LogStream,
},
/// Sent once when all stages complete successfully.
Complete {

View File

@@ -45,14 +45,6 @@ pub async fn run_script(
) -> Result<ScriptResult> {
let mut cmd = build_command(script_path, args);
// The installer can be launched from a .app bundle that is later replaced
// during self-update. Pin child scripts to a stable directory so bash/zsh
// never starts from a deleted cwd and emits getcwd/job-working-directory
// errors at the end of an otherwise successful install.
if let Some(cwd) = stable_script_cwd(script_path, hermes_home_override) {
cmd.current_dir(cwd);
}
if let Some(home) = hermes_home_override {
cmd.env("HERMES_HOME", home);
}
@@ -154,16 +146,6 @@ pub async fn run_script(
})
}
fn stable_script_cwd<'a>(script_path: &'a Path, hermes_home_override: Option<&'a str>) -> Option<&'a Path> {
if let Some(home) = hermes_home_override {
let path = Path::new(home);
if path.is_dir() {
return Some(path);
}
}
script_path.parent().filter(|p| p.is_dir())
}
async fn recv_cancel(rx: &mut Option<CancelRx>) {
match rx {
Some(r) => {
@@ -282,11 +264,4 @@ info line
assert!(parse_stage_result("just banner\n").is_none());
assert!(parse_manifest("just banner\n").is_none());
}
#[test]
fn stable_script_cwd_prefers_existing_hermes_home() {
let script = Path::new("/tmp/install.sh");
let cwd = stable_script_cwd(script, Some("/"));
assert_eq!(cwd, Some(Path::new("/")));
}
}

View File

@@ -19,11 +19,8 @@
//! the no-window creation flag — both already cfg-gated. Keep new logic
//! OS-agnostic so the mac/linux port stays "fill in the paths".
use std::env;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
@@ -31,7 +28,7 @@ use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};
use crate::events::{BootstrapEvent, StageInfo, StageState};
/// `hermes update` exit code meaning "another hermes process is holding the
/// venv shim open / dirty precondition" — see _cmd_update_impl in
@@ -43,48 +40,10 @@ const UPDATE_EXIT_CONCURRENT: i32 = 2;
const DESKTOP_EXIT_WAIT: Duration = Duration::from_secs(20);
const DESKTOP_EXIT_POLL: Duration = Duration::from_millis(500);
/// Guards against concurrent update runs. The frontend kicks `startUpdate()`
/// from a mount effect, which can fire more than once (React strict-mode
/// double-invokes effects in dev; a window reload or stray re-init can do it
/// in prod). Two `run_update` tasks racing on `git stash` corrupt the working
/// tree — one stashes the changes the other then can't find. Exactly one task
/// may hold this flag at a time.
static UPDATE_RUNNING: AtomicBool = AtomicBool::new(false);
/// Frontend → Rust: kick off the update flow. Mirrors `start_bootstrap`'s
/// fire-and-forget shape; progress arrives on the `bootstrap` event channel.
#[tauri::command]
pub async fn start_update(app: AppHandle) -> Result<(), String> {
// Re-entrancy guard (see UPDATE_RUNNING). compare_exchange lets exactly one
// caller flip false→true; any concurrent caller no-ops instead of spawning
// a second racing update.
if UPDATE_RUNNING
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
// Already running: re-emit the manifest so a duplicate startUpdate()
// call (which resets the frontend store) can recover its stage list.
let target_app = if cfg!(target_os = "macos") {
target_app_from_args(std::env::args().skip(1))
} else {
None
};
let mut stages = vec![
stage_info("update", "Updating Hermes"),
stage_info("rebuild", "Rebuilding the desktop app"),
];
if cfg!(target_os = "macos") && target_app.is_some() {
stages.push(stage_info("install", "Installing the updated app"));
}
emit(
&app,
BootstrapEvent::Manifest {
stages,
protocol_version: None,
},
);
return Ok(());
}
tokio::spawn(async move {
if let Err(err) = run_update(app.clone()).await {
// run_update already emits a Failed event on the paths that matter;
@@ -97,7 +56,6 @@ pub async fn start_update(app: AppHandle) -> Result<(), String> {
},
);
}
UPDATE_RUNNING.store(false, Ordering::SeqCst);
});
Ok(())
}
@@ -105,14 +63,6 @@ pub async fn start_update(app: AppHandle) -> Result<(), String> {
async fn run_update(app: AppHandle) -> Result<()> {
let hermes_home = crate::paths::hermes_home();
let install_root = hermes_home.join("hermes-agent");
let update_branch = update_branch_from_args(std::env::args().skip(1))
.or_else(|| option_env_string("BUILD_PIN_BRANCH"))
.unwrap_or_else(|| "main".to_string());
let target_app = if cfg!(target_os = "macos") {
target_app_from_args(std::env::args().skip(1))
} else {
None
};
let hermes = resolve_hermes(&install_root).ok_or_else(|| {
let msg = format!(
@@ -131,18 +81,13 @@ async fn run_update(app: AppHandle) -> Result<()> {
})?;
// Synthetic manifest so the existing progress UI renders our two stages.
let mut stages = vec![
stage_info("update", "Updating Hermes"),
stage_info("rebuild", "Rebuilding the desktop app"),
];
if cfg!(target_os = "macos") && target_app.is_some() {
stages.push(stage_info("install", "Installing the updated app"));
}
emit(
&app,
BootstrapEvent::Manifest {
stages,
stages: vec![
stage_info("update", "Updating Hermes"),
stage_info("rebuild", "Rebuilding the desktop app"),
],
protocol_version: None,
},
);
@@ -162,17 +107,12 @@ async fn run_update(app: AppHandle) -> Result<()> {
// reports "already up to date" against the wrong branch. The desktop
// detected the update against this same branch, so we must update against
// it too.
emit_log(
&app,
Some("update"),
LogStream::Stdout,
&format!("[update] updating against branch {update_branch}"),
);
let child_env = update_child_env(&install_root);
let mut update_args: Vec<String> =
vec!["update".into(), "--yes".into(), "--gateway".into()];
update_args.push("--branch".into());
update_args.push(update_branch);
let pin_branch = option_env_string("BUILD_PIN_BRANCH");
let mut update_args: Vec<&str> = vec!["update", "--yes", "--gateway"];
if let Some(b) = pin_branch.as_deref() {
update_args.push("--branch");
update_args.push(b);
}
emit_stage(&app, "update", StageState::Running, None, None);
let started = Instant::now();
@@ -181,7 +121,6 @@ async fn run_update(app: AppHandle) -> Result<()> {
&hermes,
&update_args,
&install_root,
&child_env,
Some("update"),
)
.await?;
@@ -243,13 +182,11 @@ async fn run_update(app: AppHandle) -> Result<()> {
// repo-root deps with --workspaces=false). This is the rebuild it skips.
emit_stage(&app, "rebuild", StageState::Running, None, None);
let started = Instant::now();
let rebuild_args: Vec<String> = vec!["desktop".into(), "--build-only".into()];
let rebuild = run_streamed(
&app,
&hermes,
&rebuild_args,
&["desktop", "--build-only"],
&install_root,
&child_env,
Some("rebuild"),
)
.await?;
@@ -280,43 +217,6 @@ async fn run_update(app: AppHandle) -> Result<()> {
}
emit_stage(&app, "rebuild", StageState::Succeeded, Some(rebuild_ms), None);
let launch_target = if let Some(target_app) = target_app {
let started = Instant::now();
emit_stage(&app, "install", StageState::Running, None, None);
match install_macos_app_update(&app, &install_root, &target_app).await {
Ok(installed_app) => {
emit_stage(
&app,
"install",
StageState::Succeeded,
Some(started.elapsed().as_millis() as u64),
None,
);
Some(installed_app)
}
Err(err) => {
let msg = format!("{err:#}");
emit_stage(
&app,
"install",
StageState::Failed,
Some(started.elapsed().as_millis() as u64),
Some(msg.clone()),
);
emit(
&app,
BootstrapEvent::Failed {
stage: Some("install".into()),
error: msg.clone(),
},
);
return Err(anyhow!(msg));
}
}
} else {
None
};
// ---- done: signal complete, then launch the fresh desktop ------------
emit(
&app,
@@ -326,17 +226,10 @@ async fn run_update(app: AppHandle) -> Result<()> {
},
);
if let Some(target_app) = launch_target {
if let Err(err) = launch_macos_app_and_exit(&app, &target_app).await {
emit_log(
&app,
None,
LogStream::Stderr,
&format!("[update] could not auto-launch desktop: {err}. Launch Hermes manually."),
);
}
} else if let Err(err) =
crate::bootstrap::launch_hermes_desktop(app.clone(), install_root.to_string_lossy().into_owned()).await
// Reuse the same detached-launch + app.exit(0) used post-install.
if let Err(err) =
crate::bootstrap::launch_hermes_desktop(app.clone(), install_root.to_string_lossy().into_owned())
.await
{
// Launch failed: don't hard-fail the update (it succeeded); surface a
// log line so the success screen can still tell the user to launch
@@ -344,7 +237,6 @@ async fn run_update(app: AppHandle) -> Result<()> {
emit_log(
&app,
None,
LogStream::Stdout,
&format!("[update] could not auto-launch desktop: {err}. Launch Hermes manually."),
);
}
@@ -359,7 +251,7 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
let shim = venv_hermes(install_root);
let deadline = Instant::now() + DESKTOP_EXIT_WAIT;
emit_log(app, Some("update"), LogStream::Stdout, "[update] waiting for Hermes to exit…");
emit_log(app, Some("update"), "[update] waiting for Hermes to exit…");
loop {
if !is_locked(&shim) {
@@ -369,7 +261,6 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
emit_log(
app,
Some("update"),
LogStream::Stdout,
"[update] timed out waiting for Hermes to exit; proceeding anyway",
);
return;
@@ -398,9 +289,8 @@ fn is_locked(path: &Path) -> bool {
async fn run_streamed(
app: &AppHandle,
program: &Path,
args: &[String],
args: &[&str],
cwd: &Path,
envs: &[(String, OsString)],
stage: Option<&str>,
) -> Result<CmdResult> {
let mut cmd = Command::new(program);
@@ -409,9 +299,6 @@ async fn run_streamed(
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (key, value) in envs {
cmd.env(key, value);
}
#[cfg(target_os = "windows")]
{
@@ -433,22 +320,22 @@ async fn run_streamed(
loop {
tokio::select! {
line = out.next_line() => match line {
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), LogStream::Stdout, &l),
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), &l),
Ok(None) => break,
Err(e) => { tracing::warn!("stdout read error: {e}"); break; }
},
line = err.next_line() => match line {
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), LogStream::Stderr, &l),
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), &format!("stderr: {l}")),
Ok(None) => {}
Err(e) => { tracing::warn!("stderr read error: {e}"); }
},
}
}
while let Ok(Some(l)) = out.next_line().await {
emit_log(app, stage_owned.as_deref(), LogStream::Stdout, &l);
emit_log(app, stage_owned.as_deref(), &l);
}
while let Ok(Some(l)) = err.next_line().await {
emit_log(app, stage_owned.as_deref(), LogStream::Stderr, &l);
emit_log(app, stage_owned.as_deref(), &format!("stderr: {l}"));
}
let status = child.wait().await.map_err(|e| anyhow!("waiting for child: {e}"))?;
@@ -491,225 +378,6 @@ fn resolve_hermes(install_root: &Path) -> Option<PathBuf> {
None
}
fn update_child_env(install_root: &Path) -> Vec<(String, OsString)> {
let hermes_home = crate::paths::hermes_home();
let mut envs = vec![(
"HERMES_HOME".to_string(),
hermes_home.as_os_str().to_os_string(),
)];
if let Some(path) = path_with_prepended_entries(&[
hermes_home.join("node").join("bin"),
venv_bin_dir(install_root),
]) {
envs.push(("PATH".to_string(), path));
}
envs
}
fn venv_bin_dir(install_root: &Path) -> PathBuf {
if cfg!(target_os = "windows") {
install_root.join("venv").join("Scripts")
} else {
install_root.join("venv").join("bin")
}
}
fn path_with_prepended_entries(entries: &[PathBuf]) -> Option<OsString> {
let mut parts: Vec<PathBuf> = entries.to_vec();
if let Some(existing) = env::var_os("PATH") {
parts.extend(env::split_paths(&existing));
}
env::join_paths(parts).ok()
}
fn update_branch_from_args<I, S>(args: I) -> Option<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
arg_value_from_args(args, "--branch")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn target_app_from_args<I, S>(args: I) -> Option<PathBuf>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
arg_value_from_args(args, "--target-app")
.map(PathBuf::from)
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("app"))
}
fn arg_value_from_args<I, S>(args: I, name: &str) -> Option<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut iter = args.into_iter().map(|s| s.as_ref().to_string()).peekable();
while let Some(arg) = iter.next() {
if arg == name {
return iter.next();
}
if let Some(value) = arg.strip_prefix(&format!("{name}=")) {
return Some(value.to_string());
}
}
None
}
#[cfg(target_os = "macos")]
async fn install_macos_app_update(
app: &AppHandle,
install_root: &Path,
target_app: &Path,
) -> Result<PathBuf> {
if target_app.extension().and_then(|e| e.to_str()) != Some("app") {
return Err(anyhow!(
"refusing to install update into non-app path: {}",
target_app.display()
));
}
let rebuilt_app = crate::bootstrap::resolve_hermes_desktop_app(install_root).ok_or_else(|| {
anyhow!(
"desktop rebuild succeeded but no Hermes.app was found under {}",
install_root.join("apps").join("desktop").join("release").display()
)
})?;
let same = match (rebuilt_app.canonicalize(), target_app.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => rebuilt_app == target_app,
};
if same {
emit_log(
app,
Some("install"),
LogStream::Stdout,
&format!(
"[update] rebuilt app is already the launch target: {}",
target_app.display()
),
);
return Ok(target_app.to_path_buf());
}
emit_log(
app,
Some("install"),
LogStream::Stdout,
&format!(
"[update] installing rebuilt app {} -> {}",
rebuilt_app.display(),
target_app.display()
),
);
if let Some(parent) = target_app.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let tmp = PathBuf::from(format!("{}.hermes-update-new", target_app.display()));
let old = PathBuf::from(format!("{}.hermes-update-old", target_app.display()));
remove_dir_if_exists(&tmp).await;
remove_dir_if_exists(&old).await;
let ditto = Command::new("/usr/bin/ditto")
.arg(&rebuilt_app)
.arg(&tmp)
.current_dir(crate::paths::hermes_home())
.status()
.await
.map_err(|e| anyhow!("running ditto: {e}"))?;
if !ditto.success() {
return Err(anyhow!(
"ditto failed while copying updated app into {}",
tmp.display()
));
}
// Atomic-as-possible swap with rollback. Extracted so the invariant
// (target is never left deleted-with-no-replacement) can be unit-tested
// without ditto / a real .app bundle.
swap_in_new_bundle(&tmp, target_app, &old).await?;
let _ = Command::new("/usr/bin/xattr")
.arg("-dr")
.arg("com.apple.quarantine")
.arg(target_app)
.current_dir(crate::paths::hermes_home())
.status()
.await;
Ok(target_app.to_path_buf())
}
/// Move a freshly-staged bundle (`tmp`) into place at `target`, parking any
/// existing bundle at `old` so the move can succeed (macOS `rename` won't
/// overwrite a non-empty directory).
///
/// Invariant: on ANY failure path, `target` is left pointing at a working
/// bundle — either the original (rolled back from `old`) or untouched — and we
/// never delete the running app with no replacement in place. The staged `tmp`
/// copy is cleaned up on failure.
async fn swap_in_new_bundle(tmp: &Path, target: &Path, old: &Path) -> Result<()> {
let moved_old = if target.exists() {
if let Err(err) = tokio::fs::rename(target, old).await {
// Could not move the existing app aside. Leave it untouched and
// bail — a failed update must not brick the install.
remove_dir_if_exists(tmp).await;
return Err(anyhow!(
"could not move existing app aside at {} (leaving it in place): {err}",
target.display()
));
}
true
} else {
false
};
if let Err(err) = tokio::fs::rename(tmp, target).await {
// Restore the original app from the backup so the user keeps a working
// install, and clean up the staged copy.
if moved_old {
let _ = tokio::fs::rename(old, target).await;
}
remove_dir_if_exists(tmp).await;
return Err(anyhow!("installing updated app at {}: {err}", target.display()));
}
remove_dir_if_exists(old).await;
Ok(())
}
#[cfg(not(target_os = "macos"))]
async fn install_macos_app_update(
_app: &AppHandle,
_install_root: &Path,
target_app: &Path,
) -> Result<PathBuf> {
Ok(target_app.to_path_buf())
}
async fn remove_dir_if_exists(path: &Path) {
if path.exists() {
let _ = tokio::fs::remove_dir_all(path).await;
}
}
#[cfg(target_os = "macos")]
async fn launch_macos_app_and_exit(app: &AppHandle, target_app: &Path) -> Result<()> {
crate::bootstrap::open_macos_app_detached(target_app)
.map_err(|e| anyhow!("launching {}: {e}", target_app.display()))?;
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
app.exit(0);
Ok(())
}
#[cfg(not(target_os = "macos"))]
async fn launch_macos_app_and_exit(_app: &AppHandle, _target_app: &Path) -> Result<()> {
Ok(())
}
// ---------------------------------------------------------------------------
// Event helpers — keep emit shape identical to bootstrap.rs so the UI is reused
// ---------------------------------------------------------------------------
@@ -761,7 +429,7 @@ fn emit_stage(
);
}
fn emit_log(app: &AppHandle, stage: Option<&str>, stream: LogStream, line: &str) {
fn emit_log(app: &AppHandle, stage: Option<&str>, line: &str) {
match stage {
Some(s) => tracing::info!(target: "bootstrap.log", stage = %s, "{line}"),
None => tracing::info!(target: "bootstrap.log", "{line}"),
@@ -771,7 +439,6 @@ fn emit_log(app: &AppHandle, stage: Option<&str>, stream: LogStream, line: &str)
BootstrapEvent::Log {
stage: stage.map(|s| s.to_string()),
line: line.to_string(),
stream,
},
);
}
@@ -792,118 +459,4 @@ mod tests {
fn missing_file_is_not_locked() {
assert!(!is_locked(Path::new("/nonexistent/does/not/exist/xyz")));
}
#[test]
fn parses_update_branch_from_space_or_equals_args() {
assert_eq!(
update_branch_from_args(["--update", "--branch", "bb/test"]),
Some("bb/test".to_string())
);
assert_eq!(
update_branch_from_args(["--update", "--branch=main"]),
Some("main".to_string())
);
assert_eq!(update_branch_from_args(["--update"]), None);
}
#[test]
fn parses_only_app_targets() {
assert_eq!(
target_app_from_args(["--update", "--target-app", "/Applications/Hermes.app"]),
Some(PathBuf::from("/Applications/Hermes.app"))
);
assert_eq!(target_app_from_args(["--target-app", "/tmp/not-an-app"]), None);
}
// Helpers for the swap tests: make a throwaway dir tree we can rename.
fn unique_tmp_dir(tag: &str) -> PathBuf {
let base = std::env::temp_dir().join(format!(
"hermes-swap-test-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&base).unwrap();
base
}
fn write_marker(dir: &Path, contents: &str) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("marker.txt"), contents).unwrap();
}
#[tokio::test]
async fn swap_installs_new_bundle_and_cleans_up() {
let base = unique_tmp_dir("ok");
let target = base.join("Hermes.app");
let tmp = base.join("Hermes.app.hermes-update-new");
let old = base.join("Hermes.app.hermes-update-old");
write_marker(&target, "OLD");
write_marker(&tmp, "NEW");
swap_in_new_bundle(&tmp, &target, &old).await.unwrap();
// New bundle is now at target; staging + backup dirs are gone.
assert_eq!(
std::fs::read_to_string(target.join("marker.txt")).unwrap(),
"NEW"
);
assert!(!tmp.exists(), "staged copy should be cleaned up");
assert!(!old.exists(), "backup should be cleaned up on success");
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test]
async fn swap_failure_never_leaves_target_missing() {
// Regression guard for the catastrophic path: the move-aside of the
// existing app fails AND the staged bundle can't be installed. The
// buggy version deleted `target` when move-aside failed and then
// skipped rollback, bricking the install. The fixed version must leave
// the original app intact on disk.
//
// Trigger both failures deterministically:
// - `old` is a NON-EMPTY dir -> rename(target, old) fails
// - `tmp` does not exist -> rename(tmp, target) fails
let base = unique_tmp_dir("fail");
let target = base.join("Hermes.app");
let tmp = base.join("Hermes.app.hermes-update-new"); // intentionally absent
let old = base.join("Hermes.app.hermes-update-old");
write_marker(&target, "OLD");
write_marker(&old, "OCCUPIED"); // non-empty => rename(target,old) fails
let result = swap_in_new_bundle(&tmp, &target, &old).await;
assert!(result.is_err(), "swap should fail when neither move can complete");
assert!(target.exists(), "original app must NOT be deleted on failure");
assert_eq!(
std::fs::read_to_string(target.join("marker.txt")).unwrap(),
"OLD",
"original app contents must be intact after a failed swap"
);
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test]
async fn swap_rolls_back_when_install_step_fails() {
// Move-aside succeeds but installing the staged bundle fails (tmp
// absent). The original must be rolled back from `old` to `target`.
let base = unique_tmp_dir("rollback");
let target = base.join("Hermes.app");
let tmp = base.join("Hermes.app.hermes-update-new"); // absent
let old = base.join("Hermes.app.hermes-update-old");
write_marker(&target, "OLD");
let result = swap_in_new_bundle(&tmp, &target, &old).await;
assert!(result.is_err());
assert!(target.exists(), "original must be restored after failed install");
assert_eq!(
std::fs::read_to_string(target.join("marker.txt")).unwrap(),
"OLD"
);
assert!(!old.exists(), "backup should be rolled back, not left behind");
let _ = std::fs::remove_dir_all(&base);
}
}

View File

@@ -3,10 +3,8 @@ import { useStore } from '@nanostores/react'
import { Button } from '../components/button'
import {
$logPath,
$mode,
openLogDir,
startInstall,
startUpdate,
type BootstrapStateModel
} from '../store'
import { RefreshCw, FileText } from 'lucide-react'
@@ -24,8 +22,6 @@ interface FailureProps {
*/
export default function Failure({ bootstrap }: FailureProps) {
const logPath = useStore($logPath)
const mode = useStore($mode)
const isUpdate = mode === 'update'
return (
<div className="hermes-fade-in flex h-full flex-col items-center justify-center gap-6 px-12 py-10">
@@ -41,27 +37,24 @@ export default function Failure({ bootstrap }: FailureProps) {
}
>
<span>
<span>{isUpdate ? 'Update didn\u2019t finish' : 'Install didn\u2019t finish'}</span>
<span>Install didn&rsquo;t finish</span>
</span>
<span aria-hidden="true">{isUpdate ? 'Update didn\u2019t finish' : 'Install didn\u2019t finish'}</span>
<span aria-hidden="true">Install didn&rsquo;t finish</span>
</p>
<p className="m-0 mx-auto max-w-xl text-center text-sm leading-normal tracking-tight text-muted-foreground">
{bootstrap.error ??
(isUpdate
? 'Something went wrong during the update.'
: 'Something went wrong during installation.')}
{bootstrap.error ?? 'Something went wrong during installation.'}
</p>
</div>
<div className="flex items-center gap-3">
<Button
onClick={() => void (isUpdate ? startUpdate() : startInstall())}
onClick={() => void startInstall()}
size="lg"
className="inline-flex items-center gap-2 px-6"
>
<RefreshCw size={16} />
{isUpdate ? 'Retry update' : 'Retry install'}
Retry install
</Button>
<Button
variant="outline"

View File

@@ -115,7 +115,9 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
key={idx}
className={clsx(
'whitespace-pre-wrap',
entry.stream === 'stderr' ? 'text-foreground/45' : 'text-foreground/70'
entry.line.startsWith('stderr:')
? 'text-destructive'
: 'text-foreground/70'
)}
>
{entry.line}

View File

@@ -42,7 +42,7 @@ export interface BootstrapStateModel {
currentStage: string | null
installRoot: string | null
error: string | null
logs: Array<{ stage?: string; line: string; stream?: 'stdout' | 'stderr' }>
logs: Array<{ stage?: string; line: string }>
}
const INITIAL: BootstrapStateModel = {
@@ -106,7 +106,6 @@ interface BootstrapLogEvent {
type: 'log'
stage?: string
line: string
stream?: 'stdout' | 'stderr'
}
interface BootstrapCompleteEvent {
@@ -193,7 +192,7 @@ export async function initialize(): Promise<void> {
break
}
case 'log': {
const logs = [...cur.logs, { stage: payload.stage, line: payload.line, stream: payload.stream }]
const logs = [...cur.logs, { stage: payload.stage, line: payload.line }]
// Keep the rolling buffer bounded so the UI doesn't get OOM'd
// during a long install (playwright chromium download is ~10k lines).
const trimmed = logs.length > 2000 ? logs.slice(-2000) : logs

View File

@@ -22,7 +22,7 @@
* { type: 'manifest', stages: [{name, title, category, needs_user_input}, ...] }
* { type: 'stage', name, state: 'running'|'succeeded'|'skipped'|'failed',
* json?, durationMs?, error? }
* { type: 'log', stage?, line, stream: 'stdout'|'stderr' } // raw line from install.ps1
* { type: 'log', stage?, line } // raw line from install.ps1
* { type: 'complete', marker: <written marker payload> }
* { type: 'failed', stage?, error } // bootstrap aborted
*
@@ -229,7 +229,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
stdoutBuf = stdoutBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
if (line) emit && emit({ type: 'log', stage: stageName, line })
}
})
@@ -241,7 +241,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
stderrBuf = stderrBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
if (line) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${line}` })
}
})
@@ -253,8 +253,8 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
child.on('close', (code, signal) => {
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
// Flush any trailing bytes
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf })
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${stderrBuf}` })
resolve({ stdout, stderr, code, signal, killed })
})
})
@@ -299,7 +299,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
stdoutBuf = stdoutBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
if (line) emit && emit({ type: 'log', stage: stageName, line })
}
})
@@ -311,7 +311,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
stderrBuf = stderrBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
if (line) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${line}` })
}
})
@@ -322,8 +322,8 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
child.on('close', (code, signal) => {
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf })
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${stderrBuf}` })
resolve({ stdout, stderr, code, signal, killed })
})
})

View File

@@ -9,7 +9,6 @@ const {
nativeImage,
nativeTheme,
net: electronNet,
powerMonitor,
protocol,
safeStorage,
session,
@@ -470,15 +469,18 @@ let bootstrapAbortController = null
// existing-install adopt branch (3b) so repair re-drives the installer instead
// of re-adopting the install we're repairing. Cleared once a bootstrap runs.
let forceBootstrapRepair = false
// The mode the desktop is currently operating against ('local' | 'remote'),
// set whenever startHermes() resolves a backend. When 'remote', the local
// helper process (if one ever spawned and is now being torn down) is NOT the
// desktop's backend, so its exit must not be reported as a fatal backend exit.
let activeConnectionMode = null
let connectionConfigCache = null
let connectionConfigCacheMtime = null
const hermesLog = []
const previewWatchers = new Map()
let previewShortcutActive = false
let desktopLogBuffer = ''
let desktopLogFlushTimer = null
let desktopLogFlushPromise = Promise.resolve()
let nativeThemeListenerInstalled = false
let bootProgressState = {
error: null,
fakeMode: BOOT_FAKE_MODE,
@@ -725,7 +727,7 @@ function broadcastBootstrapEvent(ev) {
error: ev.error ?? null
}
} else if (ev.type === 'log') {
bootstrapState.log.push({ ts: Date.now(), stage: ev.stage || null, line: ev.line, stream: ev.stream || 'stdout' })
bootstrapState.log.push({ ts: Date.now(), stage: ev.stage || null, line: ev.line })
if (bootstrapState.log.length > BOOTSTRAP_LOG_RING_MAX) {
bootstrapState.log.splice(0, bootstrapState.log.length - BOOTSTRAP_LOG_RING_MAX)
}
@@ -1103,17 +1105,9 @@ function readDesktopUpdateConfig() {
}
}
// Atomic file write: temp + rename (atomic on all platforms). Prevents
// partial writes on crash/power loss that corrupt JSON config files.
function writeFileAtomic(targetPath, data, encoding) {
const tmp = targetPath + '.tmp'
fs.writeFileSync(tmp, data, encoding)
fs.renameSync(tmp, targetPath)
}
function writeDesktopUpdateConfig(config) {
fs.mkdirSync(path.dirname(DESKTOP_UPDATE_CONFIG_PATH), { recursive: true })
writeFileAtomic(DESKTOP_UPDATE_CONFIG_PATH, JSON.stringify(config, null, 2))
fs.writeFileSync(DESKTOP_UPDATE_CONFIG_PATH, JSON.stringify(config, null, 2))
}
// Match the backend's source resolution but bias toward a real git checkout.
@@ -1330,34 +1324,16 @@ async function applyUpdates(opts = {}) {
emitUpdateProgress({ stage: 'restart', message: 'Handing off to the Hermes updater…', percent: 100 })
const updateRoot = resolveUpdateRoot()
const { branch: configuredBranch } = readDesktopUpdateConfig()
const branch = await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH)
const updaterArgs = ['--update', '--branch', branch]
const targetApp = IS_MAC ? runningAppBundle() : null
if (targetApp) {
updaterArgs.push('--target-app', targetApp)
}
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
// Detached so the updater outlives this process — it needs us GONE before
// `hermes update` will run (the venv shim is locked while we live).
const child = spawn(updater, updaterArgs, {
cwd: HERMES_HOME,
env: {
...process.env,
HERMES_HOME,
PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH]
.filter(Boolean)
.join(path.delimiter)
},
const child = spawn(updater, ['--update'], {
detached: true,
stdio: 'ignore',
windowsHide: false
})
child.unref()
rememberLog(`[updates] launched updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release venv shim`)
rememberLog(`[updates] launched updater: ${updater} --update; exiting desktop to release venv shim`)
// Give the OS a beat to register the new process, then quit. The updater
// rebuilds and relaunches us when it's done.
@@ -1638,7 +1614,7 @@ function writeBootstrapMarker(payload) {
completedAt: new Date().toISOString(),
desktopVersion: app.getVersion()
}
writeFileAtomic(BOOTSTRAP_COMPLETE_MARKER, JSON.stringify(merged, null, 2) + '\n', 'utf8')
fs.writeFileSync(BOOTSTRAP_COMPLETE_MARKER, JSON.stringify(merged, null, 2) + '\n', 'utf8')
return merged
}
@@ -2104,7 +2080,6 @@ function fetchJson(url, token, options = {}) {
},
res => {
const chunks = []
res.on('error', reject)
res.on('data', chunk => chunks.push(chunk))
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8')
@@ -2444,7 +2419,6 @@ async function resourceBufferFromUrl(rawUrl) {
return
}
const chunks = []
res.on('error', reject)
res.on('data', chunk => chunks.push(chunk))
res.on('end', () => {
resolve({
@@ -2706,32 +2680,6 @@ function sendClosePreviewRequested() {
webContents.send('hermes:close-preview-requested')
}
// Tell the renderer the machine just woke. Sleep silently drops the
// renderer's WebSocket to the local backend; the renderer reconnects on this
// signal so the chat composer doesn't stay stuck on "Starting Hermes...".
function sendPowerResume() {
if (!mainWindow || mainWindow.isDestroyed()) return
const { webContents } = mainWindow
if (!webContents || webContents.isDestroyed()) return
webContents.send('hermes:power-resume')
}
let powerResumeRegistered = false
function registerPowerResumeListeners() {
if (powerResumeRegistered) return
powerResumeRegistered = true
try {
// 'resume' covers sleep/wake; 'unlock-screen' covers lock/unlock without a
// full suspend. Either can drop an idle socket.
powerMonitor.on('resume', sendPowerResume)
powerMonitor.on('unlock-screen', sendPowerResume)
} catch {
// powerMonitor is unavailable before app 'ready' on some platforms; the
// caller registers after 'ready', so this should not normally throw.
}
}
function getAppIconPath() {
return APP_ICON_PATHS.find(fileExists)
}
@@ -3147,17 +3095,7 @@ function decryptDesktopSecret(secret) {
}
function readDesktopConnectionConfig() {
// Check if file changed on disk since last read (e.g. modified by another
// process or an external tool). Our own writes update the cache inline
// via writeDesktopConnectionConfig, but external changes would be missed.
let mtime = null
try {
mtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs
} catch {
mtime = null
}
if (connectionConfigCache && connectionConfigCacheMtime === mtime) {
if (connectionConfigCache) {
return connectionConfigCache
}
@@ -3178,16 +3116,14 @@ function readDesktopConnectionConfig() {
}
connectionConfigCache = config
connectionConfigCacheMtime = mtime
return config
}
function writeDesktopConnectionConfig(config) {
fs.mkdirSync(path.dirname(DESKTOP_CONNECTION_CONFIG_PATH), { recursive: true })
writeFileAtomic(DESKTOP_CONNECTION_CONFIG_PATH, JSON.stringify(config, null, 2))
fs.writeFileSync(DESKTOP_CONNECTION_CONFIG_PATH, JSON.stringify(config, null, 2))
connectionConfigCache = config
connectionConfigCacheMtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs
}
function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) {
@@ -3314,6 +3250,11 @@ function resetHermesConnection() {
connectionPromise = null
if (hermesProcess && !hermesProcess.killed) {
// Mark this teardown as deliberate so the process 'exit' handler does not
// broadcast a fatal backend-exit (which would latch a desktop boot failure
// even though we're intentionally tearing the local helper down — e.g. to
// switch the desktop over to a remote gateway).
hermesProcess.__deliberateTeardown = true
hermesProcess.kill('SIGTERM')
}
@@ -3339,6 +3280,7 @@ async function startHermes() {
if (remote) {
await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24)
await waitForHermes(remote.baseUrl, remote.token)
activeConnectionMode = 'remote'
updateBootProgress({
phase: 'backend.ready',
message: 'Remote Hermes backend is ready',
@@ -3368,6 +3310,7 @@ async function startHermes() {
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
rememberLog(`Starting Hermes backend via ${backend.label}`)
activeConnectionMode = 'local'
hermesProcess = spawn(backend.command, backend.args, {
cwd: hermesCwd,
@@ -3393,6 +3336,10 @@ async function startHermes() {
hermesProcess.stdout.on('data', rememberLog)
hermesProcess.stderr.on('data', rememberLog)
// Capture a stable reference for the exit/error closures: resetHermesConnection
// nulls out the module-level hermesProcess before the 'exit' event fires, so
// the handler needs its own handle to read the deliberate-teardown flag.
const spawnedProcess = hermesProcess
let backendReady = false
let rejectBackendStart = null
const backendStartFailed = new Promise((_resolve, reject) => {
@@ -3411,15 +3358,20 @@ async function startHermes() {
)
hermesProcess = null
connectionPromise = null
sendBackendExit({ code: null, signal: null, error: error.message })
sendBackendExit({ code: null, signal: null, error: error.message, mode: activeConnectionMode })
rejectBackendStart?.(error)
})
hermesProcess.once('exit', (code, signal) => {
rememberLog(`Hermes backend exited (${signal || code})`)
const deliberate = Boolean(spawnedProcess?.__deliberateTeardown)
hermesProcess = null
connectionPromise = null
sendBackendExit({ code, signal })
if (!backendReady) {
// Tag the exit so the renderer can decide whether it's fatal. A deliberate
// teardown (resetHermesConnection — e.g. switching to a remote gateway)
// must not latch a desktop boot failure: the local helper is being torn
// down on purpose, not crashing.
sendBackendExit({ code, signal, mode: activeConnectionMode, deliberate })
if (!backendReady && !deliberate) {
const message = `Hermes backend exited before it became ready (${signal || code}).`
updateBootProgress(
{
@@ -3515,12 +3467,9 @@ function createWindow() {
}
if (!IS_MAC) {
if (!nativeThemeListenerInstalled) {
nativeThemeListenerInstalled = true
nativeTheme.on('updated', () => {
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
})
}
nativeTheme.on('updated', () => {
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
})
}
mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true))
@@ -4259,7 +4208,6 @@ app.whenReady().then(() => {
registerMediaProtocol()
ensureWslWindowsFonts()
configureSpellChecker()
registerPowerResumeListeners()
createWindow()
app.on('activate', () => {

View File

@@ -83,11 +83,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
ipcRenderer.on('hermes:backend-exit', listener)
return () => ipcRenderer.removeListener('hermes:backend-exit', listener)
},
onPowerResume: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:power-resume', listener)
return () => ipcRenderer.removeListener('hermes:power-resume', listener)
},
onBootProgress: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:boot-progress', listener)

View File

@@ -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,
@@ -24,9 +25,7 @@ import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import type { SessionInfo, SessionMessage } from '@/types/hermes'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PAGE_INSET_NEG_X, PAGE_INSET_X } from '../layout-constants'
import { PageSearchShell } from '../page-search-shell'
import { sessionRoute } from '../routes'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
@@ -373,11 +372,14 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
const [kindFilter, setKindFilter] = useRouteEnumParam('tab', ARTIFACT_FILTERS, 'all')
const [refreshing, setRefreshing] = useState(false)
const [failedImageIds, setFailedImageIds] = useState<Set<string>>(() => new Set())
const [imagePage, setImagePage] = useState(1)
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)))
@@ -396,11 +398,11 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
} catch (err) {
notifyError(err, 'Artifacts failed to load')
setArtifacts([])
} finally {
setRefreshing(false)
}
}, [])
useRefreshHotkey(refreshArtifacts)
useEffect(() => {
void refreshArtifacts()
}, [refreshArtifacts])
@@ -500,11 +502,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
return (
<PageSearchShell
{...props}
onSearchChange={setQuery}
searchHidden={counts.all === 0}
searchPlaceholder="Search artifacts..."
searchValue={query}
tabs={
filters={
<>
<TextTab active={kindFilter === 'all'} onClick={() => setKindFilter('all')}>
All <TextTabMeta>({counts.all})</TextTabMeta>
@@ -520,6 +518,23 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
</TextTab>
</>
}
onSearchChange={setQuery}
searchPlaceholder="Search artifacts..."
searchTrailingAction={
<Button
aria-label={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'}
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
disabled={refreshing}
onClick={() => void refreshArtifacts()}
size="icon-xs"
title={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
>
{!artifacts ? (
<PageLoader label="Indexing recent session artifacts" />
@@ -534,16 +549,10 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
</div>
) : (
<div className="h-full overflow-y-auto">
<div className={cn('flex flex-col gap-3 pb-2', PAGE_INSET_X)}>
<div className="flex flex-col gap-3 px-2 pb-2">
{visibleImageArtifacts.length > 0 && (
<section className="flex flex-col">
<div
className={cn(
'sticky top-0 z-10 flex h-7 items-center gap-3 overflow-x-auto bg-background',
PAGE_INSET_NEG_X,
PAGE_INSET_X
)}
>
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel="images"
@@ -569,13 +578,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
{visibleFileArtifacts.length > 0 && (
<section className="flex flex-col">
<div
className={cn(
'sticky top-0 z-10 flex h-7 items-center gap-3 overflow-x-auto bg-background',
PAGE_INSET_NEG_X,
PAGE_INSET_X
)}
>
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel={itemsLabel(kindFilter)}
@@ -585,7 +588,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
total={visibleFileArtifacts.length}
/>
</div>
<div className="overflow-x-auto rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)">
<div className="overflow-x-auto rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) shadow-sm">
<ArtifactTable artifacts={pagedFileArtifacts} ctx={cellCtx} filter={kindFilter} />
</div>
</section>
@@ -657,7 +660,11 @@ interface ArtifactImageCardProps {
function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: ArtifactImageCardProps) {
return (
<article className="group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)">
<article
className={cn(
'group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) shadow-sm'
)}
>
<div
className={cn(
'relative flex h-40 w-full items-center justify-center overflow-hidden border-b border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-1.5',
@@ -667,7 +674,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
{!failedImage && (
<ZoomableImage
alt={artifact.label}
className="max-h-40 max-w-full cursor-zoom-in rounded-md object-contain"
className="max-h-40 max-w-full cursor-zoom-in rounded-md object-contain shadow-sm"
containerClassName="max-h-full"
decoding="async"
loading="lazy"
@@ -695,7 +702,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
</div>
<div className="flex flex-wrap gap-1.5">
<Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="textStrong">
<Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="outline">
<FolderOpen className="size-3" />
Chat
</Button>
@@ -734,7 +741,10 @@ function ArtifactCellAction({
return (
<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"
className={cn(
'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',
'cursor-pointer'
)}
onClick={onClick}
title={title}
type="button"
@@ -853,7 +863,7 @@ function ArtifactTable({
))}
</tr>
</thead>
<tbody>
<tbody className="divide-y divide-(--ui-stroke-quaternary)">
{artifacts.map(artifact => (
<tr className="group/artifact" key={artifact.id}>
{ARTIFACT_COLUMNS.map(col => {

View File

@@ -137,7 +137,7 @@ function PromptSnippetsDialog({
{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"
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)

View File

@@ -34,7 +34,7 @@ import {
shouldAutoDrainOnSettle,
updateQueuedPrompt
} from '@/store/composer-queue'
import { $gatewayState, $messages } from '@/store/session'
import { $messages } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions'
@@ -73,39 +73,9 @@ import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
const COMPOSER_STACK_BREAKPOINT_PX = 320
// A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem
// vertical padding). Anything taller means the text wrapped to a second line,
// which is when the composer should expand to the stacked layout.
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 {
attachments: ComposerAttachment[]
draft: string
@@ -172,7 +142,6 @@ export function ChatBar({
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
const [focusRequestId, setFocusRequestId] = useState(0)
const dragDepthRef = useRef(0)
const composingRef = useRef(false) // true during IME composition (CJK input)
const lastSpokenIdRef = useRef<string | null>(null)
const narrow = useMediaQuery('(max-width: 30rem)')
@@ -187,44 +156,7 @@ export function ChatBar({
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
const showHelpHint = draft === '?'
const gatewayState = useStore($gatewayState)
// 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
// *different* conversation. Critically, the first id assignment of a freshly
// 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)
)
const prevSessionIdRef = useRef(sessionId)
useEffect(() => {
const prev = prevSessionIdRef.current
prevSessionIdRef.current = sessionId
if (prev === sessionId) {
return
}
// null → id: the new session we're already in just got persisted. Keep the
// starter we showed instead of swapping to a follow-up under the user.
if (prev == null && sessionId) {
return
}
setRestingPlaceholder(pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS))
}, [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...'
: restingPlaceholder
const placeholder = disabled ? 'Starting Hermes...' : 'Send follow-up'
const focusInput = useCallback(() => {
focusComposerInput(editorRef.current)
@@ -315,13 +247,14 @@ export function ChatBar({
}
}, [urlOpen])
// Expansion (input on its own full-width row, controls below) is driven by
// the editor's *actual* rendered height via the ResizeObserver in
// syncComposerMetrics — it only fires when the text genuinely wraps to a
// second line, so the layout flips exactly at the wrap point rather than at
// a guessed character count. We only handle the two cases the observer
// can't: an explicit newline (expand before layout settles) and an emptied
// draft (collapse back). We never read scrollHeight per keystroke.
// Track expansion via cheap heuristics (newline or length threshold) instead
// of reading editor.scrollHeight on every keystroke. scrollHeight forces a
// synchronous layout flush — measured at 2.27 layouts per character typed
// (see scripts/leak-typing.mjs). With ~30 chars before a typical wrap on
// composer-default-width, this heuristic flips at roughly the right time
// and the user only notices if they type far past the wrap boundary
// without a newline; in that case the ResizeObserver below catches it via
// a height delta and we still expand.
useEffect(() => {
if (!draft) {
setExpanded(false)
@@ -333,7 +266,7 @@ export function ChatBar({
return
}
if (draft.includes('\n')) {
if (draft.includes('\n') || draft.length > 60) {
setExpanded(true)
}
}, [draft, expanded])
@@ -369,18 +302,6 @@ export function ChatBar({
}
}
// Expand once the input has actually wrapped past a single line. The
// observer only fires on real size changes, so this reads scrollHeight at
// most once per wrap (not per keystroke). One line ≈ 28px (1.625rem
// min-height + padding); a second line clears ~36px. We only ever expand
// here — collapse is handled by the emptied-draft effect to avoid
// oscillating across the wrap boundary as the input switches widths.
const editor = editorRef.current
if (editor && editor.scrollHeight > COMPOSER_SINGLE_LINE_MAX_PX) {
setExpanded(true)
}
if (height > 0) {
const bucket = Math.round(height / 8) * 8
@@ -400,7 +321,7 @@ export function ChatBar({
}
}, [])
useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef, editorRef)
useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef)
useEffect(() => {
return () => {
@@ -478,19 +399,13 @@ export function ChatBar({
return
}
// Trim surrounding whitespace so a copy that dragged along leading/trailing
// blank lines (common when selecting from terminals, code blocks, web pages)
// doesn't dump multiline padding into the composer. Internal newlines are
// preserved — only the edges are cleaned up.
const pastedText = event.clipboardData.getData('text').trim()
const pastedText = event.clipboardData.getData('text')
if (!pastedText) {
event.preventDefault()
return
}
if (DATA_IMAGE_URL_RE.test(pastedText)) {
if (DATA_IMAGE_URL_RE.test(pastedText.trim())) {
event.preventDefault()
return
@@ -553,13 +468,6 @@ export function ChatBar({
}, [trigger])
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
// During IME composition the DOM contains uncommitted preedit text
// mixed with real content. Skip state writes — compositionend will
// deliver the finalized text via a clean input event.
if (composingRef.current) {
return
}
const editor = event.currentTarget
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
@@ -659,18 +567,7 @@ export function ChatBar({
}
const handleEditorKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
// IME composition: Enter confirms composed text, not a message submission.
// We check both composingRef (set by compositionstart/compositionend, robust
// across browsers) and nativeEvent.isComposing (Chromium fallback). Without
// this guard, pressing Enter to finalise a Korean/Japanese/Chinese IME
// preedit fires submitDraft() and splits the message mid-word.
if (composingRef.current || event.nativeEvent.isComposing) {
return
}
// Cmd/Ctrl+Shift+K drains the next queued message. Plain Cmd/Ctrl+K is
// reserved for the global command palette.
if ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'k') {
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k') {
event.preventDefault()
if (!busy) {
@@ -1059,8 +956,7 @@ export function ChatBar({
const submitted = draft
triggerHaptic('submit')
clearDraft()
clearComposerAttachments()
void onSubmit(submitted, { attachments })
void onSubmit(submitted)
}
focusInput()
@@ -1186,10 +1082,10 @@ export function ChatBar({
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
<div
aria-label="Message"
autoCapitalize="off"
autoCorrect="off"
autoCapitalize="off"
className={cn(
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
'**:data-ref-text:cursor-default',
stacked && 'pl-3',
@@ -1199,12 +1095,6 @@ export function ChatBar({
data-placeholder={placeholder}
data-slot={RICH_INPUT_SLOT}
onBlur={() => window.setTimeout(closeTrigger, 80)}
onCompositionEnd={() => {
composingRef.current = false
}}
onCompositionStart={() => {
composingRef.current = true
}}
onDragOver={handleInputDragOver}
onDrop={handleInputDrop}
onFocus={() => markActiveComposer('main')}
@@ -1233,7 +1123,7 @@ export function ChatBar({
`asChild` swaps TextareaAutosize for a Radix Slot wrapping our
plain <textarea>, which carries the binding but skips autosize. */}
<ComposerPrimitive.Input asChild submitMode="ctrlEnter" tabIndex={-1} unstable_focusOnScrollToBottom={false}>
<ComposerPrimitive.Input asChild tabIndex={-1} unstable_focusOnScrollToBottom={false}>
<textarea aria-hidden className="sr-only" tabIndex={-1} />
</ComposerPrimitive.Input>
</div>
@@ -1253,9 +1143,6 @@ export function ChatBar({
onDrop={handleDrop}
onSubmit={e => {
e.preventDefault()
if (composingRef.current) {
return
}
submitDraft()
}}
ref={composerRef}
@@ -1358,7 +1245,7 @@ export function ChatBar({
'grid w-full',
stacked
? 'grid-cols-[auto_1fr] gap-(--composer-row-gap) [grid-template-areas:"input_input"_"menu_controls"]'
: 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
: 'grid-cols-[auto_1fr_auto] items-end gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
)}
>
<div className="flex items-center [grid-area:menu]">{contextMenu}</div>

View File

@@ -13,7 +13,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'
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
@@ -37,8 +36,7 @@ import {
$introSeed,
$messages,
$selectedStoredSessionId,
$sessions,
sessionPinId
$sessions
} from '@/store/session'
import type { ModelOptionsResponse } from '@/types/hermes'
@@ -98,27 +96,9 @@ function ChatHeader({
}: ChatHeaderProps) {
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)
const activeStoredSession =
sessions.find(session => session.id === selectedSessionId || session._lineage_root_id === selectedSessionId) || null
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
// Pins live on the durable lineage-root id, but selectedSessionId is the live
// (tip) id — resolve through the loaded row so the menu reflects the pin
// state after auto-compression rotates the id.
const selectedIsPinned = activeStoredSession
? pinnedSessionIds.includes(sessionPinId(activeStoredSession))
: selectedSessionId
? pinnedSessionIds.includes(selectedSessionId)
: false
// A brand-new session has no session to pin/delete/rename, so the header is
// just a dead "New session" label + chevron. Drop it (and its border)
// entirely until there's a real session to act on.
if (!selectedSessionId && !activeSessionId && !isRoutedSessionView) {
return null
}
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
return (
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
@@ -133,7 +113,7 @@ function ChatHeader({
title={title}
>
<Button
className="pointer-events-auto h-6 min-w-0 gap-1 border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
className="pointer-events-auto h-6 min-w-0 gap-1 rounded-md border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
type="button"
variant="ghost"
>
@@ -326,7 +306,6 @@ export function ChatView({
/>
<NotificationStack />
<PromptOverlays />
<div
className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]"

View File

@@ -82,7 +82,7 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console
>
<button
className={cn(
'mt-0.5 text-left uppercase opacity-70 transition-colors hover:opacity-100',
'mt-0.5 cursor-pointer text-left uppercase opacity-70 transition-colors hover:opacity-100',
consoleLevelClass[log.level] ?? consoleLevelClass[0]
)}
onClick={onToggleSelect}

View File

@@ -11,7 +11,6 @@ import ShikiHighlighter from 'react-shiki'
import { Streamdown } from 'streamdown'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { PageLoader } from '@/components/page-loader'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
@@ -482,7 +481,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
if (state.loading) {
return <PageLoader label="Loading preview" />
return <div className="grid h-full place-items-center text-xs text-muted-foreground">Loading preview</div>
}
if (state.error) {

View File

@@ -83,7 +83,7 @@ function PreviewLoadError({
body={
<>
<a
className="pointer-events-auto block font-mono text-muted-foreground/90 underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground"
className="pointer-events-auto block cursor-pointer font-mono text-muted-foreground/90 underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground"
href={error.url}
onClick={event => {
event.preventDefault()
@@ -608,7 +608,7 @@ export function PreviewPane({
<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"
className="pointer-events-auto inline max-w-full cursor-pointer 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"

View File

@@ -23,7 +23,6 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { KbdGroup } from '@/components/ui/kbd'
import { SearchField } from '@/components/ui/search-field'
import {
Sidebar,
SidebarContent,
@@ -37,7 +36,6 @@ import { Skeleton } from '@/components/ui/skeleton'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { cn } from '@/lib/utils'
import {
$panesFlipped,
$pinnedSessionIds,
$sidebarAgentsGrouped,
$sidebarOpen,
@@ -145,7 +143,6 @@ function searchResultToSession(result: SessionSearchResult): SessionInfo {
cwd: null,
ended_at: null,
id: result.session_id,
_lineage_root_id: result.lineage_root ?? null,
input_tokens: 0,
is_active: false,
last_active: ts,
@@ -216,7 +213,6 @@ export function ChatSidebar({
onNewSessionInWorkspace
}: ChatSidebarProps) {
const sidebarOpen = useStore($sidebarOpen)
const panesFlipped = useStore($panesFlipped)
const agentsGrouped = useStore($sidebarAgentsGrouped)
const pinnedSessionIds = useStore($pinnedSessionIds)
const pinsOpen = useStore($sidebarPinsOpen)
@@ -230,28 +226,8 @@ export function ChatSidebar({
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
const trimmedQuery = searchQuery.trim()
// Flash the ⌘N hint full-opacity (no transition) for the press, so hitting
// the shortcut visibly pings its affordance in the sidebar.
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | undefined
const onShortcut = () => {
setNewSessionKbdFlash(true)
clearTimeout(timeout)
timeout = setTimeout(() => setNewSessionKbdFlash(false), 140)
}
window.addEventListener('hermes:new-session-shortcut', onShortcut)
return () => {
window.removeEventListener('hermes:new-session-shortcut', onShortcut)
clearTimeout(timeout)
}
}, [])
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
const dndSensors = useSensors(
@@ -429,8 +405,7 @@ export function ChatSidebar({
return (
<Sidebar
className={cn(
'relative h-full min-w-0 overflow-hidden border-t-0 border-b-0 text-foreground transition-none',
panesFlipped ? 'border-l border-r-0' : 'border-r border-l-0',
'relative h-full min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground transition-none',
sidebarOpen
? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
: 'pointer-events-none border-transparent bg-transparent opacity-0'
@@ -454,7 +429,7 @@ export function ChatSidebar({
<SidebarMenuButton
aria-disabled={!isInteractive}
className={cn(
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
'flex h-7 w-full cursor-pointer justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
active &&
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
!isInteractive &&
@@ -469,10 +444,7 @@ export function ChatSidebar({
<>
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
{item.id === 'new-session' && (
<KbdGroup
className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
keys={[...NEW_SESSION_KBD]}
/>
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={[...NEW_SESSION_KBD]} />
)}
</>
)}
@@ -485,13 +457,28 @@ export function ChatSidebar({
</SidebarGroup>
{sidebarOpen && showSessionSections && (
<div className="shrink-0 px-2 pb-1 pt-1">
<SearchField
aria-label="Search sessions"
onChange={setSearchQuery}
placeholder="Search sessions"
value={searchQuery}
/>
<div className="shrink-0 pb-1 pt-1">
<div className="flex items-center gap-1.5 rounded-md border border-transparent bg-transparent px-2 transition-colors focus-within:border-(--ui-stroke-tertiary)">
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="search" size="0.75rem" />
<input
aria-label="Search sessions"
className="h-6 min-w-0 flex-1 bg-transparent text-[0.8125rem] text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
onChange={event => setSearchQuery(event.target.value)}
placeholder="Search sessions…"
type="text"
value={searchQuery}
/>
{searchQuery && (
<button
aria-label="Clear search"
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-active-background) hover:text-foreground"
onClick={() => setSearchQuery('')}
type="button"
>
<Codicon name="close" size="0.75rem" />
</button>
)}
</div>
</div>
)}
@@ -566,7 +553,7 @@ export function ChatSidebar({
<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',
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
@@ -616,7 +603,7 @@ function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSe
return (
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
<button
className="group/section-label flex w-fit items-center gap-1 bg-transparent text-left leading-none"
className="group/section-label flex w-fit cursor-pointer items-center gap-1 bg-transparent text-left leading-none"
onClick={onToggle}
type="button"
>
@@ -657,7 +644,7 @@ function SidebarPinnedEmptyState() {
<span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
<Codicon name="pin" size="0.75rem" />
</span>
<span>Shift-click a chat to pin</span>
<span>Shift-click a chat to pin · drag to reorder</span>
</div>
)
}
@@ -860,7 +847,7 @@ function SidebarWorkspaceGroup({
<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 cursor-pointer items-center gap-1 bg-transparent text-left hover:text-(--ui-text-secondary)"
onClick={() => setOpen(value => !value)}
title={group.path ?? undefined}
type="button"
@@ -875,7 +862,7 @@ function SidebarWorkspaceGroup({
{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"
className="grid size-4 shrink-0 cursor-pointer 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"
@@ -907,7 +894,7 @@ function SidebarWorkspaceGroup({
{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"
className="ml-auto grid size-5 cursor-pointer 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"
@@ -961,7 +948,7 @@ function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps)
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 cursor-pointer 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)"
disabled={loading}
onClick={onClick}
type="button"

View File

@@ -1,4 +1,3 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { Button } from '@/components/ui/button'
@@ -7,7 +6,6 @@ import type { SessionInfo } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
@@ -63,10 +61,6 @@ export function SidebarSessionRow({
const title = sessionTitle(session)
const age = formatAge(session.last_active || session.started_at)
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
// session is waiting on the user.
const needsInput = useStore($attentionSessionIds).includes(session.id)
return (
<SessionContextMenu
@@ -90,9 +84,9 @@ export function SidebarSessionRow({
style={style}
{...rest}
>
{isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />}
{isWorking && <span aria-hidden="true" className="arc-border" />}
<button
className="z-0 flex min-w-0 items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left group-hover:pr-12"
className="z-0 flex min-w-0 cursor-pointer items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left group-hover:pr-12"
onClick={event => {
if (event.shiftKey) {
event.preventDefault()
@@ -120,25 +114,16 @@ export function SidebarSessionRow({
<span
{...dragHandleProps}
aria-label={handleLabel}
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',
// 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'
)}
className="relative -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()}
>
<SidebarRowDot
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
className="transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
isWorking={isWorking}
needsInput={needsInput}
/>
<Codicon
className={cn(
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/handle:opacity-80 group-focus-within/handle:opacity-80 hover:text-(--ui-text-secondary)',
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover:opacity-80 group-focus-within:opacity-80 hover:text-(--ui-text-secondary)',
dragging && 'text-(--ui-text-secondary) opacity-100'
)}
name="grabber"
@@ -146,8 +131,8 @@ export function SidebarSessionRow({
/>
</span>
) : (
<span className={cn('grid w-3.5 shrink-0 place-items-center', needsInput ? 'overflow-visible' : 'overflow-hidden')}>
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
<span className="grid w-3.5 shrink-0 place-items-center overflow-hidden">
<SidebarRowDot isWorking={isWorking} />
</span>
)}
<span className="truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
@@ -170,7 +155,7 @@ export function SidebarSessionRow({
>
<Button
aria-label={`Actions for ${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!"
className="size-5 rounded-md 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"
variant="ghost"
@@ -184,30 +169,7 @@ export function SidebarSessionRow({
)
}
function SidebarRowDot({
isWorking,
needsInput = false,
className
}: {
isWorking: boolean
needsInput?: boolean
className?: string
}) {
// "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
// pulse of an active turn.
if (needsInput) {
return (
<span
aria-label="Needs your input"
className={cn('quest-glow relative size-1.5 rounded-full bg-amber-500', className)}
role="status"
title="Waiting for your answer"
/>
)
}
function SidebarRowDot({ isWorking, className }: { isWorking: boolean; className?: string }) {
return (
<span
aria-label={isWorking ? 'Session running' : undefined}

File diff suppressed because it is too large Load Diff

View File

@@ -1,460 +0,0 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { Dialog as DialogPrimitive } from 'radix-ui'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import { getHermesConfigRecord, listSessions } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import {
Activity,
Archive,
BarChart3,
Check,
ChevronLeft,
ChevronRight,
Clock,
Cpu,
Globe,
type IconComponent,
Info,
KeyRound,
MessageCircle,
Monitor,
Moon,
Package,
Palette,
Plus,
Settings,
Sun,
Users,
Wrench
} from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { type ThemeMode, useTheme } from '@/themes/context'
import {
AGENTS_ROUTE,
ARTIFACTS_ROUTE,
COMMAND_CENTER_ROUTE,
CRON_ROUTE,
MESSAGING_ROUTE,
NEW_CHAT_ROUTE,
PROFILES_ROUTE,
sessionRoute,
SETTINGS_ROUTE,
SKILLS_ROUTE
} from '../routes'
import { FIELD_LABELS, SECTIONS } from '../settings/constants'
import { prettyName } from '../settings/helpers'
interface PaletteItem {
active?: boolean
icon: IconComponent
id: string
/** Keep the palette open after running (live-preview pickers like theme/mode). */
keepOpen?: boolean
keywords?: string[]
label: string
/** Action to run when selected. Mutually exclusive with `to`. */
run?: () => void
/** Open a nested palette page (VS Code-style "choose X → options"). */
to?: string
}
interface PaletteGroup {
heading: string
items: PaletteItem[]
}
/** A nested page reachable from a root item via `to`. */
interface PalettePage {
groups: PaletteGroup[]
placeholder: string
title: string
}
interface SessionEntry {
id: string
preview?: string
title: string
}
type SessionRow = Awaited<ReturnType<typeof listSessions>>['sessions'][number]
const toSessionEntry = (session: SessionRow): SessionEntry => ({
id: session.id,
preview: session.preview ?? undefined,
title: sessionTitle(session)
})
const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent; keywords?: string[]; label: string; tab: string }> = [
{ icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' },
{ icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials'], label: 'API Keys', tab: 'keys' },
{ icon: Wrench, keywords: ['servers', 'tools'], label: 'MCP', tab: 'mcp' },
{ icon: Archive, keywords: ['history', 'archived'], label: 'Archived Chats', tab: 'sessions' },
{ icon: Info, keywords: ['version', 'about'], label: 'About', tab: 'about' }
]
const THEME_MODES: ReadonlyArray<{ icon: IconComponent; label: string; mode: ThemeMode }> = [
{ icon: Sun, label: 'Light', mode: 'light' },
{ icon: Moon, label: 'Dark', mode: 'dark' },
{ icon: Monitor, label: 'System', mode: 'system' }
]
function fieldLabel(key: string): string {
return FIELD_LABELS[key] ?? prettyName(key.split('.').pop() ?? key)
}
export function CommandPalette() {
const open = useStore($commandPaletteOpen)
const navigate = useNavigate()
const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme()
const [search, setSearch] = useState('')
const [page, setPage] = useState<string | null>(null)
// Server-backed sources for the type-to-search groups, fetched lazily while
// the palette is open. react-query handles caching/dedup/staleness.
const configQuery = useQuery({ queryKey: ['command-palette', 'config'], queryFn: getHermesConfigRecord, enabled: open })
const sessionsQuery = useQuery({
queryKey: ['command-palette', 'sessions'],
queryFn: () => listSessions(200, 1, 'exclude'),
enabled: open
})
const archivedQuery = useQuery({
queryKey: ['command-palette', 'archived'],
queryFn: () => listSessions(200, 0, 'only'),
enabled: open
})
const mcpServers = useMemo(() => {
const raw = configQuery.data?.mcp_servers
return raw && typeof raw === 'object' && !Array.isArray(raw) ? Object.keys(raw as Record<string, unknown>).sort() : []
}, [configQuery.data])
const sessions = useMemo(() => (sessionsQuery.data?.sessions ?? []).map(toSessionEntry), [sessionsQuery.data])
const archivedSessions = useMemo(() => (archivedQuery.data?.sessions ?? []).map(toSessionEntry), [archivedQuery.data])
// Reset the query/sub-page on close so it reopens clean.
useEffect(() => {
if (!open) {
setSearch('')
setPage(null)
}
}, [open])
const go = useCallback((path: string) => () => navigate(path), [navigate])
const baseGroups = useMemo<PaletteGroup[]>(() => {
const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}`
return [
{
heading: 'Go to',
items: [
{ icon: Plus, id: 'nav-new', keywords: ['chat', 'create'], label: 'New session', run: go(NEW_CHAT_ROUTE) },
{ icon: Settings, id: 'nav-settings', label: 'Settings', run: go(SETTINGS_ROUTE) },
{
icon: Wrench,
id: 'nav-skills',
keywords: ['tools', 'toolsets', 'providers'],
label: 'Skills & Tools',
run: go(SKILLS_ROUTE)
},
{ icon: MessageCircle, id: 'nav-messaging', label: 'Messaging', run: go(MESSAGING_ROUTE) },
{ icon: Package, id: 'nav-artifacts', label: 'Artifacts', run: go(ARTIFACTS_ROUTE) },
{ icon: Clock, id: 'nav-cron', keywords: ['schedule', 'jobs'], label: 'Cron', run: go(CRON_ROUTE) },
{ icon: Users, id: 'nav-profiles', label: 'Profiles', run: go(PROFILES_ROUTE) },
{ icon: Cpu, id: 'nav-agents', label: 'Agents', run: go(AGENTS_ROUTE) }
]
},
{
heading: 'Command Center',
items: [
{
icon: Archive,
id: 'cc-sessions',
keywords: ['command center', 'sessions', 'pin'],
label: 'Sessions',
run: go(`${COMMAND_CENTER_ROUTE}?section=sessions`)
},
{
icon: Activity,
id: 'cc-system',
keywords: ['command center', 'system', 'status', 'logs'],
label: 'System',
run: go(`${COMMAND_CENTER_ROUTE}?section=system`)
},
{
icon: BarChart3,
id: 'cc-usage',
keywords: ['command center', 'usage', 'tokens', 'cost'],
label: 'Usage',
run: go(`${COMMAND_CENTER_ROUTE}?section=usage`)
}
]
},
{
heading: 'Settings',
items: [
...SECTIONS.map(section => ({
icon: section.icon,
id: `set-config-${section.id}`,
keywords: ['settings', section.label],
label: section.label,
run: go(settingsTab(`config:${section.id}`))
})),
...NON_CONFIG_SETTINGS.map(entry => ({
icon: entry.icon,
id: `set-${entry.tab}`,
keywords: ['settings', ...(entry.keywords ?? [])],
label: entry.label,
run: go(settingsTab(entry.tab))
}))
]
},
{
heading: 'Appearance',
items: [
{
icon: Palette,
id: 'appearance-theme',
keywords: ['theme', 'appearance', 'color', 'palette', 'skin', 'dark', 'light', 'look'],
label: 'Change theme…',
to: 'theme'
},
{
icon: Sun,
id: 'appearance-mode',
keywords: ['appearance', 'color mode', 'brightness', 'dark', 'light', 'system'],
label: 'Change color mode…',
to: 'color-mode'
}
]
}
]
}, [go])
// The long, granular lists (settings fields, API keys, MCP servers, archived
// chats) only surface once the user types — otherwise they'd bury the
// navigation entries on an empty palette.
const searchGroups = useMemo<PaletteGroup[]>(() => {
if (!search.trim()) {
return []
}
const result: PaletteGroup[] = []
if (sessions.length > 0) {
result.push({
heading: 'Sessions',
items: sessions.map(session => ({
icon: MessageCircle,
id: `session-${session.id}`,
keywords: ['chat', 'session', ...(session.preview ? [session.preview] : [])],
label: session.title,
run: go(sessionRoute(session.id))
}))
})
}
const fieldItems = SECTIONS.flatMap(section =>
section.keys.map(key => ({
icon: section.icon,
id: `field-${key}`,
keywords: ['settings', key, section.label],
label: `${section.label}: ${fieldLabel(key)}`,
run: go(`${SETTINGS_ROUTE}?tab=config:${section.id}&field=${encodeURIComponent(key)}`)
}))
)
result.push({ heading: 'Settings fields', items: fieldItems })
if (mcpServers.length > 0) {
result.push({
heading: 'MCP servers',
items: mcpServers.map(name => ({
icon: Wrench,
id: `mcp-${name}`,
keywords: ['mcp', 'server', 'tool'],
label: name,
run: go(`${SETTINGS_ROUTE}?tab=mcp&server=${encodeURIComponent(name)}`)
}))
})
}
if (archivedSessions.length > 0) {
result.push({
heading: 'Archived chats',
items: archivedSessions.map(session => ({
icon: Archive,
id: `archived-${session.id}`,
keywords: ['archived', 'chat', 'session', ...(session.preview ? [session.preview] : [])],
label: session.title,
run: go(`${SETTINGS_ROUTE}?tab=sessions&session=${encodeURIComponent(session.id)}`)
}))
})
}
return result
}, [archivedSessions, go, mcpServers, search, sessions])
const groups = useMemo(() => [...baseGroups, ...searchGroups], [baseGroups, searchGroups])
// Nested palette pages (VS Code-style submenus). Reusable: add an entry here
// and point a root item at it via `to`.
const subPages = useMemo<Record<string, PalettePage>>(
() => ({
theme: {
title: 'Theme',
placeholder: 'Choose a theme…',
// Skins aren't inherently light/dark — the same skin renders in either
// mode. Group by appearance so picking an entry sets skin + mode at
// once, and keep the palette open so each pick previews live.
groups: (['light', 'dark'] as const).map(groupMode => ({
heading: groupMode === 'light' ? 'Light' : 'Dark',
items: availableThemes.map(theme => ({
active: themeName === theme.name && resolvedMode === groupMode,
icon: groupMode === 'light' ? Sun : Moon,
id: `theme-${theme.name}-${groupMode}`,
keepOpen: true,
keywords: ['theme', 'appearance', 'palette', groupMode, theme.label, theme.description ?? ''],
label: theme.label,
run: () => {
setTheme(theme.name)
setMode(groupMode)
}
}))
}))
},
'color-mode': {
title: 'Color mode',
placeholder: 'Choose color mode…',
groups: [
{
heading: 'Color mode',
items: THEME_MODES.map(entry => ({
active: mode === entry.mode,
icon: entry.icon,
id: `mode-${entry.mode}`,
keepOpen: true,
keywords: ['appearance', 'brightness', entry.label],
label: entry.label,
run: () => setMode(entry.mode)
}))
}
]
}
}),
[availableThemes, mode, resolvedMode, setMode, setTheme, themeName]
)
const activePage = page ? subPages[page] : null
const visibleGroups = activePage ? activePage.groups : groups
const placeholder = activePage ? activePage.placeholder : 'Search commands and settings...'
const handleSelect = (item: PaletteItem) => {
if (item.to) {
setPage(item.to)
setSearch('')
return
}
item.run?.()
if (!item.keepOpen) {
closeCommandPalette()
}
}
return (
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/15 backdrop-blur-[1px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
aria-describedby={undefined}
className="fixed left-1/2 top-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 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]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
>
<DialogPrimitive.Title className="sr-only">Command palette</DialogPrimitive.Title>
<Command className="bg-transparent" loop>
{activePage && (
<button
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setPage(null)}
type="button"
>
<ChevronLeft className="size-3.5" />
<span>Back</span>
<span className="text-muted-foreground/50">/</span>
<span className="font-medium text-foreground">{activePage.title}</span>
</button>
)}
<CommandInput
onKeyDown={event => {
if (!activePage) {
return
}
// In a submenu: Esc and empty-input Backspace step back out
// instead of closing the whole palette.
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
event.preventDefault()
event.stopPropagation()
setPage(null)
}
}}
onValueChange={setSearch}
placeholder={placeholder}
value={search}
/>
<CommandList className="max-h-[min(24rem,60vh)]">
<CommandEmpty>No results found.</CommandEmpty>
{visibleGroups.map(group => (
<CommandGroup
className="**:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-[0.6875rem] **:[[cmdk-group-heading]]:text-muted-foreground/70"
heading={group.heading}
key={group.heading}
>
{group.items.map(item => {
const Icon = item.icon
return (
<CommandItem
className="gap-2.5"
key={item.id}
keywords={item.keywords}
onSelect={() => handleSelect(item)}
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{item.label}</span>
{item.to ? (
<ChevronRight className="ml-auto size-4 shrink-0 text-muted-foreground/70" />
) : (
<Check className={cn('ml-auto size-4 text-foreground', !item.active && 'invisible')} />
)}
</CommandItem>
)
})}
</CommandGroup>
))}
</CommandList>
</Command>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
)
}

View File

@@ -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,12 +25,12 @@ import {
triggerCronJob,
updateCronJob
} from '@/hermes'
import { AlertTriangle, Clock } from '@/lib/icons'
import { AlertTriangle, Clock, Pause, Pencil, Play, Trash2, Zap } 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'
const DEFAULT_DELIVER = 'local'
@@ -87,16 +86,23 @@ const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
}
]
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)
@@ -299,13 +305,14 @@ function matchesQuery(job: CronJob, q: string): boolean {
)
}
interface CronViewProps {
onClose: () => void
interface CronViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
}
export function CronView({ onClose }: CronViewProps) {
export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: CronViewProps) {
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' })
@@ -313,16 +320,18 @@ 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')
} finally {
setRefreshing(false)
}
}, [])
useRefreshHotkey(refresh)
useEffect(() => {
void refresh()
}, [refresh])
@@ -417,21 +426,29 @@ export function CronView({ onClose }: CronViewProps) {
}
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>
)}
{!jobs ? (
<PageLoader label="Loading cron jobs..." />
) : visibleJobs.length === 0 ? (
<PageSearchShell
{...props}
onSearchChange={setQuery}
searchPlaceholder="Search cron jobs..."
searchTrailingAction={
<Button
aria-label={refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs'}
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
disabled={refreshing}
onClick={() => void refresh()}
size="icon-xs"
title={refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs'}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
>
{!jobs ? (
<PageLoader label="Loading cron jobs..." />
) : visibleJobs.length === 0 ? (
// Empty state owns the primary "create" CTA — we used to also have
// one in the filters bar but it was redundant. Only show the button
// when there are zero jobs total; the search-empty case ("No
@@ -446,37 +463,36 @@ export function CronView({ onClose }: CronViewProps) {
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
/>
) : (
<div className="mx-auto w-full max-w-4xl min-h-0 flex-1 overflow-y-auto px-4 py-3">
{/* Inline header replaces the old top-bar "New cron" button. We
still need a single, always-visible affordance to add a job
when the list is non-empty (rows themselves only expose
edit/pause/trigger/delete). */}
<div className="mb-2 flex items-center justify-between">
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
{enabledCount}/{totalCount} active
</span>
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
<Codicon name="add" />
New cron
</Button>
</div>
<div>
{visibleJobs.map(job => (
<CronJobRow
busy={busyJobId === job.id}
job={job}
key={job.id}
onDelete={() => setPendingDelete(job)}
onEdit={() => setEditor({ mode: 'edit', job })}
onPauseResume={() => void handlePauseResume(job)}
onTrigger={() => void handleTrigger(job)}
/>
))}
</div>
) : (
<div className="h-full overflow-y-auto px-4 py-3">
{/* Inline header replaces the old top-bar "New cron" button. We
still need a single, always-visible affordance to add a job
when the list is non-empty (rows themselves only expose
edit/pause/trigger/delete). */}
<div className="mb-2 flex items-center justify-between">
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
{enabledCount}/{totalCount} active
</span>
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
<Codicon name="add" />
New cron
</Button>
</div>
)}
</div>
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
{visibleJobs.map(job => (
<CronJobRow
busy={busyJobId === job.id}
job={job}
key={job.id}
onDelete={() => setPendingDelete(job)}
onEdit={() => setEditor({ mode: 'edit', job })}
onPauseResume={() => void handlePauseResume(job)}
onTrigger={() => void handleTrigger(job)}
/>
))}
</div>
</div>
)}
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
@@ -503,7 +519,7 @@ export function CronView({ onClose }: CronViewProps) {
</DialogFooter>
</DialogContent>
</Dialog>
</OverlayView>
</PageSearchShell>
)
}
@@ -531,20 +547,14 @@ 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>
{deliver && deliver !== DEFAULT_DELIVER && (
<Badge className="capitalize" variant="muted">
{deliver}
</Badge>
)}
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{state}</StatePill>
{deliver && deliver !== DEFAULT_DELIVER && <StatePill tone="muted">{deliver}</StatePill>}
</div>
{hasName && prompt && <p className="mt-1 truncate text-xs text-muted-foreground">{truncate(prompt, 120)}</p>}
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-[0.68rem] text-muted-foreground">
@@ -570,13 +580,13 @@ function CronJobRow({
onClick={onPauseResume}
title={isPaused ? 'Resume' : 'Pause'}
>
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
{isPaused ? <Play className="size-3.5" /> : <Pause className="size-3.5" />}
</IconAction>
<IconAction aria-label="Trigger now" disabled={busy} onClick={onTrigger} title="Trigger now">
<Codicon name="zap" size="0.875rem" />
<Zap className="size-3.5" />
</IconAction>
<IconAction aria-label="Edit cron" onClick={onEdit} title="Edit">
<Codicon name="edit" size="0.875rem" />
<Pencil className="size-3.5" />
</IconAction>
<IconAction
aria-label="Delete cron"
@@ -584,7 +594,7 @@ function CronJobRow({
onClick={onDelete}
title="Delete"
>
<Codicon name="trash" size="0.875rem" />
<Trash2 className="size-3.5" />
</IconAction>
</div>
</div>
@@ -594,8 +604,8 @@ function CronJobRow({
function IconAction({ children, className, ...props }: Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'>) {
return (
<Button
className={cn('text-muted-foreground hover:text-foreground', className)}
size="icon-sm"
className={cn('size-7 text-muted-foreground hover:text-foreground', className)}
size="icon"
variant="ghost"
{...props}
>
@@ -604,6 +614,16 @@ function IconAction({ children, className, ...props }: Omit<React.ComponentProps
)
}
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,
@@ -748,7 +768,7 @@ function CronEditorDialog({
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="cron-frequency" label="Frequency">
<Select onValueChange={handleSchedulePresetChange} value={schedulePreset}>
<SelectTrigger id="cron-frequency">
<SelectTrigger className="h-9 rounded-md" id="cron-frequency">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -763,7 +783,7 @@ function CronEditorDialog({
<Field htmlFor="cron-deliver" label="Deliver to">
<Select onValueChange={setDeliver} value={deliver}>
<SelectTrigger id="cron-deliver">
<SelectTrigger className="h-9 rounded-md" id="cron-deliver">
<SelectValue />
</SelectTrigger>
<SelectContent>

View File

@@ -13,9 +13,7 @@ import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getSessionMessages, listSessions } from '../hermes'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import { toggleCommandPalette } from '../store/command-palette'
import {
$panesFlipped,
$pinnedSessionIds,
$sessionsLimit,
bumpSessionsLimit,
@@ -36,7 +34,7 @@ import {
$selectedStoredSessionId,
$sessions,
$workingSessionIds,
mergeSessionPage,
mergeWorkingSessions,
sessionPinId,
setAwaitingResponse,
setBusy,
@@ -60,7 +58,6 @@ import {
PREVIEW_RAIL_PANE_WIDTH
} from './chat/right-rail'
import { ChatSidebar } from './chat/sidebar'
import { CommandPalette } from './command-palette'
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { ModelPickerOverlay } from './model-picker-overlay'
@@ -115,7 +112,6 @@ export function DesktopController() {
const previewTarget = useStore($previewTarget)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const terminalTakeover = useStore($terminalTakeover)
const panesFlipped = useStore($panesFlipped)
const routedSessionId = routeSessionId(location.pathname)
const routeToken = `${location.pathname}:${location.search}:${location.hash}`
@@ -129,11 +125,9 @@ export function DesktopController() {
closeOverlayToPreviousRoute,
commandCenterInitialSection,
commandCenterOpen,
cronOpen,
currentView,
openAgents,
openCommandCenterSection,
profilesOpen,
settingsOpen,
toggleCommandCenter
} = useOverlayRouting()
@@ -201,31 +195,6 @@ 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).
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) {
return
}
const key = event.key.toLowerCase()
if (key === 'k') {
event.preventDefault()
toggleCommandPalette()
} else if (key === '.') {
event.preventDefault()
toggleCommandCenter()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [toggleCommandCenter])
const refreshSessions = useCallback(async () => {
const requestId = refreshSessionsRequestRef.current + 1
refreshSessionsRequestRef.current = requestId
@@ -239,13 +208,12 @@ export function DesktopController() {
const result = await listSessions(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))
// Don't hard-replace: a session whose first turn is still in flight has
// message_count 0 in the DB, so min_messages=1 omits it. Since every
// message.complete refreshes the list, a plain replace would drop the
// other still-running new chats the moment one of them finishes. Keep
// any working session the server hasn't surfaced yet.
setSessions(prev => mergeWorkingSessions(prev, result.sessions, $workingSessionIds.get()))
setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
}
} finally {
@@ -445,8 +413,6 @@ export function DesktopController() {
event.preventDefault()
startFreshSessionDraft()
// Briefly light up the sidebar's ⌘N hint so the shortcut is discoverable.
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
}
window.addEventListener('keydown', onKeyDown)
@@ -558,9 +524,7 @@ export function DesktopController() {
inferenceStatus,
modelMenuContent,
openAgents,
freshDraftReady,
openCommandCenterSection,
requestGateway,
statusSnapshot,
toggleCommandCenter
})
@@ -597,7 +561,6 @@ export function DesktopController() {
<UpdatesOverlay />
<GatewayConnectingOverlay />
<BootFailureOverlay />
<CommandPalette />
{settingsOpen && (
<Suspense fallback={null}>
@@ -626,6 +589,7 @@ export function DesktopController() {
initialSection={commandCenterInitialSection}
onClose={closeOverlayToPreviousRoute}
onDeleteSession={removeSession}
onNavigateRoute={path => navigate(path)}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/>
</Suspense>
@@ -636,18 +600,6 @@ export function DesktopController() {
<AgentsView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{cronOpen && (
<Suspense fallback={null}>
<CronView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{profilesOpen && (
<Suspense fallback={null}>
<ProfilesView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
</>
)
@@ -659,7 +611,7 @@ export function DesktopController() {
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
onAttachDroppedItems={composer.attachDroppedItems}
onAttachImageBlob={composer.attachImageBlob}
onBranchInNewChat={branchInNewChat}
onBranchInNewChat={messageId => void branchInNewChat(messageId)}
onCancel={cancelRun}
onDeleteSelectedSession={() => {
if (selectedStoredSessionId) {
@@ -686,52 +638,12 @@ export function DesktopController() {
</div>
)
// Flipped layout mirrors the default: sessions sidebar → right, file
// browser + preview rail → left. Same panes, swapped sides.
const sidebarSide = panesFlipped ? 'right' : 'left'
const railSide = panesFlipped ? 'left' : 'right'
const previewPane = (
<Pane
disabled={!chatOpen || (!previewTarget && !filePreviewTarget)}
id="preview"
key="preview"
maxWidth={PREVIEW_RAIL_MAX_WIDTH}
minWidth={PREVIEW_RAIL_MIN_WIDTH}
resizable
side={railSide}
width={PREVIEW_RAIL_PANE_WIDTH}
>
{chatOpen ? (
<ChatPreviewRail onRestartServer={restartPreviewServer} setTitlebarToolGroup={setTitlebarToolGroup} />
) : null}
</Pane>
)
const fileBrowserPane = (
<Pane
defaultOpen={false}
disabled={!chatOpen}
id="file-browser"
key="file-browser"
maxWidth={FILE_BROWSER_MAX_WIDTH}
minWidth={FILE_BROWSER_MIN_WIDTH}
resizable
side={railSide}
width={FILE_BROWSER_DEFAULT_WIDTH}
>
<RightSidebarPane
onActivateFile={composer.attachContextFilePath}
onActivateFolder={composer.attachContextFolderPath}
onChangeCwd={changeSessionCwd}
/>
</Pane>
)
return (
<AppShell
commandCenterOpen={commandCenterOpen}
leftStatusbarItems={leftStatusbarItems}
leftTitlebarTools={titlebarToolGroups.flat.left}
onOpenSearch={() => openCommandCenterSection('sessions')}
onOpenSettings={openSettings}
overlays={overlays}
statusbarItems={statusbarItems}
@@ -743,7 +655,7 @@ export function DesktopController() {
maxWidth={SIDEBAR_MAX_WIDTH}
minWidth={SIDEBAR_DEFAULT_WIDTH}
resizable
side={sidebarSide}
side="left"
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
>
{sidebar}
@@ -776,8 +688,25 @@ export function DesktopController() {
}
path="artifacts"
/>
<Route element={null} path="cron" />
<Route element={null} path="profiles" />
<Route
element={
<Suspense fallback={null}>
<CronView setStatusbarItemGroup={setStatusbarItemGroup} />
</Suspense>
}
path="cron"
/>
<Route
element={
<Suspense fallback={null}>
<ProfilesView
setStatusbarItemGroup={setStatusbarItemGroup}
setTitlebarToolGroup={setTitlebarToolGroup}
/>
</Suspense>
}
path="profiles"
/>
<Route element={null} path="settings" />
<Route element={null} path="command-center" />
<Route element={null} path="agents" />
@@ -786,13 +715,35 @@ export function DesktopController() {
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="*" />
</Routes>
</PaneMain>
{/*
Order within a side maps to column order. Default (rail on the right):
main | preview | file-browser. Flipped (rail on the left): mirror it to
file-browser | preview | main so preview stays adjacent to the chat.
*/}
{panesFlipped ? fileBrowserPane : previewPane}
{panesFlipped ? previewPane : fileBrowserPane}
<Pane
disabled={!chatOpen || (!previewTarget && !filePreviewTarget)}
id="preview"
maxWidth={PREVIEW_RAIL_MAX_WIDTH}
minWidth={PREVIEW_RAIL_MIN_WIDTH}
resizable
side="right"
width={PREVIEW_RAIL_PANE_WIDTH}
>
{chatOpen ? (
<ChatPreviewRail onRestartServer={restartPreviewServer} setTitlebarToolGroup={setTitlebarToolGroup} />
) : null}
</Pane>
<Pane
defaultOpen={false}
disabled={!chatOpen}
id="file-browser"
maxWidth={FILE_BROWSER_MAX_WIDTH}
minWidth={FILE_BROWSER_MIN_WIDTH}
resizable
side="right"
width={FILE_BROWSER_DEFAULT_WIDTH}
>
<RightSidebarPane
onActivateFile={composer.attachContextFilePath}
onActivateFolder={composer.attachContextFolderPath}
onChangeCwd={changeSessionCwd}
/>
</Pane>
</AppShell>
)
}

View File

@@ -14,6 +14,8 @@ import { notify, notifyError } from '@/store/notifications'
import { $connection, setConnection, setGatewayState, setSessionsLoading } from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
import { isFatalBackendExit } from '@/lib/backend-exit'
interface GatewayBootOptions {
handleGatewayEvent: (event: RpcEvent) => void
onConnectionReady: (
@@ -63,94 +65,6 @@ export function useGatewayBoot({
return () => void (cancelled = true)
}
// --- Reconnect-after-sleep machinery -------------------------------------
// macOS sleep silently drops the renderer's WebSocket. The backend Python
// process keeps running, but nothing re-opened the socket on wake, so the
// composer stayed disabled forever on "Starting Hermes...". Once the
// initial boot succeeds we treat any non-open state as recoverable and
// reconnect with backoff, and we nudge a reconnect on the OS/browser
// signals that fire around wake (power resume, network online, the window
// becoming visible).
let bootCompleted = false
let reconnecting = false
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let reconnectAttempt = 0
// 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
// genuinely changes between reads).
const gatewayOpen = () => gateway.connectionState === 'open'
const clearReconnectTimer = () => {
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
}
const attemptReconnect = async () => {
if (cancelled || reconnecting || gatewayOpen()) {
return
}
reconnecting = true
try {
const conn = await desktop.getConnection()
if (cancelled) {
return
}
publish(conn)
await gateway.connect(conn.wsUrl)
if (cancelled) {
return
}
reconnectAttempt = 0
// Resync state that may have moved on the backend while we were asleep.
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
await callbacksRef.current.refreshSessions().catch(() => undefined)
} catch {
// Fall through to scheduleReconnect's backoff below.
} finally {
reconnecting = false
if (!cancelled && !gatewayOpen()) {
scheduleReconnect()
}
}
}
function scheduleReconnect() {
if (cancelled || reconnecting || reconnectTimer !== null || gatewayOpen()) {
return
}
// 1s, 2s, 4s … capped at 15s.
const delay = Math.min(15_000, 1_000 * 2 ** Math.min(reconnectAttempt, 4))
reconnectAttempt += 1
reconnectTimer = setTimeout(() => {
reconnectTimer = null
void attemptReconnect()
}, delay)
}
const reconnectNow = () => {
if (cancelled || !bootCompleted) {
return
}
clearReconnectTimer()
reconnectAttempt = 0
if (!gatewayOpen()) {
void attemptReconnect()
}
}
const offBootProgress = desktop.onBootProgress(payload => applyDesktopBootProgress(payload))
void desktop
.getBootProgress()
@@ -167,34 +81,9 @@ export function useGatewayBoot({
callbacksRef.current.onGatewayReady(gateway)
setGateway(gateway)
const offState = gateway.onState(st => {
setGatewayState(st)
if (st === 'open') {
reconnectAttempt = 0
clearReconnectTimer()
} else if (bootCompleted && (st === 'closed' || st === 'error')) {
// The socket dropped after a healthy boot (typically sleep/wake). Try
// to bring it back instead of leaving the composer stuck disabled.
scheduleReconnect()
}
})
const offState = gateway.onState(st => void setGatewayState(st))
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent(event))
// Wake signals: power resume (macOS/Windows), network coming back, and the
// window regaining focus/visibility. Each nudges an immediate reconnect.
const offPowerResume = desktop.onPowerResume?.(() => reconnectNow())
const onOnline = () => reconnectNow()
const onVisible = () => {
if (document.visibilityState === 'visible') {
reconnectNow()
}
}
window.addEventListener('online', onOnline)
document.addEventListener('visibilitychange', onVisible)
const offWindowState = desktop.onWindowStateChanged?.(payload => {
const current = $connection.get()
@@ -203,7 +92,18 @@ export function useGatewayBoot({
}
})
const offExit = desktop.onBackendExit(() => {
const offExit = desktop.onBackendExit(payload => {
// A local helper process exiting is only fatal when the desktop is
// actually attached to that local backend. In remote mode (or when the
// exit was a deliberate teardown, e.g. switching to a remote gateway),
// the local process is not our backend — ignore it so a remote-ready
// boot doesn't stay latched behind a stale local-exit failure (#37869).
const fatal = isFatalBackendExit(payload, $connection.get()?.mode)
if (!fatal) {
return
}
if ($desktopBoot.get().running || $desktopBoot.get().visible) {
failDesktopBoot('Hermes background process exited during startup.')
}
@@ -254,7 +154,6 @@ export function useGatewayBoot({
})
await callbacksRef.current.refreshSessions()
completeDesktopBoot()
bootCompleted = true
} catch (err) {
if (!cancelled) {
const message = err instanceof Error ? err.message : String(err)
@@ -269,10 +168,6 @@ export function useGatewayBoot({
return () => {
cancelled = true
clearReconnectTimer()
window.removeEventListener('online', onOnline)
document.removeEventListener('visibilitychange', onVisible)
offPowerResume?.()
offState()
offEvent()
offExit()

View File

@@ -1,45 +0,0 @@
import { useEffect, useRef } from 'react'
/**
* Binds the bare `r` key to a refresh action while the calling view is mounted.
* Ignored when a modifier is held, the event repeats, or focus is in an
* editable field (so typing "r" in a search/input never triggers it).
*/
export function useRefreshHotkey(onRefresh: () => void, enabled = true) {
const ref = useRef(onRefresh)
ref.current = onRefresh
useEffect(() => {
if (!enabled) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'r' && event.key !== 'R') {
return
}
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.repeat) {
return
}
const target = event.target as HTMLElement | null
if (
target?.isContentEditable ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
) {
return
}
event.preventDefault()
ref.current()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [enabled])
}

View File

@@ -1,13 +0,0 @@
// Responsive horizontal gutter for primary content bodies (settings right side,
// skills, artifacts, command center / sessions). Ratio-based so it scales with
// the window, but clamped so it never collapses on narrow widths or runs away
// on ultrawide displays. Headers/tabs intentionally keep their own tighter
// padding.
//
// NOTE: these must stay literal strings — Tailwind's scanner only picks up
// complete class names, so do not build them via template interpolation.
export const PAGE_INSET_X = 'px-[clamp(1.25rem,4vw,4rem)]'
// Matching negative inline-margin to bleed an element (e.g. a sticky header bar)
// out to the gutter edges before re-applying PAGE_INSET_X.
export const PAGE_INSET_NEG_X = '-mx-[clamp(1.25rem,4vw,4rem)]'

View File

@@ -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'
@@ -18,7 +17,6 @@ import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PageSearchShell } from '../page-search-shell'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
@@ -43,11 +41,11 @@ const STATE_LABELS: Record<string, string> = {
startup_failed: 'Startup failed'
}
const TONE_VARIANT: Record<StatusTone, BadgeProps['variant']> = {
good: 'default',
muted: 'muted',
warn: 'warn',
bad: 'destructive'
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 HINT_BY_STATE: Record<string, string> = {
@@ -215,8 +213,6 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
}
}, [])
useRefreshHotkey(() => void refreshPlatforms())
useEffect(() => {
void refreshPlatforms()
}, [refreshPlatforms])
@@ -347,15 +343,15 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
<PageSearchShell
{...props}
onSearchChange={setQuery}
searchHidden={(platforms?.length ?? 0) === 0}
searchPlaceholder="Search messaging..."
searchTrailingAction={null}
searchValue={query}
>
{!platforms ? (
<PageLoader label="Loading messaging platforms..." />
) : (
<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">
<aside className="min-h-0 overflow-y-auto border-b border-(--ui-stroke-tertiary) p-2 lg:border-b-0 lg:border-r">
<ul className="space-y-1">
{visiblePlatforms.map(platform => (
<li key={platform.id}>
@@ -410,8 +406,8 @@ function PlatformRow({
className={cn(
'flex w-full items-center gap-2 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'
? 'bg-(--ui-bg-tertiary) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
onClick={onSelect}
type="button"
@@ -486,7 +482,7 @@ function PlatformDetail({
{introCopy(platform)}
</p>
<div className="mt-3">
<Button asChild size="sm" variant="textStrong">
<Button asChild size="sm" variant="outline">
<a href={platform.docs_url} rel="noreferrer" target="_blank">
Open setup guide
<ExternalLink className="size-3.5" />
@@ -564,15 +560,19 @@ function PlatformDetail({
</div>
</div>
<footer className="bg-(--ui-chat-surface-background) px-5 py-2.5">
<footer className="border-t border-(--ui-stroke-tertiary) 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 ? `Disable ${platform.name}` : `Enable ${platform.name}`}
checked={platform.enabled}
disabled={saving === `enabled:${platform.id}`}
onCheckedChange={onToggle}
/>
<span className="text-xs font-medium text-muted-foreground">
{platform.enabled ? 'Enabled' : 'Disabled'}
</span>
</label>
<div className="ml-auto flex items-center gap-2">
{hasEdits && <span className="text-xs text-muted-foreground">Unsaved changes</span>}
@@ -651,7 +651,7 @@ function MessagingField({
</div>
<div className="flex items-center gap-2">
<Input
className="font-mono"
className="h-9 rounded-lg font-mono text-sm"
id={`messaging-field-${field.key}`}
onChange={event => onEdit(field.key, event.target.value)}
placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder}
@@ -698,13 +698,27 @@ function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) {
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>
)
}

View File

@@ -0,0 +1,77 @@
import type { ReactNode, RefObject } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Input } from '@/components/ui/input'
import { Loader2, Search } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface OverlaySearchInputProps {
placeholder: string
value: string
onChange: (value: string) => void
containerClassName?: string
inputClassName?: string
loading?: boolean
onClear?: () => void
inputRef?: RefObject<HTMLInputElement | null>
trailingAction?: ReactNode
}
export function OverlaySearchInput({
placeholder,
value,
onChange,
containerClassName,
inputClassName,
loading = false,
onClear,
inputRef,
trailingAction
}: OverlaySearchInputProps) {
const clear = onClear ?? (() => onChange(''))
const hasTrailing = Boolean(trailingAction)
return (
<div className={cn('relative', containerClassName)}>
<Search className="pointer-events-none absolute left-3 top-1/2 z-1 size-3.5 -translate-y-1/2 text-muted-foreground/80" />
<Input
className={cn(
'relative z-0 h-8 rounded-lg py-2 pl-8 text-[length:var(--conversation-text-font-size)]',
hasTrailing || loading || value ? 'pr-16' : 'pr-8',
inputClassName
)}
onChange={event => onChange(event.target.value)}
placeholder={placeholder}
ref={inputRef}
value={value}
/>
<div className="absolute right-1.5 top-1/2 z-1 flex -translate-y-1/2 items-center gap-0.5">
{trailingAction}
{loading ? (
<Loader2 className="pointer-events-none size-3.5 animate-spin text-muted-foreground/70" />
) : value ? (
<Button
aria-label="Clear search"
className="text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
onClick={clear}
size="icon-xs"
variant="ghost"
>
<Codicon name="close" size="0.875rem" />
</Button>
) : null}
</div>
</div>
)
}
export function PageSearchInput(props: OverlaySearchInputProps) {
return (
<OverlaySearchInput
{...props}
containerClassName={cn('mx-auto w-[min(36rem,calc(100%-2rem))] min-w-0', props.containerClassName)}
inputClassName={cn('h-8 rounded-lg py-2 pl-8', props.inputClassName)}
/>
)
}

View File

@@ -3,8 +3,6 @@ import type { ReactNode } from 'react'
import type { IconComponent } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { PAGE_INSET_X } from '../layout-constants'
interface OverlaySplitLayoutProps {
children: ReactNode
className?: string
@@ -45,9 +43,7 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
return (
<aside
className={cn(
// pt clears the floating titlebar/header; the bg itself fills from the
// card's top edge so there's no surface-colored gap above the sidebar.
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 pb-3 pt-[calc(var(--titlebar-height)+1rem)]',
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 py-3',
className
)}
>
@@ -58,15 +54,7 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
export function OverlayMain({ children, className }: OverlayMainProps) {
return (
<main
className={cn(
'flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent pb-3 pt-[calc(var(--titlebar-height)+1rem)]',
PAGE_INSET_X,
className
)}
>
{children}
</main>
<main className={cn('flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent p-3', className)}>{children}</main>
)
}

View File

@@ -64,26 +64,23 @@ export function OverlayView({
>
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[calc(var(--titlebar-height)+0.1875rem)] [-webkit-app-region:drag]">
{headerContent && (
<div className="pointer-events-auto absolute left-1/2 top-[calc(0.5rem+var(--titlebar-height)/2)] -translate-x-1/2 -translate-y-1/2 [-webkit-app-region:no-drag]">
<div className="pointer-events-auto absolute left-1/2 top-[calc(1rem+var(--titlebar-height)/2)] -translate-x-1/2 -translate-y-1/2 [-webkit-app-region:no-drag]">
{headerContent}
</div>
)}
<Button
aria-label={closeLabel}
className="pointer-events-auto absolute right-3 top-[calc(0.1875rem+var(--titlebar-height)/2)] -translate-y-1/2 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground [-webkit-app-region:no-drag]"
className="pointer-events-auto absolute right-3 top-[calc(0.1875rem+var(--titlebar-height)/2)] h-7 w-7 -translate-y-1/2 rounded-md text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground [-webkit-app-region:no-drag]"
onClick={closeOverlay}
size="icon-titlebar"
size="icon"
variant="ghost"
>
<Codicon name="close" size="1rem" />
</Button>
</div>
{/* No top padding here: the split-layout columns own their own
titlebar clearance so their backgrounds run flush to the card top
(otherwise the card surface shows as a gap above the sidebar). */}
<div className={cn('min-h-0 flex flex-1 flex-col', contentClassName)}>{children}</div>
<div className={cn('min-h-0 flex flex-1 flex-col pt-(--titlebar-height)', contentClassName)}>{children}</div>
</div>
</div>
)

View File

@@ -1,30 +1,26 @@
import type { ReactNode } from 'react'
import { SearchField } from '@/components/ui/search-field'
import { cn } from '@/lib/utils'
import { PageSearchInput } from './overlays/overlay-search-input'
interface PageSearchShellProps extends React.ComponentProps<'section'> {
children: ReactNode
/** Primary tabs shown on the top row, beside the search. */
tabs?: ReactNode
/** Secondary filters shown full-width on their own row below (expands). */
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
}
export function PageSearchShell({
children,
className,
tabs,
filters,
onSearchChange,
searchPlaceholder,
searchTrailingAction,
searchValue,
searchHidden = false,
...props
}: PageSearchShellProps) {
return (
@@ -33,42 +29,29 @@ export function PageSearchShell({
className={cn('flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)', className)}
>
{/*
Header lives in the page body, below the window chrome (the shell floats
traffic lights over the top titlebar-height strip, which the `pt` clears
and leaves draggable). Top row: primary tabs + search. Second row:
secondary filters, full-width so they expand. Interactive bits opt out
of the drag region.
This header sits in the titlebar row, so it overlaps the OS window-drag
region painted by the shell. Without `-webkit-app-region: no-drag` on
the search row, mousedown on the input gets intercepted as a window-
drag start and the input never receives focus (visible as "I can't
click the search box" on the messaging/cron/etc pages).
*/}
{/*
IMPORTANT: do NOT put `-webkit-app-region: drag` on this header. It spans
full width over the band where the floating titlebar icon clusters live,
and an overlapping OS drag region eats their clicks at the compositor
level (pointer-events / no-drag carve-outs across separate stacking
contexts don't reliably fix it on macOS). The shell already supplies a
draggable titlebar strip that is `calc()`'d around the icon clusters
(see app-shell.tsx), so window dragging still works here.
*/}
<div className="shrink-0">
{(tabs || !searchHidden) && (
<div className="flex items-center gap-3 px-3 pb-2 pt-[calc(var(--titlebar-height)+0.5rem)]">
{tabs ? (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">{tabs}</div>
) : null}
{!searchHidden && (
<div className={cn('flex shrink-0 items-center', !tabs && 'flex-1')}>
<SearchField
containerClassName="max-w-[45vw]"
onChange={onSearchChange}
placeholder={searchPlaceholder}
value={searchValue}
/>
</div>
)}
</div>
)}
{filters ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 pb-2">{filters}</div>
) : null}
<div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5 [-webkit-app-region:no-drag]">
{/* Reserve the top-right titlebar tools + native window-controls
footprint so the full-width search input never slides under them. */}
<div
style={{
paddingRight:
'max(0px, calc(var(--titlebar-tools-right, 0px) + var(--titlebar-tools-width, 0px) - 0.75rem))'
}}
>
<PageSearchInput
onChange={onSearchChange}
placeholder={searchPlaceholder}
trailingAction={searchTrailingAction}
value={searchValue}
/>
</div>
{filters ? <div className="flex flex-wrap items-center justify-center gap-1.5">{filters}</div> : null}
</div>
<div className="min-h-0 flex-1 overflow-hidden bg-(--ui-chat-surface-background)">{children}</div>
</section>

View File

@@ -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 {
@@ -28,9 +28,9 @@ import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icon
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}$/
@@ -40,18 +40,26 @@ function isValidProfileName(name: string): boolean {
return PROFILE_NAME_RE.test(name.trim())
}
interface ProfilesViewProps {
onClose: () => void
interface ProfilesViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
setTitlebarToolGroup?: SetTitlebarToolGroup
}
export function ProfilesView({ onClose }: ProfilesViewProps) {
export function ProfilesView({
setStatusbarItemGroup: _setStatusbarItemGroup,
setTitlebarToolGroup,
...props
}: ProfilesViewProps) {
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)
@@ -64,15 +72,33 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
})
} catch (err) {
notifyError(err, 'Failed to load profiles')
} finally {
setRefreshing(false)
}
}, [])
useRefreshHotkey(refresh)
useEffect(() => {
void refresh()
}, [refresh])
useEffect(() => {
if (!setTitlebarToolGroup) {
return
}
setTitlebarToolGroup('profiles', [
{
disabled: refreshing,
icon: <Codicon name="refresh" spinning={refreshing} />,
id: 'refresh-profiles',
label: refreshing ? 'Refreshing profiles' : 'Refresh profiles',
onSelect: () => void refresh()
}
])
return () => setTitlebarToolGroup('profiles', [])
}, [refresh, refreshing, setTitlebarToolGroup])
const selected = useMemo(() => {
if (!profiles) {
return null
@@ -138,58 +164,62 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
}, [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>
<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">Profiles</h2>
<span className="pointer-events-auto text-xs text-muted-foreground">
{profiles ? `${profiles.length} ${profiles.length === 1 ? 'profile' : 'profiles'}` : ''}
</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>
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85">
{!profiles ? (
<PageLoader label="Loading profiles..." />
) : (
<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" />
New profile
</Button>
</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">No profiles yet.</li>
)}
</ul>
</aside>
<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">Select a profile to view its details.</p>
</div>
</div>
)}
</main>
</div>
)}
</div>
<CreateProfileDialog
onClose={() => setCreateOpen(false)}
@@ -220,7 +250,7 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
</DialogFooter>
</DialogContent>
</Dialog>
</OverlayView>
</section>
)
}
@@ -228,10 +258,8 @@ function ProfileRow({ active, onSelect, profile }: { active: boolean; onSelect:
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"
@@ -283,30 +311,38 @@ 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">
Default
</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
</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'}
</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
@@ -315,7 +351,7 @@ function ProfileDetail({
</div>
</div>
<dl className="grid gap-2 text-xs sm:grid-cols-2">
<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="Model">
{profile.model ? (
<>
@@ -351,7 +387,7 @@ 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>
)
}
@@ -422,7 +458,9 @@ function SoulEditor({ profileName }: { profileName: string }) {
</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">
Loading SOUL.md...
</div>
) : (
<Textarea
className="min-h-72 font-mono text-xs leading-5"

View File

@@ -1,7 +1,6 @@
import { useCallback, useRef, useState } from 'react'
import { type NodeApi, type NodeRendererProps, Tree, type TreeApi } from 'react-arborist'
import { PageLoader } from '@/components/page-loader'
import { Codicon } from '@/components/ui/codicon'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { cn } from '@/lib/utils'
@@ -122,7 +121,11 @@ export function ProjectTree({
}
function TreeSizingState() {
return <PageLoader aria-label="Loading files" className="min-h-24 px-3" />
return (
<div className="flex h-full min-h-24 items-center justify-center px-3 text-[0.68rem] text-(--ui-text-tertiary)">
Loading files...
</div>
)
}
function ProjectTreeRow({

View File

@@ -7,7 +7,6 @@ import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentBranch, $currentCwd } from '@/store/session'
@@ -32,14 +31,17 @@ interface RightSidebarTab {
}
const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
{ id: 'files', label: 'File system', icon: 'list-tree' },
{ id: 'files', label: 'File system', icon: 'files' },
{ id: 'terminal', label: 'Terminal', icon: 'terminal' }
]
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
export function RightSidebarPane({
onActivateFile,
onActivateFolder,
onChangeCwd
}: RightSidebarPaneProps) {
const activeTab = useStore($rightSidebarTab)
const terminalTakeover = useStore($terminalTakeover)
const panesFlipped = useStore($panesFlipped)
const currentBranch = useStore($currentBranch).trim()
const currentCwd = useStore($currentCwd).trim()
const hasCwd = currentCwd.length > 0
@@ -51,17 +53,8 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
.pop() ?? currentCwd)
: 'No folder selected'
const {
collapseAll,
collapseNonce,
data,
loadChildren,
openState,
refreshRoot,
rootError,
rootLoading,
setNodeOpen
} = useProjectTree(currentCwd)
const { collapseAll, collapseNonce, data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } =
useProjectTree(currentCwd)
const canCollapse = Object.values(openState).some(Boolean)
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
@@ -93,17 +86,14 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
}
}
const tabs = terminalTakeover ? RIGHT_SIDEBAR_TABS.filter(tab => tab.id !== 'terminal') : RIGHT_SIDEBAR_TABS
const tabs = terminalTakeover
? RIGHT_SIDEBAR_TABS.filter(tab => tab.id !== 'terminal')
: RIGHT_SIDEBAR_TABS
return (
<aside
aria-label="Right sidebar"
className={cn(
'before:pointer-events-none relative flex h-full w-full min-w-0 flex-col overflow-hidden border-(--ui-stroke-secondary) bg-(--ui-sidebar-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary)',
panesFlipped
? 'border-r shadow-[inset_-0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
)}
className="before:pointer-events-none relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-secondary) bg-(--ui-sidebar-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary) shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)] before:absolute before:inset-x-0 before:top-(--titlebar-height) before:z-1 before:h-px before:bg-(--ui-stroke-tertiary)"
>
<RightSidebarChrome activeTab={effectiveTab} branch={currentBranch} tabs={tabs} />
@@ -145,27 +135,26 @@ function RightSidebarChrome({
}) {
return (
<header className="shrink-0 bg-transparent text-[0.75rem]">
<div className="flex items-center gap-2 px-2.5 py-1">
<div className="flex items-center gap-2 border-b border-(--ui-stroke-tertiary) px-2.5 py-1">
<nav aria-label="Right sidebar panels" className="flex min-w-0 items-center gap-1">
{tabs.map(tab => (
<Button
<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'
'grid size-6 shrink-0 place-items-center rounded-lg text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring active:bg-(--ui-control-active-background) active:text-foreground',
'data-[active=true]:bg-(--ui-control-active-background) data-[active=true]:text-foreground'
)}
data-active={tab.id === activeTab}
key={tab.id}
onClick={() => setRightSidebarTab(tab.id)}
size="icon-xs"
title={tab.label}
variant="ghost"
type="button"
>
<Codicon name={tab.icon} size="0.875rem" />
</Button>
</button>
))}
</nav>
{branch && (
<span className="ml-auto flex min-w-0 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary)">
<Codicon className="shrink-0" name="git-branch" size="0.75rem" />
@@ -186,11 +175,8 @@ interface FilesystemTabProps extends FileTreeBodyProps {
onRefresh: () => void
}
// Sidebar-specific color/hover treatment only — size, radius, cursor and the
// base focus ring come from <Button size="icon-xs">. This constant exists
// purely to share the sidebar palette + the hover-reveal behavior below.
const HEADER_ACTION_CLASS =
'text-sidebar-foreground/70 hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-sidebar-ring'
'size-6 shrink-0 rounded-md text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-2 focus-visible:ring-sidebar-ring'
const HEADER_ACTION_REVEAL_CLASS = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
@@ -224,22 +210,11 @@ function FilesystemTab({
>
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</button>
<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} />
</Button>
<Button
aria-label="Open folder"
className={HEADER_ACTION_CLASS}
onClick={() => void onChangeFolder()}
size="icon-xs"
size="icon"
title={hasCwd ? 'Open a different folder' : 'Open a folder'}
variant="ghost"
>
@@ -250,12 +225,23 @@ function FilesystemTab({
className={HEADER_ACTION_REVEAL_CLASS}
disabled={!hasCwd || !canCollapse}
onClick={onCollapseAll}
size="icon-xs"
size="icon"
title="Collapse all folders"
variant="ghost"
>
<Codicon name="collapse-all" size="0.8125rem" />
</Button>
<Button
aria-label="Refresh tree"
className={HEADER_ACTION_REVEAL_CLASS}
disabled={!hasCwd || loading}
onClick={onRefresh}
size="icon"
title="Refresh tree"
variant="ghost"
>
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
</Button>
</RightSidebarSectionHeader>
<FileTreeBody
collapseNonce={collapseNonce}
@@ -275,7 +261,7 @@ function FilesystemTab({
}
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
return <div className="flex h-7 shrink-0 items-center px-2.5">{children}</div>
return <div className="flex h-7 shrink-0 items-center px-2">{children}</div>
}
interface FileTreeBodyProps {

View File

@@ -52,21 +52,6 @@ export const APP_ROUTES = [
const APP_VIEW_BY_PATH = new Map<string, AppView>(APP_ROUTES.map(route => [route.path, route.view]))
const RESERVED_PATHS: ReadonlySet<string> = new Set(APP_ROUTES.map(route => route.path))
// Views that render as a full-screen modal card (OverlayView) over the shell.
// While one is open the app's titlebar control clusters must hide so they don't
// bleed over the overlay (they sit at a higher z-index than the overlay card).
export const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set([
'agents',
'command-center',
'cron',
'profiles',
'settings'
])
export function isOverlayView(view: AppView): boolean {
return OVERLAY_VIEWS.has(view)
}
export function isNewChatRoute(pathname: string): boolean {
return pathname === NEW_CHAT_ROUTE
}

View File

@@ -19,7 +19,6 @@ import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setClarifyRequest } from '@/store/clarify'
import { notify } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
import {
setCurrentBranch,
setCurrentCwd,
@@ -30,8 +29,7 @@ import {
setCurrentReasoningEffort,
setCurrentServiceTier,
setCurrentUsage,
setTurnStartedAt,
setYoloActive
setTurnStartedAt
} from '@/store/session'
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
import { recordToolDiff } from '@/store/tool-diffs'
@@ -313,7 +311,6 @@ export function useMessageStream({
// commit and the synthetic harness shows longtask counts drop from ~5/5s
// to ~1/5s on big sessions (see scripts/profile-typing-lag.md).
const sinceLast = performance.now() - lastFlushAtRef.current
const runFlush = () => {
flushHandleRef.current = null
lastFlushAtRef.current = performance.now()
@@ -532,8 +529,7 @@ export function useMessageStream({
streamId: null,
pendingBranchGroup: null,
awaitingResponse: false,
busy: false,
needsInput: false
busy: false
}
})
@@ -590,8 +586,7 @@ export function useMessageStream({
pendingBranchGroup: null,
sawAssistantPayload: true,
awaitingResponse: false,
busy: false,
needsInput: false
busy: false
}
})
},
@@ -660,10 +655,6 @@ export function useMessageStream({
setCurrentFastMode(payload.fast)
}
if (typeof payload?.yolo === 'boolean') {
setYoloActive(payload.yolo)
}
if (runningChanged && sessionId) {
updateSessionState(sessionId, state => {
const busy = Boolean(payload!.running)
@@ -755,13 +746,6 @@ 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()
}
flushQueuedDeltas(sessionId)
if (isActiveEvent) {
@@ -789,11 +773,6 @@ export function useMessageStream({
if (sessionId) {
flushQueuedDeltas(sessionId)
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type)
// A pending clarify blocks the turn, so the first tool.complete after
// one is the clarify resolving — drop the "needs input" flag here so
// the sidebar indicator clears as soon as it's answered, not only at
// message.complete.
updateSessionState(sessionId, state => (state.needsInput ? { ...state, needsInput: false } : state))
}
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
@@ -814,16 +793,13 @@ export function useMessageStream({
)
}
} else if (event.type === 'clarify.request') {
if (!isActiveEvent) {
return
}
// Surface the clarify tool's overlay. The Python side is blocked on
// `clarify.respond`, so without this handler the agent would hang
// forever (see tools/clarify_tool.py + tui_gateway/server.py:_block).
//
// Store the request for whichever session raised it — even a background
// one. clarify.request is a one-shot event; if we dropped it for an
// unfocused session, that session would block on `clarify.respond`
// indefinitely and re-focusing it could never recover (the event is
// gone). Parking it per-session lets the user answer once they switch
// over; the inline ClarifyTool reads the active session's entry.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
const question = typeof payload?.question === 'string' ? payload.question : ''
@@ -834,70 +810,11 @@ export function useMessageStream({
choices: Array.isArray(payload?.choices) ? payload!.choices!.filter(c => typeof c === 'string') : null,
sessionId: sessionId ?? null
})
// The transcript only renders the active session, so a background
// clarify is otherwise invisible (the row just keeps spinning like
// it's working). Flag the session so the sidebar shows a persistent
// "needs input" indicator on its row — works for the active session
// too, and survives alt-tab / window blur (unlike a toast).
if (sessionId) {
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
}
} 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}.
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
}
// 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 })
}
} 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 : ''
if (requestId) {
setSecretRequest({
requestId,
envVar: typeof payload?.env_var === 'string' ? payload.env_var : '',
prompt: typeof payload?.prompt === 'string' ? payload.prompt : ''
})
}
} 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()
}
if (looksLikeProviderSetup) {
requestDesktopOnboarding(errorMessage)
} else if (isActiveEvent) {

View File

@@ -19,7 +19,6 @@ import {
} from '@/lib/desktop-slash-commands'
import { triggerHaptic } from '@/lib/haptics'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setSessionYolo } from '@/lib/yolo-session'
import {
$composerAttachments,
addComposerAttachment,
@@ -29,15 +28,7 @@ import {
} from '@/store/composer'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import {
$busy,
$messages,
$yoloActive,
setAwaitingResponse,
setBusy,
setMessages,
setYoloActive
} from '@/store/session'
import { $busy, $messages, setAwaitingResponse, setBusy, setMessages } from '@/store/session'
import type { ClientSessionState, ImageAttachResponse, SlashExecResponse } from '../../types'
@@ -409,30 +400,6 @@ export function usePromptActions({
return
}
// /yolo maps to the status-bar YOLO control — a per-session approval
// bypass, same scope as the TUI's Shift+Tab. With no session yet we arm
// it locally; the session-create path applies it on the first message.
if (normalizedName === 'yolo') {
const sid = sessionHint || activeSessionIdRef.current
const next = !$yoloActive.get()
if (!sid) {
setYoloActive(next)
notify({ kind: 'success', message: next ? 'YOLO armed for this chat' : 'YOLO off' })
return
}
try {
const active = await setSessionYolo(requestGateway, sid, next)
appendSessionTextMessage(sid, 'system', `YOLO ${active ? 'on' : 'off'} for this session`)
} catch {
notify({ kind: 'error', title: 'YOLO', message: 'Could not toggle YOLO' })
}
return
}
if (normalizedName === 'skin' && !sessionHint && !activeSessionIdRef.current) {
notify({ kind: 'success', message: handleSkinCommand(arg) })

View File

@@ -6,7 +6,6 @@ import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
import { setSessionYolo } from '@/lib/yolo-session'
import { clearComposerAttachments, clearComposerDraft } from '@/store/composer'
import { clearQueuedPrompts } from '@/store/composer-queue'
import { $pinnedSessionIds } from '@/store/layout'
@@ -16,9 +15,7 @@ import {
$currentCwd,
$messages,
$sessions,
$yoloActive,
getRememberedWorkspaceCwd,
sessionPinId,
setActiveSessionId,
setAwaitingResponse,
setBusy,
@@ -38,8 +35,7 @@ import {
setSessions,
setSessionsTotal,
setSessionStartedAt,
setTurnStartedAt,
setYoloActive
setTurnStartedAt
} from '@/store/session'
import { reportBackendContract } from '@/store/updates'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'
@@ -251,10 +247,6 @@ function applyRuntimeInfo(
setCurrentFastMode(info.fast)
}
if (typeof info.yolo === 'boolean') {
setYoloActive(info.yolo)
}
if (info.usage) {
setCurrentUsage(current => ({ ...current, ...info.usage }))
}
@@ -350,19 +342,12 @@ export function useSessionActions({
setActiveSessionId(created.session_id)
setSelectedStoredSessionId(stored)
setSessionStartedAt(Date.now())
const yoloArmed = $yoloActive.get()
const runtimeInfo = applyRuntimeInfo(created.info)
if (runtimeInfo) {
updateSessionState(created.session_id, state => ({ ...state, ...runtimeInfo }), stored)
}
// User may have armed YOLO on the new-chat draft before the runtime
// session existed — apply it to the freshly created session.
if (yoloArmed) {
await setSessionYolo(requestGateway, created.session_id, true).catch(() => undefined)
}
return created.session_id
} finally {
window.setTimeout(() => {
@@ -707,15 +692,12 @@ export function useSessionActions({
const closingRuntimeId = wasSelected ? activeSessionId : null
const previousMessages = $messages.get()
const previousPinned = $pinnedSessionIds.get()
// Pins are keyed on the durable lineage-root id; the stored id may be the
// live tip after compression. Drop both so the pin can't linger.
const removedPinId = removed ? sessionPinId(removed) : storedSessionId
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
// doesn't keep claiming the removed row is still on the server.
setSessionsTotal(prev => Math.max(0, prev - 1))
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId && id !== removedPinId))
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
// Tear down before awaiting so the route effect can't resume the
// doomed session via the stale /<sid> URL.
@@ -787,9 +769,6 @@ export function useSessionActions({
const archived = $sessions.get().find(s => s.id === storedSessionId)
const wasSelected = selectedStoredSessionId === storedSessionId
const previousPinned = $pinnedSessionIds.get()
// Pins are keyed on the durable lineage-root id; the stored id may be the
// live tip after compression. Drop both so the pin can't linger.
const archivedPinId = archived ? sessionPinId(archived) : storedSessionId
// Soft-hide: drop from the sidebar immediately, keep the data.
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
@@ -797,7 +776,7 @@ export function useSessionActions({
// on the next refresh, so they count as "removed" for the load-more
// footer math.
setSessionsTotal(prev => Math.max(0, prev - 1))
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId && id !== archivedPinId))
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
if (wasSelected) {
startFreshSessionDraft(true)

View File

@@ -4,7 +4,7 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking } from '@/store/session'
import { $busy, $messages, noteSessionActivity, setSessionWorking } from '@/store/session'
import type { ClientSessionState } from '../../types'
@@ -152,13 +152,7 @@ export function useSessionStateCache({
setSessionWorking(previous.storedSessionId, false)
}
if (previous.storedSessionId !== next.storedSessionId || !next.needsInput) {
setSessionAttention(previous.storedSessionId, false)
}
setSessionWorking(next.storedSessionId, next.busy)
setSessionAttention(next.storedSessionId, next.needsInput)
// Every state update is effectively a "still alive" heartbeat for
// streaming events. The session-store watchdog uses this to keep the
// working flag alive during long-running turns and to clear it once
@@ -166,7 +160,6 @@ export function useSessionStateCache({
if (next.busy) {
noteSessionActivity(next.storedSessionId)
}
syncSessionStateToView(sessionId, next)
return next

View File

@@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Loader2, RefreshCw, Sparkles } from '@/lib/icons'
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$desktopVersion,
@@ -111,22 +111,29 @@ 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">
Last checked {relativeTime(status?.fetchedAt)}
{justChecked && !checking ? ' · just now' : ''}
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-4">
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
disabled={checking || applying || !supported}
onClick={() => void handleCheck()}
size="sm"
variant="textStrong"
variant="outline"
>
{checking && <Loader2 className="size-3 animate-spin" />}
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{checking ? 'Checking…' : 'Check now'}
</Button>
@@ -136,7 +143,12 @@ export function AboutSettings() {
</Button>
)}
<Button asChild className="ml-auto" size="sm" variant="text">
<Button
asChild
className="ml-auto text-xs text-muted-foreground hover:text-foreground"
size="sm"
variant="ghost"
>
<a
href={RELEASE_NOTES_URL}
onClick={event => {
@@ -146,6 +158,7 @@ export function AboutSettings() {
rel="noreferrer"
target="_blank"
>
<ExternalLink className="size-3" />
Release notes
</a>
</Button>

View File

@@ -1,16 +1,15 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { triggerHaptic } from '@/lib/haptics'
import { Check } from '@/lib/icons'
import { Check, Palette } from '@/lib/icons'
import { cn } from '@/lib/utils'
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 { prettyName } from './helpers'
import { Pill, SectionHeading, SettingsContent } from './primitives'
function ThemePreview({ name }: { name: string }) {
const t = BUILTIN_THEMES[name]
@@ -52,80 +51,146 @@ 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 { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)
const activeTheme = availableThemes.find(t => t.name === themeName)
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="Appearance" />
<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>
<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">Color Mode</div>
<div className="mt-1 text-xs text-muted-foreground">
Pick a fixed mode or let Hermes follow your system setting.
</div>
</div>
<Pill>{prettyName(mode)}</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-3">
{MODE_OPTIONS.map(({ id, label, description, icon: Icon }) => {
const active = mode === 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">{label}</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</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
<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">Tool Call Display</div>
<div className="mt-1 text-xs text-muted-foreground">
Product hides raw tool payloads; Technical shows full input/output.
</div>
</div>
<Pill>{toolViewMode === 'technical' ? 'Technical' : 'Product'}</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{(
[
{
id: 'product',
label: 'Product',
description: 'Human-friendly tool activity with concise summaries.'
},
{
id: 'technical',
label: 'Technical',
description: 'Include raw tool args/results and low-level details.'
}
value={toolViewMode}
/>
}
description="Product hides raw tool payloads; Technical shows full input/output."
title="Tool Call Display"
/>
] 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="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">Theme</div>
<div className="mt-1 text-xs text-muted-foreground">
Desktop palettes only. The selected mode is applied on top.
</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 +198,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 +208,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>
)

View File

@@ -1,6 +1,5 @@
import type { ChangeEvent, ReactNode } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
@@ -18,9 +17,10 @@ import { notify, notifyError } from '@/store/notifications'
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
import { enumOptionsFor, getNested, includesQuery, prettyName, setNested } from './helpers'
import { ModelSettings } from './model-settings'
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
import type { SearchProps } from './types'
function ConfigField({
schemaKey,
@@ -53,7 +53,8 @@ function ConfigField({
if (schema.type === 'boolean') {
return row(
<div className="flex items-center justify-end">
<div className="flex items-center justify-end gap-3">
<span className="text-xs text-muted-foreground">{value ? 'On' : 'Off'}</span>
<Switch checked={Boolean(value)} onCheckedChange={onChange} />
</div>
)
@@ -88,7 +89,7 @@ function ConfigField({
if (schema.type === 'number') {
return row(
<Input
className={CONTROL_TEXT}
className={cn('h-8', CONTROL_TEXT)}
onChange={e => {
const raw = e.target.value
const n = raw === '' ? 0 : Number(raw)
@@ -107,7 +108,7 @@ function ConfigField({
if (schema.type === 'list') {
return row(
<Input
className={CONTROL_TEXT}
className={cn('h-8', CONTROL_TEXT)}
onChange={e =>
onChange(
e.target.value
@@ -153,7 +154,7 @@ function ConfigField({
/>
) : (
<Input
className={CONTROL_TEXT}
className={cn('h-8', CONTROL_TEXT)}
onChange={e => onChange(e.target.value)}
placeholder="Not set"
value={String(value ?? '')}
@@ -164,11 +165,12 @@ function ConfigField({
}
export function ConfigSettings({
query,
activeSectionId,
onConfigSaved,
onMainModelChanged,
importInputRef
}: {
}: SearchProps & {
activeSectionId: string
onConfigSaved?: () => void
onMainModelChanged?: (provider: string, model: string) => void
@@ -263,41 +265,37 @@ export function ConfigSettings({
)
}, [schema])
const fields = sectionFields.get(activeSectionId) ?? []
const matched = useMemo(() => {
const q = query.trim().toLowerCase()
// Deep-link target from the command palette (?field=<key>): scroll the row
// into view and flash it, then drop the param so it doesn't re-fire.
const [searchParams, setSearchParams] = useSearchParams()
const targetField = searchParams.get('field')
useEffect(() => {
if (!targetField || !config || !schema) {
return
if (!schema || !q) {
return []
}
const element = document.getElementById(`setting-field-${targetField}`)
const seen = new Set<string>()
if (!element) {
return
}
return SECTIONS.flatMap(s =>
s.keys.flatMap(k => {
if (seen.has(k) || !schema[k]) {
return []
}
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
element.classList.add('setting-field-highlight')
seen.add(k)
const label = prettyName(k.split('.').pop() ?? k)
const item = schema[k]
const timeout = window.setTimeout(() => element.classList.remove('setting-field-highlight'), 1600)
const hit =
k.toLowerCase().includes(q) ||
label.toLowerCase().includes(q) ||
includesQuery(item.category, q) ||
includesQuery(item.description, q)
setSearchParams(
previous => {
const next = new URLSearchParams(previous)
next.delete('field')
return next
},
{ replace: true }
return hit ? [[k, item] as [string, ConfigFieldSchema]] : []
})
)
}, [schema, query])
return () => window.clearTimeout(timeout)
}, [config, schema, setSearchParams, targetField])
const fields = query.trim() ? matched : (sectionFields.get(activeSectionId) ?? [])
function handleImport(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
@@ -327,30 +325,34 @@ export function ConfigSettings({
return (
<SettingsContent>
{activeSectionId === 'model' && (
{activeSectionId === 'model' && !query.trim() && (
<div className="mb-6">
<ModelSettings onMainModelChanged={onMainModelChanged} />
</div>
)}
{query.trim() && (
<div className="mb-4 text-xs text-muted-foreground">
{fields.length} result{fields.length === 1 ? '' : 's'}
</div>
)}
{fields.length === 0 ? (
<EmptyState description="This section has no adjustable settings." title="Nothing to configure" />
<EmptyState description="Try a different search term or choose another section." title="No matching settings" />
) : (
<div className="grid gap-1">
<div className="divide-y divide-border/40">
{fields.map(([key, field]) => (
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
<ConfigField
enumOptions={
key === 'tts.elevenlabs.voice_id'
? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined)
: enumOptionsFor(key, getNested(config, key), config)
}
onChange={value => updateConfig(setNested(config, key, value))}
optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined}
schema={field}
schemaKey={key}
value={getNested(config, key)}
/>
</div>
<ConfigField
enumOptions={
key === 'tts.elevenlabs.voice_id'
? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined)
: enumOptionsFor(key, getNested(config, key), config)
}
key={key}
onChange={value => updateConfig(setNested(config, key, value))}
optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined}
schema={field}
schemaKey={key}
value={getNested(config, key)}
/>
))}
</div>
)}

View File

@@ -22,7 +22,7 @@ interface ProviderPrefix {
}
export const EMPTY_SELECT_VALUE = '__hermes_empty__'
export const CONTROL_TEXT = 'text-xs'
export const CONTROL_TEXT = 'text-[0.8125rem]'
export const PROVIDER_GROUPS: ProviderPrefix[] = [
{ prefix: 'NOUS_', name: 'Nous Portal', priority: 0 },
@@ -289,11 +289,21 @@ export const SECTIONS: DesktopConfigSection[] = [
export interface ModeOption {
id: ThemeMode
label: string
description: string
icon: IconComponent
}
export const MODE_OPTIONS: ModeOption[] = [
{ id: 'light', label: 'Light', icon: Sun },
{ id: 'dark', label: 'Dark', icon: Moon },
{ id: 'system', label: 'System', icon: Monitor }
{ id: 'light', label: 'Light', description: 'Bright desktop surfaces', icon: Sun },
{ id: 'dark', label: 'Dark', description: 'Low-glare workspace', icon: Moon },
{ id: 'system', label: 'System', description: 'Follow OS appearance', icon: Monitor }
]
export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string> = {
about: 'About Hermes Desktop',
config: 'Search settings...',
gateway: 'Gateway connection...',
keys: 'Search API keys...',
mcp: 'Search MCP servers...',
sessions: 'Search archived sessions...'
}

View File

@@ -237,7 +237,7 @@ export function GatewaySettings() {
/>
</div>
<div className="mt-5 grid gap-1">
<div className="mt-5 divide-y divide-border/40">
<ListRow
action={
<Input
@@ -272,30 +272,28 @@ export function GatewaySettings() {
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
<div className="mt-6 flex flex-wrap justify-end gap-3">
<Button
className="mr-auto"
disabled={state.envOverride || testing || !canUseRemote}
onClick={() => void testRemote()}
size="sm"
variant="text"
variant="outline"
>
{testing ? <Loader2 className="size-4 animate-spin" /> : null}
Test remote
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} variant="outline">
Save for next restart
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
<Button disabled={state.envOverride || saving} onClick={() => void save(true)}>
{saving ? <Loader2 className="size-4 animate-spin" /> : null}
Save and reconnect
</Button>
</div>
<div className="mt-6 grid gap-1">
<div className="mt-6 divide-y divide-border/40">
<ListRow
action={
<Button onClick={() => void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong">
<Button onClick={() => void window.hermesDesktop?.revealLogs()} variant="outline">
<FileText className="size-4" />
Open logs
</Button>

View File

@@ -1,5 +1,5 @@
import { IconDownload, IconRefresh, IconUpload } from '@tabler/icons-react'
import { useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
@@ -8,18 +8,19 @@ import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { OverlayIconButton } from '../overlays/overlay-chrome'
import { OverlaySearchInput } from '../overlays/overlay-search-input'
import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
import { AboutSettings } from './about-settings'
import { AppearanceSettings } from './appearance-settings'
import { ConfigSettings } from './config-settings'
import { SECTIONS } from './constants'
import { SEARCH_PLACEHOLDER, SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
import { KeysSettings } from './keys-settings'
import { McpSettings } from './mcp-settings'
import { SessionsSettings } from './sessions-settings'
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
@@ -33,8 +34,22 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
const [queries, setQueries] = useState<Record<SettingsQueryKey, string>>({
about: '',
config: '',
gateway: '',
keys: '',
mcp: '',
sessions: ''
})
const searchInputRef = useRef<HTMLInputElement>(null)
const importInputRef = useRef<HTMLInputElement | null>(null)
const queryKey: SettingsQueryKey = activeView.startsWith('config:') ? 'config' : (activeView as SettingsQueryKey)
const query = queries[queryKey]
const setQuery = (next: string) => setQueries(c => ({ ...c, [queryKey]: next }))
const exportConfig = async () => {
try {
const cfg = await getHermesConfigRecord()
@@ -65,8 +80,35 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
}
}
// OverlayView handles Esc; this just adds Cmd/Ctrl+P → focus search.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'p') {
e.preventDefault()
searchInputRef.current?.focus()
searchInputRef.current?.select()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
return (
<OverlayView closeLabel="Close settings" onClose={onClose}>
<OverlayView
closeLabel="Close settings"
headerContent={
<OverlaySearchInput
containerClassName="w-[min(36rem,calc(100vw-32rem))] min-w-80"
inputRef={searchInputRef}
onChange={setQuery}
placeholder={SEARCH_PLACEHOLDER[queryKey]}
value={query}
/>
}
onClose={onClose}
>
<OverlaySplitLayout>
<OverlaySidebar>
{SECTIONS.map(s => {
@@ -74,7 +116,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
return (
<OverlayNavItem
active={activeView === view}
active={activeView === view && !queries.config.trim()}
icon={s.icon}
key={s.id}
label={s.label}
@@ -140,7 +182,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
</div>
</OverlaySidebar>
<OverlayMain className="px-0 pb-0 pt-[calc(var(--titlebar-height)+1rem)]">
<OverlayMain className="p-0">
{activeView === 'config:appearance' ? (
<AppearanceSettings />
) : activeView === 'about' ? (
@@ -153,13 +195,14 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
importInputRef={importInputRef}
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
query={queries.config}
/>
) : activeView === 'keys' ? (
<KeysSettings />
<KeysSettings query={queries.keys} />
) : activeView === 'mcp' ? (
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} query={queries.mcp} />
) : (
<SessionsSettings />
<SessionsSettings query={queries.sessions} />
)}
</OverlayMain>
</OverlaySplitLayout>

View File

@@ -1,6 +1,7 @@
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'
import { Input } from '@/components/ui/input'
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
import { Check, Eye, EyeOff, Save, Settings2, Trash2, Zap } from '@/lib/icons'
@@ -9,10 +10,17 @@ import { notify, notifyError } from '@/store/notifications'
import type { EnvVarInfo } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { asText, prettyName, providerGroup, providerPriority, redactedValue, withoutKey } from './helpers'
import {
asText,
includesQuery,
prettyName,
providerGroup,
providerPriority,
redactedValue,
withoutKey
} from './helpers'
import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
import type { EnvPatch, EnvRowProps, ProviderGroup } from './types'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
import type { EnvPatch, EnvRowProps, ProviderGroup, SearchProps } from './types'
interface EnvActionsProps {
varKey: string
@@ -54,7 +62,7 @@ function EnvActions({
{isRevealed ? <EyeOff /> : <Eye />}
</Button>
)}
<Button onClick={onEdit} size="xs" variant="textStrong">
<Button onClick={onEdit} size="xs" variant="outline">
{info.is_set ? 'Replace' : 'Set'}
</Button>
{info.is_set && (
@@ -159,7 +167,8 @@ function EnvVarRow({
<Save />
{saving === varKey ? 'Saving' : 'Save'}
</Button>
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="text">
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="outline">
<Codicon name="close" />
Cancel
</Button>
</div>
@@ -170,24 +179,16 @@ function EnvVarRow({
function EnvProviderGroup({
group,
rowProps,
forceExpand = false
rowProps
}: {
group: ProviderGroup
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
forceExpand?: boolean
}) {
const setCount = group.entries.filter(([, info]) => info.is_set).length
// Default-expand providers that already have at least one key set; the
// user is much more likely to be coming back to edit those than to start
// configuring a fresh provider from scratch.
const [expanded, setExpanded] = useState(setCount > 0 || forceExpand)
useEffect(() => {
if (forceExpand) {
setExpanded(true)
}
}, [forceExpand])
const [expanded, setExpanded] = useState(setCount > 0)
return (
<div className="overflow-hidden rounded-xl bg-background/60">
@@ -208,9 +209,7 @@ function EnvProviderGroup({
{expanded && (
<div className="grid gap-2 bg-muted/20 p-3">
{group.entries.map(([key, info]) => (
<div className="scroll-mt-6 rounded-md" id={`env-var-${key}`} key={key}>
<EnvVarRow compact={!info.is_set} info={info} varKey={key} {...rowProps} />
</div>
<EnvVarRow compact={!info.is_set} info={info} key={key} varKey={key} {...rowProps} />
))}
</div>
)}
@@ -218,20 +217,12 @@ function EnvProviderGroup({
)
}
export function KeysSettings() {
export function KeysSettings({ query }: SearchProps) {
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null)
const [edits, setEdits] = useState<Record<string, string>>({})
const [revealed, setRevealed] = useState<Record<string, string>>({})
const [saving, setSaving] = useState<string | null>(null)
// Deep-link from the command palette (?key=<ENV_VAR>): force-expand the
// matching provider group, scroll the row in, and flash it.
const highlightKey = useDeepLinkHighlight({
elementId: key => `env-var-${key}`,
param: 'key',
ready: key => Boolean(vars?.[key])
})
// We used to hide ~80% of rows behind a global "Show advanced" toggle, but
// everything in this view is configuration-level — "advanced" was a poor
// distinction. The full list is rendered now and provider groups
@@ -262,12 +253,32 @@ export function KeysSettings() {
return () => void (cancelled = true)
}, [])
const filterEnv = useCallback((info: EnvVarInfo, key: string, q: string, cat: string, extra?: string) => {
if (asText(info.category) !== cat) {
return false
}
if (!q) {
return true
}
return (
key.toLowerCase().includes(q) ||
includesQuery(info.description, q) ||
Boolean(extra && extra.toLowerCase().includes(q))
)
}, [])
const providerGroups = useMemo<ProviderGroup[]>(() => {
if (!vars) {
return []
}
const entries = Object.entries(vars).filter(([, info]) => asText(info.category) === 'provider')
const q = query.trim().toLowerCase()
const entries = Object.entries(vars).filter(([key, info]) =>
filterEnv(info, key, q, 'provider', providerGroup(key))
)
const groups = new Map<string, [string, EnvVarInfo][]>()
@@ -282,13 +293,15 @@ export function KeysSettings() {
entries: entries.sort(([a], [b]) => a.localeCompare(b)),
hasAnySet: entries.some(([, info]) => info.is_set)
})).sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
}, [vars])
}, [filterEnv, query, vars])
const otherGroups = useMemo(() => {
if (!vars) {
return []
}
const q = query.trim().toLowerCase()
const labels: Record<string, string> = {
tool: 'Tools',
messaging: 'Messaging',
@@ -297,12 +310,12 @@ export function KeysSettings() {
return ['tool', 'messaging', 'setting'].flatMap(cat => {
const entries = Object.entries(vars)
.filter(([, info]) => asText(info.category) === cat)
.filter(([key, info]) => filterEnv(info, key, q, cat))
.sort(([a], [b]) => a.localeCompare(b))
return entries.length === 0 ? [] : [{ category: cat, label: labels[cat] ?? prettyName(cat), entries }]
})
}, [vars])
}, [filterEnv, query, vars])
function patchVar(key: string, patch: EnvPatch) {
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
@@ -394,12 +407,7 @@ export function KeysSettings() {
/>
<div className="grid gap-2">
{providerGroups.map(group => (
<EnvProviderGroup
forceExpand={Boolean(highlightKey) && group.entries.some(([key]) => key === highlightKey)}
group={group}
key={group.name}
rowProps={rowProps}
/>
<EnvProviderGroup group={group} key={group.name} rowProps={rowProps} />
))}
</div>
</div>
@@ -413,9 +421,7 @@ export function KeysSettings() {
/>
<div className="grid gap-2">
{group.entries.map(([key, info]) => (
<div className="scroll-mt-6 rounded-md" id={`env-var-${key}`} key={key}>
<EnvVarRow info={info} varKey={key} {...rowProps} />
</div>
<EnvVarRow info={info} key={key} varKey={key} {...rowProps} />
))}
</div>
</div>

View File

@@ -1,20 +1,20 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { OverlayActionButton, OverlayCard } from '@/app/overlays/overlay-chrome'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { getHermesConfigRecord, type HermesGateway, saveHermesConfig } from '@/hermes'
import { Wrench } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { Package, Wrench } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import { $activeSessionId } from '@/store/session'
import type { HermesConfigRecord } from '@/types/hermes'
import { EmptyState, LoadingState, Pill, SettingsContent } from './primitives'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
import { includesQuery } from './helpers'
import { EmptyState, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
import type { SearchProps } from './types'
interface McpSettingsProps {
interface McpSettingsProps extends SearchProps {
gateway?: HermesGateway | null
onConfigSaved?: () => void
}
@@ -42,7 +42,15 @@ const transportLabel = (server: Record<string, unknown>) =>
? 'stdio'
: 'custom'
export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
function serverMatches(name: string, server: Record<string, unknown>, query: string) {
if (!query) {
return true
}
return includesQuery(name, query) || includesQuery(JSON.stringify(server), query)
}
export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps) {
const activeSessionId = useStore($activeSessionId)
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const [selected, setSelected] = useState<string | null>(null)
@@ -72,13 +80,10 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
const servers = useMemo(() => getServers(config), [config])
const names = useMemo(() => Object.keys(servers).sort(), [servers])
useDeepLinkHighlight({
block: 'nearest',
elementId: serverName => `mcp-server-${serverName}`,
onResolve: setSelected,
param: 'server',
ready: serverName => Boolean(config) && serverName in servers
})
const filtered = useMemo(
() => names.filter(serverName => serverMatches(serverName, servers[serverName], query.trim().toLowerCase())),
[names, query, servers]
)
useEffect(() => {
const server = selected ? servers[selected] : null
@@ -183,32 +188,31 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
return (
<SettingsContent>
<div className="mb-4 flex items-center justify-end gap-4">
<Button onClick={() => setSelected(null)} size="xs" variant="text">
New server
</Button>
<Button disabled={reloading} onClick={() => void reloadMcp()} size="xs" variant="text">
{reloading ? 'Reloading...' : 'Reload MCP'}
</Button>
<div className="mb-4 flex items-center justify-between gap-3">
<SectionHeading icon={Package} meta={`${names.length} configured`} title="MCP servers" />
<div className="flex items-center gap-2">
<OverlayActionButton onClick={() => setSelected(null)}>New server</OverlayActionButton>
<OverlayActionButton disabled={reloading} onClick={() => void reloadMcp()}>
{reloading ? 'Reloading...' : 'Reload MCP'}
</OverlayActionButton>
</div>
</div>
<div className="grid min-h-0 gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]">
<div className="min-h-64">
{names.length === 0 ? (
<div className="grid min-h-0 gap-4 lg:grid-cols-[17rem_minmax(0,1fr)]">
<OverlayCard className="min-h-64 overflow-hidden p-2">
{filtered.length === 0 ? (
<EmptyState description="Add a stdio or HTTP server to expose MCP tools." title="No MCP servers" />
) : (
<div className="grid gap-0.5">
{names.map(serverName => {
<div className="grid gap-1">
{filtered.map(serverName => {
const server = servers[serverName]
const active = selected === serverName
return (
<button
className={cn(
'scroll-mt-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-(--chrome-action-hover)',
active ? 'bg-(--ui-bg-tertiary) text-foreground' : 'text-muted-foreground'
)}
id={`mcp-server-${serverName}`}
className={`rounded-md px-2 py-2 text-left transition-colors hover:bg-(--chrome-action-hover) ${
active ? 'bg-accent/45 text-foreground' : 'text-muted-foreground'
}`}
key={serverName}
onClick={() => setSelected(serverName)}
type="button"
@@ -223,9 +227,9 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
})}
</div>
)}
</div>
</OverlayCard>
<div className="grid content-start gap-3">
<OverlayCard className="grid gap-3 p-4">
<div className="flex items-center gap-2 text-sm font-medium">
<Wrench className="size-4 text-muted-foreground" />
{selected ? 'Edit server' : 'New server'}
@@ -245,23 +249,17 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
</label>
<div className="flex items-center justify-between">
{selected ? (
<Button
className="text-destructive hover:text-destructive"
disabled={saving}
onClick={() => void removeServer(selected)}
size="xs"
variant="text"
>
<OverlayActionButton disabled={saving} onClick={() => void removeServer(selected)} tone="danger">
Remove
</Button>
</OverlayActionButton>
) : (
<span />
)}
<Button disabled={saving} onClick={() => void saveServer()} size="sm">
<OverlayActionButton disabled={saving} onClick={() => void saveServer()}>
{saving ? 'Saving...' : 'Save server'}
</Button>
</OverlayActionButton>
</div>
</div>
</OverlayCard>
</div>
</SettingsContent>
)

View File

@@ -10,7 +10,7 @@ import {
} from '@/components/ui/select'
import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes'
import type { AuxiliaryModelsResponse, ModelOptionProvider } from '@/hermes'
import { Cpu, Loader2 } from '@/lib/icons'
import { Cpu, Loader2, Sparkles } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { CONTROL_TEXT } from './constants'
@@ -204,6 +204,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
return (
<div className="grid gap-6">
<section>
<SectionHeading
icon={Sparkles}
meta={mainModel ? `${mainModel.provider} / ${mainModel.model}` : undefined}
title="Main model"
/>
<p className="mb-3 text-xs text-muted-foreground">
Applies to new sessions. Use the model picker in the composer to hot-swap the active chat.
</p>
@@ -233,7 +238,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
</SelectContent>
</Select>
<Button disabled={!selectedProvider || !selectedModel || applying} onClick={() => void applyMainModel()} size="sm">
{applying && <Loader2 className="size-3.5 animate-spin" />}
{applying ? <Loader2 className="size-3.5 animate-spin" /> : <Sparkles className="size-3.5" />}
{applying ? 'Applying...' : 'Apply'}
</Button>
</div>
@@ -247,7 +252,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
disabled={!mainModel || applying}
onClick={() => void resetAuxiliaryModels()}
size="sm"
variant="textStrong"
variant="outline"
>
Reset all to main
</Button>
@@ -255,7 +260,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
<p className="mb-2 text-xs text-muted-foreground">
Helper tasks run on the main model by default. Assign a dedicated model to any task to override.
</p>
<div className="grid gap-1">
<div className="divide-y divide-border/40">
{AUX_TASKS.map(meta => {
const current = auxiliary?.tasks.find(entry => entry.task === meta.key)
const isAuto = !current || !current.provider || current.provider === 'auto'
@@ -270,7 +275,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
disabled={!mainModel || applying}
onClick={() => void setAuxiliaryToMain(meta.key)}
size="sm"
variant="text"
variant="ghost"
>
Set to main
</Button>
@@ -278,7 +283,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
disabled={!providers.length || applying}
onClick={() => beginAuxiliaryEdit(meta.key)}
size="sm"
variant="textStrong"
variant="outline"
>
Change
</Button>
@@ -287,7 +292,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
}
below={
isEditing && (
<div className="mt-2 flex flex-wrap items-center gap-2 pt-1">
<div className="mt-2 flex flex-wrap items-center gap-2 border-t border-border/40 pt-2">
<Select
onValueChange={value => setAuxDraft(prev => ({ ...prev, provider: value, model: '' }))}
value={auxDraft.provider}

View File

@@ -1,17 +1,14 @@
import type { ReactNode } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { IconComponent } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { PAGE_INSET_X } from '../layout-constants'
export function SettingsContent({ children }: { children: ReactNode }) {
return (
<section className="min-h-0 overflow-hidden">
<div className={cn('h-full min-h-0 overflow-y-auto pb-20', PAGE_INSET_X)}>
<div className="h-full min-h-0 overflow-y-auto px-5 py-4 pb-20">
<div className="mx-auto w-full max-w-4xl">{children}</div>
</div>
</section>
@@ -19,7 +16,16 @@ export function SettingsContent({ children }: { children: ReactNode }) {
}
export function Pill({ tone = 'muted', children }: { tone?: 'muted' | 'primary'; children: ReactNode }) {
return <Badge variant={tone === 'primary' ? 'default' : 'muted'}>{children}</Badge>
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.6875rem]',
tone === 'primary' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
)}
>
{children}
</span>
)
}
export function SectionHeading({ icon: Icon, title, meta }: { icon: IconComponent; title: string; meta?: string }) {

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
@@ -10,7 +10,7 @@ import { setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
import type { SearchProps } from './types'
const ARCHIVED_FETCH_LIMIT = 200
@@ -30,7 +30,7 @@ function workspaceLabel(cwd: null | string | undefined): string {
)
}
export function SessionsSettings() {
export function SessionsSettings({ query }: SearchProps) {
const [sessions, setLocalSessions] = useState<SessionInfo[]>([])
const [loading, setLoading] = useState(true)
const [busyId, setBusyId] = useState<string | null>(null)
@@ -87,11 +87,17 @@ export function SessionsSettings() {
}
}, [])
useDeepLinkHighlight({
elementId: id => `archived-session-${id}`,
param: 'session',
ready: id => !loading && sessions.some(session => session.id === id)
})
const filtered = useMemo(() => {
const needle = query.trim().toLowerCase()
if (!needle) {
return sessions
}
return sessions.filter(session =>
[sessionTitle(session), session.preview ?? '', session.cwd ?? ''].join(' ').toLowerCase().includes(needle)
)
}, [query, sessions])
if (loading) {
return <LoadingState label="Loading archived sessions…" />
@@ -111,48 +117,50 @@ export function SessionsSettings() {
archive it.
</p>
{sessions.length === 0 ? (
<EmptyState description="Archive a chat to hide it here." title="Nothing archived" />
{filtered.length === 0 ? (
<EmptyState
description={query.trim() ? 'No archived chats match your search.' : 'Archive a chat to hide it here.'}
title="Nothing archived"
/>
) : (
<div className="grid gap-1">
{sessions.map(session => {
<div className="divide-y divide-border/30">
{filtered.map(session => {
const label = workspaceLabel(session.cwd)
const busy = busyId === session.id
return (
<div className="scroll-mt-6 rounded-lg" id={`archived-session-${session.id}`} key={session.id}>
<ListRow
action={
<div className="flex items-center gap-1.5">
<Button
disabled={busy}
onClick={() => void unarchive(session)}
size="sm"
type="button"
variant="textStrong"
>
{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>
</div>
}
description={session.preview || undefined}
hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`}
title={sessionTitle(session)}
/>
</div>
<ListRow
action={
<div className="flex items-center gap-1.5">
<Button
disabled={busy}
onClick={() => void unarchive(session)}
size="sm"
type="button"
variant="outline"
>
{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>
</div>
}
description={session.preview || undefined}
hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`}
key={session.id}
title={sessionTitle(session)}
/>
)
})}
</div>
@@ -184,10 +192,7 @@ function DefaultProjectDirSetting() {
let alive = true
void settings.getDefaultProjectDir().then(result => {
if (!alive) {
return
}
if (!alive) return
setDir(result.dir)
setFallback(result.defaultLabel)
})
@@ -200,9 +205,7 @@ function DefaultProjectDirSetting() {
const choose = useCallback(async () => {
const settings = window.hermesDesktop?.settings
if (!settings) {
return
}
if (!settings) return
setBusy(true)
@@ -226,9 +229,7 @@ function DefaultProjectDirSetting() {
const clear = useCallback(async () => {
const settings = window.hermesDesktop?.settings
if (!settings) {
return
}
if (!settings) return
setBusy(true)
@@ -250,19 +251,13 @@ function DefaultProjectDirSetting() {
</p>
<ListRow
action={
<div className="flex items-center gap-3">
<Button
disabled={busy}
onClick={() => void choose()}
size="sm"
type="button"
variant="textStrong"
>
<div className="flex items-center gap-1.5">
<Button disabled={busy} onClick={() => void choose()} size="sm" type="button" variant="outline">
<FolderOpen className="size-3.5" />
<span>{dir ? 'Change' : 'Choose'}</span>
</Button>
{dir && (
<Button disabled={busy} onClick={() => void clear()} size="sm" type="button" variant="text">
<Button disabled={busy} onClick={() => void clear()} size="sm" type="button" variant="ghost">
Clear
</Button>
)}

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes'
@@ -122,7 +121,7 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
{revealed !== null ? <EyeOff /> : <Eye />}
</Button>
)}
<Button onClick={() => setEditing(e => !e)} size="xs" variant="textStrong">
<Button onClick={() => setEditing(e => !e)} size="xs" variant="outline">
{isSet ? 'Replace' : 'Set'}
</Button>
{isSet && (
@@ -151,7 +150,7 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
Save
</Button>
<Button onClick={() => setEditing(false)} size="sm" variant="text">
<Button onClick={() => setEditing(false)} size="sm" variant="outline">
Cancel
</Button>
</div>
@@ -211,7 +210,6 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
(cfg?.active_provider ? providers.find(p => p.name === cfg.active_provider) : undefined) ??
providers.find(p => providerConfigured(p, envState)) ??
providers[0]
setActiveProvider(selected.name)
}, [activeProvider, providers, envState, cfg])
@@ -252,7 +250,12 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
}, [cfg, loading, providers.length])
if (loading) {
return <PageLoader className="min-h-32" label="Loading configuration" />
return (
<div className="flex items-center gap-2 px-1 py-3 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
Loading configuration...
</div>
)
}
if (emptyMessage) {

View File

@@ -5,6 +5,7 @@ import type { IconComponent } from '@/lib/icons'
import type { EnvVarInfo } from '@/types/hermes'
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | `config:${string}`
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions'
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
export interface SettingsPageProps {
@@ -14,6 +15,10 @@ export interface SettingsPageProps {
onMainModelChanged?: (provider: string, model: string) => void
}
export interface SearchProps {
query: string
}
export interface ProviderGroup {
name: string
priority: number

View File

@@ -1,60 +0,0 @@
import { useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
interface DeepLinkHighlightOptions {
param: string
ready: (target: string) => boolean
elementId: (target: string) => string
onResolve?: (target: string) => void
block?: ScrollLogicalPosition
}
// Deep-link from the command palette (?<param>=<id>): once the target row is
// renderable, scroll it into view and flash it, then drop the param so it
// doesn't re-fire. Returns the pending target (null once consumed) so callers
// can force the row open before it mounts.
export function useDeepLinkHighlight({
param,
ready,
elementId,
onResolve,
block = 'center'
}: DeepLinkHighlightOptions): null | string {
const [searchParams, setSearchParams] = useSearchParams()
const target = searchParams.get(param)
useEffect(() => {
if (!target || !ready(target)) {
return
}
onResolve?.(target)
// Defer a frame so async state (expansion, selection) mounts the row first.
const scrollTimeout = window.setTimeout(() => {
const element = document.getElementById(elementId(target))
if (!element) {
return
}
element.scrollIntoView({ behavior: 'smooth', block })
element.classList.add('setting-field-highlight')
window.setTimeout(() => element.classList.remove('setting-field-highlight'), 1600)
}, 80)
setSearchParams(
previous => {
const next = new URLSearchParams(previous)
next.delete(param)
return next
},
{ replace: true }
)
return () => window.clearTimeout(scrollTimeout)
}, [block, elementId, onResolve, param, ready, setSearchParams, target])
return target
}

View File

@@ -6,7 +6,6 @@ import { PaneShell } from '@/components/pane-shell'
import { SidebarProvider } from '@/components/ui/sidebar'
import {
$fileBrowserOpen,
$panesFlipped,
$sidebarOpen,
FILE_BROWSER_DEFAULT_WIDTH,
FILE_BROWSER_PANE_ID,
@@ -21,9 +20,11 @@ import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
interface AppShellProps {
children: ReactNode
commandCenterOpen?: boolean
leftStatusbarItems?: readonly StatusbarItem[]
leftTitlebarTools?: readonly TitlebarTool[]
onOpenSettings: () => void
onOpenSearch: () => void
overlays?: ReactNode
statusbarItems?: readonly StatusbarItem[]
titlebarTools?: readonly TitlebarTool[]
@@ -46,16 +47,17 @@ const viewportIsFullscreen = () =>
export function AppShell({
children,
commandCenterOpen = false,
leftStatusbarItems,
leftTitlebarTools,
onOpenSettings,
onOpenSearch,
overlays,
statusbarItems,
titlebarTools
}: AppShellProps) {
const sidebarOpen = useStore($sidebarOpen)
const fileBrowserOpen = useStore($fileBrowserOpen)
const panesFlipped = useStore($panesFlipped)
const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID))
const connection = useStore($connection)
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
@@ -67,12 +69,7 @@ export function AppShell({
const nativeOverlayWidth = connection?.nativeOverlayWidth ?? 0
const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem'
// The inset clears the top-left titlebar buttons when nothing covers the
// window's left edge. Default layout: the sessions sidebar sits there.
// Flipped layout: the file browser does instead.
const leftEdgePaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen
const titlebarContentInset = leftEdgePaneOpen
const titlebarContentInset = sidebarOpen
? 0
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
@@ -133,7 +130,13 @@ export function AppShell({
} as CSSProperties
}
>
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
<TitlebarControls
commandCenterOpen={commandCenterOpen}
leftTools={leftTitlebarTools}
onOpenSearch={onOpenSearch}
onOpenSettings={onOpenSettings}
tools={titlebarTools}
/>
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden transition-none">
<PaneShell className="min-h-0 flex-1">

View File

@@ -78,7 +78,7 @@ export function GatewayMenuPanel({
<div className="flex items-center">
<Button
aria-label="Open system panel"
className="text-muted-foreground hover:text-foreground"
className="size-7 text-muted-foreground hover:text-foreground"
onClick={onOpenSystem}
size="icon-sm"
title="Open system panel"

View File

@@ -2,9 +2,10 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { type CommandCenterSection } from '@/app/command-center'
import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, isOverlayView, NEW_CHAT_ROUTE } from '@/app/routes'
import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, NEW_CHAT_ROUTE } from '@/app/routes'
const SECTIONS = ['sessions', 'system', 'usage'] as const
const OVERLAY_VIEWS = new Set(['settings', 'command-center', 'agents'])
export function useOverlayRouting() {
const location = useLocation()
@@ -14,10 +15,8 @@ export function useOverlayRouting() {
const settingsOpen = currentView === 'settings'
const commandCenterOpen = currentView === 'command-center'
const agentsOpen = currentView === 'agents'
const cronOpen = currentView === 'cron'
const profilesOpen = currentView === 'profiles'
const chatOpen = currentView === 'chat'
const overlayOpen = isOverlayView(currentView)
const overlayOpen = OVERLAY_VIEWS.has(currentView)
// Overlay routes (settings/command-center/agents) stash the underlying path
// so closing them returns there instead of bouncing to /.
@@ -60,11 +59,9 @@ export function useOverlayRouting() {
closeOverlayToPreviousRoute,
commandCenterInitialSection,
commandCenterOpen,
cronOpen,
currentView,
openAgents,
openCommandCenterSection,
profilesOpen,
settingsOpen,
toggleCommandCenter
}

View File

@@ -1,19 +1,17 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { useCallback, useMemo } from 'react'
import { useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { Activity, AlertCircle, ChevronDown, Clock, Command, Hash, Loader2, Sparkles, Zap, ZapFilled } from '@/lib/icons'
import { Activity, AlertCircle, ChevronDown, Clock, Command, Hash, Loader2, Sparkles } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
import { cn } from '@/lib/utils'
import { setSessionYolo } from '@/lib/yolo-session'
import { $desktopActionTasks } from '@/store/activity'
import { $previewServerRestartStatus } from '@/store/preview'
import {
$activeSessionId,
$busy,
$currentFastMode,
$currentModel,
@@ -23,9 +21,7 @@ import {
$sessionStartedAt,
$turnStartedAt,
$workingSessionIds,
$yoloActive,
setModelPickerOpen,
setYoloActive
setModelPickerOpen
} from '@/store/session'
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
import { $desktopVersion, $updateApply, $updateStatus, setUpdateOverlayOpen } from '@/store/updates'
@@ -45,8 +41,6 @@ interface StatusbarItemsOptions {
modelMenuContent?: ReactNode
openAgents: () => void
openCommandCenterSection: (section: CommandCenterSection) => void
freshDraftReady: boolean
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
statusSnapshot: StatusResponse | null
toggleCommandCenter: () => void
}
@@ -62,13 +56,9 @@ export function useStatusbarItems({
modelMenuContent,
openAgents,
openCommandCenterSection,
freshDraftReady,
requestGateway,
statusSnapshot,
toggleCommandCenter
}: StatusbarItemsOptions) {
const activeSessionId = useStore($activeSessionId)
const yoloActive = useStore($yoloActive)
const busy = useStore($busy)
const currentFastMode = useStore($currentFastMode)
const currentModel = useStore($currentModel)
@@ -88,28 +78,6 @@ export function useStatusbarItems({
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
// Per-session approval bypass (same scope as the TUI's Shift+Tab). On a
// new-chat draft (no runtime session yet) we arm locally; the session-create
// path applies it once the backend session exists.
const toggleYolo = useCallback(async () => {
const next = !$yoloActive.get()
const sid = $activeSessionId.get()
setYoloActive(next)
if (!sid) {
return
}
try {
await setSessionYolo(requestGateway, sid, next)
} catch {
setYoloActive(!next)
}
}, [requestGateway])
const showYoloToggle = gatewayState === 'open' && (!!activeSessionId || freshDraftReady)
const gatewayMenuContent = useMemo(
() => (
<GatewayMenuPanel
@@ -308,17 +276,6 @@ export function useStatusbarItems({
title: 'Runtime session elapsed',
variant: 'text'
},
{
className: cn('px-1', yoloActive && 'bg-(--chrome-action-hover)'),
hidden: !showYoloToggle,
icon: yoloActive ? <ZapFilled className="size-3.5 shrink-0" /> : <Zap className="size-3.5 shrink-0 opacity-70" />,
id: 'yolo',
onSelect: () => void toggleYolo(),
title: yoloActive
? 'YOLO on — auto-approving dangerous commands. Click to turn off.'
: 'YOLO off — click to auto-approve dangerous commands.',
variant: 'action'
},
{
id: 'model-summary',
label: (
@@ -337,12 +294,16 @@ export function useStatusbarItems({
menuAlign: 'end' as const,
menuClassName: 'w-64',
menuContent: modelMenuContent,
title: currentProvider ? `Model · ${currentProvider}: ${currentModel || 'none'}` : 'Switch model',
title: currentProvider
? `Model · ${currentProvider}: ${currentModel || 'none'}`
: 'Switch model',
variant: 'menu' as const
}
: {
onSelect: () => setModelPickerOpen(true),
title: currentProvider ? `${currentProvider} · ${currentModel || 'no model'}` : 'Open model picker',
title: currentProvider
? `${currentProvider} · ${currentModel || 'no model'}`
: 'Open model picker',
variant: 'action' as const
})
},
@@ -358,11 +319,8 @@ export function useStatusbarItems({
currentReasoningEffort,
modelMenuContent,
sessionStartedAt,
showYoloToggle,
toggleYolo,
turnStartedAt,
versionItem,
yoloActive
versionItem
]
)

View File

@@ -11,6 +11,7 @@ import {
DropdownMenuSubContent
} from '@/components/ui/dropdown-menu'
import { Switch } from '@/components/ui/switch'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import {
$activeSessionId,
@@ -183,25 +184,24 @@ export function ModelEditSubmenu({
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Options</DropdownMenuLabel>
{reasoning ? (
<DropdownMenuItem
className={dropdownMenuRow}
className={cn(dropdownMenuRow, 'cursor-pointer')}
onSelect={event => event.preventDefault()}
>
Thinking
<Switch
checked={thinkingOn}
className="ml-auto"
className="ml-auto cursor-pointer"
onCheckedChange={checked => void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)}
size="xs"
/>
</DropdownMenuItem>
) : null}
{hasFast ? (
<DropdownMenuItem
className={dropdownMenuRow}
className={cn(dropdownMenuRow, 'cursor-pointer')}
onSelect={event => event.preventDefault()}
>
Fast
<Switch checked={fastOn} className="ml-auto" onCheckedChange={toggleFast} size="xs" />
<Switch checked={fastOn} className="ml-auto cursor-pointer" onCheckedChange={toggleFast} />
</DropdownMenuItem>
) : null}
{reasoning ? (
@@ -214,7 +214,7 @@ export function ModelEditSubmenu({
>
{EFFORT_OPTIONS.map(option => (
<DropdownMenuRadioItem
className={dropdownMenuRow}
className={cn(dropdownMenuRow, 'cursor-pointer')}
key={option.value}
onSelect={event => event.preventDefault()}
value={option.value}

View File

@@ -178,7 +178,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
return (
<DropdownMenuSub key={`${group.provider.slug}:${family.id}`}>
<DropdownMenuSubTrigger
className={dropdownMenuRow}
className={cn(dropdownMenuRow, 'cursor-pointer')}
hideChevron
onClick={activate}
onKeyDown={event => {
@@ -212,7 +212,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuItem
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
className={cn(dropdownMenuRow, 'cursor-pointer text-(--ui-text-tertiary)')}
onSelect={() => setModelVisibilityOpen(true)}
>
Edit Models

View File

@@ -4,11 +4,6 @@ import { useNavigate } from 'react-router-dom'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
// Shared chrome styling for interactive statusbar items (button / link / menu
// trigger). The 'text' variant intentionally omits hover/transition/disabled.
const STATUSBAR_ACTION_CLASS =
'inline-flex h-full items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45'
export interface StatusbarMenuItem {
id: string
icon?: ReactNode
@@ -98,7 +93,10 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
className={cn(
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
item.className
)}
disabled={item.disabled}
title={title}
type="button"
@@ -169,7 +167,10 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
if (item.href || item.variant === 'link') {
return (
<a
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
className={cn(
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
item.className
)}
href={item.href}
rel="noreferrer"
target="_blank"
@@ -182,7 +183,10 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
return (
<button
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
className={cn(
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
item.className
)}
disabled={item.disabled}
onClick={() => {
if (item.to) {

View File

@@ -1,8 +1,7 @@
import { useStore } from '@nanostores/react'
import type { ComponentProps, ReactNode } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
DropdownMenu,
@@ -13,18 +12,12 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { triggerHaptic } from '@/lib/haptics'
import { Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
import {
$fileBrowserOpen,
$panesFlipped,
$sidebarOpen,
toggleFileBrowserOpen,
togglePanesFlipped,
toggleSidebarOpen
} from '@/store/layout'
import { $fileBrowserOpen, $sidebarOpen, toggleFileBrowserOpen, toggleSidebarOpen } from '@/store/layout'
import { appViewForPath, isOverlayView, PROFILES_ROUTE } from '../routes'
import { PROFILES_ROUTE } from '../routes'
import { titlebarButtonClass } from './titlebar'
@@ -48,16 +41,22 @@ export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[],
interface TitlebarControlsProps extends ComponentProps<'div'> {
leftTools?: readonly TitlebarTool[]
tools?: readonly TitlebarTool[]
commandCenterOpen?: boolean
onOpenSettings: () => void
onOpenSearch: () => void
}
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
export function TitlebarControls({
leftTools = [],
tools = [],
commandCenterOpen = false,
onOpenSettings,
onOpenSearch
}: TitlebarControlsProps) {
const navigate = useNavigate()
const location = useLocation()
const hapticsMuted = useStore($hapticsMuted)
const fileBrowserOpen = useStore($fileBrowserOpen)
const sidebarOpen = useStore($sidebarOpen)
const panesFlipped = useStore($panesFlipped)
const toggleHaptics = () => {
if (!hapticsMuted) {
@@ -71,45 +70,38 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
}
}
// Each titlebar button controls the pane physically on its side, so a flip
// swaps which pane each one toggles. Default: sessions left, file browser
// right. Flipped: file browser left, sessions right. Sidebar toggles never
// carry an active highlight — they're plain show/hide affordances.
const fileBrowserEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen }
const sessionsEdge = { open: sidebarOpen, toggle: toggleSidebarOpen }
const leftEdge = panesFlipped ? fileBrowserEdge : sessionsEdge
const rightEdge = panesFlipped ? sessionsEdge : fileBrowserEdge
const leftToolbarTools: TitlebarTool[] = [
{
icon: <Codicon name="layout-sidebar-left" />,
id: 'sidebar',
label: `${leftEdge.open ? 'Hide' : 'Show'} left sidebar`,
label: sidebarOpen ? 'Hide sidebar' : 'Show sidebar',
onSelect: () => {
triggerHaptic('tap')
leftEdge.toggle()
toggleSidebarOpen()
}
},
{
icon: <Codicon name="arrow-swap" />,
id: 'flip-panes',
label: 'Swap sidebar sides',
active: commandCenterOpen,
icon: <Codicon name="search" />,
id: 'search',
label: 'Search',
onSelect: () => {
triggerHaptic('tap')
togglePanesFlipped()
triggerHaptic('open')
onOpenSearch()
},
title: 'Swap the sessions and file browser sides'
title: 'Search sessions, views, and actions'
},
...leftTools
]
const rightSidebarTool: TitlebarTool = {
active: fileBrowserOpen,
icon: <Codicon name="layout-sidebar-right" />,
id: 'right-sidebar',
label: `${rightEdge.open ? 'Hide' : 'Show'} right sidebar`,
label: fileBrowserOpen ? 'Hide right sidebar' : 'Show right sidebar',
onSelect: () => {
triggerHaptic('tap')
rightEdge.toggle()
toggleFileBrowserOpen()
}
}
@@ -117,7 +109,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
const systemTools: TitlebarTool[] = [
{
active: hapticsMuted,
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
icon: hapticsMuted ? <VolumeX /> : <Volume2 />,
id: 'haptics',
label: hapticsMuted ? 'Unmute haptics' : 'Mute haptics',
onSelect: toggleHaptics
@@ -133,14 +125,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
}
]
// While a full-screen overlay (settings, command center, …) is open it should
// visually own the window. These control clusters are `fixed` at a higher
// z-index than the overlay card, so they'd otherwise bleed over it — hide them
// and let the overlay's own chrome (close button, drag region) take over.
if (isOverlayView(appViewForPath(location.pathname))) {
return null
}
const visibleSystemTools = systemTools.filter(tool => !tool.hidden)
const settingsTool = visibleSystemTools.find(tool => tool.id === 'settings')
const visibleSystemToolsBeforeSettings = visibleSystemTools.filter(tool => tool.id !== 'settings')
@@ -197,20 +181,15 @@ function ProfilesMenuButton({ navigate }: { navigate: ReturnType<typeof useNavig
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
<button
aria-label="Profiles"
className={cn(titlebarButtonClass, 'bg-transparent select-none')}
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent select-none [&_svg]:size-4')}
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>
<Codicon name="account" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64" sideOffset={8}>
<DropdownMenuLabel>
@@ -235,30 +214,31 @@ function ProfilesMenuButton({ navigate }: { navigate: ReturnType<typeof useNavig
}
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
// for a11y.
const className = cn(titlebarButtonClass, 'bg-transparent select-none', tool.className)
const className = cn(
titlebarButtonClass,
'grid place-items-center bg-transparent select-none [&_svg]:size-4',
tool.active && 'bg-(--ui-control-active-background)! text-foreground!',
tool.className
)
if (tool.href) {
return (
<Button asChild className={className} size="icon-titlebar" variant="ghost">
<a
aria-label={tool.label}
href={tool.href}
onPointerDown={event => event.stopPropagation()}
rel="noreferrer"
target="_blank"
title={tool.title ?? tool.label}
>
{tool.icon}
</a>
</Button>
<a
aria-label={tool.label}
className={className}
href={tool.href}
onPointerDown={event => event.stopPropagation()}
rel="noreferrer"
target="_blank"
title={tool.title ?? tool.label}
>
{tool.icon}
</a>
)
}
return (
<Button
<button
aria-label={tool.label}
aria-pressed={tool.active ?? undefined}
className={className}
@@ -271,12 +251,10 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
tool.onSelect?.()
}}
onPointerDown={event => event.stopPropagation()}
size="icon-titlebar"
title={tool.title ?? tool.label}
type="button"
variant="ghost"
>
{tool.icon}
</Button>
</button>
)
}

View File

@@ -12,10 +12,8 @@ export const TITLEBAR_FALLBACK_WINDOW_BUTTON_X = 24
// (traffic lights are hidden). Matches the right-cluster's 0.75rem padding.
export const TITLEBAR_EDGE_INSET = 14
// Titlebar palette only. All sizing/radius/cursor/centering come from the
// shared <Button size="icon-titlebar"> (used polymorphically via asChild) —
// Button is the single source of button styling.
export const titlebarButtonClass = 'text-muted-foreground/85 hover:bg-(--ui-control-hover-background) hover:text-foreground'
export const titlebarButtonClass =
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] cursor-pointer rounded-md text-muted-foreground/85 transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground'
export const titlebarHeaderBaseClass =
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]'

View File

@@ -2,7 +2,8 @@ import type * as React from 'react'
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'
@@ -10,9 +11,7 @@ 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 } from '../settings/helpers'
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
@@ -73,22 +72,25 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
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')
} finally {
setRefreshing(false)
}
}, [])
useRefreshHotkey(refreshCapabilities)
const refreshToolsets = useCallback(() => {
getToolsets()
.then(setToolsets)
@@ -179,54 +181,65 @@ 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>
</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>
))}
</>
) : undefined
}
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>
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
Skills
</TextTab>
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
Toolsets
</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)}>
All <TextTabMeta>{totalSkills}</TextTabMeta>
</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>
))}
</div>
)}
</>
}
onSearchChange={setQuery}
searchPlaceholder={mode === 'skills' ? 'Search skills...' : 'Search toolsets...'}
searchTrailingAction={
<Button
aria-label={refreshing ? 'Refreshing skills' : 'Refresh skills'}
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
disabled={refreshing}
onClick={() => void refreshCapabilities()}
size="icon-xs"
title={refreshing ? 'Refreshing skills' : 'Refresh skills'}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
>
{!skills || !toolsets ? (
<PageLoader label="Loading capabilities..." />
) : 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" />
) : (
<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"
@@ -252,7 +265,7 @@ 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" />
) : (
@@ -260,7 +273,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<div className="text-xs text-muted-foreground">
{enabledToolsets}/{toolsets.length} toolsets enabled
</div>
<div>
<div className="divide-y divide-(--ui-stroke-quaternary)">
{visibleToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = asText(toolset.label || toolset.name)
@@ -274,7 +287,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<button
aria-expanded={expanded}
aria-label={`Configure ${label}`}
className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
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"
>
@@ -320,11 +333,14 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
function StatusPill({ active, children }: { active: boolean; children: string }) {
return (
<Badge
className={active ? 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)' : 'bg-(--ui-bg-quinary) text-(--ui-text-tertiary)'}
<span
className={cn(
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem]',
active ? 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)' : 'bg-(--ui-bg-quinary) text-(--ui-text-tertiary)'
)}
>
{children}
</Badge>
</span>
)
}

View File

@@ -73,7 +73,4 @@ export interface ClientSessionState {
sawAssistantPayload: boolean
pendingBranchGroup: string | null
interrupted: boolean
/** A blocking clarify prompt is waiting on the user for this session. Drives
* the sidebar "needs input" indicator; cleared when the turn resumes/ends. */
needsInput: boolean
}

View File

@@ -4,7 +4,6 @@ import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { writeClipboardText } from '@/components/ui/copy-button'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
import { ErrorState } from '@/components/ui/error-state'
import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global'
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
import { AlertCircle, Check, CheckCircle2, Copy, Loader2, Sparkles, Terminal } from '@/lib/icons'
@@ -147,6 +146,11 @@ function IdleView({
if (!status.supported) {
return (
<CenteredStatus
action={
<Button onClick={onLater} size="sm" variant="outline">
Close
</Button>
}
body={status.message ?? 'This version of Hermes cant update itself from inside the app.'}
icon={<AlertCircle className="size-6 text-muted-foreground" />}
title="Update not available"
@@ -172,6 +176,11 @@ function IdleView({
if (behind === 0) {
return (
<CenteredStatus
action={
<Button onClick={onLater} size="sm" variant="outline">
Close
</Button>
}
body="Youre running the latest version."
icon={<CheckCircle2 className="size-7 text-emerald-600 dark:text-emerald-400" />}
title="Youre all set"
@@ -199,13 +208,11 @@ function IdleView({
<div className="grid gap-3 rounded-xl border border-border/70 bg-muted/20 px-4 py-3">
{groups.map(group => (
<div key={group.id}>
<p className="text-[0.625rem] font-semibold uppercase tracking-wide text-muted-foreground">
{group.label}
</p>
<ul className="mt-1.5 grid gap-1.5 text-xs text-foreground">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{group.label}</p>
<ul className="mt-1.5 grid gap-1.5 text-sm text-foreground">
{group.items.map(item => (
<li className="flex items-start gap-2" key={item}>
<span aria-hidden className="mt-1.5 inline-block size-1 shrink-0 rounded-full bg-primary" />
<span aria-hidden className="mt-2 inline-block size-1.5 shrink-0 rounded-full bg-primary" />
<span className="leading-snug">{item}</span>
</li>
))}
@@ -215,7 +222,7 @@ function IdleView({
</div>
<div className="grid gap-2">
<Button className="font-semibold" onClick={onInstall} size="lg">
<Button className="h-10 text-sm font-semibold" onClick={onInstall} size="default">
Update now
</Button>
<button
@@ -260,9 +267,9 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
</div>
<button
className="group flex w-full items-center justify-between gap-3 rounded-xl border border-border/70 bg-muted/30 px-4 py-3 text-left transition-colors hover:border-border hover:bg-muted/50"
onClick={handleCopy}
type="button"
onClick={handleCopy}
className="group flex w-full items-center justify-between gap-3 rounded-xl border border-border/70 bg-muted/30 px-4 py-3 text-left transition-colors hover:border-border hover:bg-muted/50"
>
<code className="select-all font-mono text-sm text-foreground">
<span className="text-muted-foreground">$ </span>
@@ -287,7 +294,7 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
Hermes will pick up the new version next time you launch it.
</p>
<Button className="font-semibold" onClick={onDone} size="lg" variant="outline">
<Button className="h-10 text-sm font-semibold" onClick={onDone} variant="outline">
Done
</Button>
</div>
@@ -332,22 +339,31 @@ function ApplyingView({ apply }: { apply: UpdateApplyState }) {
function ErrorView({ message, onDismiss, onRetry }: { message: string; onDismiss: () => void; onRetry: () => void }) {
return (
<ErrorState
className="px-6 pb-6 pt-7 pr-8"
description={
<DialogDescription className="max-w-prose text-center text-sm leading-5 text-muted-foreground">
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<span className="flex size-14 items-center justify-center rounded-2xl bg-destructive/10 text-destructive">
<AlertCircle className="size-7" />
</span>
<DialogTitle className="text-center text-xl">Update didnt finish</DialogTitle>
<DialogDescription className="text-center text-sm">
{message || 'No worries — nothing was lost. You can try again now.'}
</DialogDescription>
}
title={<DialogTitle className="text-center text-xl font-semibold tracking-tight">Update didnt finish</DialogTitle>}
>
<Button className="font-semibold" onClick={onRetry} size="lg">
Try again
</Button>
<Button onClick={onDismiss} variant="text">
Not now
</Button>
</ErrorState>
</div>
<div className="grid gap-2">
<Button className="h-10 text-sm font-semibold" onClick={onRetry}>
Try again
</Button>
<button
className="text-center text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
onClick={onDismiss}
type="button"
>
Not now
</button>
</div>
</div>
)
}

View File

@@ -8,7 +8,7 @@ import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { triggerHaptic } from '@/lib/haptics'
import { Check, HelpCircle, Loader2 } from '@/lib/icons'
import { HelpCircle, Loader2, PencilLine } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify'
import { $gateway } from '@/store/gateway'
@@ -33,23 +33,6 @@ function readClarifyArgs(args: unknown): ClarifyArgs {
}
}
// Choice and "Other" rows share a layout; only color/hover differs.
const OPTION_ROW_CLASS = 'flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors'
function RadioDot({ selected }: { selected: boolean }) {
return (
<span
aria-hidden
className={cn(
'grid size-3.5 shrink-0 place-items-center rounded-full border transition-colors',
selected ? 'border-primary' : 'border-muted-foreground/40'
)}
>
{selected && <span className="size-1.5 rounded-full bg-primary" />}
</span>
)
}
export const ClarifyTool = (props: ToolCallMessagePartProps) => {
const isPending = props.result === undefined
@@ -91,7 +74,6 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
const [typing, setTyping] = useState(false)
const [draft, setDraft] = useState('')
const [submitting, setSubmitting] = useState(false)
const [selectedChoice, setSelectedChoice] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
// Race: tool.start fires a tick before clarify.request, so request_id
@@ -121,7 +103,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
answer
})
triggerHaptic('submit')
clearClarifyRequest(matchingRequest.requestId, matchingRequest.sessionId)
clearClarifyRequest(matchingRequest.requestId)
// The matching tool.complete will land shortly after, swapping this
// panel for the ToolFallback view above.
} catch (error) {
@@ -158,49 +140,72 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
[draft, respond]
)
const handleChoiceKey = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (typing || submitting) {
return
}
const numeric = Number.parseInt(event.key, 10)
if (Number.isFinite(numeric) && numeric >= 1 && numeric <= choices.length) {
event.preventDefault()
void respond(choices[numeric - 1]!)
}
},
[choices, respond, submitting, typing]
)
return (
<div
className="relative mb-3 mt-2 grid gap-2 rounded-[0.5rem] border border-border/70 bg-card/40 px-3 py-2.5 text-sm shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]"
className={cn(
'mb-3 mt-2 grid gap-3 rounded-xl border border-border/70 bg-card/40 px-4 py-3.5 text-sm',
'shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]'
)}
data-slot="clarify-inline"
>
<span aria-hidden className="arc-border" />
<div className="flex items-center gap-2.5">
<div className="flex items-start gap-2.5">
<span
aria-hidden
className="grid size-6 shrink-0 place-items-center rounded-md bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15"
className="mt-0.5 grid size-6 shrink-0 place-items-center rounded-md bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15"
>
<HelpCircle className="size-3.5" />
</span>
<span className="flex-1 whitespace-pre-wrap font-medium leading-snug text-foreground">
{question || <em className="font-normal text-muted-foreground/70">Loading question</em>}
</span>
<div className="grid flex-1 gap-0.5">
<span className="text-[0.6875rem] font-medium uppercase tracking-wide text-muted-foreground/85">
Hermes is asking
</span>
<span className="whitespace-pre-wrap leading-snug text-foreground">
{question || <em className="text-muted-foreground/70">Loading question</em>}
</span>
</div>
</div>
{!typing && hasChoices && (
<div className="grid gap-0.5" role="group">
<div className="grid gap-1.5" onKeyDown={handleChoiceKey} role="group">
{choices.map((choice, index) => (
<button
className={cn(
OPTION_ROW_CLASS,
'text-foreground/95 hover:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-55',
selectedChoice === choice && 'bg-accent/60'
'group/choice flex w-full items-center gap-3 rounded-lg border border-border/70 bg-background/60 px-3 py-2 text-left text-sm text-foreground/95',
'transition-colors hover:border-border hover:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-55'
)}
data-choice
disabled={!ready || submitting}
key={`${index}-${choice}`}
onClick={() => {
setSelectedChoice(choice)
void respond(choice)
}}
onClick={() => void respond(choice)}
type="button"
>
<RadioDot selected={selectedChoice === choice} />
<span className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-[0.6875rem] font-mono tabular-nums text-muted-foreground group-hover/choice:bg-background">
{index + 1}
</span>
<span className="flex-1 wrap-anywhere">{choice}</span>
{selectedChoice === choice && <Check aria-hidden className="size-4 shrink-0 text-primary" />}
</button>
))}
<button
className={cn(OPTION_ROW_CLASS, 'text-muted-foreground hover:bg-accent/40 hover:text-foreground')}
className={cn(
'flex w-full items-center gap-3 rounded-lg border border-dashed border-border/60 bg-transparent px-3 py-2 text-left text-sm text-muted-foreground',
'transition-colors hover:border-border hover:bg-accent/40 hover:text-foreground'
)}
disabled={submitting}
onClick={() => {
setTyping(true)
@@ -208,7 +213,12 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
}}
type="button"
>
<RadioDot selected={false} />
<span
aria-hidden
className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground"
>
<PencilLine className="size-3" />
</span>
<span className="flex-1">Other (type your answer)</span>
</button>
</div>
@@ -217,7 +227,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
{(typing || !hasChoices) && (
<form className="grid gap-2" onSubmit={handleSubmitFreeform}>
<Textarea
className="min-h-20 resize-y rounded-lg border-transparent bg-accent/40 text-sm focus-visible:bg-background/60"
className="min-h-20 resize-y rounded-lg border-border/70 bg-background/60 text-sm"
disabled={submitting}
onChange={event => setDraft(event.target.value)}
onKeyDown={handleTextareaKey}
@@ -260,9 +270,10 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
)}
{!typing && hasChoices && (
<div className="flex justify-end">
<div className="flex items-center justify-between text-[0.6875rem] text-muted-foreground/85">
<span>1{choices.length} to pick</span>
<button
className="bg-transparent text-[0.6875rem] text-muted-foreground/70 underline-offset-4 hover:text-foreground hover:underline disabled:cursor-not-allowed disabled:opacity-50"
className="bg-transparent text-muted-foreground/85 underline-offset-4 decoration-current/20 hover:text-foreground hover:underline disabled:opacity-50"
disabled={!ready || submitting}
onClick={() => void respond('')}
type="button"

View File

@@ -1,6 +1,6 @@
'use client'
import { TextMessagePartProvider, useMessagePartText } from '@assistant-ui/react'
import { TextMessagePartProvider, useAuiState, useMessagePartText } from '@assistant-ui/react'
import {
type StreamdownTextComponents,
StreamdownTextPrimitive,
@@ -259,11 +259,6 @@ function DeferStreamingText({ children }: { children: ReactNode }) {
)
}
interface MarkdownTextSurfaceProps {
containerClassName?: string
containerProps?: ComponentProps<'div'>
}
// Headings shrink to chat scale rather than the prose default (h1≈xl). Kept
// table-driven so adding/tweaking levels is one row.
const HEADING_SIZES: Record<'h1' | 'h2' | 'h3' | 'h4', string> = {
@@ -273,24 +268,15 @@ const HEADING_SIZES: Record<'h1' | 'h2' | 'h3' | 'h4', string> = {
h4: 'text-[0.8125rem]'
}
const MARKDOWN_CONTAINER_CLASS_NAME = cn(
'aui-md prose w-full max-w-none overflow-hidden text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground',
'prose-p:leading-(--dt-line-height) prose-li:leading-(--dt-line-height)',
'prose-headings:text-foreground prose-strong:text-foreground',
'prose-a:break-words prose-p:[overflow-wrap:anywhere]',
'prose-li:marker:text-muted-foreground/70',
'prose-code:rounded-[0.25rem] prose-code:px-[0.1875rem] prose-code:py-px prose-code:font-mono prose-code:text-[0.9em] prose-code:font-normal prose-code:before:content-none prose-code:after:content-none',
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-1'
)
const MarkdownTextImpl = () => {
const isStreaming = useAuiState(s => s.message.status?.type === 'running')
function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) {
const { status } = useMessagePartText()
const isStreaming = status.type === 'running'
// Keep code parsing enabled while streaming so incomplete fenced blocks still
// render as code cards. The expensive Shiki pass is deferred by
// `SyntaxHighlighter` below when `isStreaming` is true.
const plugins = useMemo(() => ({ math: mathPlugin, code }), [])
// Stable per-state plugin object. The previous inline `{ math: mathPlugin,
// ...(isStreaming ? {} : { code }) }` created a new object identity on every
// render, which churns Streamdown's outer memo + propagates new prop
// identities into every Block. The plugin set really only varies on
// `isStreaming`, so memoize on that.
const plugins = useMemo(() => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }), [isStreaming])
const components = useMemo(
() =>
@@ -361,47 +347,33 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
[isStreaming]
)
return (
<StreamdownTextPrimitive
components={components}
containerClassName={cn(MARKDOWN_CONTAINER_CLASS_NAME, containerClassName)}
containerProps={containerProps}
lineNumbers={false}
mode="streaming"
// Always auto-close incomplete fences — even during streaming.
// Without this, an unclosed ```python ... ``` whose body contains
// `$` (very common: shell snippets, JS template strings, dollar
// amounts) leaks those dollars out to the math parser and they
// get rendered as broken inline math until the closing fence
// arrives. Shiki is independently deferred via `defer={isStreaming}`
// on the SyntaxHighlighter component, so we don't pay code-block
// tokenization on every token even with this set.
parseIncompleteMarkdown
plugins={plugins}
preprocess={preprocessMarkdown}
/>
)
}
interface MarkdownTextContentProps extends MarkdownTextSurfaceProps {
isRunning: boolean
text: string
}
export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: MarkdownTextContentProps) {
return (
<TextMessagePartProvider isRunning={isRunning} text={text}>
<DeferStreamingText>
<MarkdownTextSurface {...surfaceProps} />
</DeferStreamingText>
</TextMessagePartProvider>
)
}
const MarkdownTextImpl = () => {
return (
<DeferStreamingText>
<MarkdownTextSurface />
<StreamdownTextPrimitive
components={components}
containerClassName={cn(
'aui-md prose w-full max-w-none overflow-hidden text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground',
'prose-p:leading-(--dt-line-height) prose-li:leading-(--dt-line-height)',
'prose-headings:text-foreground prose-strong:text-foreground',
'prose-a:break-words prose-p:[overflow-wrap:anywhere]',
'prose-li:marker:text-muted-foreground/70',
'prose-code:rounded-[0.25rem] prose-code:px-[0.1875rem] prose-code:py-px prose-code:font-mono prose-code:text-[0.9em] prose-code:font-normal prose-code:before:content-none prose-code:after:content-none',
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-1'
)}
lineNumbers={false}
mode="streaming"
// Always auto-close incomplete fences — even during streaming.
// Without this, an unclosed ```python ... ``` whose body contains
// `$` (very common: shell snippets, JS template strings, dollar
// amounts) leaks those dollars out to the math parser and they
// get rendered as broken inline math until the closing fence
// arrives. Shiki is independently deferred via `defer={isStreaming}`
// on the SyntaxHighlighter component, so we don't pay code-block
// tokenization on every token even with this set.
parseIncompleteMarkdown
plugins={plugins}
preprocess={preprocessMarkdown}
/>
</DeferStreamingText>
)
}

View File

@@ -130,12 +130,12 @@ function assistantErrorMessage(error: string): ThreadMessage {
} as ThreadMessage
}
function assistantReasoningMessage(text: string, running = false): ThreadMessage {
function assistantReasoningMessage(text: string): ThreadMessage {
return {
id: 'assistant-reasoning-1',
role: 'assistant',
content: [{ type: 'reasoning', text }],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
@@ -263,20 +263,6 @@ function StreamingHarness() {
)
}
function StaticThreadHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistantMessage('complete response', false)],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function TodoHarness({ message }: { message: ThreadMessage }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
@@ -305,20 +291,6 @@ function MessageHarness({ message }: { message: ThreadMessage }) {
)
}
function RunningMessageHarness({ message }: { message: ThreadMessage }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
isRunning: true,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function ReasoningHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantReasoningMessage(' The user is asking what this file is.')],
@@ -333,20 +305,6 @@ function ReasoningHarness() {
)
}
function RunningReasoningHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantReasoningMessage('```ts\nconst answer = 42\n', true)],
isRunning: true,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function GroupedReasoningHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantMultiReasoningMessage([' First thought.', ' Second thought.'])],
@@ -457,81 +415,6 @@ describe('assistant-ui streaming renderer', () => {
expect(viewport.scrollTop).toBe(420)
})
it('does not auto-follow idle layout shifts', async () => {
const { container } = render(<StaticThreadHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
await act(async () => {
viewport.scrollTop = 420
fireEvent.scroll(viewport)
})
scrollHeight = 1_200
await act(async () => {
for (const observer of resizeObservers) {
observer.trigger(1_200)
}
})
await wait(0)
expect(viewport.scrollTop).toBe(420)
})
it('keeps sticky-bottom armed through viewport height changes during streaming', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let clientHeight = 200
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', {
configurable: true,
get: () => clientHeight
})
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
await act(async () => {
viewport.scrollTop = 800
fireEvent.scroll(viewport)
})
clientHeight = 240
await act(async () => {
viewport.scrollTop = 760
fireEvent.scroll(viewport)
})
scrollHeight = 1_200
await act(async () => {
for (const observer of resizeObservers) {
observer.trigger(1_200)
}
})
await wait(0)
expect(viewport.scrollTop).toBe(1_200)
})
it('honors the first upward wheel scroll even when a programmatic bottom-pin scroll event is still pending', async () => {
const { container } = render(<StreamingHarness />)
@@ -566,98 +449,10 @@ describe('assistant-ui streaming renderer', () => {
expect(viewport.scrollTop).toBe(420)
})
it('keeps following final code-highlight growth when a run completes at bottom', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
await act(async () => {
viewport.scrollTop = 800
fireEvent.scroll(viewport)
})
await wait(650)
scrollHeight = 1_700
await wait(0)
expect(viewport.scrollTop).toBe(1_700)
})
it('does not restart bottom-follow after completion when the user scrolled up', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
await act(async () => {
viewport.scrollTop = 800
fireEvent.scroll(viewport)
})
await act(async () => {
fireEvent.wheel(viewport, { deltaY: -120 })
viewport.scrollTop = 420
fireEvent.scroll(viewport)
})
await wait(650)
scrollHeight = 1_700
await wait(0)
expect(viewport.scrollTop).toBe(420)
})
it('renders an incomplete streaming fenced code block as a code card', async () => {
const { container } = render(<RunningMessageHarness message={assistantMessage('```ts\nconst answer = 42\n')} />)
await waitFor(() => {
expect(container.querySelector('[data-slot="code-card"]')).toBeTruthy()
})
expect(container.textContent).toContain('const answer = 42')
expect(container.textContent).not.toContain('```ts')
})
it('renders an incomplete streaming reasoning fenced code block as a code card', async () => {
const { container } = render(<RunningReasoningHarness />)
const ui = within(container)
fireEvent.click(ui.getByRole('button', { name: /thinking/i }))
await waitFor(() => {
expect(container.querySelector('[data-slot="code-card"]')).toBeTruthy()
})
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toContain('const answer = 42')
expect(container.textContent).not.toContain('```ts')
})
it('renders reasoning text without a leading token space', () => {
const { container } = render(<ReasoningHarness />)
const ui = within(container)
fireEvent.click(ui.getByRole('button', { name: /thinking/i }))
fireEvent.click(screen.getByRole('button', { name: /thinking/i }))
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toBe(
'The user is asking what this file is.'

View File

@@ -1,6 +1,6 @@
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual'
import { type ComponentProps, type FC, memo, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { type ComponentProps, type FC, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { cn } from '@/lib/utils'
import { setThreadScrolledUp } from '@/store/thread-scroll'
@@ -8,7 +8,6 @@ import { setThreadScrolledUp } from '@/store/thread-scroll'
const ESTIMATED_ITEM_HEIGHT = 220
const OVERSCAN = 4
const AT_BOTTOM_THRESHOLD = 4
const POST_RUN_BOTTOM_LOCK_MS = 1_200
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
@@ -56,7 +55,7 @@ function buildGroups(signature: string): MessageGroup[] {
return groups
}
const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
export const VirtualizedThread: FC<VirtualizedThreadProps> = ({
clampToComposer,
components,
emptyPlaceholder,
@@ -66,16 +65,11 @@ const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
const messageSignature = useAuiState(s =>
s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n')
)
const isRunning = useAuiState(s => s.thread.isRunning)
const groups = useMemo(() => buildGroups(messageSignature), [messageSignature])
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
const scrollerRef = useRef<HTMLDivElement | null>(null)
// Shared ref so scrollToFn can check whether the user is parked at the
// bottom without needing a ref from inside useThreadScrollAnchor.
const stickyBottomRef = useRef(true)
const virtualizer = useVirtualizer({
count: groups.length,
estimateSize: () => ESTIMATED_ITEM_HEIGHT,
@@ -84,39 +78,14 @@ const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
// Seed the rect so the initial range mounts something before
// `observeElementRect` reports the real layout (it overrides this).
initialRect: { height: 600, width: 800 },
overscan: OVERSCAN,
// When the virtualizer adjusts scroll due to item measurement changes,
// skip the adjustment if the user is at the bottom. Our ResizeObserver +
// pinToBottom loop handles scroll anchoring; letting the virtualizer also
// adjust creates a feedback loop where the two fight each other,
// producing visible rubber-banding (the view snaps to the composer
// then jumps back up).
scrollToFn: (offset, _options, instance) => {
const el = instance.scrollElement
if (!el) {
return
}
if (stickyBottomRef.current) {
const maxScroll = el.scrollHeight - el.clientHeight
const distFromBottom = maxScroll - el.scrollTop
if (distFromBottom <= AT_BOTTOM_THRESHOLD && offset < maxScroll) {
return
}
}
;(el as HTMLElement).scrollTo(0, offset)
}
overscan: OVERSCAN
})
useThreadScrollAnchor({
enabled: !renderEmpty,
groupCount: groups.length,
isRunning,
scrollerRef,
sessionKey: sessionKey ?? null,
stickyBottomRef,
virtualizer
})
@@ -200,34 +169,20 @@ const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
)
}
export const VirtualizedThread = memo(VirtualizedThreadInner)
interface ScrollAnchorOptions {
enabled: boolean
groupCount: number
isRunning: boolean
scrollerRef: React.RefObject<HTMLDivElement | null>
sessionKey: string | null
stickyBottomRef: React.MutableRefObject<boolean>
virtualizer: Virtualizer<HTMLDivElement, Element>
}
function useThreadScrollAnchor({
enabled,
groupCount,
isRunning,
scrollerRef,
sessionKey,
stickyBottomRef,
virtualizer
}: ScrollAnchorOptions) {
// `stickyBottomRef` = parked at bottom, content growth should follow. Cleared on
function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, virtualizer }: ScrollAnchorOptions) {
// `armed` = parked at bottom, content growth should follow. Cleared on
// user-driven upward scroll; re-armed when they reach bottom again.
// This is a shared ref — scrollToFn reads it to prevent the virtualizer's
// measurement adjustments from fighting our pinToBottom.
const armedRef = useRef(true)
const lastTopRef = useRef(0)
const lastHeightRef = useRef(0)
const lastClientHeightRef = useRef(0)
// Counter that tracks how many scroll events we expect to be ours rather
// than the user's. `pinToBottom` writes `el.scrollTop`, which fires an
// async `scroll` event; without this guard the on-scroll handler can race
@@ -253,22 +208,21 @@ function useThreadScrollAnchor({
el.scrollTop = el.scrollHeight
lastTopRef.current = el.scrollTop
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
}, [scrollerRef])
const jumpToBottom = useCallback(() => {
stickyBottomRef.current = true
armedRef.current = true
if (groupCount > 0) {
virtualizer.scrollToIndex(groupCount - 1, { align: 'end', behavior: 'auto' })
}
requestAnimationFrame(() => {
if (stickyBottomRef.current) {
if (armedRef.current) {
pinToBottom()
}
})
}, [groupCount, pinToBottom, stickyBottomRef, virtualizer])
}, [groupCount, pinToBottom, virtualizer])
useEffect(() => () => setThreadScrolledUp(false), [])
@@ -282,7 +236,7 @@ function useThreadScrollAnchor({
}
const disarm = () => {
stickyBottomRef.current = false
armedRef.current = false
programmaticScrollPendingRef.current = 0
}
@@ -300,35 +254,39 @@ function useThreadScrollAnchor({
programmaticScrollPendingRef.current -= 1
lastTopRef.current = top
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
// Always re-arm — sticky-bottom should hold through clamp races.
stickyBottomRef.current = true
armedRef.current = true
const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
setThreadScrolledUp(!atBottom)
return
}
// Disarm only when `scrollTop` decreases while both content height and
// viewport height are stable. A bare `top < lastTopRef.current` check is
// unsafe: virtualizer measurement, streaming markdown, composer resizing,
// window resizing, and toolbar/status updates can all move scrollTop as a
// layout side effect. Wheel-up and touchmove still disarm immediately via
// their own listeners below, so real user intent remains covered.
// Disarm only when `scrollTop` decreases AND `scrollHeight` did NOT
// grow this frame. A bare `top < lastTopRef.current` check is unsafe:
// when content grows (virtualizer item measurement, streaming token,
// code highlight re-tokenization, composer chip), the browser emits
// an interim `scroll` event whose `scrollTop` is smaller than the
// previous frame's because `scrollHeight` jumped — this fires before
// the rAF-scheduled `pinToBottom` runs, so `programmaticScrollPendingRef`
// is 0. Treating that as a user scroll permanently disarmed sticky-bottom
// and produced the visible at-rest backward jump (#37997). Gating on a
// stable `scrollHeight` keeps real user-driven upward intent — scrollbar
// drag, keyboard PgUp, programmatic scrollIntoView — covered without
// the false positive. Wheel-up and touchmove still disarm via their
// own listeners below.
const heightGrew = el.scrollHeight > lastHeightRef.current
const clientHeightChanged = Math.abs(el.clientHeight - lastClientHeightRef.current) > 1
if (!heightGrew && !clientHeightChanged && top + 1 < lastTopRef.current) {
stickyBottomRef.current = false
if (!heightGrew && top + 1 < lastTopRef.current) {
armedRef.current = false
}
lastTopRef.current = top
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
if (atBottom) {
stickyBottomRef.current = true
armedRef.current = true
}
setThreadScrolledUp(!atBottom)
@@ -349,7 +307,7 @@ function useThreadScrollAnchor({
el.removeEventListener('wheel', onWheel)
el.removeEventListener('touchmove', disarm)
}
}, [scrollerRef, stickyBottomRef])
}, [scrollerRef])
// Follow content growth (streaming, item measurements, loading indicator)
// while armed. During fast streaming the ResizeObserver can fire many
@@ -358,7 +316,7 @@ function useThreadScrollAnchor({
// (~20+ ms self in `Virtualizer.getMaxScrollOffset`) several times per
// token.
useEffect(() => {
if (!enabled || !isRunning) {
if (!enabled) {
return undefined
}
@@ -370,13 +328,13 @@ function useThreadScrollAnchor({
let pinRafScheduled = false
const schedulePin = () => {
if (pinRafScheduled || !stickyBottomRef.current) {
if (pinRafScheduled || !armedRef.current) {
return
}
pinRafScheduled = true
requestAnimationFrame(() => {
pinRafScheduled = false
if (stickyBottomRef.current) {
if (armedRef.current) {
pinToBottom()
}
})
@@ -392,7 +350,7 @@ function useThreadScrollAnchor({
}
return () => observer.disconnect()
}, [enabled, isRunning, pinToBottom, scrollerRef, stickyBottomRef])
}, [enabled, pinToBottom, scrollerRef])
// Jump to bottom on session change OR when an empty thread first gets
// content. Both share the same intent and the same effect.
@@ -429,61 +387,16 @@ function useThreadScrollAnchor({
if (!enabled) {
return
}
if (groupCount > prevGroupCountForLayoutRef.current && stickyBottomRef.current) {
// Defer to rAF so that browser scroll/wheel events from the current
// frame are processed first. Without this deferral, a trackpad
// scroll-up during streaming can race with this effect: the wheel
// event hasn't fired yet so stickyBottomRef is still true, and the
// immediate pinToBottom() would snap the viewport back to bottom
// against the user's intent.
if (groupCount > prevGroupCountForLayoutRef.current && armedRef.current) {
pinToBottom()
requestAnimationFrame(() => {
if (stickyBottomRef.current) {
if (armedRef.current) {
pinToBottom()
}
})
}
prevGroupCountForLayoutRef.current = groupCount
}, [enabled, groupCount, pinToBottom, stickyBottomRef])
// Completion swaps streaming placeholders/plain code for final rendered DOM
// (notably Shiki-highlighted code). Keep following the bottom briefly after
// `isRunning` flips false so that final measurement pass cannot strand the
// viewport near the top of a large code block.
const prevIsRunningForLayoutRef = useRef(isRunning)
useLayoutEffect(() => {
const finishedRun = prevIsRunningForLayoutRef.current && !isRunning
prevIsRunningForLayoutRef.current = isRunning
if (!enabled || !finishedRun || !stickyBottomRef.current) {
return undefined
}
const lockUntil = performance.now() + POST_RUN_BOTTOM_LOCK_MS
let lockRaf: number | null = null
const lockFrame = () => {
lockRaf = null
if (!stickyBottomRef.current) {
return
}
pinToBottom()
if (performance.now() < lockUntil) {
lockRaf = requestAnimationFrame(lockFrame)
}
}
pinToBottom()
lockRaf = requestAnimationFrame(lockFrame)
return () => {
if (lockRaf !== null) {
cancelAnimationFrame(lockRaf)
}
}
}, [enabled, isRunning, pinToBottom, stickyBottomRef])
}, [enabled, groupCount, pinToBottom])
useAuiEvent('thread.runStart', jumpToBottom)
}

View File

@@ -13,7 +13,6 @@ import { useStore } from '@nanostores/react'
import { IconPlayerStopFilled } from '@tabler/icons-react'
import {
type ClipboardEvent,
type ComponentProps,
type FC,
type FocusEvent,
type FormEvent,
@@ -49,13 +48,14 @@ import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/co
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
import { extractDroppedFiles, HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer'
import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool'
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { DisclosureRow } from '@/components/chat/disclosure-row'
@@ -221,7 +221,6 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
const messageStatus = useAuiState(s => s.message.status?.type)
const isPlaceholder = messageStatus === 'running' && content.length === 0
const interruptedOnly = useMemo(() => isInterruptedOnlyMessage(messageText), [messageText])
const enterRef = useEnterAnimation(messageStatus === 'running', `assistant-message:${messageId}`)
if (isPlaceholder) {
return null
@@ -232,8 +231,6 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
className="group flex w-full min-w-0 max-w-full flex-col gap-0 self-start overflow-hidden"
data-role="assistant"
data-slot="aui_assistant-message-root"
data-streaming={messageStatus === 'running' ? 'true' : undefined}
ref={enterRef}
>
<div
className={cn(
@@ -375,9 +372,7 @@ const ThinkingDisclosure: FC<{
observer.observe(content)
return () => observer.disconnect()
// Re-run when the disclosure toggles so the observer attaches to the new
// DOM after expand/collapse (refs are conditionally rendered on `open`).
}, [isPreview, open])
}, [isPreview])
return (
<div
@@ -451,19 +446,17 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => {
const displayText = text.trimStart()
const messageRunning = useAuiState(s => s.message.status?.type === 'running')
const isRunning = status?.type === 'running' || messageRunning
return (
<MarkdownTextContent
containerClassName={cn(
'text-xs leading-relaxed text-muted-foreground/85',
isRunning && 'shimmer text-muted-foreground/55'
<div
className={cn(
'whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground/85',
status?.type === 'running' && 'shimmer text-muted-foreground/55'
)}
containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>}
isRunning={isRunning}
text={displayText}
/>
data-slot="aui_reasoning-text"
>
{displayText}
</div>
)
}
@@ -618,13 +611,13 @@ const AssistantFooter: FC<MessageActionProps> = props => (
className="inline-flex h-6 items-center gap-1 text-xs text-muted-foreground"
hideWhenSingleBranch
>
<BranchPickerPrimitive.Previous className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
<BranchPickerPrimitive.Previous className="grid size-6 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
<Codicon name="chevron-left" size="0.875rem" />
</BranchPickerPrimitive.Previous>
<span className="tabular-nums">
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
<BranchPickerPrimitive.Next className="grid size-6 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
<Codicon name="chevron-right" size="0.875rem" />
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
@@ -662,7 +655,7 @@ const USER_BUBBLE_BASE_CLASS =
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left shadow-composer'
const USER_ACTION_ICON_BUTTON_CLASS =
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
'grid cursor-pointer place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
const USER_ACTION_ICON_SIZE = '0.6875rem'
const StopGlyph = <IconPlayerStopFilled aria-hidden className="size-3.5 -translate-y-px" />
@@ -805,7 +798,7 @@ const UserMessage: FC<{
>
<span aria-hidden className="checkpoint-icon size-1.5 rounded-full border border-current" />
<BranchPickerPrimitive.Previous
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
className="checkpoint-restore-text cursor-pointer rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
title="Restore previous checkpoint"
>
Restore checkpoint
@@ -814,7 +807,7 @@ const UserMessage: FC<{
<BranchPickerPrimitive.Number />/<BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
className="checkpoint-restore-text cursor-pointer rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
title="Restore next checkpoint"
>
Go forward

View File

@@ -1,78 +0,0 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HermesGateway } from '@/hermes'
import { $gateway } from '@/store/gateway'
import { $approvalRequest } from '@/store/prompts'
import { PendingToolApproval } from './tool-approval'
import type { ToolPart } from './tool-fallback-model'
function part(toolName: string): ToolPart {
return { toolName, type: `tool-${toolName}` } as unknown as ToolPart
}
function setRequest(command = 'rm -rf /tmp/x') {
$approvalRequest.set({ command, description: 'dangerous command', sessionId: 'sess-1' })
}
function mockGateway() {
const request = vi.fn().mockResolvedValue({ resolved: true })
$gateway.set({ request } as unknown as HermesGateway)
return request
}
afterEach(() => {
cleanup()
$approvalRequest.set(null)
$gateway.set(null)
})
describe('PendingToolApproval', () => {
it('renders nothing when there is no pending approval', () => {
const { container } = render(<PendingToolApproval part={part('terminal')} />)
expect(container.innerHTML).toBe('')
})
it('renders nothing for tools that never raise approval', () => {
setRequest()
const { container } = render(<PendingToolApproval part={part('read_file')} />)
expect(container.innerHTML).toBe('')
})
it('renders the inline run/reject controls on the pending terminal row', () => {
setRequest('chmod -R 777 /tmp/x')
render(<PendingToolApproval part={part('terminal')} />)
expect(screen.getByRole('button', { name: /Run/ })).toBeTruthy()
expect(screen.getByRole('button', { name: /Reject/ })).toBeTruthy()
})
it('sends approval.respond {choice: "once"} and clears the request on Run', async () => {
const request = mockGateway()
setRequest()
render(<PendingToolApproval part={part('terminal')} />)
fireEvent.click(screen.getByRole('button', { name: /Run/ }))
await waitFor(() => {
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'once', session_id: 'sess-1' })
})
expect($approvalRequest.get()).toBeNull()
})
it('sends choice "deny" on Reject', async () => {
const request = mockGateway()
setRequest()
render(<PendingToolApproval part={part('terminal')} />)
fireEvent.click(screen.getByRole('button', { name: /Reject/ }))
await waitFor(() => {
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'sess-1' })
})
})
})

View File

@@ -1,213 +0,0 @@
'use client'
import { useStore } from '@nanostores/react'
import { type FC, useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { triggerHaptic } from '@/lib/haptics'
import { ChevronDown, Loader2 } from '@/lib/icons'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
import type { ToolPart } from './tool-fallback-model'
// Inline approval control. Rendered as a compact button strip
// under the pending tool row that raised the approval (the row already shows
// the command, so the strip deliberately doesn't repeat it) instead of as a
// modal overlay.
//
// Binding is POSITIONAL, not command-matched: the desktop `tool.start` payload
// carries no structured args (only tool_id/name/context — see
// tui_gateway/server.py::_on_tool_start), so we cannot join the approval to the
// row by command string. But `approval.request` only ever fires from the
// `terminal` / `execute_code` guards and the agent thread blocks on exactly one
// approval at a time, so the single pending row of those tools IS the row that
// raised it. The command/description text comes from `$approvalRequest` (the
// event payload), which is the only place that data reliably exists.
const APPROVAL_TOOLS = new Set(['terminal', 'execute_code'])
// Canonical gateway choices (ui-tui/src/components/prompts.tsx).
type ApprovalChoice = 'once' | 'session' | 'always' | 'deny'
export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
const request = useStore($approvalRequest)
if (!request || !APPROVAL_TOOLS.has(part.toolName)) {
return null
}
return <ApprovalBar request={request} />
}
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
const gateway = useStore($gateway)
const [submitting, setSubmitting] = useState<ApprovalChoice | null>(null)
// "Always allow" persists the pattern to ~/.hermes/config.yaml permanently, so
// it goes through a confirm step rather than firing straight from the menu.
const [confirmAlways, setConfirmAlways] = useState(false)
const busy = submitting !== null
const respond = useCallback(
async (choice: ApprovalChoice) => {
// Another bar (or the keyboard path) may have already resolved this
// approval; the atom is the single source of truth, so bail if it's gone.
if (busy || !$approvalRequest.get()) {
return
}
if (!gateway) {
notifyError(new Error('Hermes gateway is not connected'), 'Could not send approval response')
return
}
setSubmitting(choice)
try {
await gateway.request<{ resolved?: boolean }>('approval.respond', {
choice,
session_id: request.sessionId ?? undefined
})
triggerHaptic(choice === 'deny' ? 'cancel' : 'submit')
clearApprovalRequest()
} catch (error) {
notifyError(error, 'Could not send approval response')
setSubmitting(null)
}
},
[busy, gateway, request.sessionId]
)
// ⌘/Ctrl+Enter → Run, Esc → Reject.
// While the confirm dialog is open it owns the keyboard (Esc closes it), so
// the strip-level shortcuts stand down to avoid denying the whole approval.
useEffect(() => {
if (confirmAlways) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
void respond('once')
} else if (event.key === 'Escape') {
event.preventDefault()
void respond('deny')
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [confirmAlways, respond])
return (
<div className="mt-1 flex items-center gap-2.5 ps-5" data-slot="tool-approval-inline">
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
<Button
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
onClick={() => void respond('once')}
size="xs"
variant="ghost"
>
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : 'Run'}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
</Button>
<span aria-hidden className="w-px self-stretch bg-primary/20" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="More approval options"
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
disabled={busy}
size="xs"
variant="ghost"
>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-44">
<DropdownMenuItem onSelect={() => void respond('session')}>Allow this session</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
// Defer one tick so the menu fully unmounts before the dialog
// mounts — otherwise Radix's focus-return races the dialog and
// dismisses it via onInteractOutside.
setTimeout(() => setConfirmAlways(true), 0)
}}
>
Always allow
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
Reject
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Button
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
disabled={busy}
onClick={() => void respond('deny')}
size="xs"
variant="ghost"
>
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : 'Reject'}
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
</Button>
<Dialog onOpenChange={setConfirmAlways} open={confirmAlways}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Always allow this command?</DialogTitle>
<DialogDescription>
This adds the {request.description} pattern to your permanent allowlist (
<code className="font-mono text-xs">~/.hermes/config.yaml</code>). Hermes wont ask again for commands
like this in this session or any future one.
</DialogDescription>
</DialogHeader>
{request.command.trim() && (
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-words rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-2.5 py-1.5 font-mono text-xs leading-snug text-foreground">
{request.command.trim()}
</pre>
)}
<DialogFooter>
<Button onClick={() => setConfirmAlways(false)} size="sm" variant="ghost">
Cancel
</Button>
<Button
onClick={() => {
setConfirmAlways(false)
void respond('always')
}}
size="sm"
variant="destructive"
>
Always allow
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -24,7 +24,6 @@ import { cn } from '@/lib/utils'
import { $toolInlineDiffs } from '@/store/tool-diffs'
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
import { PendingToolApproval } from './tool-approval'
import {
groupCopyText as buildGroupCopyText,
buildToolView,
@@ -310,7 +309,6 @@ function ToolEntry({ part }: ToolEntryProps) {
</span>
</DisclosureRow>
</div>
{isPending && <PendingToolApproval part={part} />}
{open && (
<div className="grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
@@ -389,7 +387,7 @@ function ToolEntry({ part }: ToolEntryProps) {
))}
{showRawSearchDrilldown && (
<details className="max-w-full">
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0')}>Raw response</summary>
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'cursor-pointer mb-0')}>Raw response</summary>
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
{view.rawResult}
</pre>

View File

@@ -14,11 +14,11 @@ export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButton
({ children, tooltip, side: _side = 'bottom', className, ...rest }, ref) => {
return (
<Button
size="icon-xs"
size="icon"
variant="ghost"
{...rest}
aria-label={tooltip}
className={cn('aui-button-icon', className)}
className={cn('aui-button-icon size-6 p-1', className)}
ref={ref}
title={tooltip}
>

View File

@@ -34,7 +34,7 @@ export function DisclosureRow({
// background fill, just the cursor + the affordance caret.
'flex min-w-0 max-w-fit items-start gap-1.5 text-left transition-colors',
onToggle
? 'hover:text-foreground focus-visible:text-foreground focus-visible:outline-none'
? 'cursor-pointer hover:text-foreground focus-visible:text-foreground focus-visible:outline-none'
: 'cursor-default'
)}
disabled={!onToggle}

View File

@@ -142,8 +142,6 @@ function pickCopy(copies: IntroCopy[], seed = 0): IntroCopy {
return copies[Math.abs(seed) % copies.length] || FALLBACK_COPY[0]
}
const WORDMARK = 'HERMES AGENT'
function resolveCopy(personality?: string, seed?: number): IntroCopy {
const personalityKey = normalizeKey(personality)
@@ -165,14 +163,15 @@ export function Intro({ personality, seed }: IntroProps) {
>
<div className="w-full min-w-0">
<p
aria-label={WORDMARK}
className="fit-text mx-auto mb-3 w-[88%] font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
style={{ '--fit-text-line-height': '0.9', '--fit-text-min': '2.75rem' } as CSSProperties}
className="fit-text mx-auto mb-3 w-4/5 font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
style={
{ '--fit-text-line-height': '0.9', '--fit-text-max': '8rem', '--fit-text-min': '2.75rem' } as CSSProperties
}
>
<span>
<span>{WORDMARK}</span>
<span>HERMES AGENT</span>
</span>
<span aria-hidden="true">{WORDMARK}</span>
<span aria-hidden="true">HERMES AGENT</span>
</p>
<p className="m-0 text-center leading-normal tracking-tight">{copy.body}</p>

View File

@@ -64,7 +64,7 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
return (
<CodeCard data-streaming={defer ? 'true' : undefined}>
<CodeCard>
<CodeCardHeader>
<CodeCardTitle>
<CodeCardIcon name={codiconForLanguage(label)} />

View File

@@ -177,7 +177,7 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
}
}
if (ev.type === 'log') {
const next = state.log.concat({ ts: Date.now(), stage: ev.stage ?? null, line: ev.line, stream: ev.stream })
const next = state.log.concat({ ts: Date.now(), stage: ev.stage ?? null, line: ev.line })
while (next.length > 500) next.shift()
return { ...state, log: next }
}
@@ -431,10 +431,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
) : (
<>
{state.log.map((entry, i) => (
<div
key={i}
className={cn('whitespace-pre-wrap break-words', entry.stream === 'stderr' && 'text-muted-foreground')}
>
<div key={i} className="whitespace-pre-wrap break-words">
{entry.stage ? <span className="text-muted-foreground/70">[{entry.stage}] </span> : null}
<span>{entry.line}</span>
</div>

View File

@@ -52,11 +52,11 @@ describe('onboarding Picker', () => {
expect(screen.getByText('Nous Portal')).toBeTruthy()
expect(screen.getByText('Recommended')).toBeTruthy()
expect(screen.queryByText('Anthropic API Key')).toBeNull()
expect(screen.queryByText('Anthropic Claude')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Other providers' }))
expect(screen.getByText('Anthropic API Key')).toBeTruthy()
expect(screen.getByText('Anthropic Claude')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Collapse' })).toBeTruthy()
})
@@ -64,8 +64,8 @@ describe('onboarding Picker', () => {
setProviders([provider('anthropic', 'Anthropic Claude'), provider('openai-codex', 'OpenAI Codex / ChatGPT')])
render(<Picker ctx={ctx} />)
expect(screen.getByText('Anthropic API Key')).toBeTruthy()
expect(screen.getByText('OpenAI OAuth (ChatGPT)')).toBeTruthy()
expect(screen.getByText('Anthropic Claude')).toBeTruthy()
expect(screen.getByText('OpenAI Codex / ChatGPT')).toBeTruthy()
expect(screen.queryByText('Other sign-in options')).toBeNull()
expect(screen.queryByText('Recommended')).toBeNull()
})

View File

@@ -4,7 +4,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { ModelPickerDialog } from '@/components/model-picker'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Input } from '@/components/ui/input'
import { getGlobalModelOptions } from '@/hermes'
import {
@@ -57,6 +56,8 @@ interface ApiKeyOption {
short?: string
}
const MIN_KEY_LENGTH = 8
const API_KEY_OPTIONS: ApiKeyOption[] = [
{
id: 'openrouter',
@@ -103,14 +104,12 @@ const API_KEY_OPTIONS: ApiKeyOption[] = [
const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
nous: { order: 0, title: 'Nous Portal' },
'openai-codex': { order: 1, title: 'OpenAI OAuth (ChatGPT)' },
'minimax-oauth': { order: 2, title: 'MiniMax' },
'qwen-oauth': { order: 3, title: 'Qwen Code' },
anthropic: { order: 1, title: 'Anthropic Claude' },
'openai-codex': { order: 2, title: 'OpenAI Codex / ChatGPT' },
'minimax-oauth': { order: 3, title: 'MiniMax' },
'xai-oauth': { order: 4, title: 'xAI Grok' },
// Both Anthropic entries sit at the bottom: the API-key path first, then
// the subscription OAuth path (only works with extra usage credits).
anthropic: { order: 5, title: 'Anthropic API Key' },
'claude-code': { order: 6, title: 'Anthropic OAuth: Required Extra Usage Credits to Use Subscription' }
'claude-code': { order: 5, title: 'Claude Code' },
'qwen-oauth': { order: 6, title: 'Qwen Code' }
}
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
@@ -168,20 +167,20 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
return (
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="relative w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
<div className="w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
<Header />
{onboarding.manual ? (
<Button
aria-label="Close"
className="absolute right-3 top-3 z-10 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
onClick={() => closeManualOnboarding()}
size="icon-sm"
variant="ghost"
>
<Codicon name="close" size="1rem" />
</Button>
) : null}
<div className="grid gap-3 p-5">
{onboarding.manual ? (
<div className="flex justify-end">
<button
className="text-xs font-medium text-muted-foreground transition hover:text-foreground"
onClick={() => closeManualOnboarding()}
type="button"
>
Close
</button>
</div>
) : null}
{reason ? <ReasonNotice reason={reason} /> : null}
{ready ? showPicker ? <Picker ctx={ctx} /> : <FlowPanel ctx={ctx} flow={flow} /> : <Preparing boot={boot} />}
</div>
@@ -419,9 +418,7 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
const [error, setError] = useState<null | string>(null)
const isLocal = option.envKey === 'OPENAI_BASE_URL'
// Only require a non-empty value — no length/format validation, so a short
// or unusual key can't block the user from continuing.
const canSave = value.trim().length >= 1
const canSave = value.trim().length >= (isLocal ? 1 : MIN_KEY_LENGTH)
const submit = async () => {
if (!canSave || saving) {
@@ -695,11 +692,9 @@ function ConfirmingModelPanel({
queryKey: ['onboarding-model-options', flow.providerSlug],
queryFn: () => getGlobalModelOptions()
})
const providerRow = options.data?.providers?.find(
p => String(p.slug).toLowerCase() === flow.providerSlug.toLowerCase()
)
const price = providerRow?.pricing?.[flow.currentModel]
const freeTier = providerRow?.free_tier

View File

@@ -1,7 +1,7 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { ErrorState } from '@/components/ui/error-state'
import { AlertTriangle, RefreshCw } from '@/lib/icons'
export interface ErrorBoundaryFallbackProps {
error: Error
@@ -53,25 +53,39 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) {
return (
<div className="fixed inset-0 z-[1500] grid place-items-center bg-(--ui-chat-surface-background) p-6">
<ErrorState
className="w-full max-w-[28rem]"
description={error.message || 'The view hit an unexpected error. Your chats and settings are safe.'}
title="Something broke in the interface"
>
<Button className="font-semibold" onClick={reset} size="lg">
Try again
</Button>
<Button onClick={() => window.location.reload()} variant="text">
Reload window
</Button>
<Button
onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)}
variant="text"
>
Open logs
</Button>
</ErrorState>
<div className="fixed inset-0 z-[1500] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
<div className="flex items-start gap-3 border-b border-(--ui-stroke-tertiary) px-5 py-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10 text-destructive">
<AlertTriangle className="size-5" />
</div>
<div>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Something broke in the interface</h2>
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
The view hit an unexpected error. Your chats and settings are safe - try again, or reload the window.
</p>
</div>
</div>
<div className="grid gap-4 p-5">
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 font-mono text-[0.7rem] leading-4 text-destructive">
{error.message || String(error)}
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={reset}>
<RefreshCw className="size-4" />
Try again
</Button>
<Button onClick={() => window.location.reload()} variant="outline">
Reload window
</Button>
<Button onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)} variant="ghost">
Open logs
</Button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -2,7 +2,6 @@ import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Switch } from '@/components/ui/switch'
import type { HermesGateway } from '@/hermes'
@@ -87,11 +86,7 @@ export function ModelVisibilityDialog({ gw, onOpenChange, onOpenProviders, open,
<div className="max-h-[55vh] overflow-y-auto pb-1">
{providers.length === 0 ? (
<div className="px-3 py-5 text-center text-xs text-muted-foreground">
{modelOptions.isPending ? (
<BrailleSpinner className="mx-auto text-sm" />
) : (
'No authenticated providers.'
)}
{modelOptions.isPending ? 'Loading…' : 'No authenticated providers.'}
</div>
) : (
providers.map(provider => {
@@ -123,6 +118,7 @@ export function ModelVisibilityDialog({ gw, onOpenChange, onOpenProviders, open,
</span>
<Switch
checked={visible.has(key)}
className="cursor-pointer"
onCheckedChange={() => toggle(provider, family.id)}
/>
</label>

View File

@@ -132,7 +132,7 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
function NotificationDetail({ detail }: { detail: string }) {
return (
<details className="mt-2 text-xs text-muted-foreground">
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">
<summary className="cursor-pointer select-none font-medium text-muted-foreground hover:text-foreground">
Details
</summary>
<div className="mt-1 rounded-md border border-border/70 bg-background/65 p-2">

View File

@@ -1,230 +0,0 @@
'use client'
import { useStore } from '@nanostores/react'
import { type FormEvent, useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { triggerHaptic } from '@/lib/haptics'
import { KeyRound, Loader2, Lock } from '@/lib/icons'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $secretRequest, $sudoRequest, clearSecretRequest, clearSudoRequest } from '@/store/prompts'
// Renders the modal mid-turn prompts the gateway raises and waits on: sudo
// password and skill secret capture. (Dangerous-command / execute_code approval
// is rendered INLINE on the pending tool row instead — see
// components/assistant-ui/tool-approval.tsx — so it reads like an inline "Run"
// affordance rather than a blocking modal.) Each Python-side caller blocks the
// agent thread until the matching `*.respond` RPC lands; without a renderer the
// agent stalls until its timeout and the tool is BLOCKED (the bug this fixes —
// desktop handled clarify.request but not these). Any close path (Esc, backdrop
// click) funnels through Radix's single `onOpenChange(false)` and maps to a
// refusal, so silence is never mistaken for consent, matching the TUI. We
// deliberately do NOT add onEscapeKeyDown / onInteractOutside handlers — they'd
// fire a second `*.respond` alongside onOpenChange (double-send) or block the
// backdrop-dismiss path.
function SudoDialog() {
const request = useStore($sudoRequest)
const gateway = useStore($gateway)
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
setPassword('')
setSubmitting(false)
}, [request?.requestId])
const send = useCallback(
async (value: string) => {
if (!request) {
return
}
if (!gateway) {
notifyError(new Error('Hermes gateway is not connected'), 'Could not send sudo password')
return
}
setSubmitting(true)
try {
await gateway.request<{ status?: string }>('sudo.respond', {
password: value,
request_id: request.requestId
})
triggerHaptic('submit')
clearSudoRequest(request.requestId)
} catch (error) {
notifyError(error, 'Could not send sudo password')
setSubmitting(false)
}
},
[gateway, request]
)
// Cancel → empty password. The backend treats an empty sudo response as a
// failed sudo (no command runs), so closing the dialog is a safe refusal.
const onOpenChange = useCallback(
(open: boolean) => {
if (!open && !submitting && request) {
void send('')
}
},
[request, send, submitting]
)
const onSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
void send(password)
},
[password, send]
)
if (!request) {
return null
}
return (
<Dialog onOpenChange={onOpenChange} open>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Lock className="size-4 text-primary" />
Administrator password
</DialogTitle>
<DialogDescription>
Hermes needs your sudo password to run a privileged command. It is sent only to your local agent.
</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={onSubmit}>
<Input
autoFocus
disabled={submitting}
onChange={event => setPassword(event.target.value)}
placeholder="sudo password"
type="password"
value={password}
/>
<DialogFooter>
<Button disabled={submitting} onClick={() => void send('')} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={submitting} type="submit">
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : 'Send'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function SecretDialog() {
const request = useStore($secretRequest)
const gateway = useStore($gateway)
const [value, setValue] = useState('')
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
setValue('')
setSubmitting(false)
}, [request?.requestId])
const send = useCallback(
async (secret: string) => {
if (!request) {
return
}
if (!gateway) {
notifyError(new Error('Hermes gateway is not connected'), 'Could not send secret')
return
}
setSubmitting(true)
try {
await gateway.request<{ status?: string }>('secret.respond', {
request_id: request.requestId,
value: secret
})
triggerHaptic('submit')
clearSecretRequest(request.requestId)
} catch (error) {
notifyError(error, 'Could not send secret')
setSubmitting(false)
}
},
[gateway, request]
)
const onOpenChange = useCallback(
(open: boolean) => {
if (!open && !submitting && request) {
void send('')
}
},
[request, send, submitting]
)
const onSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
void send(value)
},
[send, value]
)
if (!request) {
return null
}
return (
<Dialog onOpenChange={onOpenChange} open>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<KeyRound className="size-4 text-primary" />
{request.envVar || 'Secret required'}
</DialogTitle>
<DialogDescription>{request.prompt || 'Hermes needs a credential to continue.'}</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={onSubmit}>
<Input
autoFocus
disabled={submitting}
onChange={event => setValue(event.target.value)}
placeholder={request.envVar || 'secret value'}
type="password"
value={value}
/>
<DialogFooter>
<Button disabled={submitting} onClick={() => void send('')} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={submitting || !value} type="submit">
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : 'Send'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export function PromptOverlays() {
return (
<>
<SudoDialog />
<SecretDialog />
</>
)
}

View File

@@ -1,35 +0,0 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { Slot } from 'radix-ui'
import type * as React from 'react'
import { cn } from '@/lib/utils'
// Small status/metadata tag. App radius (not a full pill); tones map to the
// shared accent/muted/destructive surfaces so badges read consistently.
const badgeVariants = cva(
'inline-flex w-fit shrink-0 items-center gap-1 rounded-[3px] px-1.5 py-0.5 text-[0.65rem] font-medium leading-none whitespace-nowrap [&_svg]:size-3 [&_svg]:pointer-events-none',
{
variants: {
variant: {
default: 'bg-primary/10 text-primary',
muted: 'bg-muted text-muted-foreground',
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
destructive: 'bg-destructive/10 text-destructive',
outline: 'border border-(--ui-stroke-secondary) text-muted-foreground'
}
},
defaultVariants: { variant: 'default' }
}
)
export interface BadgeProps extends React.ComponentProps<'span'>, VariantProps<typeof badgeVariants> {
asChild?: boolean
}
export function Badge({ asChild = false, className, variant, ...props }: BadgeProps) {
const Comp = asChild ? Slot.Root : 'span'
return <Comp className={cn(badgeVariants({ variant }), className)} data-slot="badge" {...props} />
}
export { badgeVariants }

View File

@@ -4,11 +4,8 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
// Text buttons are square (no radius) and sized by padding + line-height — no
// fixed heights — so they stay snug and scale with content. Only icon buttons
// (inherently square) carry the shared 4px radius.
const buttonVariants = cva(
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-[2.5px] text-xs leading-4 font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-default disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-default disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
@@ -19,24 +16,17 @@ const buttonVariants = cva(
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline',
// Boxless inline-text action (no bg/border). Quiet by default — reads as
// muted label text, underlines on hover (e.g. "Cancel", "Clear").
text: 'text-muted-foreground underline-offset-4 hover:text-foreground hover:underline',
// Emphasized inline-text action: bold + always-underlined link. Use for
// the actionable affordance in a row ("Change", "Set", "Open logs", …).
textStrong: 'font-semibold text-muted-foreground underline underline-offset-4 hover:text-foreground'
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline'
},
size: {
default: 'px-3 py-1.5 has-[>svg]:px-2.5',
xs: "gap-1 px-2 py-0.5 text-[0.6875rem] leading-4 has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: 'px-2.5 py-1 has-[>svg]:px-2',
lg: 'px-5 py-2 text-sm leading-5 has-[>svg]:px-4',
icon: 'size-9 rounded-[4px]',
'icon-xs': "size-6 rounded-[4px] [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8 rounded-[4px]',
'icon-lg': 'size-10 rounded-[4px]',
'icon-titlebar': 'h-(--titlebar-control-height) w-(--titlebar-control-size) rounded-[4px] [&_.codicon]:text-[0.875rem]'
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8',
'icon-lg': 'size-10'
}
},
defaultVariants: {

View File

@@ -1,24 +0,0 @@
import { cva, type VariantProps } from 'class-variance-authority'
// Single source of truth for non-composer form-control chrome — Input,
// Textarea, and SelectTrigger all consume this. Mirrors `buttonVariants`:
// 2.5px radius, 12px text, padding-driven sizing (no fixed heights). The visual
// chrome (background, border tint, hover, focus glow, invalid state) comes from
// the `desktop-input-chrome` CSS so every control shares one exact look.
export const controlVariants = cva(
'desktop-input-chrome w-full min-w-0 rounded-[2.5px] border text-xs leading-4 text-foreground outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
size: {
sm: 'px-2 py-1',
default: 'px-2.5 py-1.5',
lg: 'px-3 py-2 text-sm leading-5'
}
},
defaultVariants: {
size: 'default'
}
}
)
export type ControlVariantProps = VariantProps<typeof controlVariants>

View File

@@ -110,7 +110,7 @@ function DropdownMenuItem({
return (
<DropdownMenuPrimitive.Item
className={cn(
"relative flex items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary) data-[variant=destructive]:*:[svg]:text-destructive!",
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary) data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
data-inset={inset}
@@ -131,7 +131,7 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
checked={checked}
className={cn(
"relative flex items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
data-slot="dropdown-menu-checkbox-item"
@@ -157,7 +157,7 @@ function DropdownMenuRadioItem({
return (
<DropdownMenuPrimitive.RadioItem
className={cn(
"relative flex items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
data-slot="dropdown-menu-radio-item"
@@ -226,7 +226,7 @@ function DropdownMenuSubTrigger({
return (
<DropdownMenuPrimitive.SubTrigger
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[inset]:pl-7 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary)",
"flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[inset]:pl-7 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-(--ui-text-tertiary)",
className
)}
data-inset={inset}

View File

@@ -1,45 +0,0 @@
import type { ReactNode } from 'react'
import { AlertCircle } from '@/lib/icons'
import { cn } from '@/lib/utils'
export interface ErrorStateProps {
/** Optional actions row/stack rendered below the copy. */
children?: ReactNode
className?: string
description?: ReactNode
/** Defaults to a destructive AlertCircle. */
icon?: ReactNode
title: ReactNode
}
// Shared, presentation-only error layout: a destructive icon chip over a
// centered title + body, with an optional actions stack. Used by both the
// top-level React error boundary and the in-dialog update error so every
// failure state reads the same. Title/description accept nodes so callers in a
// Radix Dialog can pass DialogTitle/DialogDescription for accessibility.
export function ErrorState({ children, className, description, icon, title }: ErrorStateProps) {
return (
<div className={cn('grid gap-5', className)}>
<div className="flex flex-col items-center gap-3 text-center">
<span className="flex size-14 items-center justify-center rounded-2xl bg-destructive/10 text-destructive">
{icon ?? <AlertCircle className="size-7" />}
</span>
{typeof title === 'string' ? (
<h2 className="text-center text-xl font-semibold tracking-tight">{title}</h2>
) : (
title
)}
{typeof description === 'string' ? (
<p className="max-w-prose text-center text-sm leading-5 text-muted-foreground">{description}</p>
) : (
description
)}
</div>
{children && <div className="grid gap-2">{children}</div>}
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More