Compare commits

..

1 Commits

Author SHA1 Message Date
Brooklyn Nicholson
d459868776 docs(analysis): map IDE coding-agent harness techniques onto Hermes
Study of the five harness subsystems that make in-editor coding agents
outperform the raw model (same models underneath), with a grounded map of
each onto the Hermes codebase: indexed semantic retrieval, retrieval-as-
accuracy-driver, decoupled apply model, ambient context, per-task routing.

Findings reference real files (tools/fuzzy_match.py, agent/auxiliary_client.py,
agent/prompt_builder.py, hermes_state.py). Identifies semantic codebase
retrieval as the one structural gap; apply-reliability and model-routing as
existing strengths.
2026-06-04 01:54:49 -05:00
424 changed files with 11447 additions and 18602 deletions

View File

@@ -3,21 +3,6 @@
.gitignore
.gitmodules
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
# Virtual environments
venv/
env/
ENV/
# Dependencies
node_modules
**/node_modules
@@ -39,20 +24,7 @@ ui-tui/packages/hermes-ink/dist/
# Environment files
.env
.env.*
# IDE
.vscode/
.idea/
*.swp
*.swo
# Testing
.pytest_cache/
.coverage
htmlcov/
# Documentation
*.md
# Runtime data (bind-mounted at /opt/data; must not leak into build context)

8
.gitattributes vendored
View File

@@ -1,10 +1,2 @@
# Auto-generated files — collapse diffs and exclude from language stats
web/package-lock.json linguist-generated=true
# Enforce LF for scripts that run inside Linux containers.
# Without this, Windows checkout converts to CRLF and breaks `exec` in the
# container entrypoint with "no such file or directory".
*.sh text eol=lf
Dockerfile text eol=lf
*.dockerfile text eol=lf
docker/entrypoint.sh text eol=lf

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -7,7 +7,7 @@ name: Docker / shell lint
#
# Rules and ignores are documented in .hadolint.yaml at the repo root.
# shellcheck severity is pinned to `error` so SC1091-style "can't follow
# sourced script" info-level warnings don't fail the job — the venv
# sourced script" info-level warnings don't fail the job — the .venv
# activate script doesn't exist at lint time.
on:

View File

@@ -112,8 +112,8 @@ jobs:
- name: Install Python dependencies (for docker tests)
run: |
uv venv venv --python 3.11
source venv/bin/activate
uv venv .venv --python 3.11
source .venv/bin/activate
# ``dev`` extra pulls in pytest, pytest-asyncio, pytest-timeout —
# everything tests/docker/ needs. We deliberately avoid ``all``
# here because the docker tests only drive the container via
@@ -130,7 +130,7 @@ jobs:
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
run: |
source venv/bin/activate
source .venv/bin/activate
python -m pytest tests/docker/ -v --tb=short
- name: Log in to Docker Hub

View File

@@ -61,8 +61,8 @@ jobs:
- name: Install dependencies
run: |
uv venv venv --python 3.11
source venv/bin/activate
uv venv .venv --python 3.11
source .venv/bin/activate
uv pip install -e ".[all,dev]"
- name: Run tests (slice ${{ matrix.slice }}/6)
@@ -90,7 +90,7 @@ jobs:
# estimate and get split roughly evenly by count — still correct,
# just not perfectly balanced.
run: |
source venv/bin/activate
source .venv/bin/activate
python scripts/run_tests_parallel.py --slice ${{ matrix.slice }}/6
env:
# Ensure tests don't accidentally call real APIs
@@ -167,18 +167,18 @@ jobs:
- name: Install dependencies
run: |
uv venv venv --python 3.11
source venv/bin/activate
uv venv .venv --python 3.11
source .venv/bin/activate
uv pip install -e ".[all,dev]"
- name: Packaged-wheel i18n smoke test
run: |
source venv/bin/activate
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
source .venv/bin/activate
python -m pytest tests/e2e/ -v --tb=short
env:
OPENROUTER_API_KEY: ""

7
.gitignore vendored
View File

@@ -1,6 +1,5 @@
.DS_Store
/venv/
/venv.old/
/_pycache/
*.pyc*
__pycache__/
@@ -117,9 +116,3 @@ scripts/out/
# stores the published notes. They are not a build artifact and must never be
# committed to the repo root. See the hermes-release skill.
RELEASE_v*.md
# Post-pull stamp written by `hermes update`'s post-pull phase into the install
# dir (the repo root for source checkouts). Records the commit the post-pull
# steps last ran for; the per-launch gate compares it against live HEAD. Local
# runtime state, never a repo artifact.
.post_pull_stamp

137
AGENTS.md
View File

@@ -7,11 +7,11 @@ Instructions for AI coding assistants and developers working on the hermes-agent
## Development Environment
```bash
# Prefer venv; fall back to .venv if that's what your checkout has.
source venv/bin/activate # or: source .venv/bin/activate
# Prefer .venv; fall back to venv if that's what your checkout has.
source .venv/bin/activate # or: source venv/bin/activate
```
`scripts/run_tests.sh` probes `venv` first, then `.venv`, then
`scripts/run_tests.sh` probes `.venv` first, then `venv`, then
`$HOME/.hermes/hermes-agent/venv` (for worktrees that share a venv with the
main checkout).
@@ -189,30 +189,23 @@ All slash commands are defined in a central `COMMAND_REGISTRY` list of `CommandD
### Adding a Slash Command
1. Add a `CommandDef` entry to `COMMAND_REGISTRY` in `hermes_cli/commands.py`:
```python
CommandDef("mycommand", "Description of what it does", "Session",
aliases=("mc",), args_hint="[arg]"),
```
2. Add handler in `HermesCLI.process_command()` in `cli.py`:
```python
elif canonical == "mycommand":
self._handle_mycommand(cmd_original)
```
3. If the command is available in the gateway, add a handler in `gateway/run.py`:
```python
if canonical == "mycommand":
return await self._handle_mycommand(event)
```
4. For persistent settings, use `save_config_value()` in `cli.py`
**CommandDef fields:**
- `name` — canonical name without slash (e.g. `"background"`)
- `description` — human-readable description
- `category` — one of `"Session"`, `"Configuration"`, `"Tools & Skills"`, `"Info"`, `"Exit"`
@@ -247,16 +240,16 @@ Newline-delimited JSON-RPC over stdio. Requests from Ink, events from Python. Se
### Key Surfaces
| Surface | Ink component | Gateway method |
| ------------------- | --------------------------------- | ------------------------------------------------- |
| Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit``message.delta/complete` |
| Tool activity | `thinking.tsx` | `tool.start/progress/complete` |
| Approvals | `prompts.tsx` | `approval.respond``approval.request` |
| Clarify/sudo/secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify/sudo/secret.respond` |
| Session picker | `sessionPicker.tsx` | `session.list/resume` |
| Slash commands | Local handler + fallthrough | `slash.exec``_SlashWorker`, `command.dispatch` |
| Completions | `useCompletion` hook | `complete.slash`, `complete.path` |
| Theming | `theme.ts` + `branding.tsx` | `gateway.ready` with skin data |
| Surface | Ink component | Gateway method |
|---------|---------------|----------------|
| Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit``message.delta/complete` |
| Tool activity | `thinking.tsx` | `tool.start/progress/complete` |
| Approvals | `prompts.tsx` | `approval.respond``approval.request` |
| Clarify/sudo/secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify/sudo/secret.respond` |
| Session picker | `sessionPicker.tsx` | `session.list/resume` |
| Slash commands | Local handler + fallthrough | `slash.exec``_SlashWorker`, `command.dispatch` |
| Completions | `useCompletion` hook | `complete.slash`, `complete.path` |
| Theming | `theme.ts` + `branding.tsx` | `gateway.ready` with skin data |
### Slash Command Flow
@@ -279,7 +272,7 @@ npm test # vitest
### TUI in the Dashboard (`hermes dashboard` → `/chat`)
The dashboard embeds the real `hermes --tui`**not** a rewrite. See `hermes_cli/pty_bridge.py` + the `@app.websocket("/api/pty")` endpoint in `hermes_cli/web_server.py`.
The dashboard embeds the real `hermes --tui`**not** a rewrite. See `hermes_cli/pty_bridge.py` + the `@app.websocket("/api/pty")` endpoint in `hermes_cli/web_server.py`.
- Browser loads `web/src/pages/ChatPage.tsx`, which mounts xterm.js's `Terminal` with the WebGL renderer, `@xterm/addon-fit` for container-driven resize, and `@xterm/addon-unicode11` for modern wide-character widths.
- `/api/pty?token=…` upgrades to a WebSocket; auth uses the same ephemeral `_SESSION_TOKEN` as REST, via query param (browsers can't set `Authorization` on WS upgrade).
@@ -321,7 +314,6 @@ core Hermes tool that should ship in the base system.
Built-in/core tools require changes in **2 files**:
**1. Create `tools/your_tool.py`:**
```python
import json, os
from tools.registry import registry
@@ -342,7 +334,7 @@ registry.register(
)
```
**2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset. **This step is required:** auto-discovery imports the tool and registers its schema, but the tool is only _exposed to an agent_ if its name appears in a toolset. `_HERMES_CORE_TOOLS` is not dead code — it's the default bundle every platform's base toolset inherits from.
**2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset. **This step is required:** auto-discovery imports the tool and registers its schema, but the tool is only *exposed to an agent* if its name appears in a toolset. `_HERMES_CORE_TOOLS` is not dead code — it's the default bundle every platform's base toolset inherits from.
Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain. Wiring into a toolset is still a deliberate, manual step.
@@ -362,15 +354,14 @@ All dependencies must have upper bounds to limit supply-chain attack surface.
This policy was established after the litellm compromise (PR #2796, #2810) and
reinforced after the Mini Shai-Hulud worm campaign (May 2026).
| Source type | Treatment | Example |
| -------------- | --------------------- | ------------------------------------ |
| PyPI package | `>=floor,<next_major` | `"httpx>=0.28.1,<1"` |
| Git URL | Commit SHA | `git+https://...@<40-char-sha>` |
| GitHub Actions | Commit SHA + comment | `uses: actions/checkout@<sha> # v4` |
| CI-only pip | `==exact` | `pyyaml==6.0.2` |
| Source type | Treatment | Example |
|---|---|---|
| PyPI package | `>=floor,<next_major` | `"httpx>=0.28.1,<1"` |
| Git URL | Commit SHA | `git+https://...@<40-char-sha>` |
| GitHub Actions | Commit SHA + comment | `uses: actions/checkout@<sha> # v4` |
| CI-only pip | `==exact` | `pyyaml==6.0.2` |
**When adding a new dependency to `pyproject.toml`:**
1. Pin to `>=current_version,<next_major` for post-1.0 (e.g. `>=1.5.0,<2`).
2. For pre-1.0 packages, use `<0.(current_minor + 2)` (e.g. `>=0.29,<0.32`).
3. Never commit a bare `>=X.Y.Z` without a ceiling — CI and reviewers will reject it.
@@ -383,7 +374,6 @@ Reference: #2810 (bounds pass), #9801 (SHA pinning + audit CI).
## Adding Configuration
### config.yaml options:
1. Add to `DEFAULT_CONFIG` in `hermes_cli/config.py`
2. Bump `_config_version` (check the current value at the top of `DEFAULT_CONFIG`)
ONLY if you need to actively migrate/transform existing user config
@@ -408,9 +398,7 @@ its own provider/model/base_url/max_tokens/reasoning_effort. See
`archive_after_days`, `backup` (nested).
### .env variables (SECRETS ONLY — API keys, tokens, passwords):
1. Add to `OPTIONAL_ENV_VARS` in `hermes_cli/config.py` with metadata:
```python
"NEW_API_KEY": {
"description": "What it's for",
@@ -428,17 +416,16 @@ the env var in code (see `gateway_timeout`, `terminal.cwd` → `TERMINAL_CWD`).
### Config loaders (three paths — know which one you're in):
| Loader | Used by | Location |
| ------------------- | ---------------------------------------------------- | ------------------------------------------------------------ |
| `load_cli_config()` | CLI mode | `cli.py` — merges CLI-specific defaults + user YAML |
| `load_config()` | `hermes tools`, `hermes setup`, most CLI subcommands | `hermes_cli/config.py` — merges `DEFAULT_CONFIG` + user YAML |
| Direct YAML load | Gateway runtime | `gateway/run.py` + `gateway/config.py` — reads user YAML raw |
| Loader | Used by | Location |
|--------|---------|----------|
| `load_cli_config()` | CLI mode | `cli.py` — merges CLI-specific defaults + user YAML |
| `load_config()` | `hermes tools`, `hermes setup`, most CLI subcommands | `hermes_cli/config.py` — merges `DEFAULT_CONFIG` + user YAML |
| Direct YAML load | Gateway runtime | `gateway/run.py` + `gateway/config.py` — reads user YAML raw |
If you add a new key and the CLI sees it but the gateway doesn't (or vice
versa), you're on the wrong loader. Check `DEFAULT_CONFIG` coverage.
### Working directory:
- **CLI** — uses the process's current directory (`os.getcwd()`).
- **Messaging** — uses `terminal.cwd` from `config.yaml`. The gateway bridges this
to the `TERMINAL_CWD` env var for child tools. **`MESSAGING_CWD` has been
@@ -467,24 +454,24 @@ hermes_cli/skin_engine.py # SkinConfig dataclass, built-in skins, YAML loader
### What skins customize
| Element | Skin Key | Used By |
| ------------------------ | ------------------------- | --------------------------------- |
| Banner panel border | `colors.banner_border` | `banner.py` |
| Banner panel title | `colors.banner_title` | `banner.py` |
| Banner section headers | `colors.banner_accent` | `banner.py` |
| Banner dim text | `colors.banner_dim` | `banner.py` |
| Banner body text | `colors.banner_text` | `banner.py` |
| Response box border | `colors.response_border` | `cli.py` |
| Spinner faces (waiting) | `spinner.waiting_faces` | `display.py` |
| Spinner faces (thinking) | `spinner.thinking_faces` | `display.py` |
| Spinner verbs | `spinner.thinking_verbs` | `display.py` |
| Spinner wings (optional) | `spinner.wings` | `display.py` |
| Tool output prefix | `tool_prefix` | `display.py` |
| Per-tool emojis | `tool_emojis` | `display.py``get_tool_emoji()` |
| Agent name | `branding.agent_name` | `banner.py`, `cli.py` |
| Welcome message | `branding.welcome` | `cli.py` |
| Response box label | `branding.response_label` | `cli.py` |
| Prompt symbol | `branding.prompt_symbol` | `cli.py` |
| Element | Skin Key | Used By |
|---------|----------|---------|
| Banner panel border | `colors.banner_border` | `banner.py` |
| Banner panel title | `colors.banner_title` | `banner.py` |
| Banner section headers | `colors.banner_accent` | `banner.py` |
| Banner dim text | `colors.banner_dim` | `banner.py` |
| Banner body text | `colors.banner_text` | `banner.py` |
| Response box border | `colors.response_border` | `cli.py` |
| Spinner faces (waiting) | `spinner.waiting_faces` | `display.py` |
| Spinner faces (thinking) | `spinner.thinking_faces` | `display.py` |
| Spinner verbs | `spinner.thinking_verbs` | `display.py` |
| Spinner wings (optional) | `spinner.wings` | `display.py` |
| Tool output prefix | `tool_prefix` | `display.py` |
| Per-tool emojis | `tool_emojis` | `display.py``get_tool_emoji()` |
| Agent name | `branding.agent_name` | `banner.py`, `cli.py` |
| Welcome message | `branding.welcome` | `cli.py` |
| Response box label | `branding.response_label` | `cli.py` |
| Prompt symbol | `branding.prompt_symbol` | `cli.py` |
### Built-in skins
@@ -609,7 +596,6 @@ discovery system** — scanned on first `get_provider_profile()` or
`list_providers()` call, NOT by the general PluginManager.
Scan order:
1. Bundled: `<repo>/plugins/model-providers/<name>/`
2. User: `$HERMES_HOME/plugins/model-providers/<name>/`
3. Legacy: `<repo>/providers/<name>.py` (back-compat)
@@ -679,7 +665,6 @@ violate them.
the implementation. No marketing words ("powerful",
"comprehensive", "seamless", "advanced"). Don't repeat the skill
name. Verify with:
```python
import re, pathlib
m = re.search(r'^description: (.*)$',
@@ -819,7 +804,6 @@ go to `~/.hermes/skills/.archive/` and are restorable.
archived), `pinned`.
Invariants:
- Curator only touches skills with `created_by: "agent"` provenance —
bundled + hub-installed skills are off-limits.
- Never deletes; max destructive action is archive.
@@ -845,7 +829,6 @@ schedule jobs via the `cronjob` tool; users via `hermes cron <verb>`
`/cron` slash command.
Supported schedule formats:
- Duration: `"30m"`, `"2h"`, `"1d"`
- "every" phrase: `"every 2h"`, `"every monday 9am"`
- 5-field cron expression: `"0 9 * * *"`
@@ -859,7 +842,6 @@ job B's prompt), `workdir` (run in a specific directory with its
`AGENTS.md`/`CLAUDE.md` loaded), and multi-platform delivery.
Hardening invariants:
- **3-minute hard interrupt** on cron sessions — runaway agent loops
cannot monopolize the scheduler.
- Catchup window: half the job's period, clamped to 120s2h.
@@ -902,11 +884,10 @@ kanban task.
standalone dispatcher deployment).
Isolation model:
- **Board** is the hard boundary — workers are spawned with
`HERMES_KANBAN_BOARD` pinned in their env so they can't see other
boards.
- **Tenant** is a soft namespace _within_ a board — one specialist
- **Tenant** is a soft namespace *within* a board — one specialist
fleet can serve multiple businesses with workspace-path + memory-key
isolation.
- After `kanban.failure_limit` consecutive non-success attempts on the
@@ -922,7 +903,6 @@ Full user-facing docs: `website/docs/user-guide/features/kanban.md`.
### Prompt Caching Must Not Break
Hermes-Agent ensures caching remains valid throughout a conversation. **Do NOT implement changes that would:**
- Alter past context mid-conversation
- Change toolsets mid-conversation
- Reload memories or rebuild system prompts mid-conversation
@@ -961,7 +941,6 @@ automatically scope to the active profile.
1. **Use `get_hermes_home()` for all HERMES_HOME paths.** Import from `hermes_constants`.
NEVER hardcode `~/.hermes` or `Path.home() / ".hermes"` in code that reads/writes state.
```python
# GOOD
from hermes_constants import get_hermes_home
@@ -973,7 +952,6 @@ automatically scope to the active profile.
2. **Use `display_hermes_home()` for user-facing messages.** Import from `hermes_constants`.
This returns `~/.hermes` for default or `~/.hermes/profiles/<name>` for profiles.
```python
# GOOD
from hermes_constants import display_hermes_home
@@ -989,7 +967,6 @@ automatically scope to the active profile.
4. **Tests that mock `Path.home()` must also set `HERMES_HOME`** — since code now uses
`get_hermes_home()` (reads env var), not `Path.home() / ".hermes"`:
```python
with patch.object(Path, "home", return_value=tmp_path), \
patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
@@ -1010,13 +987,11 @@ automatically scope to the active profile.
## Known Pitfalls
### DO NOT hardcode `~/.hermes` paths
Use `get_hermes_home()` from `hermes_constants` for code paths. Use `display_hermes_home()`
for user-facing print/log messages. Hardcoding `~/.hermes` breaks profiles — each profile
has its own `HERMES_HOME` directory. This was the source of 5 bugs fixed in PR #3575.
### DO NOT introduce new `simple_term_menu` usage
Existing call sites in `hermes_cli/main.py` remain for legacy fallback only;
the preferred UI is curses (stdlib) because `simple_term_menu` has
ghost-duplication rendering bugs in tmux/iTerm2 with arrow keys. New
@@ -1024,19 +999,15 @@ interactive menus must use `hermes_cli/curses_ui.py` — see
`hermes_cli/tools_config.py` for the canonical pattern.
### DO NOT use `\033[K` (ANSI erase-to-EOL) in spinner/display code
Leaks as literal `?[K` text under `prompt_toolkit`'s `patch_stdout`. Use space-padding: `f"\r{line}{' ' * pad}"`.
### `_last_resolved_tool_names` is a process-global in `model_tools.py`
`_run_single_child()` in `delegate_tool.py` saves and restores this global around subagent execution. If you add new code that reads this global, be aware it may be temporarily stale during child agent runs.
### DO NOT hardcode cross-tool references in schema descriptions
Tool schema descriptions must not mention tools from other toolsets by name (e.g., `browser_navigate` saying "prefer web_search"). Those tools may be unavailable (missing API keys, disabled toolset), causing the model to hallucinate calls to non-existent tools. If a cross-reference is needed, add it dynamically in `get_tool_definitions()` in `model_tools.py` — see the `browser_navigate` / `execute_code` post-processing blocks for the pattern.
### The gateway has TWO message guards — both must bypass approval/control commands
When an agent is running, messages pass through two sequential guards:
(1) **base adapter** (`gateway/platforms/base.py`) queues messages in
`_pending_messages` when `session_key in self._active_sessions`, and
@@ -1048,7 +1019,6 @@ guards and be dispatched inline, not via `_process_message_background()`
(which races session lifecycle).
### Squash merges from stale branches silently revert recent fixes
Before squash-merging a PR, ensure the branch is up to date with `main`
(`git fetch origin main && git reset --hard origin/main` in the worktree,
then re-apply the PR's commits). A stale branch's version of an unrelated
@@ -1057,19 +1027,16 @@ with `git diff HEAD~1..HEAD` after merging — unexpected deletions are a
red flag.
### Don't wire in dead code without E2E validation
Unused code that was never shipped was dead for a reason. Before wiring an
unused module into a live code path, E2E test the real resolution chain
with actual imports (not mocks) against a temp `HERMES_HOME`.
### Tests must not write to `~/.hermes/`
The `_isolate_hermes_home` autouse fixture in `tests/conftest.py` redirects `HERMES_HOME` to a temp dir. Never hardcode `~/.hermes/` paths in tests.
**Profile tests**: When testing profile features, also mock `Path.home()` so that
`_get_profiles_root()` and `_get_default_hermes_home()` resolve within the temp dir.
Use the pattern from `tests/hermes_cli/test_profiles.py`:
```python
@pytest.fixture
def profile_env(tmp_path, monkeypatch):
@@ -1123,13 +1090,13 @@ Implementation notes:
Five real sources of local-vs-CI drift the script closes:
| | Without wrapper | With wrapper |
| ------------------- | ------------------------------------------- | -------------------------------------------------------------------- |
| Provider API keys | Whatever is in your env (auto-detects pool) | All `*_API_KEY`/`*_TOKEN`/etc. unset |
| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
| Timezone | Local TZ (PDT etc.) | UTC |
| Locale | Whatever is set | C.UTF-8 |
| xdist workers | `-n auto` = all cores | `-n auto` (safe — subprocess isolation prevents cross-worker flakes) |
| | Without wrapper | With wrapper |
|---|---|---|
| Provider API keys | Whatever is in your env (auto-detects pool) | All `*_API_KEY`/`*_TOKEN`/etc. unset |
| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
| Timezone | Local TZ (PDT etc.) | UTC |
| Locale | Whatever is set | C.UTF-8 |
| xdist workers | `-n auto` = all cores | `-n auto` (safe — subprocess isolation prevents cross-worker flakes) |
`tests/conftest.py` also enforces points 1-4 as an autouse fixture so ANY pytest
invocation (including IDE integrations) gets hermetic behavior — but the wrapper

View File

@@ -158,7 +158,7 @@ RUN npm install --prefer-offline --no-audit && \
# 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
# 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
@@ -188,13 +188,13 @@ RUN cd web && npm run build && \
# /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
# 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/gateway /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
@@ -285,7 +285,7 @@ ENV HERMES_HOME=/opt/data
# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
# Pre-s6 entrypoint.sh did `source venv/bin/activate` which exported
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the
# same for the container's main process, but `docker exec` and our
# cont-init.d scripts don't pass through the wrapper. Expose the venv
@@ -296,7 +296,7 @@ COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
# shim wins PATH resolution. The shim's last act is to exec the venv
# binary by absolute path, so this PATH ordering is transparent to
# every other consumer.
ENV PATH="/opt/hermes/bin:/opt/hermes/venv/bin:/opt/data/.local/bin:${PATH}"
ENV PATH="/opt/hermes/bin:/opt/hermes/.venv/bin:/opt/data/.local/bin:${PATH}"
RUN mkdir -p /opt/data
VOLUME [ "/opt/data" ]

315
PLAN.md
View File

@@ -1,315 +0,0 @@
# Plan: Split `_cmd_update_impl` into pull + post-pull phases
## Problem
After `git pull` (or ZIP extraction) overwrites source files on disk, the
running Python process still has the **old** code in `sys.modules` and
`__pycache__`. The current monolith runs post-pull steps (pip install, node
deps, skills sync, config migration, gateway restart) under stale bytecode,
which causes `ImportError` on gateway restart and subtle drift bugs.
## Solution: Two-phase update with re-exec
Split `_cmd_update_impl` into:
1. **`_cmd_update_pull_new_version`** — download new code onto disk
2. **Re-exec** into a fresh Python process
3. **`_cmd_update_post_pull`** — run all post-pull steps under the new code
The re-exec guarantees a clean `sys.modules` / `__pycache__` for phase 2.
---
## Phase 1: `_cmd_update_pull_new_version(args, gateway_mode)`
Everything up to and including "code is on disk, stash is restored":
1. Windows concurrent-hermes guard (exit 2 if locked)
2. Pre-update backup (`_run_pre_update_backup`)
3. Route detection:
- No `.git` + Windows → `_update_files_via_zip` (download + extract only)
- No `.git` + non-Windows + pip → **error** (pip self-update ripped out)
- No `.git` + non-Windows → reinstall hint + exit 1
- `.git` exists → git flow (below)
4. Git config setup (autocrlf, appendAtomically)
5. Discard lockfile churn
6. Fork detection + origin URL check
7. Fetch + branch logic + stash
8. Check for new commits (if 0, restore + "already up to date" + return)
9. Pre-update snapshot (`create_quick_snapshot`)
10. `git pull --ff-only` (or `reset --hard` on divergence)
11. Post-pull syntax guard (`_validate_critical_files_syntax`) + rollback on failure
12. Restore stashed changes
13. `_invalidate_update_cache()`
14. `_clear_bytecode_cache()`
At this point new code is on disk and the working tree is clean.
**Return `True`** to signal "re-exec needed".
### ZIP path change
`_update_files_via_zip` currently does the full pipeline (download, extract,
clear bytecode, pip install, node deps, skills sync, curator notices, kill
dashboard). We split it:
- **`_update_files_via_zip`** → rename to `_download_and_extract_zip(args)`:
downloads + extracts + clears bytecode. Returns `True` when new code landed.
- The pip install / node deps / skills sync / etc moves to
`_cmd_update_post_pull` (shared by both git and zip).
### Already-up-to-date fast path
If git reports 0 new commits (or zip extraction shows no changes), we return
`False` from pull — no re-exec needed. The caller prints "already up to date"
and exits cleanly.
---
## Re-exec mechanism
After `_cmd_update_pull_new_version` returns `True`:
```python
def _reexec_for_post_pull(args, gateway_mode: bool):
"""Replace this process with a fresh Python running --post-pull."""
argv = list(sys.argv)
# Insert --post-pull before any positional args (there shouldn't be any)
argv.append("--post-pull")
if gateway_mode:
argv.append("--gateway")
# Pass pre-update snapshot ID if we have one
snapshot_id = getattr(args, "_pre_update_snapshot_id", None)
if snapshot_id:
argv.extend(["--pre-update-snapshot", snapshot_id])
if sys.platform == "win32":
# Windows: os.execvp internally does spawn+exit (PID changes).
# The bootstrap installer watches the original PID via run_streamed().
# If we execvp, the installer sees exit-0 before post-pull finishes.
# Instead: relay through a subprocess so the parent PID stays alive
# until the child completes. The installer's child.wait() sees the
# real exit code.
result = subprocess.run([sys.executable, "-m", "hermes_cli.main"] + argv)
sys.exit(result.returncode)
else:
# POSIX: true exec — same PID, no stale modules.
os.execvp(sys.executable, [sys.executable, "-m", "hermes_cli.main"] + argv)
```
### Why subprocess.run on Windows (not Popen+exit)
`Popen` + `sys.exit` has a race: if the parent exits before the child's
stdout pipe is drained, the bootstrap installer's `BufReader` gets a broken
pipe and the child may get SIGPIPE. `subprocess.run` waits for the child to
finish and properly reaps it, then we forward the exit code. This is
functionally identical to what the bootstrap installer already does when it
spawns `hermes update` directly.
### Gateway output file continuity
In gateway mode, `hermes update --gateway` writes to an output file that the
gateway watches. On POSIX, `os.execvp` inherits FDs so the output file
continues being written. On Windows, the relay subprocess inherits the
same FDs (subprocess.run passes them through). The `--gateway` flag is
passed to the child so `_install_hangup_protection` / `_finalize_update_output`
work correctly.
**However**, the gateway's spawn path (gateway/run.py) launches the update
detached with its own output redirection. The output file is opened by the
*child*, not inherited from the parent. So on the re-exec path the child
re-opens the same output path via `_install_hangup_protection` (which
already sets up the update.log mirror). No special handling needed.
### Bootstrap installer interaction
The Windows bootstrap installer (`update.rs`) calls `hermes update --yes
--gateway` via `run_streamed()`, which:
1. Spawns the hermes binary as a child process
2. Reads stdout/stderr line-by-line
3. Waits for exit via `child.wait().await`
4. Checks exit code (0 = success, 2 = concurrent lock)
Our Windows relay pattern is safe because:
- The **parent** (original `hermes update` PID) stays alive until the
**child** (post-pull phase) exits
- `child.wait()` in the installer sees the parent exit only after the
child finishes → correct exit code propagation
- The parent's stdout/stderr are piped to the installer's `BufReader`;
the child inherits those FDs, so output streams continue seamlessly
---
## Phase 2: `_cmd_update_post_pull(args, gateway_mode)`
All post-pull steps, running under freshly-exec'd Python with clean
`sys.modules`. Steps are identical regardless of whether code arrived via
git or zip:
1. `_refresh_active_lazy_features()`
2. Python dependency install (uv/pip — the entire `ensure_uv` +
`_install_python_dependencies_with_optional_fallback` block)
3. `_update_node_dependencies()`
4. `_build_web_ui`
5. Desktop app rebuild check + build
6. Skills sync (`sync_skills` + profile seed)
7. Honcho profile sync
8. Config migration (missing env, missing config, version bump, interactive prompts)
9. Cron jobs safety-net restore
10. Curator notices (first-run + recent-run)
11. FHS PATH guard
12. cua-driver refresh (macOS)
13. Gateway restart (systemd, launchd, manual processes, survivor sweep)
14. Legacy unit warning
15. Kill stale dashboard processes
16. Print "Update complete!" + tip
---
## Argparse changes
Add to `update_parser`:
```python
update_parser.add_argument(
"--post-pull",
action="store_true",
default=False,
help=argparse.SUPPRESS, # internal flag — not for users
)
update_parser.add_argument(
"--pre-update-snapshot",
default=None,
metavar="ID",
help=argparse.SUPPRESS, # carries snapshot ID across re-exec
)
```
## `cmd_update` dispatch changes
```python
def cmd_update(args):
# ... existing managed/docker/check guards ...
_update_io_state = _install_hangup_protection(gateway_mode=gateway_mode)
try:
if getattr(args, "post_pull", False):
_cmd_update_post_pull(args, gateway_mode=gateway_mode)
else:
needs_reexec = _cmd_update_pull_new_version(args, gateway_mode=gateway_mode)
if needs_reexec:
_reexec_for_post_pull(args, gateway_mode=gateway_mode)
# if not needs_reexec, we're already done (already up to date)
finally:
_finalize_update_output(_update_io_state)
```
**Important**: `_finalize_update_output` runs in the parent on the
non-reexec path. On the re-exec path, it runs in the parent *before*
the exec replaces the process. On Windows relay, it runs before
`subprocess.run`. The post-pull child has its own
`_install_hangup_protection` / `_finalize_update_output` cycle because
`cmd_update` is re-entered via the fresh process.
Wait — actually on the re-exec path the parent process is *replaced* (POSIX)
or *exits* (Windows relay). So `_finalize_update_output` would run in the
`finally` block *after* `_reexec_for_post_pull` returns. But on POSIX,
`os.execvp` never returns (or raises on failure). On Windows,
`sys.exit(result.returncode)` never returns. So the `finally` block only
runs if `_reexec_for_post_pull` raises (e.g. execvp fails). That's the
right behavior — cleanup only on failure.
On the post-pull path, `cmd_update` is called fresh in the new process,
so `_install_hangup_protection` + `_finalize_update_output` wrap the
post-pull phase correctly.
---
## Pip install path: RIP
`_cmd_update_pip` is removed. `cmd_update` already checks for
`is_managed()` and `detect_install_method() == "docker"` before entering
the impl. We add pip to the early-exit guards:
```python
def cmd_update(args):
# ... existing is_managed() check ...
if detect_install_method(PROJECT_ROOT) == "docker":
print(format_docker_update_message())
sys.exit(1)
if detect_install_method(PROJECT_ROOT) == "pip":
from hermes_cli.config import recommended_update_command
print("✗ Self-update is not supported for pip/uv-tool installs.")
print(f" Run '{recommended_update_command()}' instead.")
sys.exit(1)
# ... --check and --post-pull dispatch ...
```
Also update `_cmd_update_check` to keep its existing pip check path
(it just checks PyPI — no code mutation, no re-exec needed).
---
## Cross-phase state
| State | Mechanism |
|-------|-----------|
| `gateway_mode` | Passed via `--gateway` flag (already exists) |
| `assume_yes` | Passed via `--yes` flag (already exists) |
| `pre_update_snapshot_id` | New `--pre-update-snapshot` arg |
| `auto_stash_ref` | NOT needed — stash is restored in phase 1 |
| `branch` | Passed via `--branch` (already exists) |
| `force` | Passed via `--force` (already exists) |
| `backup` / `no_backup` | Passed via `--backup` / `--no-backup` (already exists) |
The only new cross-phase arg is `--pre-update-snapshot`, because the
snapshot is created in phase 1 but consumed in phase 2 (cron safety net).
---
## `_update_files_via_zip` refactor
Rename current function to `_download_and_extract_zip(args) -> bool`:
- Downloads ZIP, extracts, clears bytecode
- Returns `True` if new code was written
- Does NOT do pip install, node deps, skills sync, etc.
The old post-extraction code moves to `_cmd_update_post_pull`.
---
## Test impact
| Test file | Change needed |
|-----------|--------------|
| `test_cmd_update.py` | Update mocks for two-phase flow; test `--post-pull` dispatch |
| `test_cmd_update_docker.py` | Minimal — docker bailout is unchanged |
| `test_update_autostash.py` | Stash logic stays in phase 1 — should work as-is |
| `test_update_concurrent_quarantine.py` | Concurrent check stays in phase 1 — should work as-is |
| `test_managed_installs.py` | Add pip install error test (was previously allowed) |
| `test_uv_tool_update.py` | Remove — pip/uv-tool self-update is now an error |
---
## Execution order summary
```
hermes update
└─ cmd_update()
├─ is_managed? → error
├─ docker? → error + hint
├─ pip? → error + hint (NEW — was _cmd_update_pip)
├─ --check? → _cmd_update_check() (unchanged)
├─ --post-pull? → _cmd_update_post_pull() (phase 2)
└─ else → _cmd_update_pull_new_version() (phase 1)
├─ download code (git pull or zip)
├─ validate + stash restore + clear bytecode
└─ if new code: _reexec_for_post_pull()
├─ POSIX: os.execvp → same PID, fresh Python
└─ Windows: subprocess.run + sys.exit → relay
```

View File

@@ -265,6 +265,9 @@ _API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = {
"stepfun": "step-3.5-flash",
"kimi-coding-cn": "kimi-k2-turbo-preview",
"gmi": "google/gemini-3.1-flash-lite-preview",
"minimax": "MiniMax-M2.7",
"minimax-oauth": "MiniMax-M2.7-highspeed",
"minimax-cn": "MiniMax-M2.7",
"anthropic": "claude-haiku-4-5-20251001",
"opencode-zen": "gemini-3-flash",
"opencode-go": "glm-5",
@@ -4753,14 +4756,10 @@ def _is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool:
def _convert_openai_images_to_anthropic(messages: list) -> list:
"""Convert OpenAI ``image_url``/``video_url`` blocks to Anthropic format.
"""Convert OpenAI ``image_url`` content blocks to Anthropic ``image`` blocks.
Converts:
- ``image_url`` blocks to Anthropic ``image`` blocks
- ``video_url`` blocks to Anthropic ``video`` blocks (MiniMax M3 compat)
Only touches messages that have list-type content with ``image_url`` or
``video_url`` blocks; plain text messages pass through unchanged.
Only touches messages that have list-type content with ``image_url`` blocks;
plain text messages pass through unchanged.
"""
converted = []
for msg in messages:
@@ -4797,39 +4796,6 @@ def _convert_openai_images_to_anthropic(messages: list) -> list:
},
})
changed = True
elif block.get("type") == "video_url":
# MiniMax's Anthropic-compatible endpoint expects a "video"
# block (not OpenAI's "video_url", and not "input_video").
# See https://platform.minimax.io/docs/api-reference/text-anthropic-api
# — the Messages-field table lists type="video" (M3 only,
# URL/base64/mm_file://). The source shape mirrors the "image"
# block: base64 → {type:"base64", media_type, data}, URL →
# {type:"url", url}.
video_url_val = (block.get("video_url") or {}).get("url", "")
if video_url_val.startswith("data:"):
# Parse data URI: data:<media_type>;base64,<data>
header, _, b64data = video_url_val.partition(",")
media_type = "video/mp4"
if ":" in header and ";" in header:
media_type = header.split(":", 1)[1].split(";", 1)[0]
new_content.append({
"type": "video",
"source": {
"type": "base64",
"media_type": media_type,
"data": b64data,
},
})
else:
# URL-based video
new_content.append({
"type": "video",
"source": {
"type": "url",
"url": video_url_val,
},
})
changed = True
else:
new_content.append(block)
converted.append({**msg, "content": new_content} if changed else msg)

View File

@@ -646,11 +646,6 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
# much larger; shrinking to 4 MB here loses quality but only fires
# after a confirmed provider rejection, so the alternative is failure.
target_bytes = 4 * 1024 * 1024
# Anthropic enforces an 8000px per-side dimension cap independently of
# the 5 MB byte cap. A tall screenshot can be well under 5 MB yet far
# over 8000px (e.g. 1200×12000 at 0.06 MB). We check pixel dimensions
# even when the byte budget is fine.
max_dimension = 8000
changed_count = 0
# Track parts that are over the target but could NOT be shrunk under it.
# If any survive, retrying is pointless — the same oversized payload will
@@ -663,30 +658,9 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
"""Return a smaller data URL, or None if shrink can't help."""
if not isinstance(url, str) or not url.startswith("data:"):
return None
# Check both byte size AND pixel dimensions.
needs_shrink = len(url) > target_bytes # over byte budget
if not needs_shrink:
# Even if bytes are fine, check pixel dimensions against
# Anthropic's 8000px cap. A tall image can be tiny in bytes
# yet huge in pixels.
try:
import base64 as _b64_dim
header_d, _, data_d = url.partition(",")
if not data_d:
return None
raw_d = _b64_dim.b64decode(data_d)
from PIL import Image as _PILImage
import io as _io_dim
with _PILImage.open(_io_dim.BytesIO(raw_d)) as _img:
if max(_img.size) <= max_dimension:
return None # both bytes and pixels are fine
needs_shrink = True # pixels exceed limit, force shrink
except Exception:
# If we can't check dimensions (Pillow unavailable, corrupt
# image, etc.), fall back to byte-only check.
return None
if len(url) <= target_bytes:
# This specific image wasn't the oversized one.
return None
try:
header, _, data = url.partition(",")
mime = "image/jpeg"
@@ -710,7 +684,6 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
Path(tmp.name),
mime_type=mime,
max_base64_bytes=target_bytes,
max_dimension=max_dimension,
)
finally:
try:

View File

@@ -171,9 +171,6 @@ _IMAGE_TOO_LARGE_PATTERNS = [
"image too large", # generic
"image_too_large", # error_code variant
"image size exceeds", # variant
"image dimensions exceed", # Anthropic: "image dimensions exceed max allowed size: 8000 pixels"
"dimensions exceed max allowed size", # Anthropic dimension-cap (wording variant)
"max allowed size: 8000", # Anthropic dimension-cap (explicit pixel ceiling)
# "request_too_large" on a request known to contain an image → image is
# the likely culprit; we still try the shrink path before giving up.
]

View File

@@ -441,10 +441,6 @@ def is_local_endpoint(base_url: str) -> bool:
# Docker / Podman / Lima internal DNS names (e.g. host.docker.internal)
if any(host.endswith(suffix) for suffix in _CONTAINER_LOCAL_SUFFIXES):
return True
# Unqualified hostnames (no dots) are local by definition — Docker
# Compose service names, /etc/hosts entries, or mDNS names.
if host and "." not in host:
return True
# RFC-1918 private ranges, link-local, and Tailscale CGNAT
try:
addr = ipaddress.ip_address(host)
@@ -1144,18 +1140,6 @@ def _model_name_suggests_minimax_m3(model: str) -> bool:
return "minimax-m3" in model.lower()
def _model_name_suggests_grok_4_3(model: str) -> bool:
"""Return True if the model name looks like a Grok 4.3 variant.
Catches ``grok-4.3``, ``grok-4.3-latest``, and similar slugs.
Used as a guard against stale cache entries seeded by pre-catalog builds
that resolved grok-4.3 via the generic ``grok-4`` catch-all (256,000)
before the ``grok-4.3`` (1M) entry was added to DEFAULT_CONTEXT_LENGTHS
on 2026-05-15.
"""
return "grok-4.3" in model.lower()
def _query_local_context_length(model: str, base_url: str, api_key: str = "") -> Optional[int]:
"""Query a local server for the model's context length."""
import httpx
@@ -1580,19 +1564,6 @@ def get_model_context_length(
model, base_url, f"{cached:,}",
)
_invalidate_cached_context_length(model, base_url)
# Invalidate stale ≤256,000 cache entries for Grok-4.3. The
# ``grok-4.3`` (1M) entry was added to DEFAULT_CONTEXT_LENGTHS on
# 2026-05-15; prior to that, grok-4.3 slugs resolved via the
# ``grok-4`` catch-all (256,000) and that value was persisted.
# grok-4.3 is 1M, so any sub-262K cached value is a pre-catalog
# leftover — drop it and fall through to the hardcoded default.
elif cached <= 256_000 and _model_name_suggests_grok_4_3(model):
logger.info(
"Dropping stale Grok-4.3 cache entry %s@%s -> %s (pre-catalog value); "
"re-resolving via hardcoded defaults",
model, base_url, f"{cached:,}",
)
_invalidate_cached_context_length(model, base_url)
# Nous Portal: the portal /v1/models endpoint is authoritative.
# Bypass the persistent cache so step 5b can always reconcile
# against it — this corrects pre-fix entries seeded from the

View File

@@ -22,7 +22,6 @@ from agent.skill_utils import (
get_disabled_skill_names,
iter_skill_index_files,
parse_frontmatter,
skill_matches_environment,
skill_matches_platform,
)
from utils import atomic_json_write
@@ -1006,13 +1005,6 @@ def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]:
if not skill_matches_platform(frontmatter):
return False, frontmatter, ""
# Environment relevance gate (offer-time only): hide skills tagged for
# a runtime environment that isn't active (e.g. kanban-only skills for
# non-kanban users, s6-only skills outside the container). Explicit
# loads (skill_view / --skills) bypass this — see skill_matches_environment.
if not skill_matches_environment(frontmatter):
return False, frontmatter, ""
return True, frontmatter, extract_skill_description(frontmatter)
except Exception as e:
logger.warning("Failed to parse skill file %s: %s", skill_file, e)

View File

@@ -270,7 +270,7 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
_skill_commands_platform = _resolve_skill_commands_platform()
_skill_commands = {}
try:
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, skill_matches_environment, _get_disabled_skill_names
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, _get_disabled_skill_names
from agent.skill_utils import get_external_skills_dirs, iter_skill_index_files
disabled = _get_disabled_skill_names()
seen_names: set = set()
@@ -291,10 +291,6 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
# Skip skills incompatible with the current OS platform
if not skill_matches_platform(frontmatter):
continue
# Skip skills not relevant to the current runtime env
# (kanban/docker/s6). Offer-time only; explicit load bypasses.
if not skill_matches_environment(frontmatter):
continue
name = frontmatter.get('name', skill_md.parent.name)
if name in seen_names:
continue

View File

@@ -169,106 +169,6 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
return False
# ── Environment matching ──────────────────────────────────────────────────
# Recognized environment tags and how each is detected. An environment tag is
# a *relevance* gate, not a hard-compatibility gate (that is what ``platforms:``
# is for). A skill tagged for an environment it isn't relevant to is hidden from
# the skills index / offer surfaces so it does not add noise for users who will
# never need it — but it can ALWAYS still be loaded explicitly (``skill_view``,
# ``--skills``), because an explicit request is explicit consent.
#
# Detection is cached for the process lifetime via ``_ENV_DETECT_CACHE``.
_KNOWN_ENVIRONMENTS = frozenset({"kanban", "docker", "s6"})
_ENV_DETECT_CACHE: Dict[str, bool] = {}
def _detect_environment(env: str) -> bool:
"""Return True when the named runtime environment is currently active.
Cached per process. Unknown env names return True (fail-open: never hide a
skill because of a tag we don't understand).
"""
if env in _ENV_DETECT_CACHE:
return _ENV_DETECT_CACHE[env]
result = True
if env == "kanban":
# Kanban is "active" either as a dispatcher-spawned worker (the
# dispatcher sets ``HERMES_KANBAN_TASK`` / ``HERMES_KANBAN_BOARD`` in the
# worker env) or as an orchestrator profile that has opted into the
# kanban toolset. Mirror the same signals the kanban tools themselves
# gate on (``tools/kanban_tools.py``) so the offer filter agrees with
# tool availability.
if os.getenv("HERMES_KANBAN_TASK") or os.getenv("HERMES_KANBAN_BOARD"):
result = True
else:
try:
from tools.kanban_tools import _profile_has_kanban_toolset
result = bool(_profile_has_kanban_toolset())
except Exception:
result = False
elif env == "docker":
try:
from hermes_constants import is_container
result = is_container()
except Exception:
result = False
elif env == "s6":
# The Hermes Docker image runs s6-overlay as PID 1 (/init). s6 plants
# its runtime scaffolding under /run/s6 and ships its admin tree under
# /package/admin/s6-overlay. Either marker means we're inside an
# s6-supervised container.
result = os.path.isdir("/run/s6") or os.path.isdir(
"/package/admin/s6-overlay"
)
_ENV_DETECT_CACHE[env] = result
return result
def skill_matches_environment(frontmatter: Dict[str, Any]) -> bool:
"""Return True when the skill is relevant to the current runtime environment.
Skills may declare an ``environments`` list in their YAML frontmatter::
environments: [kanban] # only relevant when kanban is active
environments: [s6] # only relevant inside the s6 Docker image
environments: [docker] # only relevant inside any container
If the field is absent or empty the skill is relevant in **all**
environments (backward-compatible default).
This is an OFFER-time filter: it controls whether a skill shows up in the
skills index / autocomplete / slash-command list. It is intentionally NOT
enforced by ``skill_view`` or ``--skills`` preloading — an explicit load is
explicit consent, and load-bearing force-loads (e.g. the kanban dispatcher
injecting ``--skills kanban-worker``) must always succeed regardless of how
the offer surfaces filter the skill.
A skill matches when ANY of its declared environments is currently active
(OR semantics, mirroring ``platforms``). Unknown env tags fail open.
"""
environments = frontmatter.get("environments")
if not environments:
return True
if not isinstance(environments, list):
environments = [environments]
for env in environments:
normalized = str(env).lower().strip()
if not normalized:
continue
if normalized not in _KNOWN_ENVIRONMENTS:
# Tag we don't understand — don't hide the skill over it.
return True
if _detect_environment(normalized):
return True
return False
# ── Disabled skills ───────────────────────────────────────────────────────

View File

@@ -35,7 +35,7 @@ use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};
/// `hermes update` exit code meaning "another hermes process is holding the
/// venv shim open / dirty precondition" — see _cmd_update_impl in
/// hermes_cli/update.py (sys.exit(2)). We surface a targeted message for this.
/// hermes_cli/main.py (sys.exit(2)). We surface a targeted message for this.
const UPDATE_EXIT_CONCURRENT: i32 = 2;
/// How long to wait for the old desktop process to release the venv shim

View File

@@ -94,7 +94,7 @@ Installers are built and uploaded to GitHub Releases manually. macOS/Windows sig
### How it works
The packaged app ships only the Electron shell. On first launch it installs the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. The renderer (React, in `src/`) talks to a `hermes dashboard` backend over the standard gateway APIs and reuses the embedded TUI rather than reimplementing chat. The install, backend-resolution, and self-update logic all live in `electron/main.cjs`.
The packaged app ships only the Electron shell. On first launch it installs the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. The renderer (React, in `src/`) talks to a `hermes dashboard --tui` backend over the standard gateway APIs and reuses the embedded TUI rather than reimplementing chat. The install, backend-resolution, and self-update logic all live in `electron/main.cjs`.
### Verification

View File

@@ -67,9 +67,7 @@ test('verifyHermesCli returns true when --version exits 0', () => {
} finally {
try {
fs.unlinkSync(scriptPath)
} catch {
void 0
}
} catch {}
}
})

View File

@@ -52,9 +52,7 @@ function detectRemoteDisplay(options = {}) {
const env = options.env ?? process.env
const platform = options.platform ?? process.platform
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '')
.trim()
.toLowerCase()
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '').trim().toLowerCase()
if (GPU_OVERRIDE_ON.has(override)) return 'override (HERMES_DESKTOP_DISABLE_GPU)'
if (GPU_OVERRIDE_OFF.has(override)) return null

View File

@@ -45,17 +45,11 @@ test('detectRemoteDisplay does not treat WSLg as remote', () => {
// WSLg renders locally via vGPU and doesn't show the flicker, so a WSL
// session with a local DISPLAY keeps hardware acceleration on.
assert.equal(detectRemoteDisplay({ env: { WSL_DISTRO_NAME: 'Ubuntu', DISPLAY: ':0' }, platform: 'linux' }), null)
assert.equal(
detectRemoteDisplay({ env: { WSL_INTEROP: '/run/WSL/1_interop', DISPLAY: ':0' }, platform: 'linux' }),
null
)
assert.equal(detectRemoteDisplay({ env: { WSL_INTEROP: '/run/WSL/1_interop', DISPLAY: ':0' }, platform: 'linux' }), null)
})
test('detectRemoteDisplay flags SSH sessions on any platform', () => {
assert.equal(
detectRemoteDisplay({ env: { SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' }, platform: 'linux' }),
'ssh-session'
)
assert.equal(detectRemoteDisplay({ env: { SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' }, platform: 'linux' }), 'ssh-session')
assert.equal(detectRemoteDisplay({ env: { SSH_CLIENT: '1.2.3.4 5 22' }, platform: 'darwin' }), 'ssh-session')
assert.equal(detectRemoteDisplay({ env: { SSH_TTY: '/dev/pts/0' }, platform: 'win32' }), 'ssh-session')
})

View File

@@ -101,9 +101,7 @@ function downloadInstallScript(commit, destPath) {
.get(res.headers.location, res2 => {
if (res2.statusCode !== 200) {
reject(
new Error(
`Failed to download ${scriptName}: HTTP ${res2.statusCode} from redirect ${res.headers.location}`
)
new Error(`Failed to download ${scriptName}: HTTP ${res2.statusCode} from redirect ${res.headers.location}`)
)
return
}
@@ -123,9 +121,7 @@ function downloadInstallScript(commit, destPath) {
out.close()
try {
fs.unlinkSync(tmpPath)
} catch {
void 0
}
} catch {}
reject(new Error(`Failed to download ${scriptName}: HTTP ${res.statusCode} from ${url}`))
return
}
@@ -138,18 +134,14 @@ function downloadInstallScript(commit, destPath) {
out.on('error', err => {
try {
fs.unlinkSync(tmpPath)
} catch {
void 0
}
} catch {}
reject(err)
})
})
.on('error', err => {
try {
fs.unlinkSync(tmpPath)
} catch {
void 0
}
} catch {}
reject(err)
})
})
@@ -176,19 +168,13 @@ async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome,
const cached = cachedScriptPath(hermesHome, installStamp.commit)
try {
await fsp.access(cached, fs.constants.R_OK)
emit({
type: 'log',
line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}`
})
emit({ type: 'log', line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}` })
return { path: cached, source: 'cache', commit: installStamp.commit, kind: installScriptKind() }
} catch {
// not cached; download
}
emit({
type: 'log',
line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub`
})
emit({ type: 'log', line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub` })
await downloadInstallScript(installStamp.commit, cached)
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
@@ -221,9 +207,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
killed = true
try {
child.kill('SIGTERM')
} catch {
void 0
}
} catch {}
}
if (abortSignal) {
if (abortSignal.aborted) {
@@ -294,9 +278,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
killed = true
try {
child.kill('SIGTERM')
} catch {
void 0
}
} catch {}
}
if (abortSignal) {
if (abortSignal.aborted) {
@@ -387,9 +369,7 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
hermesHome
})
if (result.code !== 0) {
throw new Error(
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${result.stderr || result.stdout}`
)
throw new Error(`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${result.stderr || result.stdout}`)
}
// The manifest is the LAST JSON line on stdout (install.ps1 may print
// banner / info lines first depending on Console.OutputEncoding effects).
@@ -401,13 +381,9 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
if (parsed && Array.isArray(parsed.stages)) {
return parsed
}
} catch {
void 0
}
} catch {}
}
throw new Error(
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} produced no parseable JSON payload\n${result.stdout}`
)
throw new Error(`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} produced no parseable JSON payload\n${result.stdout}`)
}
// Parse the JSON result frame from a stage run. The protocol guarantees
@@ -421,9 +397,7 @@ function parseStageResult(stdout) {
if (parsed && typeof parsed.ok === 'boolean' && typeof parsed.stage === 'string') {
return parsed
}
} catch {
void 0
}
} catch {}
}
return null
}
@@ -434,20 +408,13 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
const isPosix = installerKind === 'posix'
const args = isPosix
? [
'--stage',
stage.name,
'--non-interactive',
'--json',
...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })
]
? ['--stage', stage.name, '--non-interactive', '--json', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })]
: ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp)]
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
emit,
stageName: stage.name,
abortSignal,
hermesHome
})
const result = await (isPosix ? spawnBash : spawnPowerShell)(
scriptPath,
args,
{ emit, stageName: stage.name, abortSignal, hermesHome }
)
const durationMs = Date.now() - startedAt
@@ -482,14 +449,7 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
emit(ev)
return ev
}
const ev = {
type: 'stage',
name: stage.name,
state: 'failed',
durationMs,
json,
error: json.reason || `exit code ${result.code}`
}
const ev = { type: 'stage', name: stage.name, state: 'failed', durationMs, json, error: json.reason || `exit code ${result.code}` }
emit(ev)
return ev
}
@@ -529,9 +489,7 @@ async function runBootstrap(opts) {
if (typeof onEvent === 'function') {
try {
onEvent({ type: 'failed', error: 'bootstrap cancelled by user' })
} catch {
void 0
}
} catch {}
}
return { ok: false, cancelled: true }
}
@@ -543,9 +501,7 @@ async function runBootstrap(opts) {
const emit = ev => {
try {
runLog.stream.write(JSON.stringify(ev) + '\n')
} catch {
void 0
}
} catch {}
try {
if (typeof onEvent === 'function') onEvent(ev)
} catch (err) {
@@ -622,9 +578,7 @@ async function runBootstrap(opts) {
} finally {
try {
runLog.stream.end()
} catch {
void 0
}
} catch {}
}
}

View File

@@ -1,118 +0,0 @@
/**
* connection-config.cjs
*
* Pure, electron-free helpers for the desktop's remote-gateway connection
* config: URL normalization, WS-URL construction (token vs OAuth ticket),
* auth-mode classification, and the auth-mode coercion rules.
*
* Kept standalone (no `require('electron')`) so it can be unit-tested with
* `node --test` — same pattern as backend-probes.cjs / bootstrap-platform.cjs.
* main.cjs requires these and wires them into the electron-coupled IPC layer.
*
* Background on the two auth models a remote gateway can use:
* - 'token': legacy static dashboard session token. REST uses an
* `X-Hermes-Session-Token` header; WS uses `?token=`.
* - 'oauth': hosted gateways gate behind an OAuth provider. REST is authed
* by an HttpOnly session cookie; WS upgrades require a single-use
* `?ticket=` minted at POST /api/auth/ws-ticket. The gateway advertises
* this via the public `/api/status` field `auth_required: true`.
*/
// Bare + prefixed variants of the access-token cookie the gateway may set,
// depending on its deploy shape (HTTPS direct → __Host-, behind a path prefix
// → __Secure-, loopback HTTP → bare). Mirrors
// hermes_cli/dashboard_auth/cookies.py.
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
function normalizeRemoteBaseUrl(rawUrl) {
const value = String(rawUrl || '').trim()
if (!value) {
throw new Error('Remote gateway URL is required.')
}
let parsed
try {
parsed = new URL(value)
} catch (error) {
throw new Error(`Remote gateway URL is not valid: ${error.message}`)
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`Remote gateway URL must be http:// or https://, got ${parsed.protocol}`)
}
parsed.hash = ''
parsed.search = ''
parsed.pathname = parsed.pathname.replace(/\/+$/, '')
return parsed.toString().replace(/\/+$/, '')
}
function buildGatewayWsUrl(baseUrl, token) {
const parsed = new URL(baseUrl)
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
const prefix = parsed.pathname.replace(/\/+$/, '')
return `${wsScheme}://${parsed.host}${prefix}/api/ws?token=${encodeURIComponent(token)}`
}
function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
const parsed = new URL(baseUrl)
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
const prefix = parsed.pathname.replace(/\/+$/, '')
return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}`
}
function tokenPreview(value) {
const raw = String(value || '')
if (!raw) {
return null
}
return raw.length <= 8 ? 'set' : `...${raw.slice(-6)}`
}
/**
* Classify a gateway's auth mode from its public /api/status body.
* `auth_required: true` → OAuth gate engaged; otherwise legacy token auth.
* Returns 'oauth' | 'token'.
*/
function authModeFromStatus(statusBody) {
return statusBody && statusBody.auth_required ? 'oauth' : 'token'
}
/**
* Resolve the effective auth mode for a coerce/save operation.
* Explicit input wins; otherwise inherit the saved value; default 'token'.
* Returns 'oauth' | 'token'.
*/
function resolveAuthMode(inputAuthMode, existingAuthMode) {
if (inputAuthMode === 'oauth') return 'oauth'
if (inputAuthMode === 'token') return 'token'
if (existingAuthMode === 'oauth') return 'oauth'
return 'token'
}
/**
* True if any cookie in `cookies` is a hermes session access-token cookie
* with a non-empty value. `cookies` is an array of {name, value} (the shape
* Electron's session.cookies.get returns).
*/
function cookiesHaveSession(cookies) {
if (!Array.isArray(cookies)) return false
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
}
module.exports = {
AT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
cookiesHaveSession,
normalizeRemoteBaseUrl,
resolveAuthMode,
tokenPreview
}

View File

@@ -1,161 +0,0 @@
/**
* Tests for electron/connection-config.cjs.
*
* Run with: node --test electron/connection-config.test.cjs
* (Wire into npm test:desktop:platforms in package.json.)
*
* These are the pure helpers behind the remote-gateway connection settings:
* URL normalization, WS-URL construction (token vs OAuth ticket), auth-mode
* classification from /api/status, the coerce-time auth-mode resolution rules,
* and the OAuth session-cookie detector.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
AT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
cookiesHaveSession,
normalizeRemoteBaseUrl,
resolveAuthMode,
tokenPreview
} = require('./connection-config.cjs')
// --- normalizeRemoteBaseUrl ---
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/'), 'https://gw.example.com')
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/hermes/'), 'https://gw.example.com/hermes')
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/hermes?x=1#frag'), 'https://gw.example.com/hermes')
})
test('normalizeRemoteBaseUrl preserves a path prefix', () => {
assert.equal(normalizeRemoteBaseUrl('https://host/hermes'), 'https://host/hermes')
})
test('normalizeRemoteBaseUrl rejects empty input', () => {
assert.throws(() => normalizeRemoteBaseUrl(''), /required/)
assert.throws(() => normalizeRemoteBaseUrl(' '), /required/)
})
test('normalizeRemoteBaseUrl rejects non-http(s) protocols', () => {
assert.throws(() => normalizeRemoteBaseUrl('ftp://host'), /http:\/\/ or https:\/\//)
assert.throws(() => normalizeRemoteBaseUrl('file:///etc/passwd'), /http:\/\/ or https:\/\//)
})
test('normalizeRemoteBaseUrl rejects garbage', () => {
assert.throws(() => normalizeRemoteBaseUrl('not a url'), /not valid/)
})
// --- buildGatewayWsUrl (token) ---
test('buildGatewayWsUrl uses wss for https and bakes the token', () => {
assert.equal(buildGatewayWsUrl('https://gw.example.com', 'tok123'), 'wss://gw.example.com/api/ws?token=tok123')
})
test('buildGatewayWsUrl uses ws for http', () => {
assert.equal(buildGatewayWsUrl('http://127.0.0.1:9119', 'abc'), 'ws://127.0.0.1:9119/api/ws?token=abc')
})
test('buildGatewayWsUrl honors a path prefix', () => {
assert.equal(buildGatewayWsUrl('https://host/hermes', 't'), 'wss://host/hermes/api/ws?token=t')
})
test('buildGatewayWsUrl url-encodes the token', () => {
assert.equal(buildGatewayWsUrl('https://host', 'a/b c+d'), 'wss://host/api/ws?token=a%2Fb%20c%2Bd')
})
// --- buildGatewayWsUrlWithTicket (oauth) ---
test('buildGatewayWsUrlWithTicket uses ?ticket= not ?token=', () => {
const url = buildGatewayWsUrlWithTicket('https://gw.example.com/hermes', 'tkt-9')
assert.equal(url, 'wss://gw.example.com/hermes/api/ws?ticket=tkt-9')
assert.ok(!url.includes('token='))
})
test('buildGatewayWsUrlWithTicket url-encodes the ticket', () => {
assert.equal(buildGatewayWsUrlWithTicket('https://host', 'a+b/c'), 'wss://host/api/ws?ticket=a%2Bb%2Fc')
})
// --- authModeFromStatus ---
test('authModeFromStatus returns oauth when auth_required is true', () => {
assert.equal(authModeFromStatus({ auth_required: true, auth_providers: ['nous'] }), 'oauth')
})
test('authModeFromStatus returns token when auth_required is false/missing', () => {
assert.equal(authModeFromStatus({ auth_required: false }), 'token')
assert.equal(authModeFromStatus({}), 'token')
assert.equal(authModeFromStatus(null), 'token')
assert.equal(authModeFromStatus(undefined), 'token')
})
// --- resolveAuthMode ---
test('resolveAuthMode: explicit input wins over existing', () => {
assert.equal(resolveAuthMode('oauth', 'token'), 'oauth')
assert.equal(resolveAuthMode('token', 'oauth'), 'token')
})
test('resolveAuthMode: falls back to existing when input absent', () => {
assert.equal(resolveAuthMode(undefined, 'oauth'), 'oauth')
assert.equal(resolveAuthMode(undefined, 'token'), 'token')
assert.equal(resolveAuthMode('', 'oauth'), 'oauth')
})
test('resolveAuthMode: defaults to token when nothing is set', () => {
assert.equal(resolveAuthMode(undefined, undefined), 'token')
assert.equal(resolveAuthMode(null, null), 'token')
})
test('resolveAuthMode: ignores unknown values, defaults to token', () => {
assert.equal(resolveAuthMode('bogus', 'also-bogus'), 'token')
})
// --- cookiesHaveSession ---
test('cookiesHaveSession detects the bare access-token cookie', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: 'x' }]), true)
})
test('cookiesHaveSession detects the __Host- and __Secure- prefixed variants', () => {
assert.equal(cookiesHaveSession([{ name: '__Host-hermes_session_at', value: 'x' }]), true)
assert.equal(cookiesHaveSession([{ name: '__Secure-hermes_session_at', value: 'x' }]), true)
})
test('cookiesHaveSession is false for an empty value', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: '' }]), false)
})
test('cookiesHaveSession ignores unrelated cookies', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_rt', value: 'x' }]), false)
assert.equal(cookiesHaveSession([{ name: 'other', value: 'x' }]), false)
})
test('cookiesHaveSession handles non-arrays', () => {
assert.equal(cookiesHaveSession(null), false)
assert.equal(cookiesHaveSession(undefined), false)
assert.equal(cookiesHaveSession([]), false)
})
test('AT_COOKIE_VARIANTS covers all three deploy shapes', () => {
assert.deepEqual(AT_COOKIE_VARIANTS, ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at'])
})
// --- tokenPreview ---
test('tokenPreview returns null for empty', () => {
assert.equal(tokenPreview(''), null)
assert.equal(tokenPreview(null), null)
})
test('tokenPreview returns set for short tokens', () => {
assert.equal(tokenPreview('12345678'), 'set')
})
test('tokenPreview returns a masked suffix for long tokens', () => {
assert.equal(tokenPreview('abcdefghijklmnop'), '...klmnop')
})

File diff suppressed because it is too large Load Diff

View File

@@ -2,15 +2,11 @@ const { contextBridge, ipcRenderer, webUtils } = require('electron')
contextBridge.exposeInMainWorld('hermesDesktop', {
getConnection: () => ipcRenderer.invoke('hermes:connection'),
getGatewayWsUrl: () => ipcRenderer.invoke('hermes:gateway:ws-url'),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),
api: request => ipcRenderer.invoke('hermes:api', request),
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),

View File

@@ -35,7 +35,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -146,7 +146,6 @@
"package.json"
],
"beforeBuild": "scripts/before-build.cjs",
"beforePack": "scripts/before-pack.cjs",
"afterPack": "scripts/after-pack.cjs",
"extraResources": [
{

View File

@@ -1,78 +0,0 @@
'use strict'
/**
* before-pack.cjs — electron-builder beforePack hook.
*
* Removes any stale unpacked app directory (`appOutDir`) before
* electron-builder stages the Electron binaries into it.
*
* WHY THIS EXISTS
* ---------------
* electron-builder's final packaging step copies the stock `electron`
* binary into `release/<platform>-unpacked/` and then renames it to the
* product name (`Hermes`). If a PREVIOUS `npm run pack` was interrupted
* (Ctrl-C, OOM kill, crash, full disk) the unpacked directory is left in a
* corrupted partial state: it keeps the already-renamed `LICENSE.electron.txt`
* and the Chromium payload (.pak/.so/icudtl.dat/chrome-sandbox) but is MISSING
* the `electron` binary itself.
*
* On the next run, electron-builder sees the destination directory already
* populated, skips re-copying the binary it thinks is present, then tries to
* rename a `electron` file that no longer exists. The build dies with:
*
* ENOENT: no such file or directory, rename
* '.../release/linux-unpacked/electron' -> '.../release/linux-unpacked/Hermes'
*
* This is a hard failure with no obvious cause for the user — `hermes desktop`
* just prints "Desktop GUI build failed" and the only fix is to manually
* `rm -rf` the release directory, which a normal user has no way to know.
*
* The packaging step is not idempotent across an interrupted run, so we make
* it idempotent ourselves: wipe the target unpacked directory up front so
* electron-builder always stages into a clean tree. This is safe — the
* directory is a pure build artifact that electron-builder fully recreates
* on every pack; nothing else depends on its prior contents.
*
* Cross-platform: the same partial-state trap exists on macOS
* (the mac-unpacked Hermes.app bundle) and Windows (win-unpacked), so we
* clean whatever `appOutDir` electron-builder hands us regardless of platform.
*
* Best-effort: a cleanup failure must never mask the real build. We log and
* resolve rather than throw — worst case electron-builder hits the original
* ENOENT, which is no worse than not having this hook at all.
*
* electron-builder passes a context with:
* - appOutDir: the unpacked app directory about to be staged
* - electronPlatformName: 'win32' | 'darwin' | 'linux'
*/
const fs = require('node:fs')
function cleanStaleAppOutDir(appOutDir) {
if (!appOutDir || typeof appOutDir !== 'string') {
return false
}
if (!fs.existsSync(appOutDir)) {
return false
}
// Recursive + force so a half-written tree (read-only bits, partial files)
// can't block the wipe. retry/maxRetries rides out transient EBUSY on
// Windows where an AV/indexer may briefly hold a handle.
fs.rmSync(appOutDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
return true
}
exports.cleanStaleAppOutDir = cleanStaleAppOutDir
exports.default = async function beforePack(context) {
const appOutDir = context && context.appOutDir
try {
if (cleanStaleAppOutDir(appOutDir)) {
console.log(`[before-pack] removed stale unpacked dir before staging: ${appOutDir}`)
}
} catch (err) {
// Never fail the build over cleanup; surface why so a genuinely stuck
// directory (permissions, mount) is still diagnosable.
console.warn(`[before-pack] could not clean ${appOutDir} (${err.message}); continuing`)
}
}

View File

@@ -1,53 +0,0 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { cleanStaleAppOutDir } = require('../scripts/before-pack.cjs')
test('cleanStaleAppOutDir removes a populated unpacked directory', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
try {
const appOutDir = path.join(tempRoot, 'linux-unpacked')
fs.mkdirSync(appOutDir, { recursive: true })
// Reproduce the corrupted partial state: license + payload present,
// electron binary missing — exactly what trips the ENOENT rename.
fs.writeFileSync(path.join(appOutDir, 'LICENSE.electron.txt'), 'x', 'utf8')
fs.writeFileSync(path.join(appOutDir, 'resources.pak'), 'x', 'utf8')
fs.mkdirSync(path.join(appOutDir, 'resources'), { recursive: true })
fs.writeFileSync(path.join(appOutDir, 'resources', 'app.asar'), 'x', 'utf8')
const removed = cleanStaleAppOutDir(appOutDir)
assert.equal(removed, true)
assert.equal(fs.existsSync(appOutDir), false)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('cleanStaleAppOutDir is a no-op when the directory is absent', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
try {
const missing = path.join(tempRoot, 'does-not-exist')
assert.equal(cleanStaleAppOutDir(missing), false)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('cleanStaleAppOutDir ignores empty or invalid input', () => {
assert.equal(cleanStaleAppOutDir(''), false)
assert.equal(cleanStaleAppOutDir(undefined), false)
assert.equal(cleanStaleAppOutDir(null), false)
assert.equal(cleanStaleAppOutDir(42), false)
})
test('beforePack default export resolves even when cleanup throws', async () => {
const { default: beforePack } = require('../scripts/before-pack.cjs')
// A directory path that rmSync can't remove is simulated by passing a
// context whose appOutDir is a file the hook will try (and be allowed) to
// remove; the contract under test is that the hook never rejects.
await assert.doesNotReject(beforePack({ appOutDir: '', electronPlatformName: 'linux' }))
})

View File

@@ -2,7 +2,13 @@ import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
@@ -98,8 +104,8 @@ export function ContextMenu({
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference
files inline.
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference files
inline.
</div>
</DropdownMenuContent>
</DropdownMenu>
@@ -114,7 +120,12 @@ export function ContextMenu({
)
}
function PromptSnippetsDialog({ onInsertText, onOpenChange, open, snippets }: PromptSnippetsDialogProps) {
function PromptSnippetsDialog({
onInsertText,
onOpenChange,
open,
snippets
}: PromptSnippetsDialogProps) {
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md gap-3">
@@ -149,7 +160,12 @@ function PromptSnippetsDialog({ onInsertText, onOpenChange, open, snippets }: Pr
)
}
export function ContextMenuItem({ children, disabled, icon: Icon, onSelect }: ContextMenuItemProps) {
export function ContextMenuItem({
children,
disabled,
icon: Icon,
onSelect
}: ContextMenuItemProps) {
return (
<DropdownMenuItem disabled={disabled} onSelect={onSelect}>
<Icon />

View File

@@ -16,7 +16,6 @@ interface SlashItemMetadata extends Record<string, string> {
command: string
display: string
meta: string
rawText: string
}
function textValue(value: unknown, fallback = ''): string {
@@ -92,13 +91,7 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
const metadata: SlashItemMetadata = {
command,
display,
meta,
// Provide rawText so hermesDirectiveFormatter.serialize uses the
// direct-insertion path instead of the legacy @type:id fallback.
// Without this, the item.id (which includes a "|index" suffix for
// trigger-adapter uniqueness) leaks into the serialized chip text
// and the submitted command.
rawText: command
meta
}
return {

View File

@@ -18,11 +18,14 @@ import { Button } from '@/components/ui/button'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { chatMessageText } from '@/lib/chat-messages'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
import {
$composerAttachments,
clearComposerAttachments,
type ComposerAttachment
} from '@/store/composer'
import {
$queuedPromptsBySession,
enqueueQueuedPrompt,
@@ -169,7 +172,7 @@ 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 composingRef = useRef(false) // true during IME composition (CJK input)
const lastSpokenIdRef = useRef<string | null>(null)
const narrow = useMediaQuery('(max-width: 30rem)')
@@ -1038,19 +1041,7 @@ export function ChatBar({
if (queueEdit) {
exitQueuedEdit('save')
} else if (busy) {
// Slash commands should execute immediately even while the agent is
// busy — they're client-side operations (/yolo, /skin, /new, /help,
// etc.) or self-contained gateway RPCs (/status, /compress). onSubmit
// routes them to executeSlashCommand, which has its own per-command
// busy guard for commands that genuinely need an idle session (skill
// /send directives). Queuing them would make every slash command wait
// for the current turn to finish, which is how the TUI never behaves.
if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
const submitted = draft
triggerHaptic('submit')
clearDraft()
void onSubmit(submitted)
} else if (hasComposerPayload) {
if (hasComposerPayload) {
queueCurrentDraft()
} else {
// Stop button: an explicit interrupt must actually halt the running
@@ -1262,11 +1253,9 @@ export function ChatBar({
onDrop={handleDrop}
onSubmit={e => {
e.preventDefault()
if (composingRef.current) {
return
}
submitDraft()
}}
ref={composerRef}

View File

@@ -37,10 +37,7 @@ function Harness({
const refreshTrigger = useCallback(() => {
const editor = editorRef.current
if (!editor) {
return
}
if (!editor) {return}
const raw = editor.textContent ?? ''
if (!raw.includes('@') && !raw.includes('/')) {

View File

@@ -1,6 +1,6 @@
import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react'
import { $messages, setBusy, setMessages } from '@/store/session'
import { $messages, setMessages, setBusy } from '@/store/session'
type Sample = {
id: string
@@ -40,16 +40,13 @@ if (typeof window !== 'undefined' && !window.__PERF_PROBE__) {
},
summary: () => {
const byId = new Map<string, number[]>()
for (const s of samples) {
const k = `${s.id}:${s.phase}`
const arr = byId.get(k) ?? []
arr.push(s.actualDuration)
byId.set(k, arr)
}
const out: Record<string, { count: number; total: number; max: number; p50: number; p95: number }> = {}
for (const [k, arr] of byId) {
arr.sort((a, b) => a - b)
const total = arr.reduce((a, b) => a + b, 0)
@@ -58,27 +55,19 @@ if (typeof window !== 'undefined' && !window.__PERF_PROBE__) {
total: Math.round(total * 100) / 100,
max: Math.round(arr[arr.length - 1] * 100) / 100,
p50: Math.round(arr[Math.floor(arr.length * 0.5)] * 100) / 100,
p95: Math.round(arr[Math.floor(arr.length * 0.95)] * 100) / 100
p95: Math.round(arr[Math.floor(arr.length * 0.95)] * 100) / 100,
}
}
return out
}
},
}
}
const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration, baseDuration, startTime, commitTime) => {
const probe = typeof window !== 'undefined' ? window.__PERF_PROBE__ : undefined
if (!probe || !probe.enabled) {
return
}
if (!probe || !probe.enabled) return
probe.samples.push({ id, phase, actualDuration, baseDuration, startTime, commitTime })
if (probe.samples.length > 5000) {
probe.samples.splice(0, probe.samples.length - 5000)
}
if (probe.samples.length > 5000) probe.samples.splice(0, probe.samples.length - 5000)
}
if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
@@ -97,11 +86,7 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
snapshotMsgs: () => $messages.get().length,
reset: () => {
activeHandle?.stop()
if (baseline) {
setMessages(baseline)
}
if (baseline) setMessages(baseline)
baseline = null
setBusy(false)
},
@@ -119,11 +104,7 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
}: { chunk?: string; intervalMs?: number; totalTokens?: number; flushMinMs?: number } = {}) => {
activeHandle?.stop()
const current = $messages.get()
if (!baseline) {
baseline = current
}
if (!baseline) baseline = current
const msgId = `synthetic-${Date.now()}`
// Seed an empty assistant message — assistant-ui will see it grow.
setMessages([
@@ -145,20 +126,13 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
let flushHandle: number | null = null
const applyDelta = (delta: string) => {
if (!delta) {
return
}
if (!delta) return
setMessages(prev =>
prev.map(m => {
if (m.id !== msgId) {
return m
}
if (m.id !== msgId) return m
const head = m.parts.slice(0, -1)
const last = m.parts.at(-1)
const lastText = last && last.type === 'text' ? last.text : ''
return {
...m,
parts: [...head, { type: 'text', text: lastText + delta }]
@@ -176,16 +150,8 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
}
const scheduleFlush = () => {
if (flushHandle !== null) {
return
}
if (flushMinMs <= 0) {
flushNow()
return
}
if (flushHandle !== null) return
if (flushMinMs <= 0) { flushNow(); return }
const since = performance.now() - lastFlushAt
const wait = Math.max(0, flushMinMs - since)
flushHandle =
@@ -196,62 +162,48 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
const handle: SyntheticDriverHandle = {
stop: () => {
if (timer) {
clearTimeout(timer)
}
if (timer) clearTimeout(timer)
timer = null
if (flushHandle !== null) {
clearTimeout(flushHandle)
cancelAnimationFrame?.(flushHandle)
}
flushHandle = null
if (pendingDelta) {
applyDelta(pendingDelta)
pendingDelta = ''
}
activeHandle = null
// Mark message finalized.
setMessages(prev => prev.map(m => (m.id === msgId ? { ...m, pending: false } : m)))
setMessages(prev =>
prev.map(m =>
m.id === msgId
? { ...m, pending: false }
: m
)
)
setBusy(false)
}
}
activeHandle = handle
const tick = () => {
if (activeHandle !== handle) {
return
}
if (activeHandle !== handle) return
if (pushed >= totalTokens) {
if (pendingDelta) {
flushNow()
}
if (pendingDelta) flushNow()
handle.stop()
return
}
pushed += 1
if (flushMinMs > 0) {
pendingDelta += chunk
scheduleFlush()
} else {
applyDelta(chunk)
}
timer = setTimeout(tick, intervalMs)
}
timer = setTimeout(tick, intervalMs)
return handle
}
}

View File

@@ -101,17 +101,12 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
// memory. `onMouseDown` swallows the middle-button press so
// Chromium doesn't switch into autoscroll mode.
onAuxClick={event => {
if (event.button !== 1) {
return
}
if (event.button !== 1) return
event.preventDefault()
closeRightRailTab(tab.id)
}}
onMouseDown={event => {
if (event.button === 1) {
event.preventDefault()
}
if (event.button === 1) event.preventDefault()
}}
>
{active && (

View File

@@ -35,7 +35,6 @@ import {
} from '@/components/ui/sidebar'
import { Skeleton } from '@/components/ui/skeleton'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { sessionMatchesSearch } from '@/lib/session-search'
import { cn } from '@/lib/utils'
import {
$panesFlipped,
@@ -331,10 +330,11 @@ export function ChatSidebar({
return []
}
const needle = trimmedQuery.toLowerCase()
const out = new Map<string, SessionInfo>()
for (const s of sortedSessions) {
if (sessionMatchesSearch(s, trimmedQuery)) {
if (`${s.title ?? ''} ${s.preview ?? ''} ${s.cwd ?? ''}`.toLowerCase().includes(needle)) {
out.set(s.id, s)
}
}

View File

@@ -146,12 +146,7 @@ export function SidebarSessionRow({
/>
</span>
) : (
<span
className={cn(
'grid w-3.5 shrink-0 place-items-center',
needsInput ? 'overflow-visible' : 'overflow-hidden'
)}
>
<span className={cn('grid w-3.5 shrink-0 place-items-center', needsInput ? 'overflow-visible' : 'overflow-hidden')}>
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
)}

View File

@@ -6,7 +6,14 @@ import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { SearchField } from '@/components/ui/search-field'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway, updateHermes } from '@/hermes'
import {
getActionStatus,
getLogs,
getStatus,
getUsageAnalytics,
restartGateway,
updateHermes
} from '@/hermes'
import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { Activity, AlertCircle, BarChart3, type IconComponent, Pin } from '@/lib/icons'
@@ -121,7 +128,12 @@ function EmptyPanel({ action, description, title }: { action?: ReactNode; descri
)
}
export function CommandCenterView({ initialSection, onClose, onDeleteSession, onOpenSession }: CommandCenterViewProps) {
export function CommandCenterView({
initialSection,
onClose,
onDeleteSession,
onOpenSession
}: CommandCenterViewProps) {
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)
@@ -156,7 +168,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
}
return sorted.filter(session => {
const haystack = `${sessionTitle(session)} ${session.id} ${session._lineage_root_id ?? ''}`.toLowerCase()
const haystack = `${sessionTitle(session)} ${session.id}`.toLowerCase()
return haystack.includes(needle)
})

View File

@@ -4,7 +4,14 @@ 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 {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import { getHermesConfigRecord, listSessions } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import {
@@ -27,11 +34,9 @@ import {
Palette,
Plus,
Settings,
Settings2,
Sun,
Users,
Wrench,
Zap
Wrench
} from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
@@ -93,31 +98,8 @@ const toSessionEntry = (session: SessionRow): SessionEntry => ({
})
const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent; keywords?: string[]; label: string; tab: string }> = [
{
icon: Zap,
keywords: ['accounts', 'sign in', 'oauth', 'login', 'subscription', 'models', 'anthropic', 'openai'],
label: 'Providers',
tab: 'providers&pview=accounts'
},
{
icon: KeyRound,
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'],
label: 'Provider API keys',
tab: 'providers&pview=keys'
},
{ icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' },
{
icon: KeyRound,
keywords: ['api', 'secrets', 'tokens', 'credentials', 'browser', 'search'],
label: 'Tools & Keys',
tab: 'keys&kview=tools'
},
{
icon: Settings2,
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env'],
label: 'Tools & Keys settings',
tab: 'keys&kview=settings'
},
{ 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' }
@@ -142,11 +124,7 @@ export function CommandPalette() {
// 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 configQuery = useQuery({ queryKey: ['command-palette', 'config'], queryFn: getHermesConfigRecord, enabled: open })
const sessionsQuery = useQuery({
queryKey: ['command-palette', 'sessions'],
@@ -163,9 +141,7 @@ export function CommandPalette() {
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()
: []
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])
@@ -193,7 +169,7 @@ export function CommandPalette() {
{
icon: Wrench,
id: 'nav-skills',
keywords: ['tools', 'toolsets'],
keywords: ['tools', 'toolsets', 'providers'],
label: 'Skills & Tools',
run: go(SKILLS_ROUTE)
},
@@ -230,28 +206,6 @@ export function CommandPalette() {
}
]
},
{
// Declared before Settings: cmdk keeps group order, so this keeps the
// theme/mode pickers on top for "theme"/"color" queries instead of
// buried under a fuzzy Settings match.
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'
}
]
},
{
heading: 'Settings',
items: [
@@ -270,6 +224,25 @@ export function CommandPalette() {
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])

View File

@@ -1,108 +0,0 @@
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { triggerHaptic } from '@/lib/haptics'
interface CronJobActions {
busy?: boolean
isPaused: boolean
title: string
onDelete: () => void
onEdit: () => void
onPauseResume: () => void
onTrigger: () => void
}
interface CronJobActionsMenuProps
extends CronJobActions, Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> {
children: React.ReactNode
}
export function CronJobActionsMenu({
align = 'end',
busy = false,
children,
isPaused,
onDelete,
onEdit,
onPauseResume,
onTrigger,
sideOffset = 6,
title
}: CronJobActionsMenuProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={`Actions for ${title}`}
className="w-44"
sideOffset={sideOffset}
>
<DropdownMenuItem
disabled={busy}
onSelect={() => {
triggerHaptic('selection')
onPauseResume()
}}
>
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
<span>{isPaused ? 'Resume' : 'Pause'}</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={busy}
onSelect={() => {
triggerHaptic('selection')
onTrigger()
}}
>
<Codicon name="zap" size="0.875rem" />
<span>Trigger now</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
triggerHaptic('selection')
onEdit()
}}
>
<Codicon name="edit" size="0.875rem" />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
triggerHaptic('warning')
onDelete()
}}
variant="destructive"
>
<Codicon name="trash" size="0.875rem" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
interface CronJobActionsTriggerProps extends Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'> {
title: string
}
export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) {
return (
<Button
aria-label={`Actions for ${title}`}
className={className}
size="icon-sm"
title="Cron job actions"
variant="ghost"
{...props}
>
<Codicon className="text-muted-foreground" name="ellipsis" size="0.875rem" />
</Button>
)
}

View File

@@ -27,13 +27,12 @@ import {
updateCronJob
} from '@/hermes'
import { AlertTriangle, Clock } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayView } from '../overlays/overlay-view'
import { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu'
const DEFAULT_DELIVER = 'local'
const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [
@@ -433,20 +432,20 @@ export function CronView({ onClose }: CronViewProps) {
{!jobs ? (
<PageLoader label="Loading cron jobs..." />
) : visibleJobs.length === 0 ? (
// Empty state owns the primary "create" CTA — we used to also have
// one in the filters bar but it was redundant. Only show the button
// when there are zero jobs total; the search-empty case ("No
// matches") just asks the user to broaden their query.
<EmptyState
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
description={
totalCount === 0
? 'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.'
: 'Try a broader search query.'
}
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
/>
// Empty state owns the primary "create" CTA — we used to also have
// one in the filters bar but it was redundant. Only show the button
// when there are zero jobs total; the search-empty case ("No
// matches") just asks the user to broaden their query.
<EmptyState
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
description={
totalCount === 0
? 'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.'
: 'Try a broader search query.'
}
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
@@ -564,27 +563,47 @@ function CronJobRow({
)}
</button>
<div className="flex shrink-0 items-center">
<CronJobActionsMenu
busy={busy}
isPaused={isPaused}
onDelete={onDelete}
onEdit={onEdit}
onPauseResume={onPauseResume}
onTrigger={onTrigger}
title={jobTitle(job)}
<div className="flex shrink-0 items-center gap-0.5">
<IconAction
aria-label={isPaused ? 'Resume cron' : 'Pause cron'}
disabled={busy}
onClick={onPauseResume}
title={isPaused ? 'Resume' : 'Pause'}
>
<CronJobActionsTrigger
className="text-muted-foreground hover:text-foreground"
onClick={event => event.stopPropagation()}
title={jobTitle(job)}
/>
</CronJobActionsMenu>
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
</IconAction>
<IconAction aria-label="Trigger now" disabled={busy} onClick={onTrigger} title="Trigger now">
<Codicon name="zap" size="0.875rem" />
</IconAction>
<IconAction aria-label="Edit cron" onClick={onEdit} title="Edit">
<Codicon name="edit" size="0.875rem" />
</IconAction>
<IconAction
aria-label="Delete cron"
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
title="Delete"
>
<Codicon name="trash" size="0.875rem" />
</IconAction>
</div>
</div>
)
}
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"
variant="ghost"
{...props}
>
{children}
</Button>
)
}
function EmptyState({
actionLabel,
description,

View File

@@ -316,7 +316,7 @@ export function DesktopController() {
})
const openProviderSettings = useCallback(() => {
navigate(`${SETTINGS_ROUTE}?tab=providers`)
navigate(`${SETTINGS_ROUTE}?tab=keys`)
}, [navigate])
const modelMenuContent = useMemo(

View File

@@ -2,7 +2,6 @@ import { useEffect, useRef } from 'react'
import type { HermesConnection } from '@/global'
import { HermesGateway } from '@/hermes'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import {
$desktopBoot,
applyDesktopBootProgress,
@@ -104,15 +103,7 @@ export function useGatewayBoot({
}
publish(conn)
// Re-mint the WS URL before reconnecting. OAuth tickets are single-use
// with a short TTL, so the ticket baked into the cached conn.wsUrl is
// dead on every reconnect after the initial boot — reusing it surfaces
// as an opaque "Could not connect to Hermes gateway". resolveGatewayWsUrl
// mints a fresh ticket (or throws a reauth error in OAuth mode rather
// than connecting with a stale one). For local/token gateways the URL
// carries a long-lived token and the re-mint is a cheap no-op.
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await gateway.connect(wsUrl)
await gateway.connect(conn.wsUrl)
if (cancelled) {
return
@@ -122,14 +113,8 @@ export function useGatewayBoot({
// 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 (err) {
// OAuth session expired mid-reconnect: surface the actionable "sign in
// again" message once instead of silently looping the backoff against a
// ticket that can never succeed. Transport failures fall through to the
// backoff in the finally block below.
if (!cancelled && isGatewayReauthRequired(err)) {
notifyError(err, 'Gateway sign-in required')
}
} catch {
// Fall through to scheduleReconnect's backoff below.
} finally {
reconnecting = false
@@ -194,7 +179,6 @@ export function useGatewayBoot({
scheduleReconnect()
}
})
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent(event))
// Wake signals: power resume (macOS/Windows), network coming back, and the
@@ -202,7 +186,6 @@ export function useGatewayBoot({
const offPowerResume = desktop.onPowerResume?.(() => reconnectNow())
const onOnline = () => reconnectNow()
const onVisible = () => {
if (document.visibilityState === 'visible') {
reconnectNow()
@@ -247,13 +230,7 @@ export function useGatewayBoot({
progress: 95
})
publish(conn)
// Mint a fresh WS URL right before connecting. For OAuth gateways the
// ticket is single-use with a short TTL, so the ticket baked into
// conn.wsUrl is stale; resolveGatewayWsUrl() re-mints it and, on
// failure, throws a reauth error rather than connecting with a dead
// ticket (which would surface as an opaque "connection closed").
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await gateway.connect(wsUrl)
await gateway.connect(conn.wsUrl)
if (cancelled) {
return

View File

@@ -2,7 +2,6 @@ import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef } from 'react'
import type { HermesGateway } from '@/hermes'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import { $gatewayState, setConnection } from '@/store/session'
export function useGatewayRequest() {
@@ -15,10 +14,6 @@ export function useGatewayRequest() {
const gatewayStateRef = useRef(gatewayState)
const reconnectingRef = useRef<Promise<HermesGateway | null> | null>(null)
// Holds the reauth error from the most recent failed reconnect so
// requestGateway can surface the gateway's "session expired, sign in again"
// message instead of the opaque "connection closed" that triggered the retry.
const reauthErrorRef = useRef<unknown>(null)
useEffect(() => {
gatewayStateRef.current = gatewayState
@@ -46,26 +41,14 @@ export function useGatewayRequest() {
return null
}
reauthErrorRef.current = null
try {
const conn = await desktop.getConnection()
connectionRef.current = conn
setConnection(conn)
// Re-mint the WS URL before reconnecting. OAuth tickets are single-use
// and short-lived, so the cached conn.wsUrl ticket is dead here;
// resolveGatewayWsUrl() throws a reauth error in OAuth mode rather than
// connecting with a stale ticket. Stash it so requestGateway can show
// the actionable "sign in again" message.
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await existing.connect(wsUrl)
await existing.connect(conn.wsUrl)
return existing
} catch (error) {
if (isGatewayReauthRequired(error)) {
reauthErrorRef.current = error
}
} catch {
connectionRef.current = null
setConnection(null)
@@ -98,15 +81,6 @@ export function useGatewayRequest() {
const recovered = await ensureGatewayOpen()
if (!recovered) {
// Prefer the reauth error from the failed reconnect (OAuth session
// expired) over the generic transport error that triggered the retry.
const reauthError = reauthErrorRef.current
reauthErrorRef.current = null
if (reauthError) {
throw reauthError
}
throw error
}

View File

@@ -18,8 +18,6 @@ import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { CREDENTIAL_CONTROL_CLASS } from '../settings/credential-key-ui'
import { ListRow } from '../settings/primitives'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PageSearchShell } from '../page-search-shell'
@@ -110,47 +108,6 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
help: 'first, all, or off.',
advanced: true
},
DISCORD_ALLOW_ALL_USERS: {
label: 'Allow all Discord users',
help: 'Development only. When true, anyone can DM the bot without an allowlist.',
advanced: true
},
DISCORD_HOME_CHANNEL: {
label: 'Home channel ID',
help: 'Channel where the bot sends proactive messages (cron output, reminders).',
advanced: true
},
DISCORD_HOME_CHANNEL_NAME: {
label: 'Home channel name',
help: 'Display name for the home channel in logs and status output.',
advanced: true
},
BLUEBUBBLES_ALLOW_ALL_USERS: {
label: 'Allow all iMessage users',
help: 'When true, skip the BlueBubbles allowlist.',
advanced: true
},
MATTERMOST_ALLOW_ALL_USERS: {
label: 'Allow all Mattermost users',
advanced: true
},
MATTERMOST_HOME_CHANNEL: {
label: 'Home channel',
advanced: true
},
QQ_ALLOW_ALL_USERS: {
label: 'Allow all QQ users',
advanced: true
},
QQBOT_HOME_CHANNEL: {
label: 'QQ home channel',
help: 'Default channel or group for cron delivery.',
advanced: true
},
QQBOT_HOME_CHANNEL_NAME: {
label: 'QQ home channel name',
advanced: true
},
SLACK_BOT_TOKEN: {
label: 'Slack bot token',
help: 'Starts with xoxb-. Found under OAuth & Permissions after installing your Slack app.',
@@ -540,7 +497,7 @@ function PlatformDetail({
<section>
<SectionTitle>Required</SectionTitle>
<div className="mt-3 grid gap-1">
<div className="mt-3 space-y-4">
{requiredFields.length > 0 ? (
requiredFields.map(field => (
<MessagingField
@@ -563,7 +520,7 @@ function PlatformDetail({
{optionalFields.length > 0 && (
<section>
<SectionTitle>Recommended</SectionTitle>
<div className="mt-3 grid gap-1">
<div className="mt-3 space-y-4">
{optionalFields.map(field => (
<MessagingField
edits={edits}
@@ -589,7 +546,7 @@ function PlatformDetail({
<DisclosureCaret open={showAdvanced} size="0.875rem" />
</button>
{showAdvanced && (
<div className="mt-3 grid gap-1">
<div className="mt-3 space-y-4">
{advancedFields.map(field => (
<MessagingField
edits={edits}
@@ -683,48 +640,45 @@ function MessagingField({
saving: string | null
}) {
const copy = fieldCopy(field)
const fieldId = `messaging-field-${field.key}`
return (
<ListRow
action={
<div className="flex items-center gap-2">
<Input
className={CREDENTIAL_CONTROL_CLASS}
id={fieldId}
onChange={event => onEdit(field.key, event.target.value)}
placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder}
type={field.is_password ? 'password' : 'text'}
value={edits[field.key] || ''}
/>
{field.url && (
<Button asChild className="size-8 shrink-0" title="Open docs" variant="ghost">
<a href={field.url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3.5" />
</a>
</Button>
)}
{field.is_set && (
<Button
className="size-8 shrink-0"
disabled={saving === `clear:${field.key}`}
onClick={() => onClear(field.key)}
title={`Clear ${field.key}`}
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
)}
</div>
}
description={copy.help}
title={
<span className="flex flex-wrap items-center gap-2">
<label htmlFor={fieldId}>{copy.label}</label>
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">Saved</span>}
</span>
}
/>
<div className="space-y-1.5">
<div className="flex flex-wrap items-baseline gap-2">
<label className="text-sm font-medium text-foreground" htmlFor={`messaging-field-${field.key}`}>
{copy.label}
</label>
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">Saved</span>}
</div>
<div className="flex items-center gap-2">
<Input
className="font-mono"
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}
type={field.is_password ? 'password' : 'text'}
value={edits[field.key] || ''}
/>
{field.url && (
<Button asChild size="icon-sm" title="Open docs" variant="ghost">
<a href={field.url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3.5" />
</a>
</Button>
)}
{field.is_set && (
<Button
disabled={saving === `clear:${field.key}`}
onClick={() => onClear(field.key)}
size="icon-sm"
title={`Clear ${field.key}`}
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
)}
</div>
{copy.help && <p className="text-xs leading-5 text-muted-foreground">{copy.help}</p>}
</div>
)
}

View File

@@ -1,3 +1,5 @@
import type { ComponentType, SVGProps } from 'react'
import {
SiApple,
SiBilibili,
@@ -12,7 +14,6 @@ import {
SiWechat,
SiWhatsapp
} from '@icons-pack/react-simple-icons'
import type { ComponentType, SVGProps } from 'react'
import { Globe, Link as LinkIcon, MessageSquareText } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -68,7 +69,10 @@ export function PlatformAvatar({ className, platformId, platformName }: Platform
if (!spec) {
return (
<span aria-hidden="true" className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}>
<span
aria-hidden="true"
className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}
>
{platformName.charAt(0).toUpperCase()}
</span>
)

View File

@@ -24,9 +24,6 @@ interface OverlayNavItemProps {
active: boolean
icon: IconComponent
label: string
// Renders as an indented child of another nav item: smaller icon and a
// lighter active state so it never competes with the boxed parent item.
nested?: boolean
onClick: () => void
trailing?: ReactNode
}
@@ -73,29 +70,19 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
)
}
export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) {
export function OverlayNavItem({ active, icon: Icon, label, onClick, trailing }: OverlayNavItemProps) {
return (
<button
className={cn(
'flex h-7 w-full items-center justify-start gap-2 rounded-md border px-2 text-left text-[length:var(--conversation-text-font-size)] font-normal transition-colors',
nested
? active
? 'border-transparent bg-(--chrome-action-hover) font-medium text-foreground'
: 'border-transparent bg-transparent text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
: active
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground'
: 'border-transparent bg-transparent text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
active
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground'
: 'border-transparent bg-transparent text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
onClick={onClick}
type="button"
>
<Icon
className={cn(
'shrink-0',
nested ? 'size-3.5' : 'size-4',
active ? 'text-foreground/80' : 'text-muted-foreground/80'
)}
/>
<Icon className={cn('size-4 shrink-0', active ? 'text-foreground/80' : 'text-muted-foreground/80')} />
<span className="min-w-0 flex-1 truncate">{label}</span>
{trailing}
</button>

View File

@@ -51,7 +51,9 @@ export function PageSearchShell({
<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}
{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
@@ -64,7 +66,9 @@ export function PageSearchShell({
)}
</div>
)}
{filters ? <div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 pb-2">{filters}</div> : null}
{filters ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 pb-2">{filters}</div>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-hidden bg-(--ui-chat-surface-background)">{children}</div>
</section>

View File

@@ -166,7 +166,9 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
profile={profile}
/>
))}
{profiles.length === 0 && <p className="px-1.5 py-3 text-xs text-muted-foreground">No profiles yet.</p>}
{profiles.length === 0 && (
<p className="px-1.5 py-3 text-xs text-muted-foreground">No profiles yet.</p>
)}
</OverlaySidebar>
<OverlayMain className="px-0">

View File

@@ -31,7 +31,6 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
if (takeover) {
setRightSidebarTab('terminal')
}
setTerminalTakeover(!takeover)
}

View File

@@ -1,10 +1,9 @@
import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { TERMINAL_BG } from './selection'
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
import { TerminalTab } from './index'
import { TERMINAL_BG } from './selection'
/**
* One xterm Terminal mounted at the layout root and CSS-overlayed onto
@@ -22,17 +21,11 @@ export function TerminalSlot({ className = SLOT_CLASS }: { className?: string })
useEffect(() => {
const el = ref.current
if (!el) {
return
}
if (!el) return
$slot.set(el)
return () => {
if ($slot.get() === el) {
$slot.set(null)
}
if ($slot.get() === el) $slot.set(null)
}
}, [])
@@ -62,7 +55,6 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
useLayoutEffect(() => {
if (!slot) {
setRect(null)
return
}
@@ -80,17 +72,13 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
if (!sameRect(prev, next)) {
prev = next
setRect(next)
if (next.width > 0 && next.height > 0) {
setReady(true)
}
if (next.width > 0 && next.height > 0) setReady(true)
}
frame = requestAnimationFrame(tick)
}
tick()
return () => cancelAnimationFrame(frame)
}, [slot])

View File

@@ -96,18 +96,11 @@ interface UseTerminalSessionOptions {
}
function transferHasDropCandidates(t: DataTransfer): boolean {
if (t.types?.includes(HERMES_PATHS_MIME)) {
return true
}
if ((t.files?.length ?? 0) > 0) {
return true
}
if (t.types?.includes(HERMES_PATHS_MIME)) return true
if ((t.files?.length ?? 0) > 0) return true
for (let i = 0; i < (t.items?.length ?? 0); i += 1) {
if (t.items[i]?.kind === 'file') {
return true
}
if (t.items[i]?.kind === 'file') return true
}
return false
@@ -115,38 +108,22 @@ function transferHasDropCandidates(t: DataTransfer): boolean {
function collectDroppedPaths(t: DataTransfer): string[] {
const seen = new Set<string>()
const push = (value: unknown) => {
if (typeof value !== 'string') {
return
}
if (typeof value !== 'string') return
const path = value.trim()
if (path) {
seen.add(path)
}
if (path) seen.add(path)
}
try {
const raw = t.getData(HERMES_PATHS_MIME)
if (raw) {
for (const entry of JSON.parse(raw) as { path?: unknown }[]) {
push(entry?.path)
}
}
if (raw) for (const entry of JSON.parse(raw) as { path?: unknown }[]) push(entry?.path)
} catch {
// Malformed in-app drag payload — fall through to OS files.
}
const getPath = window.hermesDesktop?.getPathForFile
const addFile = (file: File | null) => {
if (!file || !getPath) {
return
}
if (!file || !getPath) return
try {
push(getPath(file))
} catch {
@@ -154,16 +131,10 @@ function collectDroppedPaths(t: DataTransfer): string[] {
}
}
for (let i = 0; i < (t.files?.length ?? 0); i += 1) {
addFile(t.files.item(i))
}
for (let i = 0; i < (t.files?.length ?? 0); i += 1) addFile(t.files.item(i))
for (let i = 0; i < (t.items?.length ?? 0); i += 1) {
const item = t.items[i]
if (item?.kind === 'file') {
addFile(item.getAsFile())
}
if (item?.kind === 'file') addFile(item.getAsFile())
}
return [...seen]
@@ -171,15 +142,8 @@ function collectDroppedPaths(t: DataTransfer): string[] {
function quotePathForShell(path: string, shellName: string): string {
const shell = shellName.toLowerCase()
if (shell.includes('powershell') || shell.includes('pwsh')) {
return `'${path.replace(/'/g, "''")}'`
}
if (shell.includes('cmd')) {
return `"${path.replace(/"/g, '""')}"`
}
if (shell.includes('powershell') || shell.includes('pwsh')) return `'${path.replace(/'/g, "''")}'`
if (shell.includes('cmd')) return `"${path.replace(/"/g, '""')}"`
return `'${path.replace(/'/g, "'\\''")}'`
}
@@ -286,14 +250,12 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
webgl.onContextLoss(() => webgl.dispose())
term.loadAddon(webgl)
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
}
const onDragOver = (e: DragEvent) => {
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
return
}
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) return
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
@@ -301,19 +263,11 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
const onDrop = (e: DragEvent) => {
const id = sessionIdRef.current
if (!id || !e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
return
}
if (!id || !e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) return
e.preventDefault()
e.stopPropagation()
const paths = collectDroppedPaths(e.dataTransfer)
if (!paths.length) {
return
}
if (!paths.length) return
void terminalApi.write(id, `${paths.map(p => quotePathForShell(p, shellNameRef.current)).join(' ')} `)
term.focus()
triggerHaptic('selection')
@@ -351,18 +305,11 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
// synchronously while sibling panes are mid-transition (e.g. file browser
// collapsing to 0px) crashes the WebGL renderer mid texture-atlas rebuild.
let pendingFrame = 0
const scheduleResize = () => {
if (pendingFrame) {
return
}
if (pendingFrame) return
pendingFrame = window.requestAnimationFrame(() => {
pendingFrame = 0
if (!disposed) {
fitAndResize()
}
if (!disposed) fitAndResize()
})
}
@@ -370,10 +317,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
resizeObserver.observe(host)
cleanup.push(() => {
resizeObserver.disconnect()
if (pendingFrame) {
window.cancelAnimationFrame(pendingFrame)
}
if (pendingFrame) window.cancelAnimationFrame(pendingFrame)
})
const dataDisposable = term.onData(data => {

View File

@@ -55,7 +55,13 @@ const RESERVED_PATHS: ReadonlySet<string> = new Set(APP_ROUTES.map(route => rout
// 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 const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set([
'agents',
'command-center',
'cron',
'profiles',
'settings'
])
export function isOverlayView(view: AppView): boolean {
return OVERLAY_VIEWS.has(view)

View File

@@ -326,7 +326,10 @@ export function useMessageStream({
return
}
flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, STREAM_DELTA_FLUSH_MS - sinceLast))
flushHandleRef.current = window.setTimeout(
runFlush,
Math.max(0, STREAM_DELTA_FLUSH_MS - sinceLast)
)
}, [flushQueuedDeltas])
const queueDelta = useCallback(

View File

@@ -18,7 +18,6 @@ import {
isDesktopSlashCommand
} from '@/lib/desktop-slash-commands'
import { triggerHaptic } from '@/lib/haptics'
import { setMutableRef } from '@/lib/mutable-ref'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setSessionYolo } from '@/lib/yolo-session'
import {
@@ -247,7 +246,7 @@ export function usePromptActions({
}
const releaseBusy = () => {
setMutableRef(busyRef, false)
busyRef.current = false
setBusy(false)
setAwaitingResponse(false)
}
@@ -291,7 +290,7 @@ export function usePromptActions({
)
}
setMutableRef(busyRef, true)
busyRef.current = true
setBusy(true)
setAwaitingResponse(true)
clearNotifications()
@@ -595,7 +594,7 @@ export function usePromptActions({
const cancelRun = useCallback(async () => {
const sessionId = activeSessionId || activeSessionIdRef.current
setMutableRef(busyRef, false)
busyRef.current = false
setBusy(false)
setAwaitingResponse(false)
@@ -754,7 +753,7 @@ export function usePromptActions({
const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] }
clearNotifications()
setMutableRef(busyRef, true)
busyRef.current = true
setBusy(true)
setAwaitingResponse(true)
updateSessionState(sessionId, state => ({
@@ -792,7 +791,7 @@ export function usePromptActions({
}
}
setMutableRef(busyRef, false)
busyRef.current = false
setBusy(false)
setAwaitingResponse(false)
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))

View File

@@ -36,8 +36,8 @@ import {
setMessages,
setSelectedStoredSessionId,
setSessions,
setSessionStartedAt,
setSessionsTotal,
setSessionStartedAt,
setTurnStartedAt,
setYoloActive
} from '@/store/session'
@@ -311,77 +311,74 @@ export function useSessionActions({
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
)
const createBackendSessionForSend = useCallback(
async (preview: string | null = null): Promise<string | null> => {
const startingActiveSessionId = activeSessionIdRef.current
const startingStoredSessionId = selectedStoredSessionIdRef.current
const startingRouteToken = getRouteToken()
const createBackendSessionForSend = useCallback(async (preview: string | null = null): Promise<string | null> => {
const startingActiveSessionId = activeSessionIdRef.current
const startingStoredSessionId = selectedStoredSessionIdRef.current
const startingRouteToken = getRouteToken()
creatingSessionRef.current = true
creatingSessionRef.current = true
try {
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
const stored = created.stored_session_id ?? null
try {
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
const stored = created.stored_session_id ?? null
if (
activeSessionIdRef.current !== startingActiveSessionId ||
selectedStoredSessionIdRef.current !== startingStoredSessionId ||
getRouteToken() !== startingRouteToken
) {
await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined)
if (
activeSessionIdRef.current !== startingActiveSessionId ||
selectedStoredSessionIdRef.current !== startingStoredSessionId ||
getRouteToken() !== startingRouteToken
) {
await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined)
return null
}
activeSessionIdRef.current = created.session_id
selectedStoredSessionIdRef.current = stored
ensureSessionState(created.session_id, stored)
if (stored) {
// Seed the sidebar preview with the user's first message so the row
// reads meaningfully while the turn is in flight, instead of flashing
// "Untitled session" until the turn persists and auto-title runs. The
// server later returns its own preview/title and supersedes this.
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
navigate(sessionRoute(stored), { replace: true })
}
setFreshDraftReady(false)
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(() => {
creatingSessionRef.current = false
}, 0)
return null
}
},
[
activeSessionIdRef,
creatingSessionRef,
ensureSessionState,
getRouteToken,
navigate,
requestGateway,
selectedStoredSessionIdRef,
updateSessionState
]
)
activeSessionIdRef.current = created.session_id
selectedStoredSessionIdRef.current = stored
ensureSessionState(created.session_id, stored)
if (stored) {
// Seed the sidebar preview with the user's first message so the row
// reads meaningfully while the turn is in flight, instead of flashing
// "Untitled session" until the turn persists and auto-title runs. The
// server later returns its own preview/title and supersedes this.
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
navigate(sessionRoute(stored), { replace: true })
}
setFreshDraftReady(false)
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(() => {
creatingSessionRef.current = false
}, 0)
}
}, [
activeSessionIdRef,
creatingSessionRef,
ensureSessionState,
getRouteToken,
navigate,
requestGateway,
selectedStoredSessionIdRef,
updateSessionState
])
const selectSidebarItem = useCallback(
(item: SidebarNavItem) => {

View File

@@ -4,7 +4,6 @@ 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 { setMutableRef } from '@/lib/mutable-ref'
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking } from '@/store/session'
import type { ClientSessionState } from '../../types'
@@ -39,7 +38,7 @@ export function useSessionStateCache({
}, [activeSessionId])
useEffect(() => {
setMutableRef(busyRef, busy)
busyRef.current = busy
}, [busy, busyRef])
useEffect(() => {
@@ -90,7 +89,7 @@ export function useSessionStateCache({
setMessages(preserveLocalAssistantErrors(pending.state.messages, $messages.get()))
setBusy(pending.state.busy)
setMutableRef(busyRef, pending.state.busy)
busyRef.current = pending.state.busy
setAwaitingResponse(pending.state.awaitingResponse)
}, [busyRef, setAwaitingResponse, setBusy, setMessages])

View File

@@ -15,21 +15,9 @@ import type { ThemeMode } from '@/themes/context'
import type { DesktopConfigSection } from './types'
// Provider group definitions used to fold raw env-var names like
// ``XAI_API_KEY`` into a single "xAI" card with a friendly label, short
// description, and signup URL. Membership is determined by longest
// prefix match (see ``providerGroup`` in helpers.ts) so more specific
// prefixes (``MINIMAX_CN_``) correctly beat their general parents
// (``MINIMAX_``). New providers should be added here so they get their
// own card in Settings → Keys instead of being lumped into "Other".
interface ProviderPrefix {
prefix: string
name: string
/** Optional one-line tagline shown beneath the group name. */
description?: string
/** Optional canonical signup/console URL surfaced from the card header. */
docsUrl?: string
/** Lower numbers float to the top of the providers list. */
priority: number
}
@@ -37,180 +25,24 @@ export const EMPTY_SELECT_VALUE = '__hermes_empty__'
export const CONTROL_TEXT = 'text-xs'
export const PROVIDER_GROUPS: ProviderPrefix[] = [
{
prefix: 'NOUS_',
name: 'Nous Portal',
description: 'Hosted Hermes & Nous-trained models',
docsUrl: 'https://portal.nousresearch.com',
priority: 0
},
{
prefix: 'OPENROUTER_',
name: 'OpenRouter',
description: 'Aggregator for hundreds of frontier models',
docsUrl: 'https://openrouter.ai/keys',
priority: 1
},
{
prefix: 'ANTHROPIC_',
name: 'Anthropic',
description: 'Claude API access (Sonnet, Opus, Haiku)',
docsUrl: 'https://console.anthropic.com/settings/keys',
priority: 2
},
{
prefix: 'XAI_',
name: 'xAI',
description: 'Grok models (use OAuth for SuperGrok / Premium+)',
docsUrl: 'https://console.x.ai/',
priority: 3
},
{
prefix: 'GOOGLE_',
name: 'Gemini',
description: 'Google AI Studio (Gemini 1.5 / 2.0 / 2.5)',
docsUrl: 'https://aistudio.google.com/app/apikey',
priority: 4
},
{ prefix: 'NOUS_', name: 'Nous Portal', priority: 0 },
{ prefix: 'ANTHROPIC_', name: 'Anthropic', priority: 1 },
{ prefix: 'DASHSCOPE_', name: 'DashScope (Qwen)', priority: 2 },
{ prefix: 'HERMES_QWEN_', name: 'DashScope (Qwen)', priority: 2 },
{ prefix: 'DEEPSEEK_', name: 'DeepSeek', priority: 3 },
{ prefix: 'GOOGLE_', name: 'Gemini', priority: 4 },
{ prefix: 'GEMINI_', name: 'Gemini', priority: 4 },
{ prefix: 'HERMES_GEMINI_', name: 'Gemini', priority: 4 },
{
prefix: 'DEEPSEEK_',
name: 'DeepSeek',
description: 'Direct DeepSeek API (V3.x, R1)',
docsUrl: 'https://platform.deepseek.com/api_keys',
priority: 5
},
{
prefix: 'DASHSCOPE_',
name: 'DashScope (Qwen)',
description: 'Alibaba Cloud DashScope — Qwen and multi-vendor models',
docsUrl: 'https://modelstudio.console.alibabacloud.com/',
priority: 6
},
{ prefix: 'HERMES_QWEN_', name: 'DashScope (Qwen)', priority: 6 },
{
prefix: 'GLM_',
name: 'GLM / Z.AI',
description: 'Zhipu GLM-4.6 and Z.AI hosted endpoints',
docsUrl: 'https://z.ai/',
priority: 7
},
{ prefix: 'ZAI_', name: 'GLM / Z.AI', priority: 7 },
{ prefix: 'Z_AI_', name: 'GLM / Z.AI', priority: 7 },
{
prefix: 'KIMI_',
name: 'Kimi / Moonshot',
description: 'Moonshot Kimi K2 / coding endpoints',
docsUrl: 'https://platform.moonshot.cn/',
priority: 8
},
{
prefix: 'KIMI_CN_',
name: 'Kimi (China)',
description: 'Moonshot China endpoint',
docsUrl: 'https://platform.moonshot.cn/',
priority: 9
},
{
prefix: 'MINIMAX_',
name: 'MiniMax',
description: 'MiniMax-M2 and Hailuo international endpoints',
docsUrl: 'https://www.minimax.io/',
priority: 10
},
{
prefix: 'MINIMAX_CN_',
name: 'MiniMax (China)',
description: 'MiniMax mainland China endpoint',
docsUrl: 'https://www.minimaxi.com/',
priority: 11
},
{
prefix: 'HF_',
name: 'Hugging Face',
description: 'Inference Providers — 20+ open models via router.huggingface.co',
docsUrl: 'https://huggingface.co/settings/tokens',
priority: 12
},
{
prefix: 'OPENCODE_ZEN_',
name: 'OpenCode Zen',
description: 'Pay-as-you-go access to curated coding models',
docsUrl: 'https://opencode.ai/auth',
priority: 13
},
{
prefix: 'OPENCODE_GO_',
name: 'OpenCode Go',
description: '$10/month subscription for open coding models',
docsUrl: 'https://opencode.ai/auth',
priority: 14
},
{
prefix: 'NVIDIA_',
name: 'NVIDIA NIM',
description: 'build.nvidia.com or your own local NIM endpoint',
docsUrl: 'https://build.nvidia.com/',
priority: 15
},
{
prefix: 'OLLAMA_',
name: 'Ollama Cloud',
description: 'Cloud-hosted open models from ollama.com',
docsUrl: 'https://ollama.com/settings',
priority: 16
},
{
prefix: 'LM_',
name: 'LM Studio',
description: 'Local LM Studio server (OpenAI-compatible)',
docsUrl: 'https://lmstudio.ai/docs/local-server',
priority: 17
},
{
prefix: 'STEPFUN_',
name: 'StepFun',
description: 'StepFun Step Plan coding models',
docsUrl: 'https://platform.stepfun.com/',
priority: 18
},
{
prefix: 'XIAOMI_',
name: 'Xiaomi MiMo',
description: 'MiMo-V2.5 and Xiaomi proprietary models',
docsUrl: 'https://platform.xiaomimimo.com',
priority: 19
},
{
prefix: 'ARCEEAI_',
name: 'Arcee AI',
description: 'Arcee-hosted small + medium models',
docsUrl: 'https://chat.arcee.ai/',
priority: 20
},
{ prefix: 'ARCEE_', name: 'Arcee AI', priority: 20 },
{
prefix: 'GMI_',
name: 'GMI Cloud',
description: 'GMI Cloud GPU + model serving',
docsUrl: 'https://www.gmicloud.ai/',
priority: 21
},
{
prefix: 'AZURE_FOUNDRY_',
name: 'Azure Foundry',
description: 'Azure AI Foundry custom endpoints (OpenAI / Anthropic-compatible)',
docsUrl: 'https://ai.azure.com/',
priority: 22
},
{
prefix: 'AWS_',
name: 'AWS Bedrock',
description: 'Authenticate via AWS profile + region',
docsUrl: 'https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-regions.html',
priority: 23
}
{ prefix: 'GLM_', name: 'GLM / Z.AI', priority: 5 },
{ prefix: 'ZAI_', name: 'GLM / Z.AI', priority: 5 },
{ prefix: 'Z_AI_', name: 'GLM / Z.AI', priority: 5 },
{ prefix: 'HF_', name: 'Hugging Face', priority: 6 },
{ prefix: 'KIMI_', name: 'Kimi / Moonshot', priority: 7 },
{ prefix: 'MINIMAX_', name: 'MiniMax', priority: 8 },
{ prefix: 'MINIMAX_CN_', name: 'MiniMax (China)', priority: 9 },
{ prefix: 'OPENCODE_GO_', name: 'OpenCode Go', priority: 10 },
{ prefix: 'OPENCODE_ZEN_', name: 'OpenCode Zen', priority: 11 },
{ prefix: 'OPENROUTER_', name: 'OpenRouter', priority: 12 },
{ prefix: 'XIAOMI_', name: 'Xiaomi MiMo', priority: 13 }
]
export const BUILTIN_PERSONALITIES = [

View File

@@ -1,363 +0,0 @@
import { type ChangeEvent, type KeyboardEvent } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ChevronDown, ExternalLink, Loader2, Save } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { EnvVarInfo } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { prettyName, withoutKey } from './helpers'
import { ListRow } from './primitives'
import type { EnvRowProps } from './types'
export type KeyRowProps = Omit<EnvRowProps, 'info' | 'varKey'>
/** Matches Advanced / config field controls (ListRow + Input). */
export const CREDENTIAL_CONTROL_CLASS = cn('h-8', CONTROL_TEXT)
export const isKeyVar = (key: string, info: EnvVarInfo) =>
info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key)
export const friendlyFieldLabel = (key: string, info: EnvVarInfo) =>
info.description?.trim() ||
key
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, c => c.toUpperCase())
export const credentialPlaceholder = (key: string, info: EnvVarInfo, label: string): string =>
isKeyVar(key, info) ? `Paste ${label} key` : /URL$/i.test(key) ? 'https://…' : 'Optional'
// A single credential field: a set key shows as a filled read-only input
// (redacted value) that edits in place on click. Save appears once typed; a set
// key also offers Remove, and Esc cancels without closing the overlay.
export function KeyField({
info,
placeholder,
rowProps,
varKey
}: {
info: EnvVarInfo
placeholder?: string
rowProps: KeyRowProps
varKey: string
}) {
const { edits, onClear, onSave, saving, setEdits } = rowProps
const editing = edits[varKey] !== undefined
const draft = edits[varKey] ?? ''
const dirty = draft.trim().length > 0
const busy = saving === varKey
const masked = info.redacted_value ?? '••••••••'
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
const cancel = () => setEdits(c => withoutKey(c, varKey))
const update = (e: ChangeEvent<HTMLInputElement>) => setEdits(c => ({ ...c, [varKey]: e.target.value }))
const keydown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && dirty) {
void onSave(varKey)
} else if (e.key === 'Escape' && editing) {
e.preventDefault()
e.stopPropagation()
cancel()
}
}
const editType = info.is_password ? 'password' : 'text'
if (info.is_set && !editing) {
return (
<Input
className={cn(CREDENTIAL_CONTROL_CLASS, 'cursor-pointer text-muted-foreground')}
onFocus={startEdit}
readOnly
value={masked}
/>
)
}
return (
<div className="grid gap-1">
<div className="flex items-center gap-2">
<Input
autoFocus={editing}
className={cn(CREDENTIAL_CONTROL_CLASS, 'min-w-0 flex-1')}
onChange={update}
onKeyDown={keydown}
placeholder={placeholder ?? 'Paste key'}
type={editType}
value={draft}
/>
{dirty && (
<Button className="h-8 shrink-0" disabled={busy} onClick={() => void onSave(varKey)} size="sm">
{busy ? <Loader2 className="size-4 animate-spin" /> : <Save />}
{busy ? 'Saving' : 'Save'}
</Button>
)}
</div>
{editing && (
<div className="flex items-center gap-1 text-[0.6875rem]">
{info.is_set && (
<>
<Button
className="h-auto px-0 py-0 text-[0.6875rem] text-destructive hover:text-destructive"
disabled={busy}
onClick={() => void onClear(varKey)}
type="button"
variant="text"
>
Remove
</Button>
<span className="text-muted-foreground">or</span>
</>
)}
<span className="text-muted-foreground">esc to cancel</span>
</div>
)}
</div>
)
}
function CredentialDocsLink({ href }: { href: string }) {
return (
<a
className="inline-flex w-fit items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary) underline-offset-4 transition-colors hover:text-foreground hover:underline"
href={href}
onClick={e => e.stopPropagation()}
rel="noreferrer"
target="_blank"
>
Get a key
<ExternalLink className="size-3" />
</a>
)
}
/** One credential row — collapsible; description and docs link expand on click. */
export function CredentialKeyCard({
expanded,
info,
label,
onExpand,
onToggle,
placeholder,
rowProps,
varKey
}: CredentialKeyCardProps) {
const docsUrl = info.url?.trim()
const description = info.description?.trim()
const expandable = Boolean(description || docsUrl)
return (
<div
className={cn(
'group/card rounded-[6px] px-2 py-1 transition-colors',
expandable && 'cursor-pointer',
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
)}
onClick={expandable ? onToggle : undefined}
onKeyDown={
expandable
? e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}
: undefined
}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
>
<div className="grid gap-3 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
'size-2 shrink-0 rounded-full',
info.is_set ? 'bg-primary' : 'bg-(--ui-stroke-secondary)'
)}
/>
<span className="min-w-0 truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{label}
</span>
{expandable && (
<ChevronDown
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition',
expanded ? 'rotate-180 opacity-100' : 'opacity-0 group-hover/card:opacity-100'
)}
/>
)}
</div>
<div
className="min-w-0 sm:justify-self-end"
onClick={e => e.stopPropagation()}
onFocus={() => {
if (expandable && !expanded) {
onExpand()
}
}}
>
<KeyField info={info} placeholder={placeholder} rowProps={rowProps} varKey={varKey} />
</div>
</div>
{expandable && expanded && (
<div className="grid gap-2.5 pb-2 pl-4" onClick={e => e.stopPropagation()}>
{description && (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</p>
)}
{docsUrl && <CredentialDocsLink href={docsUrl} />}
</div>
)}
</div>
)
}
/** Provider API key group — collapsible card; description, docs link, and advanced fields expand on click. */
export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps }: ProviderKeyRowsProps) {
const docsUrl = group.docsUrl?.trim()
const description = group.description?.trim()
const expandable = Boolean(description || docsUrl || group.advanced.length > 0)
return (
<div
className={cn(
'group/card rounded-[6px] px-2 py-1 transition-colors',
expandable && 'cursor-pointer',
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
)}
onClick={expandable ? onToggle : undefined}
onKeyDown={
expandable
? e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}
: undefined
}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
>
<div className="grid gap-3 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
'size-2 shrink-0 rounded-full',
group.hasAnySet ? 'bg-primary' : 'bg-(--ui-stroke-secondary)'
)}
/>
<span className="min-w-0 truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{group.name}
</span>
{expandable && (
<ChevronDown
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition',
expanded ? 'rotate-180 opacity-100' : 'opacity-0 group-hover/card:opacity-100'
)}
/>
)}
</div>
<div
className="min-w-0 sm:justify-self-end"
onClick={e => e.stopPropagation()}
onFocus={() => {
if (expandable && !expanded) {
onExpand()
}
}}
>
<KeyField
info={group.primary[1]}
placeholder={`Paste ${group.name} key`}
rowProps={rowProps}
varKey={group.primary[0]}
/>
</div>
</div>
{expandable && expanded && (
<div className="grid gap-2.5 pb-2 pl-4" onClick={e => e.stopPropagation()}>
{description && (
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</p>
)}
{group.advanced.map(([key, info]) => {
const fieldLabel = isKeyVar(key, info)
? prettyName(key.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, ''))
: friendlyFieldLabel(key, info)
return (
<ListRow
action={
<KeyField
info={info}
placeholder={credentialPlaceholder(key, info, fieldLabel)}
rowProps={rowProps}
varKey={key}
/>
}
key={key}
title={fieldLabel}
/>
)
})}
{docsUrl && <CredentialDocsLink href={docsUrl} />}
</div>
)}
</div>
)
}
export function credentialRowLabel(varKey: string, info: EnvVarInfo): string {
if (isKeyVar(varKey, info)) {
return prettyName(varKey.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, ''))
}
return prettyName(varKey)
}
interface CredentialKeyCardProps {
expanded: boolean
info: EnvVarInfo
label: string
onExpand: () => void
onToggle: () => void
placeholder: string
rowProps: KeyRowProps
varKey: string
}
interface ProviderKeyRowsProps {
expanded: boolean
group: ProviderKeyRowGroup
onExpand: () => void
onToggle: () => void
rowProps: KeyRowProps
}
export interface ProviderKeyRowGroup {
advanced: [string, EnvVarInfo][]
description?: string
docsUrl?: string
hasAnySet: boolean
name: string
primary: [string, EnvVarInfo]
}

View File

@@ -1,194 +0,0 @@
import { useEffect, useState } from 'react'
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
import { type IconComponent } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import type { EnvVarInfo } from '@/types/hermes'
import { asText, includesQuery, redactedValue, withoutKey } from './helpers'
import { Pill } from './primitives'
import type { EnvRowProps } from './types'
// Shared filter used by every credential surface (Providers + Keys pages):
// category gate first, then a free-text match across key name + description.
export function filterEnv(info: EnvVarInfo, key: string, q: string, cat: string, extra?: string): boolean {
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))
)
}
export function SettingsCategoryHeading({ count, icon: Icon, title }: CategoryHeadingProps) {
return (
<div className="mb-3 flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
<Icon className="size-4 text-muted-foreground" />
<span>{title}</span>
{count && <Pill>{count}</Pill>}
</div>
)
}
// Owns the env-var fetch + the edit/reveal/save/delete lifecycle so multiple
// credential pages (Providers, Keys) share one source of truth and one set of
// mutation handlers instead of duplicating the plumbing.
export function useEnvCredentials(): UseEnvCredentials {
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)
// Best-effort cleanup of a retired localStorage flag (global "Show
// advanced" toggle) — everything in these views is configuration-level.
useEffect(() => {
try {
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
} catch {
// Ignore — old key cleanup is best-effort.
}
}, [])
useEffect(() => {
let cancelled = false
void (async () => {
try {
const next = await getEnvVars()
if (!cancelled) {
setVars(next)
}
} catch (err) {
notifyError(err, 'API keys failed to load')
}
})()
return () => void (cancelled = true)
}, [])
function patchVar(key: string, patch: Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>) {
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
}
function clearLocalState(key: string) {
setEdits(c => withoutKey(c, key))
setRevealed(c => withoutKey(c, key))
}
async function handleSave(key: string) {
const value = edits[key]
if (!value) {
return
}
setSaving(key)
try {
await setEnvVar(key, value)
patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
clearLocalState(key)
notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` })
} catch (err) {
notifyError(err, `Failed to save ${key}`)
} finally {
setSaving(null)
}
}
// Direct save for a known value (no edit-state round-trip) — used by the
// onboarding-style key form, which owns its own input. Returns a result so
// the form can surface inline errors instead of only toasting.
async function saveValue(key: string, value: string): Promise<{ message?: string; ok: boolean }> {
const trimmed = value.trim()
if (!trimmed) {
return { message: 'Enter a value first.', ok: false }
}
setSaving(key)
try {
await setEnvVar(key, trimmed)
patchVar(key, { is_set: true, redacted_value: redactedValue(trimmed) })
clearLocalState(key)
notify({ kind: 'success', message: `${key} updated.`, title: 'Credential saved' })
return { ok: true }
} catch (err) {
notifyError(err, `Failed to save ${key}`)
return { message: err instanceof Error ? err.message : 'Could not save credential.', ok: false }
} finally {
setSaving(null)
}
}
async function handleClear(key: string) {
if (!window.confirm(`Remove ${key} from .env?`)) {
return
}
setSaving(key)
try {
await deleteEnvVar(key)
patchVar(key, { is_set: false, redacted_value: null })
clearLocalState(key)
notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` })
} catch (err) {
notifyError(err, `Failed to remove ${key}`)
} finally {
setSaving(null)
}
}
async function handleReveal(key: string) {
if (revealed[key]) {
setRevealed(c => withoutKey(c, key))
return
}
try {
const result = await revealEnvVar(key)
setRevealed(c => ({ ...c, [key]: result.value }))
} catch (err) {
notifyError(err, `Failed to reveal ${key}`)
}
}
return {
saveValue,
vars,
rowProps: {
edits,
revealed,
saving,
setEdits,
onSave: handleSave,
onClear: handleClear,
onReveal: handleReveal
}
}
}
interface CategoryHeadingProps {
count?: string
icon: IconComponent
title: string
}
interface UseEnvCredentials {
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
saveValue: (key: string, value: string) => Promise<{ message?: string; ok: boolean }>
vars: Record<string, EnvVarInfo> | null
}

View File

@@ -1,130 +0,0 @@
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Eye, EyeOff, ExternalLink, Trash2 } from '@/lib/icons'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
interface EnvVarActionsMenuProps
extends Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> {
children: React.ReactNode
clearDisabled?: boolean
docsUrl?: string | null
isRevealed?: boolean
isSet: boolean
label: string
onClear?: () => void
onEdit: () => void
onReveal?: () => void
showReveal?: boolean
}
export function EnvVarActionsMenu({
align = 'end',
children,
clearDisabled = false,
docsUrl,
isRevealed = false,
isSet,
label,
onClear,
onEdit,
onReveal,
showReveal = true,
sideOffset = 6
}: EnvVarActionsMenuProps) {
const hasClear = isSet && onClear
const hasReveal = isSet && showReveal && onReveal
const hasDocs = Boolean(docsUrl?.trim())
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={`Actions for ${label}`}
className="w-44"
sideOffset={sideOffset}
>
{hasDocs && (
<DropdownMenuItem
onSelect={event => {
event.preventDefault()
triggerHaptic('selection')
window.open(docsUrl!, '_blank', 'noopener,noreferrer')
}}
>
<ExternalLink className="size-3.5" />
<span>Docs</span>
</DropdownMenuItem>
)}
{hasReveal && (
<DropdownMenuItem
onSelect={() => {
triggerHaptic('selection')
onReveal()
}}
>
{isRevealed ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
<span>{isRevealed ? 'Hide value' : 'Reveal value'}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={() => {
triggerHaptic('selection')
onEdit()
}}
>
<Codicon name="edit" size="0.875rem" />
<span>{isSet ? 'Replace' : 'Set'}</span>
</DropdownMenuItem>
{hasClear && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={clearDisabled}
onSelect={() => {
triggerHaptic('warning')
onClear()
}}
variant="destructive"
>
<Trash2 className="size-3.5" />
<span>Clear</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
interface EnvVarActionsTriggerProps extends Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'> {
label: string
}
export function EnvVarActionsTrigger({ className, label, ...props }: EnvVarActionsTriggerProps) {
return (
<Button
aria-label={`Actions for ${label}`}
className={cn('text-muted-foreground hover:text-foreground', className)}
size="icon-sm"
title="Credential actions"
variant="ghost"
{...props}
>
<Codicon name="ellipsis" size="0.875rem" />
</Button>
)
}

View File

@@ -1,9 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
import { AlertCircle, Check, FileText, Globe, Loader2, Monitor } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -11,14 +10,10 @@ import { CONTROL_TEXT } from './constants'
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
type Mode = 'local' | 'remote'
type AuthMode = 'oauth' | 'token'
type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
interface GatewaySettingsState {
envOverride: boolean
mode: Mode
remoteAuthMode: AuthMode
remoteOauthConnected: boolean
remoteTokenPreview: string | null
remoteTokenSet: boolean
remoteUrl: string
@@ -27,8 +22,6 @@ interface GatewaySettingsState {
const EMPTY_STATE: GatewaySettingsState = {
envOverride: false,
mode: 'local',
remoteAuthMode: 'token',
remoteOauthConnected: false,
remoteTokenPreview: null,
remoteTokenSet: false,
remoteUrl: ''
@@ -78,18 +71,10 @@ export function GatewaySettings() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [signingIn, setSigningIn] = useState(false)
const [state, setState] = useState<GatewaySettingsState>(EMPTY_STATE)
const [remoteToken, setRemoteToken] = useState('')
const [lastTest, setLastTest] = useState<null | string>(null)
// Auth-mode probe: as the user types a remote URL we ask the gateway (via
// its public /api/status) whether it gates with OAuth or a static session
// token, so we can show the right control (login button vs token box).
const [probeStatus, setProbeStatus] = useState<ProbeStatus>('idle')
const [probe, setProbe] = useState<DesktopConnectionProbeResult | null>(null)
const probeSeq = useRef(0)
useEffect(() => {
let cancelled = false
const desktop = window.hermesDesktop
@@ -119,129 +104,15 @@ export function GatewaySettings() {
return () => void (cancelled = true)
}, [])
// Debounced probe of the entered remote URL. Only runs in remote mode with a
// syntactically plausible URL. The probe result drives whether we render the
// OAuth login button or the session-token entry box. The effective auth mode
// prefers a fresh probe result over the saved value.
const trimmedUrl = state.remoteUrl.trim()
useEffect(() => {
if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
setProbeStatus('idle')
setProbe(null)
return
}
const desktop = window.hermesDesktop
if (!desktop?.probeConnectionConfig) {
return
}
const seq = ++probeSeq.current
setProbeStatus('probing')
const timer = setTimeout(() => {
desktop
.probeConnectionConfig(trimmedUrl)
.then(result => {
if (seq !== probeSeq.current) {
return
}
setProbe(result)
setProbeStatus(result.reachable ? 'done' : 'error')
})
.catch(() => {
if (seq !== probeSeq.current) {
return
}
setProbe(null)
setProbeStatus('error')
})
}, 500)
return () => clearTimeout(timer)
}, [state.mode, trimmedUrl])
// Effective auth mode: a reachable probe wins; otherwise fall back to the
// saved config's mode so a re-open of settings doesn't flicker.
const authMode: AuthMode = useMemo(() => {
if (probeStatus === 'done' && probe && probe.authMode !== 'unknown') {
return probe.authMode
}
return state.remoteAuthMode
}, [probe, probeStatus, state.remoteAuthMode])
// Whether we actually KNOW how this gateway authenticates yet. Until we do,
// neither the OAuth button nor the session-token box should render —
// `authMode` defaults to 'token', so without this gate the token box flashes
// for every gateway (including OAuth ones) during the idle/probing window
// before the first probe lands. The scheme is known when either:
// * the live probe finished (probeStatus 'done'), or
// * we're idle but showing a previously-saved remote config (re-opening
// settings for a gateway already signed-in or with a saved token), so
// its control appears immediately with no flicker.
// While probing (or after a probe error), the scheme is unknown and we show
// the probe status row instead of a control.
const hasSavedRemote = state.remoteTokenSet || state.remoteOauthConnected
const authResolved = useMemo(() => {
if (probeStatus === 'done') {
return true
}
return probeStatus === 'idle' && hasSavedRemote
}, [probeStatus, hasSavedRemote])
const providerLabel = useMemo(() => {
const providers: DesktopAuthProvider[] = probe?.providers ?? []
if (providers.length === 1) {
return providers[0].displayName || providers[0].name
}
if (providers.length > 1) {
return providers.map(p => p.displayName || p.name).join(' / ')
}
return 'your identity provider'
}, [probe])
// A username/password gateway authenticates through a credential form on the
// gateway's /login page (POST /auth/password-login) rather than an OAuth
// redirect. Everything downstream — the session cookie, the ws-ticket mint,
// the persistent partition — is identical, so the desktop drives it through
// the same sign-in window; only the button copy changes. We treat the
// gateway as password-style only when EVERY advertised provider supports
// password, so a mixed deployment keeps the generic OAuth copy.
const isPasswordProvider = useMemo(() => {
const providers: DesktopAuthProvider[] = probe?.providers ?? []
return providers.length > 0 && providers.every(p => p.supportsPassword)
}, [probe])
const oauthConnected = state.remoteOauthConnected
const canUseRemote = useMemo(() => {
if (!trimmedUrl) {
return false
}
if (authMode === 'oauth') {
return oauthConnected
}
return Boolean(remoteToken.trim()) || state.remoteTokenSet
}, [authMode, oauthConnected, remoteToken, state.remoteTokenSet, trimmedUrl])
const canUseRemote = useMemo(
() => Boolean(state.remoteUrl.trim()) && (Boolean(remoteToken.trim()) || state.remoteTokenSet),
[remoteToken, state.remoteTokenSet, state.remoteUrl]
)
const payload = () => ({
mode: state.mode,
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
remoteToken: remoteToken.trim() || undefined,
remoteUrl: state.remoteUrl.trim()
})
const save = async (apply: boolean) => {
@@ -249,10 +120,7 @@ export function GatewaySettings() {
notify({
kind: 'warning',
title: 'Remote gateway incomplete',
message:
authMode === 'oauth'
? 'Enter a remote URL and sign in before switching to remote.'
: 'Enter a remote URL and session token before switching to remote.'
message: 'Enter a remote URL and session token before switching to remote.'
})
return
@@ -279,73 +147,12 @@ export function GatewaySettings() {
}
}
// OAuth sign-in: persist the URL + oauth mode first (so the saved config has
// the URL the login window needs), then open the gateway login window and
// refresh the connection status from the saved config once it completes.
const signIn = async () => {
if (!trimmedUrl) {
notify({ kind: 'warning', title: 'Remote gateway incomplete', message: 'Enter a remote URL first.' })
return
}
setSigningIn(true)
try {
// Save (don't apply/restart) so the login window has a URL to use and the
// oauth mode is persisted, without yet flipping the live connection.
const saved = await window.hermesDesktop.saveConnectionConfig({
mode: state.mode,
remoteAuthMode: 'oauth',
remoteUrl: trimmedUrl
})
setState(saved)
const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
if (result.connected) {
const refreshed = await window.hermesDesktop.getConnectionConfig()
setState(refreshed)
notify({ kind: 'success', title: 'Signed in', message: `Connected to ${providerLabel}.` })
} else {
notify({
kind: 'warning',
title: 'Sign-in incomplete',
message: 'The login window closed before authentication finished.'
})
}
} catch (err) {
notifyError(err, 'Sign-in failed')
} finally {
setSigningIn(false)
}
}
const signOut = async () => {
setSigningIn(true)
try {
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)
const refreshed = await window.hermesDesktop.getConnectionConfig()
setState(refreshed)
notify({ kind: 'success', title: 'Signed out', message: 'Cleared the remote gateway session.' })
} catch (err) {
notifyError(err, 'Sign-out failed')
} finally {
setSigningIn(false)
}
}
const testRemote = async () => {
if (!canUseRemote) {
notify({
kind: 'warning',
title: 'Remote gateway incomplete',
message:
authMode === 'oauth'
? 'Enter a remote URL and sign in before testing.'
: 'Enter a remote URL and session token before testing.'
message: 'Enter a remote URL and session token before testing.'
})
return
@@ -357,9 +164,8 @@ export function GatewaySettings() {
try {
const result = await window.hermesDesktop.testConnectionConfig({
mode: 'remote',
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
remoteToken: remoteToken.trim() || undefined,
remoteUrl: state.remoteUrl.trim()
})
const message = `Connected to ${result.baseUrl}${result.version ? ` · Hermes ${result.version}` : ''}`
@@ -423,7 +229,7 @@ export function GatewaySettings() {
/>
<ModeCard
active={state.mode === 'remote'}
description="Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token."
description="Connect this desktop shell to a remote Hermes backend using its session token."
disabled={state.envOverride}
icon={Globe}
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
@@ -445,75 +251,23 @@ export function GatewaySettings() {
description="Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes."
title="Remote URL"
/>
{state.mode === 'remote' && probeStatus === 'probing' ? (
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<Loader2 className="size-4 animate-spin" />
Checking how this gateway authenticates
</div>
) : null}
{state.mode === 'remote' && probeStatus === 'error' ? (
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
Could not reach this gateway yet. Check the URL the auth method will appear once it responds.
</div>
) : null}
{/* OAuth / password gateways: present a sign-in button + connection status. */}
{state.mode === 'remote' && authResolved && authMode === 'oauth' ? (
<ListRow
action={
oauthConnected ? (
<div className="flex items-center gap-2">
<Pill tone="primary">
<Check className="size-3" /> Signed in
</Pill>
<Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline">
{signingIn ? <Loader2 className="size-4 animate-spin" /> : null}
Sign out
</Button>
</div>
) : (
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
{signingIn ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
{isPasswordProvider ? 'Sign in' : `Sign in with ${providerLabel}`}
</Button>
)
}
description={
oauthConnected
? isPasswordProvider
? 'This gateway uses a username and password. You are signed in; the session refreshes automatically.'
: 'This gateway uses OAuth. You are signed in; the session refreshes automatically.'
: isPasswordProvider
? 'This gateway uses a username and password. Sign in to authorize this desktop app.'
: `This gateway uses OAuth. Sign in with ${providerLabel} to authorize this desktop app.`
}
title="Authentication"
/>
) : null}
{/* Session-token gateways: keep the existing token entry box. */}
{state.mode === 'remote' && authResolved && authMode === 'token' ? (
<ListRow
action={
<Input
autoComplete="off"
className={cn('h-8 font-mono', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setRemoteToken(event.target.value)}
placeholder={
state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'
}
type="password"
value={remoteToken}
/>
}
description="The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token."
title="Session token"
/>
) : null}
<ListRow
action={
<Input
autoComplete="off"
className={cn('h-8 font-mono', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setRemoteToken(event.target.value)}
placeholder={
state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'
}
type="password"
value={remoteToken}
/>
}
description="The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token."
title="Session token"
/>
</div>
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { HermesConfigRecord } from '@/types/hermes'
import { getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
import { getNested, setNested } from './helpers'
describe('settings helpers', () => {
it('reads and writes nested config paths', () => {
@@ -20,48 +20,4 @@ describe('settings helpers', () => {
expect(() => setNested(config, 'constructor.prototype.polluted', true)).toThrow('Unsafe config path')
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
})
describe('stripToolsetLabel', () => {
it('removes leading emoji prefixes from registry labels', () => {
expect(stripToolsetLabel('⏰ Cron Jobs')).toBe('Cron Jobs')
expect(stripToolsetLabel('⚡ Code Execution')).toBe('Code Execution')
expect(stripToolsetLabel('❓ Clarifying Questions')).toBe('Clarifying Questions')
expect(stripToolsetLabel('🌐 Browser Automation')).toBe('Browser Automation')
expect(stripToolsetLabel('🎨 Image Generation')).toBe('Image Generation')
})
it('leaves plain titles unchanged', () => {
expect(stripToolsetLabel('Terminal & Processes')).toBe('Terminal & Processes')
})
})
describe('toolsetDisplayLabel', () => {
it('strips emoji from toolset rows', () => {
expect(toolsetDisplayLabel({ name: 'cronjob', label: '⏰ Cron Jobs' })).toBe('Cron Jobs')
})
})
describe('providerGroup', () => {
it('maps a provider env var to its labeled group', () => {
expect(providerGroup('XAI_API_KEY')).toBe('xAI')
expect(providerGroup('NOUS_API_KEY')).toBe('Nous Portal')
expect(providerGroup('OPENROUTER_API_KEY')).toBe('OpenRouter')
})
it('prefers the longest matching prefix so CN/regional buckets win', () => {
// MINIMAX_CN_ must beat the generic MINIMAX_ prefix.
expect(providerGroup('MINIMAX_CN_API_KEY')).toBe('MiniMax (China)')
expect(providerGroup('MINIMAX_API_KEY')).toBe('MiniMax')
// KIMI_CN_ likewise must beat KIMI_.
expect(providerGroup('KIMI_CN_API_KEY')).toBe('Kimi (China)')
expect(providerGroup('KIMI_API_KEY')).toBe('Kimi / Moonshot')
// HERMES_QWEN_ and HERMES_GEMINI_ both share the HERMES_ stem.
expect(providerGroup('HERMES_QWEN_BASE_URL')).toBe('DashScope (Qwen)')
expect(providerGroup('HERMES_GEMINI_CLIENT_ID')).toBe('Gemini')
})
it('falls back to "Other" for un-grouped env vars', () => {
expect(providerGroup('SOMETHING_RANDOM')).toBe('Other')
})
})
})

View File

@@ -8,13 +8,6 @@ export const includesQuery = (v: unknown, q: string) => asText(v).toLowerCase().
export const prettyName = (v: string) => v.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
/** Strip leading emoji from toolset titles (CLI registry prefixes labels with icons). */
export const stripToolsetLabel = (label: string): string =>
label.replace(/^[\p{Emoji}\p{Extended_Pictographic}\s]+/u, '').trim() || label
export const toolsetDisplayLabel = (toolset: Pick<ToolsetInfo, 'label' | 'name'>): string =>
stripToolsetLabel(asText(toolset.label || toolset.name))
export const toolNames = (t: ToolsetInfo) => (Array.isArray(t.tools) ? t.tools.map(asText).filter(Boolean) : [])
export const withoutKey = <T>(record: Record<string, T>, key: string) => {
@@ -26,30 +19,9 @@ export const withoutKey = <T>(record: Record<string, T>, key: string) => {
export const redactedValue = (v: string) => (v.length <= 8 ? '••••' : `${v.slice(0, 4)}...${v.slice(-4)}`)
// Longest-prefix match so a more specific group like ``MINIMAX_CN_`` is
// chosen over its shorter parent ``MINIMAX_``. Falls back to the bucket
// "Other" used by the Keys settings view for un-grouped env vars.
export const providerGroup = (key: string) => {
let best: (typeof PROVIDER_GROUPS)[number] | undefined
export const providerGroup = (key: string) => PROVIDER_GROUPS.find(g => key.startsWith(g.prefix))?.name ?? 'Other'
for (const candidate of PROVIDER_GROUPS) {
if (!key.startsWith(candidate.prefix)) {
continue
}
if (!best || candidate.prefix.length > best.prefix.length) {
best = candidate
}
}
return best?.name ?? 'Other'
}
export const providerMeta = (name: string) =>
PROVIDER_GROUPS.find(g => g.name === name && (g.description || g.docsUrl)) ??
PROVIDER_GROUPS.find(g => g.name === name)
export const providerPriority = (name: string) => providerMeta(name)?.priority ?? 99
export const providerPriority = (name: string) => PROVIDER_GROUPS.find(g => g.name === name)?.priority ?? 99
const POLLUTING_PATH_PARTS = new Set(['__proto__', 'constructor', 'prototype'])

View File

@@ -3,7 +3,7 @@ import { useRef } from 'react'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
import { Archive, Globe, Info, KeyRound, Wrench } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@@ -16,15 +16,13 @@ import { AppearanceSettings } from './appearance-settings'
import { ConfigSettings } from './config-settings'
import { SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
import { KeysSettings } from './keys-settings'
import { McpSettings } from './mcp-settings'
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
import { SessionsSettings } from './sessions-settings'
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
'providers',
'gateway',
'keys',
'mcp',
@@ -34,20 +32,6 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
// Providers subnav (Accounts vs API keys) lives in its own param so each
// sub-view is deep-linkable and survives a refresh.
const [providerView, setProviderView] = useRouteEnumParam<ProviderView>('pview', PROVIDER_VIEWS, 'accounts')
const [keysView, setKeysView] = useRouteEnumParam<KeysView>('kview', KEYS_VIEWS, 'tools')
const openProviderView = (view: ProviderView) => {
setActiveView('providers')
setProviderView(view)
}
const openKeysView = (view: KeysView) => {
setActiveView('keys')
setKeysView(view)
}
const importInputRef = useRef<HTMLInputElement | null>(null)
@@ -99,30 +83,6 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
)
})}
<div className="my-2 h-px bg-border/30" />
<OverlayNavItem
active={activeView === 'providers'}
icon={Zap}
label="Providers"
onClick={() => setActiveView('providers')}
/>
{activeView === 'providers' && (
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
<OverlayNavItem
active={providerView === 'accounts'}
icon={Sparkles}
label="Accounts"
nested
onClick={() => openProviderView('accounts')}
/>
<OverlayNavItem
active={providerView === 'keys'}
icon={KeyRound}
label="API keys"
nested
onClick={() => openProviderView('keys')}
/>
</div>
)}
<OverlayNavItem
active={activeView === 'gateway'}
icon={Globe}
@@ -132,27 +92,9 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
<OverlayNavItem
active={activeView === 'keys'}
icon={KeyRound}
label="Tools & Keys"
label="API Keys"
onClick={() => setActiveView('keys')}
/>
{activeView === 'keys' && (
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
<OverlayNavItem
active={keysView === 'tools'}
icon={Wrench}
label="Tools"
nested
onClick={() => openKeysView('tools')}
/>
<OverlayNavItem
active={keysView === 'settings'}
icon={Settings2}
label="Settings"
nested
onClick={() => openKeysView('settings')}
/>
</div>
)}
<OverlayNavItem
active={activeView === 'mcp'}
icon={Wrench}
@@ -212,10 +154,8 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
/>
) : activeView === 'providers' ? (
<ProvidersSettings onViewChange={setProviderView} view={providerView} />
) : activeView === 'keys' ? (
<KeysSettings view={keysView} />
<KeysSettings />
) : activeView === 'mcp' ? (
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
) : (

View File

@@ -1,94 +1,425 @@
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
import { Check, Eye, EyeOff, Save, Settings2, Trash2, Zap } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { EnvVarInfo } from '@/types/hermes'
import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './credential-key-ui'
import { useEnvCredentials } from './env-credentials'
import { asText } from './helpers'
import { LoadingState, SettingsContent } from './primitives'
import { CONTROL_TEXT } from './constants'
import { asText, 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'
// Sub-views surfaced as sidebar subnav under Tools & Keys (see settings/index.tsx).
export const KEYS_VIEWS = ['tools', 'settings'] as const
export type KeysView = (typeof KEYS_VIEWS)[number]
// Providers live on their own page; messaging-platform credentials live on the
// dedicated Messaging page (and are hidden here via `channel_managed`). This
// view covers tool API keys plus server/setting env vars (API server, webhook,
// gateway), which fold into the Settings subnav.
// Backend categories that surface under each subnav. Platform credentials use the
// `messaging` category but are flagged ``channel_managed`` and configured on
// the Messaging page; only gateway-wide ``messaging`` rows (e.g. GATEWAY_PROXY)
// appear here alongside ``setting``.
const VIEW_CATEGORIES: Record<KeysView, readonly string[]> = {
settings: ['setting', 'messaging'],
tools: ['tool']
interface EnvActionsProps {
varKey: string
info: EnvVarInfo
saving: string | null
onEdit: () => void
onClear: (key: string) => void
onReveal: (key: string) => void
isRevealed: boolean
showReveal?: boolean
}
export function KeysSettings({ view }: KeysSettingsProps) {
const { rowProps, vars } = useEnvCredentials()
const [openKey, setOpenKey] = useState<null | string>(null)
function EnvActions({
varKey,
info,
saving,
onEdit,
onClear,
onReveal,
isRevealed,
showReveal = true
}: EnvActionsProps) {
return (
<div className="flex shrink-0 items-center gap-1.5">
{info.url && (
<Button asChild size="xs" title="Open provider docs" variant="ghost">
<a href={info.url} rel="noreferrer" target="_blank">
Docs
</a>
</Button>
)}
{info.is_set && showReveal && (
<Button
onClick={() => onReveal(varKey)}
size="icon-xs"
title={isRevealed ? 'Hide value' : 'Reveal value'}
variant="ghost"
>
{isRevealed ? <EyeOff /> : <Eye />}
</Button>
)}
<Button onClick={onEdit} size="xs" variant="textStrong">
{info.is_set ? 'Replace' : 'Set'}
</Button>
{info.is_set && (
<Button
disabled={saving === varKey}
onClick={() => onClear(varKey)}
size="icon-xs"
title="Clear value"
variant="ghost"
>
<Trash2 />
</Button>
)}
</div>
)
}
function EnvVarRow({
varKey,
info,
edits,
revealed,
saving,
setEdits,
onSave,
onClear,
onReveal,
compact = false
}: EnvRowProps) {
const isEditing = edits[varKey] !== undefined
const isRevealed = revealed[varKey] !== undefined
const value = isRevealed ? revealed[varKey] : info.redacted_value
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
if (compact && !isEditing) {
return (
<div className="flex items-center justify-between gap-3 py-1.5">
<div className="min-w-0">
<div className="truncate font-mono text-[0.72rem] text-muted-foreground">{varKey}</div>
<div className="truncate text-[0.68rem] text-muted-foreground/70">{info.description}</div>
</div>
<EnvActions
info={info}
isRevealed={isRevealed}
onClear={onClear}
onEdit={startEdit}
onReveal={onReveal}
saving={saving}
showReveal={false}
varKey={varKey}
/>
</div>
)
}
return (
<div className="grid gap-2 rounded-xl bg-background/55 p-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-medium">{varKey}</span>
<Pill tone={info.is_set ? 'primary' : 'muted'}>
{info.is_set && <Check className="size-3" />}
{info.is_set ? 'Set' : 'Not set'}
</Pill>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{info.description}</p>
</div>
<EnvActions
info={info}
isRevealed={isRevealed}
onClear={onClear}
onEdit={startEdit}
onReveal={onReveal}
saving={saving}
varKey={varKey}
/>
</div>
{!isEditing && info.is_set && (
<div
className={cn(
'rounded-md px-3 py-2 font-mono text-xs',
isRevealed ? 'bg-background text-foreground' : 'bg-muted/30 text-muted-foreground'
)}
>
{value || '---'}
</div>
)}
{isEditing && (
<div className="flex flex-wrap items-center gap-2">
<Input
autoFocus
className={cn('min-w-56 flex-1 font-mono', CONTROL_TEXT)}
onChange={e => setEdits(c => ({ ...c, [varKey]: e.target.value }))}
placeholder={info.is_set ? 'Replace current value' : 'Enter value'}
type={info.is_password ? 'password' : 'text'}
value={edits[varKey]}
/>
<Button disabled={saving === varKey || !edits[varKey]} onClick={() => onSave(varKey)} size="sm">
<Save />
{saving === varKey ? 'Saving' : 'Save'}
</Button>
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="text">
Cancel
</Button>
</div>
)}
</div>
)
}
function EnvProviderGroup({
group,
rowProps,
forceExpand = false
}: {
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(() => {
setOpenKey(null)
}, [view])
if (forceExpand) {
setExpanded(true)
}
}, [forceExpand])
const groups = useMemo(() => {
return (
<div className="overflow-hidden rounded-xl bg-background/60">
<button
className="flex w-full items-center justify-between gap-3 bg-transparent px-3 py-2.5 text-left hover:bg-accent/50"
onClick={() => setExpanded(e => !e)}
type="button"
>
<span className="flex min-w-0 items-center gap-2">
<Zap className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium">
{group.name === 'Other' ? 'Other providers' : group.name}
</span>
{setCount > 0 && <Pill tone="primary">{setCount} set</Pill>}
</span>
<span className="text-xs text-muted-foreground">{group.entries.length} keys</span>
</button>
{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>
))}
</div>
)}
</div>
)
}
export function KeysSettings() {
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
// default-collapsed-unless-set keep the surface manageable.
useEffect(() => {
try {
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
} catch {
// Ignore — old key cleanup is best-effort.
}
}, [])
useEffect(() => {
let cancelled = false
void (async () => {
try {
const next = await getEnvVars()
if (!cancelled) {
setVars(next)
}
} catch (err) {
notifyError(err, 'API keys failed to load')
}
})()
return () => void (cancelled = true)
}, [])
const providerGroups = useMemo<ProviderGroup[]>(() => {
if (!vars) {
return []
}
return KEYS_VIEWS.flatMap(v => {
const cats = VIEW_CATEGORIES[v]
const entries = Object.entries(vars).filter(([, info]) => asText(info.category) === 'provider')
const groups = new Map<string, [string, EnvVarInfo][]>()
for (const entry of entries) {
const name = providerGroup(entry[0])
groups.set(name, [...(groups.get(name) ?? []), entry])
}
return Array.from(groups, ([name, entries]) => ({
name,
priority: providerPriority(name),
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])
const otherGroups = useMemo(() => {
if (!vars) {
return []
}
const labels: Record<string, string> = {
tool: 'Tools',
messaging: 'Messaging',
setting: 'Settings'
}
return ['tool', 'messaging', 'setting'].flatMap(cat => {
const entries = Object.entries(vars)
.filter(([, info]) => !info.channel_managed && cats.includes(asText(info.category)))
.filter(([, info]) => asText(info.category) === cat)
.sort(([a], [b]) => a.localeCompare(b))
return entries.length === 0 ? [] : [{ category: v, entries }]
return entries.length === 0 ? [] : [{ category: cat, label: labels[cat] ?? prettyName(cat), entries }]
})
}, [vars])
function patchVar(key: string, patch: EnvPatch) {
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
}
function clearLocalState(key: string) {
setEdits(c => withoutKey(c, key))
setRevealed(c => withoutKey(c, key))
}
async function handleSave(key: string) {
const value = edits[key]
if (!value) {
return
}
setSaving(key)
try {
await setEnvVar(key, value)
patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
clearLocalState(key)
notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` })
} catch (err) {
notifyError(err, `Failed to save ${key}`)
} finally {
setSaving(null)
}
}
async function handleClear(key: string) {
if (!window.confirm(`Remove ${key} from .env?`)) {
return
}
setSaving(key)
try {
await deleteEnvVar(key)
patchVar(key, { is_set: false, redacted_value: null })
clearLocalState(key)
notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` })
} catch (err) {
notifyError(err, `Failed to remove ${key}`)
} finally {
setSaving(null)
}
}
async function handleReveal(key: string) {
if (revealed[key]) {
setRevealed(c => withoutKey(c, key))
return
}
try {
const result = await revealEnvVar(key)
setRevealed(c => ({ ...c, [key]: result.value }))
} catch (err) {
notifyError(err, `Failed to reveal ${key}`)
}
}
if (!vars) {
return <LoadingState label="Loading API keys and credentials..." />
}
const visible = groups.filter(g => g.category === view)
const rowProps = {
edits,
revealed,
saving,
setEdits,
onSave: handleSave,
onClear: handleClear,
onReveal: handleReveal
}
const configuredCount = providerGroups.filter(g => g.hasAnySet).length
return (
<SettingsContent>
{visible.map(group => (
<div className="grid gap-2" key={group.category}>
{group.entries.map(([key, info]: [string, EnvVarInfo]) => {
const label = credentialRowLabel(key, info)
<div className="mb-6">
<SectionHeading
icon={Zap}
meta={`${configuredCount} of ${providerGroups.length} configured`}
title="LLM providers"
/>
<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}
/>
))}
</div>
</div>
return (
<CredentialKeyCard
expanded={openKey === key}
info={info}
key={key}
label={label}
onExpand={() => setOpenKey(key)}
onToggle={() => setOpenKey(prev => (prev === key ? null : key))}
placeholder={credentialPlaceholder(key, info, label)}
rowProps={rowProps}
varKey={key}
/>
)
})}
{otherGroups.map(group => (
<div className="mb-6" key={group.category}>
<SectionHeading
icon={Settings2}
meta={`${group.entries.filter(([, i]) => i.is_set).length} of ${group.entries.length} set`}
title={group.label}
/>
<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>
))}
</div>
</div>
))}
{visible.length === 0 && (
<div className="rounded-lg border border-dashed border-(--ui-stroke-tertiary) px-4 py-8 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
Nothing configured in this category yet.
</div>
)}
</SettingsContent>
)
}
interface KeysSettingsProps {
view: KeysView
}

View File

@@ -1,7 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes'
import type { AuxiliaryModelsResponse, ModelOptionProvider } from '@/hermes'
import { Cpu, Loader2 } from '@/lib/icons'
@@ -23,6 +29,7 @@ const AUX_TASKS: readonly AuxTaskMeta[] = [
{ key: 'vision', label: 'Vision', hint: 'Image analysis' },
{ key: 'web_extract', label: 'Web extract', hint: 'Page summarization' },
{ key: 'compression', label: 'Compression', hint: 'Context compaction' },
{ key: 'session_search', label: 'Session search', hint: 'Recall queries' },
{ key: 'skills_hub', label: 'Skills hub', hint: 'Skill search' },
{ key: 'approval', label: 'Approval', hint: 'Smart auto-approve' },
{ key: 'mcp', label: 'MCP', hint: 'MCP tool routing' },
@@ -225,11 +232,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
))}
</SelectContent>
</Select>
<Button
disabled={!selectedProvider || !selectedModel || applying}
onClick={() => void applyMainModel()}
size="sm"
>
<Button disabled={!selectedProvider || !selectedModel || applying} onClick={() => void applyMainModel()} size="sm">
{applying && <Loader2 className="size-3.5 animate-spin" />}
{applying ? 'Applying...' : 'Apply'}
</Button>
@@ -330,9 +333,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
}
description={
<span className="font-mono text-[0.68rem]">
{isAuto
? 'auto · use main model'
: `${current.provider} · ${current.model || '(provider default)'}`}
{isAuto ? 'auto · use main model' : `${current.provider} · ${current.model || '(provider default)'}`}
</span>
}
key={meta.key}

View File

@@ -1,251 +0,0 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useState } from 'react'
import {
FEATURED_ID,
FeaturedProviderRow,
KeyProviderRow,
ProviderRow,
sortProviders
} from '@/components/desktop-onboarding-overlay'
import { Button } from '@/components/ui/button'
import { listOAuthProviders } from '@/hermes'
import { ChevronDown, KeyRound } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding'
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
import { isKeyVar, ProviderKeyRows } from './credential-key-ui'
import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
import { providerGroup, providerMeta, providerPriority } from './helpers'
import { LoadingState, SettingsContent } from './primitives'
// Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys.
export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
export type ProviderView = (typeof PROVIDER_VIEWS)[number]
// Group the env catalog by provider — one ListRow per vendor plus optional
// advanced overrides (base URL, region, etc.). Groups without a key field and
// the "Other" bucket are skipped.
function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGroup[] {
const buckets = new Map<string, [string, EnvVarInfo][]>()
for (const [key, info] of Object.entries(vars)) {
if (info.category !== 'provider') {
continue
}
const name = providerGroup(key)
if (name === 'Other') {
continue
}
buckets.set(name, [...(buckets.get(name) ?? []), [key, info]])
}
const groups: ProviderKeyGroup[] = []
for (const [name, entries] of buckets) {
const primary = entries.find(([k, i]) => !i.advanced && isKeyVar(k, i)) ?? entries.find(([k, i]) => isKeyVar(k, i))
if (!primary) {
continue
}
const meta = providerMeta(name)
groups.push({
// Advanced = the provider's non-key knobs (base URL, region, deployment).
// Skip redundant alias key vars (e.g. ANTHROPIC_TOKEN vs ANTHROPIC_API_KEY)
// so we never render a second "Paste key" input — unless one is already
// set, in which case keep it visible so it stays clearable.
advanced: entries
.filter(([k, i]) => k !== primary[0] && (!isKeyVar(k, i) || i.is_set))
.sort(([a], [b]) => a.localeCompare(b)),
description: meta?.description ?? primary[1].description,
docsUrl: meta?.docsUrl ?? primary[1].url ?? undefined,
hasAnySet: entries.some(([, i]) => i.is_set),
name,
primary,
priority: providerPriority(name)
})
}
return groups.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
}
// Deliberately a near-1:1 replica of the first-run onboarding picker
// (`Picker` in desktop-onboarding-overlay): same recommended card, same
// provider rows, same "Other providers" disclosure, same OpenRouter quick-key
// row, and the same bottom-right "I have an API key" affordance. The leaf cards
// are the exact shared components, so the two surfaces stay visually identical.
// Selecting a provider hands off to the shared onboarding overlay, which runs
// that provider's real sign-in flow; the key affordances open the API-key
// catalog below.
function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; providers: OAuthProvider[] }) {
const [showAll, setShowAll] = useState(false)
const ordered = useMemo(() => sortProviders(providers), [providers])
if (ordered.length === 0) {
return null
}
const select = (p: OAuthProvider) => startManualProviderOAuth(p.id)
const featured = ordered.find(p => p.id === FEATURED_ID) ?? null
const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered
// Keep connected accounts grouped and always visible; only the unconnected
// providers hide behind the disclosure, so the page leads with what's set up.
const connected = rest.filter(p => p.status?.logged_in)
const others = rest.filter(p => !p.status?.logged_in)
const collapsible = others.length > 0
const showOthers = !collapsible || showAll
return (
<section className="mb-5 grid gap-2">
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
<SettingsCategoryHeading icon={KeyRound} title="Connect an account" />
<Button
className="h-auto px-0 py-0 text-[length:var(--conversation-caption-font-size)]"
onClick={onWantApiKey}
type="button"
variant="textStrong"
>
Have an API key instead?
</Button>
</div>
<p className="-mt-2 mb-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
Sign in with a subscription no API key to copy. Hermes runs the browser sign-in for you, right here in the
app.
</p>
{featured && <FeaturedProviderRow onSelect={select} provider={featured} />}
{connected.length > 0 && (
<>
<p className="mt-1 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
Connected
</p>
{connected.map(p => (
<ProviderRow key={p.id} onSelect={select} provider={p} />
))}
</>
)}
{showOthers && (
<>
{others.map(p => (
<ProviderRow key={p.id} onSelect={select} provider={p} />
))}
<KeyProviderRow onClick={onWantApiKey} />
</>
)}
{collapsible && (
<Button
className="h-auto px-0 py-1 text-[length:var(--conversation-caption-font-size)]"
onClick={() => setShowAll(v => !v)}
type="button"
variant="text"
>
{showAll ? 'Collapse' : connected.length > 0 ? 'Connect another provider' : 'Other providers'}
<ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
</Button>
)}
</section>
)
}
function NoProviderKeys() {
return (
<div className="grid min-h-32 place-items-center px-4 py-8 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
No provider API keys available.
</div>
)
}
export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) {
const { rowProps, vars } = useEnvCredentials()
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
const [openProvider, setOpenProvider] = useState<null | string>(null)
// The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we
// re-read connection state when the user finishes (or dismisses) a sign-in
// they launched from this page — otherwise the cards keep their stale status.
const onboardingActive = useStore($desktopOnboarding).manual
useEffect(() => {
if (onboardingActive) {
return
}
let cancelled = false
// OAuth providers are best-effort — a failure here just hides the panel.
void (async () => {
try {
const { providers } = await listOAuthProviders()
if (!cancelled) {
setOauthProviders(providers)
}
} catch {
// Ignore — the OAuth panel just won't render.
}
})()
return () => void (cancelled = true)
}, [onboardingActive])
if (!vars) {
return <LoadingState label="Loading providers..." />
}
const hasOauth = oauthProviders.length > 0
// The sidebar subnav owns the Accounts/API-keys split now; with no OAuth
// providers there's nothing for the "Accounts" view to show, so fall to keys.
const showApiKeys = view === 'keys' || !hasOauth
const keyGroups = buildProviderKeyGroups(vars)
if (showApiKeys) {
return (
<SettingsContent>
{keyGroups.length > 0 ? (
<div className="grid gap-2">
{keyGroups.map(group => (
<ProviderKeyRows
expanded={openProvider === group.name}
group={group}
key={group.name}
onExpand={() => setOpenProvider(group.name)}
onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))}
rowProps={rowProps}
/>
))}
</div>
) : (
<NoProviderKeys />
)}
</SettingsContent>
)
}
return (
<SettingsContent>
<OAuthPicker onWantApiKey={() => onViewChange('keys')} providers={oauthProviders} />
</SettingsContent>
)
}
interface ProviderKeyGroup {
advanced: [string, EnvVarInfo][]
description?: string
docsUrl?: string
hasAnySet: boolean
name: string
primary: [string, EnvVarInfo]
priority: number
}
interface ProvidersSettingsProps {
onViewChange: (view: ProviderView) => void
view: ProviderView
}

View File

@@ -251,7 +251,13 @@ function DefaultProjectDirSetting() {
<ListRow
action={
<div className="flex items-center gap-3">
<Button disabled={busy} onClick={() => void choose()} size="sm" type="button" variant="textStrong">
<Button
disabled={busy}
onClick={() => void choose()}
size="sm"
type="button"
variant="textStrong"
>
<FolderOpen className="size-3.5" />
<span>{dir ? 'Change' : 'Choose'}</span>
</Button>

View File

@@ -4,12 +4,11 @@ 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'
import { Check, Loader2, Save } from '@/lib/icons'
import { Check, ExternalLink, Eye, EyeOff, Loader2, Save, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { ToolEnvVar, ToolProvider, ToolsetConfig } from '@/types/hermes'
import { EnvVarActionsMenu, EnvVarActionsTrigger } from './env-var-actions-menu'
import { Pill } from './primitives'
interface ToolsetConfigPanelProps {
@@ -109,26 +108,33 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
<p className="mt-0.5 text-[0.7rem] text-muted-foreground">{envVar.prompt}</p>
)}
</div>
{!editing && (
<EnvVarActionsMenu
clearDisabled={busy}
docsUrl={envVar.url}
isRevealed={revealed !== null}
isSet={isSet}
label={envVar.key}
onClear={() => void handleClear()}
onEdit={() => setEditing(true)}
onReveal={() => void handleReveal()}
>
<EnvVarActionsTrigger label={envVar.key} onClick={event => event.stopPropagation()} />
</EnvVarActionsMenu>
)}
<div className="flex shrink-0 items-center gap-1.5">
{envVar.url && (
<Button asChild size="xs" title="Open provider docs" variant="ghost">
<a href={envVar.url} rel="noreferrer" target="_blank">
Docs
<ExternalLink className="size-3" />
</a>
</Button>
)}
{isSet && (
<Button onClick={() => void handleReveal()} size="icon-xs" title="Reveal value" variant="ghost">
{revealed !== null ? <EyeOff /> : <Eye />}
</Button>
)}
<Button onClick={() => setEditing(e => !e)} size="xs" variant="textStrong">
{isSet ? 'Replace' : 'Set'}
</Button>
{isSet && (
<Button disabled={busy} onClick={() => void handleClear()} size="icon-xs" title="Clear value" variant="ghost">
<Trash2 />
</Button>
)}
</div>
</div>
{isSet && revealed !== null && (
<div className="rounded-md bg-background px-2.5 py-1.5 font-mono text-xs text-foreground">
{revealed || '---'}
</div>
<div className="rounded-md bg-background px-2.5 py-1.5 font-mono text-xs text-foreground">{revealed || '---'}</div>
)}
{editing && (

View File

@@ -4,7 +4,7 @@ import type { HermesGateway } from '@/hermes'
import type { IconComponent } from '@/lib/icons'
import type { EnvVarInfo } from '@/types/hermes'
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'providers' | 'sessions' | `config:${string}`
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | `config:${string}`
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
export interface SettingsPageProps {

View File

@@ -4,18 +4,7 @@ import { useCallback, 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, Zap, ZapFilled } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
@@ -322,11 +311,7 @@ export function useStatusbarItems({
{
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" />
),
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

View File

@@ -55,7 +55,9 @@ export function resolveFastControl(
// Only a toggle if there's a base to switch back to; otherwise it's a
// standalone fast model with no "off" state.
return providerModels.includes(baseId) ? { kind: 'variant', baseId, fastId: model, on: true } : { kind: 'none' }
return providerModels.includes(baseId)
? { kind: 'variant', baseId, fastId: model, on: true }
: { kind: 'none' }
}
const fastId = `${model}-fast`
@@ -180,20 +182,24 @@ export function ModelEditSubmenu({
<>
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Options</DropdownMenuLabel>
{reasoning ? (
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
<DropdownMenuItem
className={dropdownMenuRow}
onSelect={event => event.preventDefault()}
>
Thinking
<Switch
checked={thinkingOn}
className="ml-auto"
onCheckedChange={checked =>
void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)
}
onCheckedChange={checked => void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)}
size="xs"
/>
</DropdownMenuItem>
) : null}
{hasFast ? (
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
<DropdownMenuItem
className={dropdownMenuRow}
onSelect={event => event.preventDefault()}
>
Fast
<Switch checked={fastOn} className="ml-auto" onCheckedChange={toggleFast} size="xs" />
</DropdownMenuItem>

View File

@@ -157,10 +157,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
// Grayed text: active row shows live state (Fast + effort);
// others show a fast-capability hint.
const meta = isCurrent
? [
fastControl.kind !== 'none' && fastControl.on ? 'Fast' : null,
reasoningEffortLabel(currentReasoningEffort) || 'Med'
]
? [fastControl.kind !== 'none' && fastControl.on ? 'Fast' : null, reasoningEffortLabel(currentReasoningEffort) || 'Med']
.filter(Boolean)
.join(' ')
: caps?.fast || family.fastId

View File

@@ -15,8 +15,7 @@ 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 = 'text-muted-foreground/85 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

@@ -74,17 +74,6 @@ describe('SkillsView toolset management', () => {
await waitFor(() => expect(toggleToolset).toHaveBeenCalledWith('web', false))
})
it('renders toolset titles without leading emoji', async () => {
getToolsets.mockResolvedValue([
toolset({ name: 'cronjob', label: '⏰ Cron Jobs', description: 'cron tools' })
])
await renderSkills()
expect(screen.getByText('Cron Jobs')).toBeTruthy()
expect(screen.queryByText(/⏰/)).toBeNull()
})
it('keeps the configured pill alongside the switch', async () => {
await renderSkills()

View File

@@ -14,7 +14,7 @@ import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PAGE_INSET_X } from '../layout-constants'
import { PageSearchShell } from '../page-search-shell'
import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers'
import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers'
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
@@ -52,17 +52,14 @@ function filteredToolsets(toolsets: ToolsetInfo[], query: string): ToolsetInfo[]
return true
}
const label = toolsetDisplayLabel(toolset)
return (
includesQuery(toolset.name, q) ||
includesQuery(label, q) ||
includesQuery(toolset.label, q) ||
includesQuery(toolset.description, q) ||
toolNames(toolset).some(name => includesQuery(name, q))
)
})
.sort((a, b) => toolsetDisplayLabel(a).localeCompare(toolsetDisplayLabel(b)))
.sort((a, b) => asText(a.label || a.name).localeCompare(asText(b.label || b.name)))
}
interface SkillsViewProps extends React.ComponentProps<'section'> {
@@ -163,17 +160,16 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
try {
await toggleToolset(toolset.name, enabled)
setToolsets(
current =>
current?.map(row => (row.name === toolset.name ? { ...row, enabled, available: enabled } : row)) ?? current
setToolsets(current =>
current?.map(row => (row.name === toolset.name ? { ...row, enabled, available: enabled } : row)) ?? current
)
notify({
kind: 'success',
title: enabled ? 'Toolset enabled' : 'Toolset disabled',
message: `${toolsetDisplayLabel(toolset)} applies to new sessions.`
message: `${asText(toolset.label || toolset.name)} applies to new sessions.`
})
} catch (err) {
notifyError(err, `Failed to update ${toolsetDisplayLabel(toolset)}`)
notifyError(err, `Failed to update ${asText(toolset.label || toolset.name)}`)
} finally {
setSavingToolset(null)
}
@@ -267,7 +263,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<div>
{visibleToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = toolsetDisplayLabel(toolset)
const label = asText(toolset.label || toolset.name)
const expanded = expandedToolset === toolset.name
return (
@@ -279,9 +275,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
aria-expanded={expanded}
aria-label={`Configure ${label}`}
className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
onClick={() =>
setExpandedToolset(current => (current === toolset.name ? null : toolset.name))
}
onClick={() => setExpandedToolset(current => (current === toolset.name ? null : toolset.name))}
type="button"
>
<StatusPill active={toolset.configured}>
@@ -327,9 +321,7 @@ 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)'
}
className={active ? 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)' : 'bg-(--ui-bg-quinary) text-(--ui-text-tertiary)'}
>
{children}
</Badge>

View File

@@ -199,7 +199,9 @@ 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>
<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">
{group.items.map(item => (
<li className="flex items-start gap-2" key={item}>
@@ -337,9 +339,7 @@ function ErrorView({ message, onDismiss, onRetry }: { message: string; onDismiss
{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>
}
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

View File

@@ -1,18 +1,7 @@
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, memo, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import { setMutableRef } from '@/lib/mutable-ref'
import { cn } from '@/lib/utils'
import { setThreadScrolledUp } from '@/store/thread-scroll'
@@ -77,7 +66,6 @@ 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])
@@ -105,7 +93,6 @@ const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
// then jumps back up).
scrollToFn: (offset, _options, instance) => {
const el = instance.scrollElement
if (!el) {
return
}
@@ -215,10 +202,6 @@ const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
export const VirtualizedThread = memo(VirtualizedThreadInner)
function scrollElementToBottom(el: HTMLDivElement) {
el.scrollTop = el.scrollHeight
}
interface ScrollAnchorOptions {
enabled: boolean
groupCount: number
@@ -267,14 +250,14 @@ function useThreadScrollAnchor({
// Hold the disarm gate across the scroll event the next line will fire.
programmaticScrollPendingRef.current += 1
scrollElementToBottom(el)
el.scrollTop = el.scrollHeight
lastTopRef.current = el.scrollTop
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
}, [scrollerRef])
const jumpToBottom = useCallback(() => {
setMutableRef(stickyBottomRef, true)
stickyBottomRef.current = true
if (groupCount > 0) {
virtualizer.scrollToIndex(groupCount - 1, { align: 'end', behavior: 'auto' })
@@ -299,7 +282,7 @@ function useThreadScrollAnchor({
}
const disarm = () => {
setMutableRef(stickyBottomRef, false)
stickyBottomRef.current = false
programmaticScrollPendingRef.current = 0
}
@@ -319,7 +302,7 @@ function useThreadScrollAnchor({
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
// Always re-arm — sticky-bottom should hold through clamp races.
setMutableRef(stickyBottomRef, true)
stickyBottomRef.current = true
const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
setThreadScrolledUp(!atBottom)
@@ -334,9 +317,8 @@ function useThreadScrollAnchor({
// their own listeners below, so real user intent remains covered.
const heightGrew = el.scrollHeight > lastHeightRef.current
const clientHeightChanged = Math.abs(el.clientHeight - lastClientHeightRef.current) > 1
if (!heightGrew && !clientHeightChanged && top + 1 < lastTopRef.current) {
setMutableRef(stickyBottomRef, false)
stickyBottomRef.current = false
}
lastTopRef.current = top
@@ -346,7 +328,7 @@ function useThreadScrollAnchor({
const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
if (atBottom) {
setMutableRef(stickyBottomRef, true)
stickyBottomRef.current = true
}
setThreadScrolledUp(!atBottom)
@@ -387,16 +369,13 @@ function useThreadScrollAnchor({
}
let pinRafScheduled = false
const schedulePin = () => {
if (pinRafScheduled || !stickyBottomRef.current) {
return
}
pinRafScheduled = true
requestAnimationFrame(() => {
pinRafScheduled = false
if (stickyBottomRef.current) {
pinToBottom()
}
@@ -450,7 +429,6 @@ 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
@@ -464,7 +442,6 @@ function useThreadScrollAnchor({
}
})
}
prevGroupCountForLayoutRef.current = groupCount
}, [enabled, groupCount, pinToBottom, stickyBottomRef])

View File

@@ -1,161 +0,0 @@
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $approvalRequest } from '@/store/prompts'
import { $toolDisclosureStates } from '@/store/tool-view'
import { Thread } from './thread'
// Regression coverage for the "approval buried behind a collapsed tool group"
// bug. When 2+ tools group into a collapsed "Tool actions · N steps" row, the
// pending tool's inline ApprovalBar lives inside the group body — which is
// `hidden` until expanded. A live approval must surface WITHOUT the user
// expanding anything, so ToolGroupSlot force-opens its body while an approval
// targeting one of its pending tools is in flight.
const createdAt = new Date('2026-06-03T00:00:00.000Z')
const resizeObservers = new Set<TestResizeObserver>()
class TestResizeObserver {
private target: Element | null = null
constructor(private readonly callback: ResizeObserverCallback) {
resizeObservers.add(this)
}
observe(target: Element) {
this.target = target
}
unobserve() {}
disconnect() {
resizeObservers.delete(this)
}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
Element.prototype.scrollTo = function scrollTo() {}
Element.prototype.animate = function animate() {
return {
cancel: () => {},
finished: Promise.resolve()
} as unknown as Animation
}
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
fallback: number
) {
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
Object.defineProperty(HTMLElement.prototype, prop, {
configurable: true,
get() {
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
}
})
}
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
// A running assistant message with two tools: a completed read_file plus a
// pending terminal (no result). Two visible tools → ToolGroupSlot groups them
// behind a collapsed "Tool actions · 2 steps" header.
function groupedPendingMessage(): ThreadMessage {
return {
id: 'assistant-group-1',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'read-1',
toolName: 'read_file',
args: { path: '/etc/hosts' },
argsText: JSON.stringify({ path: '/etc/hosts' }),
result: { content: '127.0.0.1 localhost' }
},
{
type: 'tool-call',
toolCallId: 'term-1',
toolName: 'terminal',
args: { command: 'rm -rf /tmp/x' },
argsText: JSON.stringify({ command: 'rm -rf /tmp/x' })
}
],
status: { type: 'running' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function GroupHarness({ message }: { message: ThreadMessage }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
isRunning: message.status?.type === 'running',
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
beforeEach(() => {
$approvalRequest.set(null)
$toolDisclosureStates.set({})
})
afterEach(() => {
cleanup()
$approvalRequest.set(null)
})
describe('ToolGroupSlot approval surfacing', () => {
it('hides the grouped pending tool body when there is no approval', async () => {
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
// Group header renders collapsed; the inline approval strip lives in the
// hidden body, so with no live approval it must not render at all (the
// ApprovalBar returns null when $approvalRequest is empty).
await waitFor(() => {
expect(screen.getByText(/Tool actions/)).toBeTruthy()
})
expect(container.querySelector('[data-slot="tool-approval-inline"]')).toBeNull()
})
it('force-opens the group body so the approval surfaces without expanding', async () => {
$approvalRequest.set({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
// Even though the group defaults collapsed, the live approval forces the
// body open so the inline controls are visible (and reachable, not in a
// hidden subtree) immediately.
await waitFor(() => {
const bar = container.querySelector('[data-slot="tool-approval-inline"]')
expect(bar).not.toBeNull()
// The forced-open group body must not be hidden — assert no ancestor
// carries the `hidden` attribute that would keep the bar off-screen.
expect(bar?.closest('[hidden]')).toBeNull()
})
})
})

View File

@@ -12,7 +12,12 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
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'
@@ -34,7 +39,7 @@ import type { ToolPart } from './tool-fallback-model'
// 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.
export const APPROVAL_TOOLS = new Set(['terminal', 'execute_code'])
const APPROVAL_TOOLS = new Set(['terminal', 'execute_code'])
// Canonical gateway choices (ui-tui/src/components/prompts.tsx).
type ApprovalChoice = 'once' | 'session' | 'always' | 'deny'

View File

@@ -21,11 +21,10 @@ import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } f
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { $approvalRequest } from '@/store/prompts'
import { $toolInlineDiffs } from '@/store/tool-diffs'
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
import { APPROVAL_TOOLS, PendingToolApproval } from './tool-approval'
import { PendingToolApproval } from './tool-approval'
import {
groupCopyText as buildGroupCopyText,
buildToolView,
@@ -459,24 +458,7 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
// tools append to the end), so user-driven open/close persists across
// streaming.
const disclosureId = `tool-group:${messageId}:${startIndex}`
const userOpen = useDisclosureOpen(disclosureId)
// A live approval request must NEVER be buried inside a collapsed group —
// the user has to be able to act on it without first expanding "Tool
// actions · N steps". When an approval is in flight and this group hosts
// the pending approval-eligible tool that raised it (terminal /
// execute_code with no result yet — see tool-approval.tsx for why the
// single pending row IS the one that raised it), force the body open so
// the inline ApprovalBar surfaces. The user can still collapse the group
// again once the approval resolves.
const approvalRequest = useStore($approvalRequest)
const hostsLiveApproval =
approvalRequest !== null &&
messageRunning &&
visibleParts.some(p => p.result === undefined && APPROVAL_TOOLS.has(p.toolName))
const open = userOpen || hostsLiveApproval
const open = useDisclosureOpen(disclosureId)
const enterRef = useEnterAnimation(messageRunning, disclosureId)
const status = groupStatus(visibleParts)

View File

@@ -33,7 +33,9 @@ export function DisclosureRow({
// max-w-fit so the click target hugs the title text width — no
// 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-default'
onToggle
? 'hover:text-foreground focus-visible:text-foreground focus-visible:outline-none'
: 'cursor-default'
)}
disabled={!onToggle}
onClick={onToggle}

View File

@@ -1,6 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type {
DesktopBootstrapEvent,
DesktopBootstrapStageDescriptor,
@@ -8,8 +10,6 @@ import type {
DesktopBootstrapStageState,
DesktopBootstrapState
} from '@/global'
import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
/**
* DesktopInstallOverlay
@@ -59,10 +59,7 @@ const STATE_LABEL: Record<DesktopBootstrapStageState, string> = {
function formatStageName(name: string): string {
// 'system-packages' -> 'System packages'; 'uv' stays 'uv'
if (name.length <= 3) {
return name
}
if (name.length <= 3) return name
return name
.split('-')
.map((word, i) => (i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word))
@@ -70,61 +67,38 @@ function formatStageName(name: string): string {
}
function formatDuration(ms: number | null | undefined): string {
if (typeof ms !== 'number' || !Number.isFinite(ms)) {
return ''
}
if (ms < 1000) {
return `${ms} ms`
}
if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''
if (ms < 1000) return `${ms} ms`
const s = ms / 1000
if (s < 60) {
return `${s.toFixed(1)}s`
}
if (s < 60) return `${s.toFixed(1)}s`
const m = Math.floor(s / 60)
const rs = Math.round(s - m * 60)
return `${m}m ${rs}s`
}
// Live elapsed for a running stage, as m:ss (or s for sub-minute).
function formatElapsed(ms: number): string {
const s = Math.max(0, Math.floor(ms / 1000))
if (s < 60) {
return `${s}s`
}
if (s < 60) return `${s}s`
const m = Math.floor(s / 60)
return `${m}:${String(s - m * 60).padStart(2, '0')}`
}
function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
const state: DesktopBootstrapStageState = result?.state || 'pending'
const elapsed =
state === 'running' && typeof result?.startedAt === 'number' ? formatElapsed(now - result.startedAt) : ''
const icon = useMemo(() => {
switch (state) {
case 'running':
return <Loader2 className="h-4 w-4 animate-spin text-primary" />
case 'succeeded':
return <Check className="h-4 w-4 text-emerald-600" />
case 'skipped':
return <Check className="h-4 w-4 text-muted-foreground" />
case 'failed':
return <AlertTriangle className="h-4 w-4 text-destructive" />
case 'pending':
default:
return <div className="h-2 w-2 rounded-full border border-muted-foreground/40" />
}
@@ -172,11 +146,9 @@ const EMPTY_STATE: DesktopBootstrapState = {
function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): DesktopBootstrapState {
if (ev.type === 'manifest') {
const stages: Record<string, DesktopBootstrapStageResult> = {}
for (const stage of ev.stages) {
stages[stage.name] = { state: 'pending', durationMs: null, startedAt: null, json: null, error: null }
}
return {
...state,
active: true,
@@ -186,10 +158,8 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
startedAt: state.startedAt || Date.now()
}
}
if (ev.type === 'stage') {
const prev = state.stages[ev.name]
return {
...state,
stages: {
@@ -206,25 +176,17 @@ 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 })
while (next.length > 500) {
next.shift()
}
while (next.length > 500) next.shift()
return { ...state, log: next }
}
if (ev.type === 'complete') {
return { ...state, active: false, completedAt: Date.now(), error: null }
}
if (ev.type === 'failed') {
return { ...state, active: false, error: ev.error || 'unknown error' }
}
if (ev.type === 'unsupported-platform') {
return {
...state,
@@ -237,7 +199,6 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
}
}
}
return state
}
@@ -252,35 +213,23 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
// Tick once a second while a bootstrap is in flight so running steps show a
// live elapsed timer. Stops when nothing is active to avoid idle renders.
useEffect(() => {
if (!state.active) {
return
}
if (!state.active) return
const id = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(id)
}, [state.active])
// Subscribe to bootstrap events + load initial snapshot
useEffect(() => {
if (!enabled) {
return
}
if (!enabled) return
const desktop = window.hermesDesktop
if (!desktop || typeof desktop.onBootstrapEvent !== 'function') {
return
}
if (!desktop || typeof desktop.onBootstrapEvent !== 'function') return
let cancelled = false
desktop
.getBootstrapState()
.then(snapshot => {
if (!cancelled && snapshot) {
setState(snapshot)
}
if (!cancelled && snapshot) setState(snapshot)
})
.catch(() => {
// Older Electron build without the IPC handler -- bootstrap UI just
@@ -288,7 +237,6 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
})
const off = desktop.onBootstrapEvent(ev => setState(prev => applyEvent(prev, ev)))
return () => {
cancelled = true
off?.()
@@ -307,37 +255,21 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
// the top-level error message and the user has to click "Show installer
// output" to see WHY the stage failed.
useEffect(() => {
if (state.error) {
setLogOpen(true)
}
if (state.error) setLogOpen(true)
}, [state.error])
// Mount logic: show whenever a bootstrap is in flight, completed-with-error,
// or actively running with a manifest. Hide entirely after a successful
// completion so the rest of the UI can take over.
const shouldShow = useMemo(() => {
if (!enabled) {
return false
}
if (state.active) {
return true
}
if (state.error) {
return true
}
if (state.unsupportedPlatform) {
return true
}
if (!enabled) return false
if (state.active) return true
if (state.error) return true
if (state.unsupportedPlatform) return true
return false
}, [enabled, state.active, state.error, state.unsupportedPlatform])
if (!shouldShow) {
return null
}
if (!shouldShow) return null
// Unsupported-platform branch: macOS/Linux packaged builds hit this when
// there's no Hermes Agent installed yet and we can't drive install.sh
@@ -346,7 +278,6 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
if (state.unsupportedPlatform) {
const ups = state.unsupportedPlatform
const platformLabel = ups.platform === 'darwin' ? 'macOS' : ups.platform === 'linux' ? 'Linux' : ups.platform
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md">
<div className="w-full max-w-xl rounded-xl border bg-card p-8 shadow-xl">
@@ -363,20 +294,20 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
</pre>
<div className="mt-2 flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
void navigator.clipboard?.writeText(ups.installCommand).catch(() => {})
}}
size="sm"
variant="secondary"
>
Copy command
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
window.hermesDesktop?.openExternal?.(ups.docsUrl)
}}
size="sm"
variant="ghost"
>
View install docs
</Button>
@@ -387,7 +318,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<span className="text-xs text-muted-foreground">
Will install to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">{ups.activeRoot}</code>
</span>
<Button onClick={() => window.location.reload()} size="sm" variant="default">
<Button variant="default" size="sm" onClick={() => window.location.reload()}>
I{'\u2019'}ve run it -- retry
</Button>
</div>
@@ -398,11 +329,9 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
const stages = state.manifest?.stages || []
const currentStage = stages.find(s => state.stages[s.name]?.state === 'running')?.name
const completedCount = stages.filter(
s => state.stages[s.name]?.state === 'succeeded' || state.stages[s.name]?.state === 'skipped'
).length
const totalCount = stages.length
const failed = Boolean(state.error)
const progressPct = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0
@@ -467,11 +396,11 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<ol className="mb-4 space-y-1">
{stages.map(stage => (
<StageRow
descriptor={stage}
isCurrent={stage.name === currentStage}
key={stage.name}
now={now}
descriptor={stage}
result={state.stages[stage.name]}
isCurrent={stage.name === currentStage}
now={now}
/>
))}
</ol>
@@ -479,9 +408,9 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<div className="border-t pt-3">
<button
className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setLogOpen(v => !v)}
type="button"
onClick={() => setLogOpen(v => !v)}
className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
{logOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<span>{logOpen ? 'Hide installer output' : 'Show installer output'}</span>
@@ -503,11 +432,8 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<>
{state.log.map((entry, i) => (
<div
className={cn(
'whitespace-pre-wrap break-words',
entry.stream === 'stderr' && 'text-muted-foreground'
)}
key={i}
className={cn('whitespace-pre-wrap break-words', entry.stream === 'stderr' && 'text-muted-foreground')}
>
{entry.stage ? <span className="text-muted-foreground/70">[{entry.stage}] </span> : null}
<span>{entry.line}</span>
@@ -556,13 +482,13 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
</span>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={async () => {
const text = state.log
.map(entry => (entry.stage ? `[${entry.stage}] ${entry.line}` : entry.line))
.join('\n')
const fullText = state.error ? `Error: ${state.error}\n\n${text}` : text
try {
await navigator.clipboard.writeText(fullText)
setCopied(true)
@@ -571,12 +497,12 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
// ignore -- some environments forbid clipboard writes
}
}}
size="sm"
variant="secondary"
>
{copied ? 'Copied!' : 'Copy output'}
</Button>
<Button
variant="default"
size="sm"
onClick={async () => {
// Tell main.cjs to clear its latched failure BEFORE we
// reload. Otherwise the renderer reload calls getConnection
@@ -587,11 +513,8 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
} catch {
// best-effort -- continue with reload regardless
}
window.location.reload()
}}
size="sm"
variant="default"
>
Reload and retry
</Button>

View File

@@ -1,9 +1,10 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { $desktopOnboarding, type DesktopOnboardingState, type OnboardingContext } from '@/store/onboarding'
import type { OAuthProvider } from '@/types/hermes'
import { $desktopOnboarding, type DesktopOnboardingState, type OnboardingContext } from '@/store/onboarding'
import { Picker } from './desktop-onboarding-overlay'
function provider(id: string, name = id): OAuthProvider {

View File

@@ -24,14 +24,12 @@ import { $desktopBoot, type DesktopBootState } from '@/store/boot'
import {
$desktopOnboarding,
cancelOnboardingFlow,
clearPendingProviderOAuth,
closeManualOnboarding,
confirmOnboardingModel,
copyDeviceCode,
copyExternalCommand,
type OnboardingContext,
type OnboardingFlow,
peekPendingProviderOAuth,
recheckExternalSignin,
refreshOnboarding,
saveOnboardingApiKey,
@@ -49,7 +47,7 @@ interface DesktopOnboardingOverlayProps {
requestGateway: OnboardingContext['requestGateway']
}
export interface ApiKeyOption {
interface ApiKeyOption {
description: string
docsUrl: string
envKey: string
@@ -127,7 +125,7 @@ const FLOW_SUBTITLES: Record<OAuthProvider['flow'], string> = {
const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
export const sortProviders = (providers: OAuthProvider[]) =>
const sortProviders = (providers: OAuthProvider[]) =>
[...providers].sort((a, b) => orderOf(a) - orderOf(b) || a.name.localeCompare(b.name))
export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway }: DesktopOnboardingOverlayProps) {
@@ -150,36 +148,6 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
}
}, [ctx, enabled, onboarding.requested])
// When the Providers settings page asked to connect a specific provider, the
// store stashed its id. Once the provider list has loaded and we're back at
// an idle picker, launch that exact OAuth flow so the user lands directly in
// sign-in instead of the picker they just came from.
useEffect(() => {
if (!onboarding.manual || onboarding.providers === null || onboarding.flow.status !== 'idle') {
return
}
const pendingId = peekPendingProviderOAuth()
if (!pendingId) {
return
}
const provider = onboarding.providers.find(p => p.id === pendingId)
if (provider) {
// Only clear once we've committed to launching it, so a failed/empty
// provider fetch doesn't silently drop the hand-off.
clearPendingProviderOAuth()
void startProviderOAuth(provider, ctx)
} else if (onboarding.providers.length > 0) {
// The list loaded but the id isn't a real provider — drop the stale
// hand-off. An empty list means the fetch isn't ready yet, so keep it
// and let a later refresh retry.
clearPendingProviderOAuth()
}
}, [ctx, onboarding.flow.status, onboarding.manual, onboarding.providers])
// Mount from frame 1 so we replace the boot overlay seamlessly. The
// configured field stays null until the runtime check resolves; only then
// do we know whether to dismiss (true) or surface the picker (false).
@@ -222,12 +190,9 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
)
}
// The launch reason is a prompt ("why am I seeing this"), not an error — real
// provider-setup failures are filtered out upstream and surfaced by FlowPanel.
// Keep it neutral so it never reads as a failure.
function ReasonNotice({ reason }: { reason: string }) {
return (
<div className="rounded-2xl border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/40 px-4 py-3 text-sm text-muted-foreground">
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{reason}
</div>
)
@@ -281,7 +246,7 @@ function Header() {
)
}
export const FEATURED_ID = 'nous'
const FEATURED_ID = 'nous'
const FEATURED_PITCH = 'One subscription, 300+ frontier models the recommended way to run Hermes'
const SHOW_ALL_KEY = 'hermes-onboarding-show-all-v1'
@@ -310,13 +275,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
const hasOauth = ordered.length > 0
if (mode === 'apikey' || !hasOauth) {
return (
<ApiKeyForm
canGoBack={hasOauth}
onBack={() => setOnboardingMode('oauth')}
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
/>
)
return <ApiKeyForm canGoBack={hasOauth} ctx={ctx} />
}
if (providers === null) {
@@ -365,7 +324,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
)
}
export function FeaturedProviderRow({
function FeaturedProviderRow({
onSelect,
provider
}: {
@@ -376,17 +335,17 @@ export function FeaturedProviderRow({
return (
<button
className="group relative flex w-full items-center justify-between gap-4 rounded-[8px] bg-primary/[0.06] px-3 py-2.5 text-left transition-colors hover:bg-primary/10"
className={cn(
'group flex w-full items-center justify-between gap-4 rounded-2xl border-2 border-primary/50 bg-primary/5 p-4 text-left transition hover:border-primary hover:bg-primary/10',
loggedIn && 'border-primary'
)}
onClick={() => onSelect(provider)}
type="button"
>
<span aria-hidden className="arc-border arc-reverse arc-nous" />
<div className="min-w-0">
<div className="flex items-center gap-2">
<img alt="" className="size-5 shrink-0 rounded" src={assetPath('apple-touch-icon.png')} />
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">
{providerTitle(provider)}
</span>
<span className="text-base font-semibold">{providerTitle(provider)}</span>
{loggedIn ? (
<ConnectedTag />
) : (
@@ -398,7 +357,7 @@ export function FeaturedProviderRow({
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FEATURED_PITCH}</p>
</div>
<ChevronRight className="size-4 shrink-0 text-primary transition group-hover:translate-x-0.5" />
<ChevronRight className="size-5 shrink-0 text-primary transition group-hover:translate-x-0.5" />
</button>
)
}
@@ -412,15 +371,15 @@ function ConnectedTag() {
)
}
export function KeyProviderRow({ onClick }: { onClick: () => void }) {
function KeyProviderRow({ onClick }: { onClick: () => void }) {
return (
<button
className="group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
className="group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-3 text-left transition hover:border-primary/40 hover:bg-accent/40"
onClick={onClick}
type="button"
>
<div className="min-w-0">
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">OpenRouter</span>
<span className="text-sm font-semibold">OpenRouter</span>
<p className="mt-1 text-xs leading-5 text-muted-foreground">One key, hundreds of models — a solid default</p>
</div>
<ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" />
@@ -428,27 +387,22 @@ export function KeyProviderRow({ onClick }: { onClick: () => void }) {
)
}
export function ProviderRow({
onSelect,
provider
}: {
onSelect: (provider: OAuthProvider) => void
provider: OAuthProvider
}) {
function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvider) => void; provider: OAuthProvider }) {
const loggedIn = provider.status?.logged_in
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
return (
<button
className="group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
className={cn(
'group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-3 text-left transition hover:border-primary/40 hover:bg-accent/40',
loggedIn && 'border-primary/30'
)}
onClick={() => onSelect(provider)}
type="button"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">
{providerTitle(provider)}
</span>
<span className="text-sm font-semibold">{providerTitle(provider)}</span>
{loggedIn ? <ConnectedTag /> : null}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FLOW_SUBTITLES[provider.flow]}</p>
@@ -458,62 +412,13 @@ export function ProviderRow({
)
}
// Presentational two-column key picker. Onboarding feeds it its curated
// options + a ctx-bound save; the Providers settings page feeds it the full
// provider catalog + a setEnvVar-backed save (plus `isSet`/`onClear` so it can
// double as a manage surface). Keep it free of store/ctx coupling so both
// surfaces render the identical form.
export function ApiKeyForm({
canGoBack,
isSet,
onBack,
onClear,
onSave,
options = API_KEY_OPTIONS,
redactedValue
}: {
canGoBack: boolean
isSet?: (envKey: string) => boolean
onBack: () => void
onClear?: (envKey: string) => void
onSave: (envKey: string, value: string, name: string) => Promise<{ message?: string; ok: boolean }>
options?: ApiKeyOption[]
redactedValue?: (envKey: string) => null | string | undefined
}) {
const [option, setOption] = useState<ApiKeyOption>(options[0])
function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingContext }) {
const [option, setOption] = useState<ApiKeyOption>(API_KEY_OPTIONS[0])
const [value, setValue] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
// `options` can change at runtime when callers filter the catalog (e.g. the
// Providers page wiring its search into this grid). Keep the selection valid
// by snapping back to the first remaining option when the current one drops.
useEffect(() => {
if (options.length > 0 && !options.some(o => o.id === option.id)) {
setOption(options[0])
setValue('')
setError(null)
}
}, [option.id, options])
// The catalog grid can be tall, leaving the entry field far below the fold.
// On selection we scroll the field into view and focus it so it's always
// obvious where to paste next.
const entryRef = useRef<HTMLDivElement>(null)
const pick = (o: ApiKeyOption) => {
setOption(o)
setValue('')
setError(null)
requestAnimationFrame(() => {
entryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
entryRef.current?.querySelector('input')?.focus()
})
}
const isLocal = option.envKey === 'OPENAI_BASE_URL'
const alreadySet = isSet?.(option.envKey) ?? false
// When set, surface the backend's redacted value (e.g. "sk-12…wxyz") as the
// placeholder so users can eyeball that the right key is in place.
const currentRedacted = alreadySet ? (redactedValue?.(option.envKey) ?? null) : null
// 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
@@ -525,7 +430,7 @@ export function ApiKeyForm({
setSaving(true)
setError(null)
const result = await onSave(option.envKey, value, option.name)
const result = await saveOnboardingApiKey(option.envKey, value, option.name, ctx)
if (result.ok) {
setValue('')
@@ -541,7 +446,7 @@ export function ApiKeyForm({
{canGoBack ? (
<button
className="-mt-1 flex items-center gap-1 self-start text-xs font-medium text-muted-foreground hover:text-foreground"
onClick={onBack}
onClick={() => setOnboardingMode('oauth')}
type="button"
>
<ChevronLeft className="size-3" />
@@ -550,30 +455,30 @@ export function ApiKeyForm({
) : null}
<div className="grid gap-2 sm:grid-cols-2">
{options.map(o => (
{API_KEY_OPTIONS.map(o => (
<button
className={cn(
'rounded-2xl border bg-background/60 p-3 text-left transition hover:bg-accent/50',
option.id === o.id ? 'border-primary ring-2 ring-primary/20' : 'border-border'
)}
key={o.id}
onClick={() => pick(o)}
onClick={() => {
setOption(o)
setValue('')
setError(null)
}}
type="button"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{o.name}</span>
{option.id === o.id ? (
<Check className="size-4 text-primary" />
) : isSet?.(o.envKey) ? (
<Check className="size-3.5 text-muted-foreground" />
) : null}
{option.id === o.id ? <Check className="size-4 text-primary" /> : null}
</div>
{o.short ? <p className="mt-1 text-xs text-muted-foreground">{o.short}</p> : null}
</button>
))}
</div>
<div className="grid scroll-mt-4 gap-2" ref={entryRef}>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<p className="text-sm leading-6 text-muted-foreground">{option.description}</p>
{option.docsUrl ? <DocsLink href={option.docsUrl}>Get a key</DocsLink> : null}
@@ -584,26 +489,17 @@ export function ApiKeyForm({
className="font-mono"
onChange={e => setValue(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void submit()}
placeholder={
currentRedacted ?? (alreadySet ? 'Replace current value' : option.placeholder || 'Paste API key')
}
placeholder={option.placeholder || 'Paste API key'}
type={isLocal ? 'text' : 'password'}
value={value}
/>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
<div className="flex items-center justify-between gap-3">
<div>
{alreadySet && onClear ? (
<Button onClick={() => onClear(option.envKey)} size="sm" variant="ghost">
Remove
</Button>
) : null}
</div>
<div className="flex justify-end">
<Button disabled={!canSave || saving} onClick={() => void submit()}>
{saving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
{saving ? 'Connecting' : alreadySet ? 'Update' : 'Connect'}
{saving ? 'Connecting' : 'Connect'}
</Button>
</div>
</div>
@@ -678,8 +574,8 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
return (
<Step title={`Sign in with ${title}`}>
<p className="text-sm text-muted-foreground">
We opened {title} in your browser. Authorize Hermes there and you'll be connected automatically — nothing to
copy or paste.
We opened {title} in your browser. Authorize Hermes there and you'll be connected
automatically — nothing to copy or paste.
</p>
<FlowFooter left={<DocsLink href={flow.start.auth_url}>Re-open sign-in page</DocsLink>}>
<span className="flex items-center gap-2 text-xs text-muted-foreground">

View File

@@ -65,7 +65,10 @@ function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) {
<Button onClick={() => window.location.reload()} variant="text">
Reload window
</Button>
<Button onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)} variant="text">
<Button
onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)}
variant="text"
>
Open logs
</Button>
</ErrorState>

View File

@@ -185,7 +185,6 @@ function ModelResults({
}
const q = search.trim().toLowerCase()
const matches = (provider: ModelOptionProvider, model: string) =>
!q ||
model.toLowerCase().includes(q) ||

View File

@@ -25,13 +25,7 @@ interface ModelVisibilityDialogProps {
sessionId?: string | null
}
export function ModelVisibilityDialog({
gw,
onOpenChange,
onOpenProviders,
open,
sessionId
}: ModelVisibilityDialogProps) {
export function ModelVisibilityDialog({ gw, onOpenChange, onOpenProviders, open, sessionId }: ModelVisibilityDialogProps) {
const [search, setSearch] = useState('')
const stored = useStore($visibleModels)
@@ -93,11 +87,17 @@ export function ModelVisibilityDialog({
<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 ? (
<BrailleSpinner className="mx-auto text-sm" />
) : (
'No authenticated providers.'
)}
</div>
) : (
providers.map(provider => {
const models = collapseModelFamilies(provider.models ?? []).filter(family => matches(provider, family.id))
const models = collapseModelFamilies(provider.models ?? []).filter(family =>
matches(provider, family.id)
)
if (models.length === 0) {
return null
@@ -121,7 +121,10 @@ export function ModelVisibilityDialog({
{name}
{tag ? <span className="text-(--ui-text-tertiary)"> {tag}</span> : null}
</span>
<Switch checked={visible.has(key)} onCheckedChange={() => toggle(provider, family.id)} />
<Switch
checked={visible.has(key)}
onCheckedChange={() => toggle(provider, family.id)}
/>
</label>
)
})}

View File

@@ -132,7 +132,9 @@ 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">Details</summary>
<summary className="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">
<pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed">
{detail}

View File

@@ -4,14 +4,7 @@ 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 { 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'

View File

@@ -36,8 +36,7 @@ const buttonVariants = cva(
'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]'
'icon-titlebar': 'h-(--titlebar-control-height) w-(--titlebar-control-size) rounded-[4px] [&_.codicon]:text-[0.875rem]'
}
},
defaultVariants: {

View File

@@ -10,7 +10,6 @@ export const controlVariants = cva(
{
variants: {
size: {
xs: 'px-2 py-0.5 text-[0.6875rem] leading-4',
sm: 'px-2 py-1',
default: 'px-2.5 py-1.5',
lg: 'px-3 py-2 text-sm leading-5'

View File

@@ -4,7 +4,12 @@ import { cn } from '@/lib/utils'
import { type ControlVariantProps, controlVariants } from './control'
function Input({ className, type, size, ...props }: Omit<React.ComponentProps<'input'>, 'size'> & ControlVariantProps) {
function Input({
className,
type,
size,
...props
}: Omit<React.ComponentProps<'input'>, 'size'> & ControlVariantProps) {
return (
<input
className={cn(

View File

@@ -73,14 +73,13 @@ function SidebarProvider({
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
React.useEffect(() => {
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
}, [open])
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open)

View File

@@ -5,7 +5,13 @@ import { cn } from '@/lib/utils'
import { type ControlVariantProps, controlVariants } from './control'
function Textarea({ className, size, ...props }: React.ComponentProps<'textarea'> & ControlVariantProps) {
return <textarea className={cn(controlVariants({ size }), 'min-h-16', className)} data-slot="textarea" {...props} />
return (
<textarea
className={cn(controlVariants({ size }), 'min-h-16', className)}
data-slot="textarea"
{...props}
/>
)
}
export { Textarea }

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