Compare commits

..

1 Commits

Author SHA1 Message Date
Austin Pickett
ea0efea2bd fix(cli): /plugins shows installed-but-not-enabled plugins
The /plugins slash command read from the live PluginManager, which only
knows about *loaded* plugins. A freshly-installed plugin that hadn't been
enabled yet showed 'No plugins installed. Drop plugin directories into
~/.hermes/plugins/' — even though it was on disk and a valid plugin.

Switch to the same disk-discovery path as 'hermes plugins list'
(_discover_all_plugins + enabled/disabled sets + _plugin_status), so an
installed plugin now appears with its activation state ([not enabled],
enabled, or disabled) plus the exact enable command.

Default the quick /plugins view to user-installed plugins and summarize
bundled providers/platforms on one line (the full catalog stays behind
'hermes plugins list') so the output isn't drowned by 60+ bundled
provider plugins.
2026-06-09 00:13:38 -04:00
367 changed files with 4329 additions and 24311 deletions

View File

@@ -63,45 +63,3 @@ data/
# Compose/profile runtime state (bind-mounted; avoid ownership/secret issues)
hermes-config/
runtime/
# ---------- Not needed inside the Docker image ----------
# Desktop app source (Tauri/Electron); never installed in the container
apps/
# Test suite — not shipped in production images
tests/
# Documentation site (Docusaurus) and supplementary docs
website/
docs/
# Assets only used by the GitHub README
assets/
infographic/
# Plugin-level docs (hermes-achievements ships docs/ but the runtime doesn't read them)
plugins/hermes-achievements/docs/
# Nix / Homebrew / AUR packaging metadata — irrelevant to Docker
nix/
flake.nix
flake.lock
packaging/
# Design and planning documents
plans/
.plans/
# ACP registry manifest (icon + agent.json) — not consumed at runtime
acp_registry/
# Repo-level dotfiles that are git-only or dev-tooling config
.env.example
.envrc
.gitattributes
.hadolint.yaml
.mailmap
# Top-level LICENSE (not matched by *.md); not needed inside the container
LICENSE

View File

@@ -44,7 +44,7 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
node-version: 20
cache: npm
cache-dependency-path: website/package-lock.json

View File

@@ -18,7 +18,7 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
node-version: 20
cache: npm
cache-dependency-path: website/package-lock.json

View File

@@ -55,31 +55,15 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
with:
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
# Keyed on the dependency manifests, so the cache is reused until
# pyproject.toml or uv.lock changes. `uv sync` still runs every
# time, but resolves from the warm cache instead of re-downloading
# and re-building wheels.
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Set up Python 3.11
run: uv python install 3.11
- name: Install dependencies
# `uv sync --locked` installs the exact pinned set from uv.lock (and
# fails if the lock is out of sync with pyproject.toml), giving a
# reproducible env. It also creates .venv itself, so no separate
# `uv venv` step is needed.
run: uv sync --locked --python 3.11 --extra all --extra dev
- name: Minimize uv cache
# Optimized for CI: prunes pre-built wheels that are cheap to
# re-download, keeping the persisted cache small and fast to restore.
run: uv cache prune --ci
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
uv pip install -e ".[all,dev]"
- name: Run tests (slice ${{ matrix.slice }}/6)
# Per-file isolation via scripts/run_tests_parallel.py: discovers
@@ -177,31 +161,15 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
with:
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
# Keyed on the dependency manifests, so the cache is reused until
# pyproject.toml or uv.lock changes. `uv sync` still runs every
# time, but resolves from the warm cache instead of re-downloading
# and re-building wheels.
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Set up Python 3.11
run: uv python install 3.11
- name: Install dependencies
# `uv sync --locked` installs the exact pinned set from uv.lock (and
# fails if the lock is out of sync with pyproject.toml), giving a
# reproducible env. It also creates .venv itself, so no separate
# `uv venv` step is needed.
run: uv sync --locked --python 3.11 --extra all --extra dev
- name: Minimize uv cache
# Optimized for CI: prunes pre-built wheels that are cheap to
# re-download, keeping the persisted cache small and fast to restore.
run: uv cache prune --ci
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
uv pip install -e ".[all,dev]"
- name: Packaged-wheel i18n smoke test
run: |

View File

@@ -1,25 +0,0 @@
# .github/workflows/typecheck.yml
name: Typecheck
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
typecheck:
runs-on: ubuntu-latest
strategy:
matrix:
package:
[ui-tui, web, apps/bootstrap-installer, apps/desktop, apps/shared]
fail-fast: false # report all failures, not just the first one
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run --prefix ${{ matrix.package }} typecheck

6
.gitignore vendored
View File

@@ -114,12 +114,6 @@ docs/superpowers/*
# treat it as a local edit and autostash it on every run (#38529).
.hermes-bootstrap-complete
# Interrupted-update breadcrumb + recovery lock written next to the shared venv
# by `hermes update` / launch-time self-heal. Runtime state, never a code change
# — ignore so `git status` stays clean and update's autostash skips them.
.update-incomplete
.update-incomplete.lock
# Tool Search live-test harness output — non-deterministic model transcripts,
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
scripts/out/

205
AGENTS.md
View File

@@ -4,201 +4,6 @@ Instructions for AI coding assistants and developers working on the hermes-agent
**Never give up on the right solution.**
## What Hermes Is
Hermes is a personal AI agent that runs the same agent core across a CLI, a
messaging gateway (Telegram, Discord, Slack, and ~20 other platforms), a TUI,
and an Electron desktop app. It learns across sessions (memory + skills),
delegates to subagents, runs scheduled jobs, and drives a real terminal and
browser. It is extended primarily through **plugins and skills**, not by
growing the core.
Two properties shape almost every design decision and are the lens for
reviewing any change:
- **Per-conversation prompt caching is sacred.** A long-lived conversation
reuses a cached prefix every turn. Anything that mutates past context,
swaps toolsets, or rebuilds the system prompt mid-conversation invalidates
that cache and multiplies the user's cost. We do not do it (the one
exception is context compression).
- **The core is a narrow waist; capability lives at the edges.** Every model
tool we add is sent on every API call, so the bar for a new *core* tool is
high. Most new capability should arrive as a CLI command + skill, a
service-gated tool, or a plugin — not as core surface.
## Contribution Rubric — What We Want / What We Don't
This is the project's intent layer. Use it two ways:
1. **For humans and for your own work** — what gets merged and what gets
rejected, so a contribution aims at the target.
2. **For automated review (the triage sweeper)** — guidance on when a PR is
safe to close on the three allowed reasons (`implemented_on_main`,
`cannot_reproduce`, `incoherent`) and, just as important, **when NOT to
close** one. Taste-based "we don't want this / out of scope" closes are NOT
an automated decision — those stay with a human maintainer. The sweeper's
job here is to recognize design intent and *avoid wrongly closing a
legitimate contribution*, not to make the won't-implement call itself.
Read the balance right: Hermes ships a **lot** — most merges are bug fixes to
real reported behavior, and the product surface (platforms, channels,
providers, models, desktop/TUI features) expands aggressively and on purpose.
The restraint below is aimed squarely at the **core agent + the model tool
schema**, the one place where every addition is paid for on every API call.
"Smallest footprint" governs *how a capability is wired into the core*, NOT
whether the product is allowed to grow. We are expansive at the edges and
conservative at the waist.
### What we want
- **Fix real bugs, well.** The bulk of what lands is `fix(...)` against an
actual reported symptom. A good fix reproduces the symptom on current
`main`, points to the exact line where it manifests, and fixes the whole bug
class — sibling call paths included — not just the one site the reporter hit.
- **Expand reach at the edges.** New platform adapters, channels, providers,
models, and desktop/TUI/dashboard features are welcome and land routinely,
including large ones (a new messaging channel, a session-cap feature, a
Windows PTY bridge). Breadth in the product is a goal, not a footprint
concern — as long as it integrates with the existing setup/config UX
(`hermes tools`, `hermes setup`, auto-install) rather than bolting on a raw
env var.
- **Refactor god-files into clean modules.** Extracting a multi-thousand-line
cluster out of `cli.py` / `run_agent.py` / `gateway/run.py` into a focused
mixin or module is wanted work, even when the diff is huge and mechanical
(large `+N/-N` refactors merge regularly). The "every line traces to the
request" test applies to *feature* PRs; a declared refactor's request IS the
extraction.
- **Keep the core narrow.** New *model tools* are the expensive exception —
every tool ships on every API call. Prefer, in order: extend existing code →
CLI command + skill → service-gated tool (`check_fn`) → plugin → MCP server
in the catalog → new core tool (last resort). See "The Footprint Ladder."
- **Extend, don't duplicate.** Before adding a module/manager/hook, check
whether existing infrastructure already covers the use case. When several PRs
integrate the same *category*, design one shared interface instead of merging
them one at a time (see the ABC + orchestrator note under the Footprint
Ladder).
- **Behavior contracts over snapshots.** Tests should assert how two pieces of
data must relate (invariants), not freeze a current value (model lists,
config version literals, enumeration counts). See "Don't write
change-detector tests."
- **E2E validation, not just green unit mocks.** For anything touching
resolution chains, config propagation, security boundaries, remote
backends, or file/network I/O, exercise the real path with real imports
against a temp `HERMES_HOME`. Mocks hide integration bugs.
- **Cache-, alternation-, and invariant-safe.** Preserve prompt caching, strict
message role alternation (never two same-role messages in a row; never a
synthetic user message injected mid-loop), and a system prompt that is
byte-stable for the life of a conversation.
- **Contributor credit preserved.** Salvage external work by cherry-picking
(rebase-merge) so authorship survives in git history; don't reimplement from
scratch when you can build on top.
### What we don't want (rejected even when well-built)
- **Speculative infrastructure.** Hooks, callbacks, or extension points with no
concrete consumer. Adding a hook is easy; removing one after plugins depend
on it is hard. A hook is NOT speculative if a contributor has a real, stated
use case — even if the consumer ships separately.
- **New `HERMES_*` env vars for non-secret config.** `.env` is for secrets
only (API keys, tokens, passwords). All behavioral settings — timeouts,
thresholds, feature flags, display prefs — go in `config.yaml`. Bridge to an
internal env var if the mechanism needs one, but user-facing docs point to
`config.yaml`. Reject PRs that tell users to "set X in your .env" unless X
is a credential.
- **A new core tool when terminal + file already do the job, or when a skill
would.** If the only barrier is file visibility on a remote backend, fix the
mount, not the toolset.
- **Lazy-reading escape hatches on instructional tools.** No `offset`/`limit`
pagination on tools that load content the agent must read fully (skills,
prompts, playbooks). Models will read page 1 and skip the rest.
- **"Fixes" that destroy the feature they secure.** A mitigation that kills the
feature's purpose is the wrong mitigation. Read the original commit's intent
(`git log -p -S`) before restricting behavior; find a fix that preserves the
feature.
- **Outbound telemetry / usage attribution without opt-in gating.** No new
analytics, third-party identifier tagging, or attribution tags until a
generic user-facing opt-in (config gate + setup prompt + `hermes tools`
toggle) exists. Park behind a label, do not merge.
- **Change-detector tests, cache-breaking mid-conversation, dead code wired in
without E2E proof, and plugins that touch core files.** Plugins live in their
own directory and work within the ABCs/hooks we provide; if a plugin needs
more, widen the generic plugin surface, don't special-case it in core.
### Before you call it a bug — verify the premise (and when NOT to close)
The most common reason a well-written PR gets closed is not code quality — it
is that the change is built on a **wrong premise**, or it treats an
**intentional design as a gap**. These patterns cut both ways: they tell a
human reviewer what to scrutinize, and they tell the automated sweeper when a
PR is NOT safe to close as `implemented_on_main` / `cannot_reproduce` (when in
doubt, leave it open for a human). They are distilled from real closes.
- **"Intentional design, not a gap."** A limitation that looks like an
oversight is often deliberate. Before "fixing" a missing link or a
restriction, ask whether the isolation IS the design. Example: profiles are
independent islands on purpose — a PR adding live config inheritance from the
default profile was closed because coupling profiles together is exactly what
the design prevents (the copy-at-creation `--clone` path already covers the
legitimate "start from my default" case). Read the original commit's intent
(`git log -p -S "<symbol>"`) before assuming something is unfinished.
- **"The premise doesn't hold against how X actually works."** A PR's
justification frequently rests on a wrong mental model of an existing
mechanism. Trace the real code/runtime before accepting the rationale. Two
real closes: a rate-limit "re-probe during cooldown" PR (the breaker only
trips on a *confirmed-empty* account bucket, so re-probing just hammers a
bucket we've already proven empty); a usage-accumulation fix whose new branch
**never executes at runtime** because an earlier guard already popped the
state it depended on. If you can't point to the exact line where the bug
manifests AND show the fix changes that line's behavior, you haven't verified
the premise.
- **"This fix was wrong — the absence/omission was deliberate."** Adding the
obvious-looking missing piece can break things the omission was protecting.
Example: restoring "missing" `__init__.py` files made a test tree importable
as a dotted package that shadowed the real plugin, deleting its `register()`
at import time. The absence was load-bearing.
- **"Overreached / resurrected an approach we'd moved past."** Scope creep that
supersedes an agreed-on base, or revives a direction the maintainers
deliberately closed, gets rejected even when the code works. Keep the change
to the narrow piece that was actually agreed; offer the rest as a focused
follow-up.
The throughline: **verify the claim AND the intent against the codebase before
writing or merging a fix.** A confirmed reproduction on current `main` plus a
line-level account of where the fix acts beats a plausible-sounding rationale
every time. When in doubt about intent, it is cheaper to ask than to ship a
fix that fights the design.
### The Footprint Ladder (new capability decision)
Each rung adds more permanent surface than the one above. Choose the highest
(least-footprint) rung that correctly solves the problem:
1. **Extend existing code** — the capability is a variation of something that
already exists. Zero new surface.
2. **CLI command + skill** — manages config/state/infra expressible as shell
commands. The agent runs `hermes <subcommand>` guided by a skill. Zero
model-tool footprint. Default choice for subscriptions, scheduled tasks,
service setup. Examples: `hermes webhook`, `hermes cron`, `hermes tools`.
3. **Service-gated tool (`check_fn`)** — needs structured params/returns AND
only appears when a prerequisite is configured. Zero footprint otherwise.
Examples: Home Assistant tools (gated on token), memory-provider tools.
4. **Plugin** — third-party/niche/user-specific capability that doesn't ship in
core. Lives in `~/.hermes/plugins/` or a pip package, discovered at runtime.
5. **MCP server (in the catalog)** — if the capability genuinely needs to be a
tool (structured I/O the agent invokes) but isn't core-fundamental, prefer
building it as an MCP server and adding it to the MCP catalog over growing
the core toolset. The agent connects to it through the built-in MCP client;
zero permanent core-schema footprint, and it's reusable by any MCP host.
6. **New core tool** — only when the capability is fundamental, broadly useful
to nearly every user, and unreachable via terminal + file (or an MCP server).
Examples of correct core tools: terminal, read_file, web_search,
browser_navigate.
When 3+ open PRs try to integrate the same *category* of thing (memory
backends, providers, notifiers), don't merge them one at a time — design an
ABC + orchestrator, wrap the existing built-in as the first provider, and turn
the competing PRs into plugins against that interface.
## Development Environment
```bash
@@ -459,7 +264,7 @@ npm install # first time
npm run dev # watch mode (rebuilds hermes-ink + tsx --watch)
npm start # production
npm run build # full build (hermes-ink + tsc)
npm run typecheck # typecheck only (tsc --noEmit)
npm run type-check # typecheck only (tsc --noEmit)
npm run lint # eslint
npm run fmt # prettier
npm test # vitest
@@ -497,11 +302,9 @@ A **separate** chat surface from both the classic CLI and the dashboard's embedd
## Adding New Tools
Before adding any tool, settle the footprint question first (see "The
Footprint Ladder" in the Contribution Rubric): most capabilities should NOT
be core tools. For custom or local-only tools, do **not** edit Hermes core.
Use the plugin route instead: create `~/.hermes/plugins/<name>/plugin.yaml`
and `~/.hermes/plugins/<name>/__init__.py`, then register tools with
For most custom or local-only tools, do **not** edit Hermes core. Use the plugin
route instead: create `~/.hermes/plugins/<name>/plugin.yaml` and
`~/.hermes/plugins/<name>/__init__.py`, then register tools with
`ctx.register_tool(...)`. Plugin toolsets are discovered automatically and can be
enabled or disabled without touching `tools/` or `toolsets.py`.

View File

@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
# hermes process, the dashboard, and per-profile gateways.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
rm -rf /var/lib/apt/lists/*
# ---------- s6-overlay install ----------
@@ -146,9 +146,9 @@ RUN npm install --prefer-offline --no-audit && \
#
# `uv sync --frozen --no-install-project --extra all --extra messaging`
# installs the deps reachable through the composite `[all]` extra
# (handpicked set intended for the production image — excludes `[dev]`),
# plus gateway messaging adapters that should work in the published image
# without a first-boot lazy install. We do NOT use `--all-extras`:
# (handpicked set intended for the production image), plus gateway
# messaging adapters that should work in the published image without a
# first-boot lazy install. We do NOT use `--all-extras`:
# that would pull in `[rl]` (atroposlib + tinker + torch + wandb from
# git), `[yc-bench]` (another git dep), and `[termux-all]` (Android
# redundancy), none of which belong in the published container.
@@ -164,30 +164,19 @@ RUN npm install --prefer-offline --no-audit && \
# image update and recall/retain then fails with
# `ModuleNotFoundError: No module named 'hindsight_client'` (#38128).
#
# The Matrix gateway's deps ([matrix] extra) are baked in because
# python-olm (transitive via mautrix[encryption]) builds from source on
# Python/image combinations without usable wheels. The Docker image is
# Linux-only, so keeping the native libolm/build-toolchain packages here
# avoids the cross-platform failures that kept [matrix] out of [all]
# while still making Matrix work in the published container. Fixes #30399.
#
# The editable link is created after the source copy below.
COPY pyproject.toml uv.lock ./
RUN touch ./README.md
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight --extra matrix
# ---------- Frontend build (cached independently from Python source) ----------
# Copy only the frontend source trees first so that Python-only changes don't
# invalidate the (relatively slow) web + ui-tui build layer.
COPY web/ web/
COPY ui-tui/ ui-tui/
RUN cd web && npm run build && \
cd ../ui-tui && npm run build
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight
# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
COPY --chown=hermes:hermes . .
# Build browser dashboard and terminal UI assets.
RUN cd web && npm run build && \
cd ../ui-tui && npm run build
# ---------- Permissions ----------
# Make install dir world-readable so any HERMES_UID can read it at runtime.
# The venv needs to be traversable too.

View File

@@ -1,6 +1,5 @@
graft skills
graft optional-skills
graft optional-mcps
graft locales
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs

View File

@@ -3,9 +3,7 @@
</p>
# Hermes Agent ☤
<p align="center">
<a href="https://hermes-agent.nousresearch.com/">Hermes Agent</a> | <a href="https://hermes-agent.nousresearch.com/">Hermes Desktop</a>
</p>
<p align="center">
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentation"></a>
<a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>

View File

@@ -187,7 +187,6 @@ def init_agent(
thinking_callback: callable = None,
reasoning_callback: callable = None,
clarify_callback: callable = None,
read_terminal_callback: callable = None,
step_callback: callable = None,
stream_delta_callback: callable = None,
interim_assistant_callback: callable = None,
@@ -418,7 +417,6 @@ def init_agent(
agent.thinking_callback = thinking_callback
agent.reasoning_callback = reasoning_callback
agent.clarify_callback = clarify_callback
agent.read_terminal_callback = read_terminal_callback
agent.step_callback = step_callback
agent.stream_delta_callback = stream_delta_callback
agent.interim_assistant_callback = interim_assistant_callback

View File

@@ -49,7 +49,7 @@ def _ra():
AGENT_RUNTIME_POST_HOOK_TOOL_NAMES = frozenset(
{"todo", "session_search", "memory", "clarify", "read_terminal", "delegate_task"}
{"todo", "session_search", "memory", "clarify", "delegate_task"}
)
@@ -1784,17 +1784,6 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
),
next_args,
)
elif function_name == "read_terminal":
def _execute(next_args: dict) -> Any:
from tools.read_terminal_tool import read_terminal_tool as _read_terminal_tool
return _finish_agent_tool(
_read_terminal_tool(
start_line=next_args.get("start_line"),
count=next_args.get("count"),
callback=getattr(agent, "read_terminal_callback", None),
),
next_args,
)
elif function_name == "delegate_task":
def _execute(next_args: dict) -> Any:
return _finish_agent_tool(agent._dispatch_delegate_task(next_args), next_args)

View File

@@ -73,50 +73,20 @@ ADAPTIVE_EFFORT_MAP = {
"minimal": "low",
}
# ── Anthropic thinking-mode classification ────────────────────────────
# Claude 4.6 replaced budget-based extended thinking with *adaptive* thinking,
# and 4.7 additionally forbids the manual ``thinking`` block entirely and drops
# temperature/top_p/top_k. Newer Claude releases (4.8, and named models like
# claude-fable-5) follow the same modern contract — but they share no common
# version substring, so an allowlist of version numbers ("4.6", "4.7", …) goes
# stale the moment a model ships without a recognized number and silently
# routes it down the legacy manual-thinking path.
#
# Instead we DEFAULT unknown Claude models to the modern contract and keep an
# explicit *legacy* list of the older Claude families that still require manual
# thinking. This mirrors _get_anthropic_max_output's "default to newest" design
# (future models are unlikely to regress to the older contract), so each new
# Claude release works without a code change.
#
# Non-Claude Anthropic-Messages models (minimax, qwen3, GLM, …) are NOT Claude,
# so they fall through to the legacy path automatically — exactly what those
# manual-thinking endpoints need.
# Older Claude families that DON'T support adaptive thinking (manual thinking
# with budget_tokens only). Substring-matched against the model name.
_LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS = (
"claude-3", # 3, 3.5, 3.7
"claude-opus-4-0", "claude-opus-4.0", "claude-opus-4-1", "claude-opus-4.1",
"claude-sonnet-4-0", "claude-sonnet-4.0",
"claude-opus-4-2025", "claude-sonnet-4-2025", # date-stamped 4.0 IDs
"claude-opus-4-5", "claude-opus-4.5",
"claude-sonnet-4-5", "claude-sonnet-4.5",
"claude-haiku-4-5", "claude-haiku-4.5",
)
# Older Claude families that DON'T accept the "xhigh" effort level (4.6 only
# supports low/medium/high/max). xhigh arrived with Opus 4.7. Adaptive models
# not in this list (4.7, 4.8, fable, future) accept xhigh.
_NO_XHIGH_CLAUDE_SUBSTRINGS = (
"claude-opus-4-6", "claude-opus-4.6",
"claude-sonnet-4-6", "claude-sonnet-4.6",
)
def _is_claude_model(model: str | None) -> bool:
return "claude" in (model or "").lower()
# Models that accept the "xhigh" output_config.effort level. Opus 4.7 added
# xhigh as a distinct level between high and max; older adaptive-thinking
# models (4.6) reject it with a 400. Keep this substring list in sync with
# the Anthropic migration guide as new model families ship.
_XHIGH_EFFORT_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8")
# Models where extended thinking is deprecated/removed (4.6+ behavior: adaptive
# is the only supported mode; 4.7 additionally forbids manual thinking entirely
# and drops temperature/top_p/top_k).
_ADAPTIVE_THINKING_SUBSTRINGS = ("4-6", "4.6", "4-7", "4.7", "4-8", "4.8")
# Models where temperature/top_p/top_k return 400 if set to non-default values.
# This is the Opus 4.7 contract; future 4.x+ models are expected to follow it.
_NO_SAMPLING_PARAMS_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8")
_FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
# ── Max output token limits per Anthropic model ───────────────────────
@@ -124,8 +94,6 @@ _FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
# max_tokens as a mandatory field. Previously we hardcoded 16384, which
# starves thinking-enabled models (thinking tokens count toward the limit).
_ANTHROPIC_OUTPUT_LIMITS = {
# Mythos-class named models (claude-fable-5, …) — 1M context, reasoning
"claude-fable": 128_000,
# Claude 4.8
"claude-opus-4-8": 128_000,
# Claude 4.7
@@ -240,17 +208,8 @@ def _resolve_anthropic_messages_max_tokens(
def _supports_adaptive_thinking(model: str) -> bool:
"""Return True for Claude models that use adaptive thinking (4.6+).
Defaults *unknown* Claude models to adaptive (the modern contract) and
only returns False for the explicit legacy list of older Claude families
that require manual budget-based thinking. Non-Claude Anthropic-Messages
models (minimax, qwen3, …) return False so they keep the manual path.
"""
if not _is_claude_model(model):
return False
m = model.lower()
return not any(v in m for v in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS)
"""Return True for Claude 4.6+ models that support adaptive thinking."""
return any(v in model for v in _ADAPTIVE_THINKING_SUBSTRINGS)
def _supports_xhigh_effort(model: str) -> bool:
@@ -260,33 +219,18 @@ def _supports_xhigh_effort(model: str) -> bool:
Pre-4.7 adaptive models (Opus/Sonnet 4.6) only accept low/medium/high/max
and reject xhigh with an HTTP 400. Callers should downgrade xhigh→max
when this returns False.
Defaults unknown adaptive Claude models to accepting xhigh (4.7+ contract);
only the 4.6 family and legacy manual-thinking models are excluded.
"""
if not _supports_adaptive_thinking(model):
return False
m = model.lower()
return not any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS)
return any(v in model for v in _XHIGH_EFFORT_SUBSTRINGS)
def _forbids_sampling_params(model: str) -> bool:
"""Return True for models that 400 on any non-default temperature/top_p/top_k.
Opus 4.7 introduced this restriction; later Claude releases follow it.
Defaults unknown Claude models to forbidding sampling params (the modern
contract). The 4.6 family still accepts them, and the legacy manual-thinking
families (4.5 and older) accept them too, so both are excluded. Non-Claude
models are unaffected. Callers should omit these fields entirely rather than
passing zero/default values (the API rejects anything non-null).
Opus 4.7 explicitly rejects sampling parameters; later Claude releases are
expected to follow suit. Callers should omit these fields entirely rather
than passing zero/default values (the API rejects anything non-null).
"""
if not _is_claude_model(model):
return False
m = model.lower()
# 4.6 family is adaptive but still accepts sampling params.
if any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS):
return False
return not any(v in m for v in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS)
return any(v in model for v in _NO_SAMPLING_PARAMS_SUBSTRINGS)
def _supports_fast_mode(model: str) -> bool:
@@ -877,7 +821,6 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
capture_output=True,
text=True,
timeout=5,
stdin=subprocess.DEVNULL,
)
except (OSError, subprocess.TimeoutExpired):
logger.debug("Keychain: security command not available or timed out")
@@ -1220,10 +1163,7 @@ def run_oauth_setup_token() -> Optional[str]:
"Install it with: npm install -g @anthropic-ai/claude-code"
)
# Run interactively — stdin/stdout/stderr inherited so the user can
# complete the OAuth login prompt. Must keep inherited stdin; the TUI-EOF
# concern does not apply to an interactive login the user explicitly
# invokes. noqa: subprocess-stdin
# Run interactively — stdin/stdout/stderr inherited so user can interact
try:
subprocess.run([claude_path, "setup-token"])
except (KeyboardInterrupt, EOFError):

View File

@@ -102,7 +102,7 @@ OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance
from agent.credential_pool import load_pool
from hermes_cli.config import get_hermes_home
from hermes_constants import OPENROUTER_BASE_URL
from utils import base_url_host_matches, base_url_hostname, model_forces_max_completion_tokens, normalize_proxy_env_vars
from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_vars
logger = logging.getLogger(__name__)
@@ -4300,15 +4300,13 @@ def get_auxiliary_extra_body() -> dict:
return _nous_extra_body() if auxiliary_is_nous else {}
def auxiliary_max_tokens_param(value: int, *, model: Optional[str] = None) -> dict:
def auxiliary_max_tokens_param(value: int) -> dict:
"""Return the correct max tokens kwarg for the auxiliary client's provider.
OpenRouter and local models use 'max_tokens'. Direct OpenAI with newer
models (gpt-4o, gpt-4.1, gpt-5+, o-series) requires 'max_completion_tokens'.
models (gpt-4o, o-series, gpt-5+) requires 'max_completion_tokens'.
The Codex adapter translates max_tokens internally, so we use max_tokens
for it as well. Pass ``model`` so third-party OpenAI-compatible endpoints
fronting the newer families are also recognised — URL-only detection
misses the case where a custom base URL serves e.g. ``gpt-5.4``.
for it as well.
"""
custom_base = _current_custom_base_url()
or_key = os.getenv("OPENROUTER_API_KEY")
@@ -4318,9 +4316,6 @@ def auxiliary_max_tokens_param(value: int, *, model: Optional[str] = None) -> di
and _read_nous_auth() is None
and base_url_hostname(custom_base) in {"api.openai.com", "api.githubcopilot.com"}):
return {"max_completion_tokens": value}
# ...and for any caller serving a newer OpenAI-family model by name.
if model_forces_max_completion_tokens(model):
return {"max_completion_tokens": value}
return {"max_tokens": value}

View File

@@ -25,154 +25,6 @@ from typing import Any, Dict, List
logger = logging.getLogger(__name__)
def _coerce_usage_int(value: Any) -> int:
if isinstance(value, bool):
return 0
if isinstance(value, int):
return max(value, 0)
if isinstance(value, float):
return max(int(value), 0)
if isinstance(value, str):
try:
return max(int(value), 0)
except ValueError:
return 0
return 0
def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
"""Translate Codex app-server token usage into Hermes accounting.
Codex app-server reports usage via thread/tokenUsage/updated as:
inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens,
totalTokens.
Hermes' canonical prompt bucket includes uncached input + cached input.
The Codex app-server protocol does not currently expose cache-write tokens,
so that bucket remains zero on this runtime.
Even when Codex omits usage for a turn, Hermes should still count that turn
as one API call for session/status accounting.
"""
agent.session_api_calls += 1
usage = getattr(turn, "token_usage_last", None)
if not isinstance(usage, dict) or not usage:
if agent._session_db and agent.session_id:
try:
if not agent._session_db_created:
agent._ensure_db_session()
agent._session_db.update_token_counts(
agent.session_id,
model=agent.model,
api_call_count=1,
)
except Exception as exc:
logger.debug(
"Codex app-server api-call persistence failed (session=%s): %s",
agent.session_id, exc,
)
return {}
from agent.usage_pricing import CanonicalUsage, estimate_usage_cost
input_tokens = _coerce_usage_int(usage.get("inputTokens"))
cache_read_tokens = _coerce_usage_int(usage.get("cachedInputTokens"))
output_tokens = _coerce_usage_int(usage.get("outputTokens"))
reasoning_tokens = _coerce_usage_int(usage.get("reasoningOutputTokens"))
reported_total = _coerce_usage_int(usage.get("totalTokens"))
canonical_usage = CanonicalUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_read_tokens=cache_read_tokens,
cache_write_tokens=0,
reasoning_tokens=reasoning_tokens,
raw_usage=usage,
)
prompt_tokens = canonical_usage.prompt_tokens
completion_tokens = canonical_usage.output_tokens
total_tokens = reported_total or canonical_usage.total_tokens
usage_dict = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
"input_tokens": canonical_usage.input_tokens,
"output_tokens": canonical_usage.output_tokens,
"cache_read_tokens": canonical_usage.cache_read_tokens,
"cache_write_tokens": canonical_usage.cache_write_tokens,
"reasoning_tokens": canonical_usage.reasoning_tokens,
}
compressor = getattr(agent, "context_compressor", None)
if compressor is not None:
try:
compressor.update_from_response(usage_dict)
context_window = getattr(turn, "model_context_window", None)
if isinstance(context_window, int) and context_window > 0:
compressor.context_length = context_window
except Exception:
logger.debug("codex app-server usage update failed", exc_info=True)
agent.session_prompt_tokens += prompt_tokens
agent.session_completion_tokens += completion_tokens
agent.session_total_tokens += total_tokens
agent.session_input_tokens += canonical_usage.input_tokens
agent.session_output_tokens += canonical_usage.output_tokens
agent.session_cache_read_tokens += canonical_usage.cache_read_tokens
agent.session_cache_write_tokens += canonical_usage.cache_write_tokens
agent.session_reasoning_tokens += canonical_usage.reasoning_tokens
cost_result = estimate_usage_cost(
agent.model,
canonical_usage,
provider=agent.provider,
base_url=agent.base_url,
api_key=getattr(agent, "api_key", ""),
)
if cost_result.amount_usd is not None:
agent.session_estimated_cost_usd += float(cost_result.amount_usd)
agent.session_cost_status = cost_result.status
agent.session_cost_source = cost_result.source
if agent._session_db and agent.session_id:
try:
if not agent._session_db_created:
agent._ensure_db_session()
agent._session_db.update_token_counts(
agent.session_id,
input_tokens=canonical_usage.input_tokens,
output_tokens=canonical_usage.output_tokens,
cache_read_tokens=canonical_usage.cache_read_tokens,
cache_write_tokens=canonical_usage.cache_write_tokens,
reasoning_tokens=canonical_usage.reasoning_tokens,
estimated_cost_usd=float(cost_result.amount_usd)
if cost_result.amount_usd is not None else None,
cost_status=cost_result.status,
cost_source=cost_result.source,
billing_provider=agent.provider,
billing_base_url=agent.base_url,
billing_mode="subscription_included"
if cost_result.status == "included" else None,
model=agent.model,
api_call_count=1,
)
except Exception as exc:
logger.debug(
"Codex app-server token persistence failed (session=%s, tokens=%d): %s",
agent.session_id, total_tokens, exc,
)
return {
**usage_dict,
"last_prompt_tokens": prompt_tokens,
"estimated_cost_usd": float(cost_result.amount_usd)
if cost_result.amount_usd is not None else None,
"cost_status": cost_result.status,
"cost_source": cost_result.source,
}
def run_codex_app_server_turn(
agent,
*,
@@ -268,8 +120,6 @@ def run_codex_app_server_turn(
agent._iters_since_skill = (
getattr(agent, "_iters_since_skill", 0) + turn.tool_iterations
)
usage_result = _record_codex_app_server_usage(agent, turn)
api_calls = 1
# Now check the skill nudge AFTER iters were incremented — same
# pattern the chat_completions path uses (line ~15432).
@@ -314,13 +164,12 @@ def run_codex_app_server_turn(
return {
"final_response": turn.final_text,
"messages": messages,
"api_calls": api_calls,
"api_calls": 1, # one app-server "turn" maps to one logical API call
"completed": not turn.interrupted and turn.error is None,
"partial": turn.interrupted or turn.error is not None,
"error": turn.error,
"codex_thread_id": turn.thread_id,
"codex_turn_id": turn.turn_id,
**usage_result,
}

View File

@@ -246,14 +246,7 @@ def _expand_file_reference(
if not path.is_file():
return f"{ref.raw}: path is not a file", None
if _is_binary_file(path):
# A binary file can't be inlined as text, but it IS on disk (the agent's
# tools run where this resolves — the local cwd, or the staged copy in a
# remote session workspace). Returning a bare "not supported" warning
# with no content was a dead end: the model saw a failure and gave up
# (told the user the file type wasn't supported). Instead, hand it an
# actionable block — the path, type, size, and a nudge to use its tools —
# so it can read/convert/view the file itself.
return None, _binary_reference_block(ref, path)
return f"{ref.raw}: binary files are not supported", None
text = path.read_text(encoding="utf-8")
if ref.line_start is not None:
@@ -297,7 +290,6 @@ def _expand_git_reference(
capture_output=True,
text=True,
timeout=30,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired:
return f"{ref.raw}: git command timed out (30s)", None
@@ -490,7 +482,6 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
capture_output=True,
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
)
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return None
@@ -500,30 +491,6 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
return files[:limit]
def _human_bytes(n: int) -> str:
size = float(n)
for unit in ("B", "KB", "MB", "GB"):
if size < 1024 or unit == "GB":
return f"{int(size)} {unit}" if unit == "B" else f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} GB"
def _binary_reference_block(ref: ContextReference, path: Path) -> str:
mime, _ = mimetypes.guess_type(path.name)
mime = mime or "application/octet-stream"
try:
size = _human_bytes(path.stat().st_size)
except OSError:
size = "unknown size"
return (
f"📎 {ref.raw} ({mime}, {size}) — binary file, not inlined as text. "
f"It is available on disk at `{path}`. Use your tools to work with it "
f"(read or convert it, extract its text, or view/render it as needed); "
f"do not tell the user the file type is unsupported."
)
def _file_metadata(path: Path) -> str:
if _is_binary_file(path):
return f"{path.stat().st_size} bytes"

View File

@@ -25,6 +25,7 @@ import json
import logging
import os
import re
import tempfile
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -32,7 +33,6 @@ from typing import Any, Callable, Dict, List, NamedTuple, Optional, Set
from hermes_constants import get_hermes_home
from tools import skill_usage
from utils import atomic_json_write
logger = logging.getLogger(__name__)
@@ -97,7 +97,20 @@ def load_state() -> Dict[str, Any]:
def save_state(data: Dict[str, Any]) -> None:
path = _state_file()
try:
atomic_json_write(path, data, indent=2, sort_keys=True)
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".curator_state_", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
except Exception as e:
logger.debug("Failed to save curator state: %s", e, exc_info=True)

View File

@@ -966,34 +966,6 @@ def _classify_400(
should_fallback=False,
)
# Request-validation errors (unsupported / unknown parameter) MUST be
# checked BEFORE context_overflow. A GPT-5 model rejecting max_tokens
# returns:
# "Unsupported parameter: 'max_tokens' is not supported with this model.
# Use 'max_completion_tokens' instead."
# That string contains the literal substring "max_tokens", which is one of
# the _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is
# misclassified as context_overflow, routed into the compression loop,
# re-sent with the same bad parameter, and ends in "Cannot compress
# further". These errors are deterministic (every retry gets the identical
# rejection), so classify as a non-retryable format_error and fall back.
#
# NOTE: we deliberately do NOT key off the generic ``invalid_request_error``
# code here — OpenAI stamps that same code on genuine context-overflow 400s,
# so matching it would mis-route real overflows away from compression. The
# unambiguous signals are the explicit "unsupported/unknown parameter"
# message text and the specific parameter-level error codes.
if (
any(p in error_msg for p in _REQUEST_VALIDATION_PATTERNS
if p != "invalid_request_error")
or error_code_lower in {"unknown_parameter", "unsupported_parameter"}
):
return result_fn(
FailoverReason.format_error,
retryable=False,
should_fallback=True,
)
# Context overflow from 400
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(

View File

@@ -262,7 +262,6 @@ def _install_npm(
capture_output=True,
text=True,
timeout=300,
stdin=subprocess.DEVNULL,
)
if proc.returncode != 0:
logger.warning(
@@ -311,7 +310,6 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]:
text=True,
timeout=600,
env=env,
stdin=subprocess.DEVNULL,
)
if proc.returncode != 0:
logger.warning(
@@ -349,7 +347,6 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
capture_output=True,
text=True,
timeout=300,
stdin=subprocess.DEVNULL,
)
if proc.returncode != 0:
logger.warning(

View File

@@ -141,8 +141,6 @@ DEFAULT_CONTEXT_LENGTHS = {
# fuzzy-match collisions (e.g. "anthropic/claude-sonnet-4" is a
# substring of "anthropic/claude-sonnet-4.6").
# OpenRouter-prefixed models resolve via OpenRouter live API or models.dev.
"claude-fable-5": 1000000,
"claude-fable": 1000000,
"claude-opus-4-8": 1000000,
"claude-opus-4.8": 1000000,
"claude-opus-4-7": 1000000,
@@ -970,16 +968,6 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
# OpenRouter/Nous phrasing of the same condition.
"in the output" in error_lower
and "maximum context length" in error_lower
) or (
# LM Studio / llama.cpp / some OpenAI-compatible servers:
# "This model's maximum context length is 65536 tokens. However, you
# requested 65536 output tokens and your prompt contains 77409
# characters ..."
# The "requested N output tokens" phrasing means the OUTPUT cap is the
# problem (the input itself fits) — reduce max_tokens, don't compress.
"maximum context length" in error_lower
and "requested" in error_lower
and "output tokens" in error_lower
)
if not is_output_cap_error:
return None
@@ -1011,22 +999,6 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
if _available >= 1:
return _available
# LM Studio / llama.cpp style: context window is reported in tokens but the
# prompt size is reported in CHARACTERS, e.g.
# "maximum context length is 65536 tokens ... your prompt contains 77409
# characters ...".
# Estimate the input tokens conservatively (~3 chars/token, which
# over-reserves the input so the retried output cap stays safely inside the
# window) and leave the remainder of the window for output.
_m_ctx_tok = re.search(r'maximum context length is (\d+)\s*token', error_lower)
_m_chars = re.search(r'prompt contains (\d+)\s*character', error_lower)
if _m_ctx_tok and _m_chars:
_ctx = int(_m_ctx_tok.group(1))
_est_input = (int(_m_chars.group(1)) + 2) // 3
_available = _ctx - _est_input
if _available >= 1:
return _available
return None
@@ -1812,43 +1784,10 @@ def get_model_context_length(
if ctx is not None:
save_context_length(model, base_url, ctx)
return ctx
# 5f. OpenRouter live /models metadata — authoritative for OpenRouter-routed
# models. OpenRouter's catalog carries per-model context_length (e.g.
# anthropic/claude-fable-5 -> 1M) and refreshes as new slugs ship, so it
# must win over both models.dev (step 5g) and the hardcoded family catch-all
# (step 8). Before this branch, an OpenRouter selection set
# effective_provider="openrouter", which (a) made the models.dev lookup miss
# brand-new slugs and (b) skipped the step-6 OR fallback (gated on `not
# effective_provider`), so a fresh slug like claude-fable-5 fell through to
# the generic "claude": 200K entry and under-reported a 1M window. Mirrors
# the dedicated Nous/Copilot/GMI branches above.
if effective_provider == "openrouter":
metadata = fetch_model_metadata()
entry = metadata.get(model)
if entry:
or_ctx = entry.get("context_length")
# Guard against the known OpenRouter Kimi-family 32k underreport
# (same class the hardcoded overrides exist to mitigate).
if isinstance(or_ctx, int) and or_ctx > 0 and not (
or_ctx == 32768 and _model_name_suggests_kimi(model)
):
return or_ctx
if effective_provider:
from agent.models_dev import lookup_models_dev_context
ctx = lookup_models_dev_context(effective_provider, model)
if ctx:
# MiniMax M3: models.dev reports 512K but actual context is 1M.
# Prefer hardcoded catalog over stale probe value.
if _model_name_suggests_minimax_m3(model):
catalog = DEFAULT_CONTEXT_LENGTHS.get("minimax-m3")
if catalog and ctx < catalog:
logger.info(
"Rejecting models.dev context=%s for %r "
"(MiniMax-M3 underreport); using hardcoded default %s",
ctx, model, f"{catalog:,}",
)
ctx = catalog
return ctx
# 6. OpenRouter live API metadata — provider-unaware fallback.

View File

@@ -885,22 +885,6 @@ def build_environment_hints() -> str:
f"`uname -a && whoami && pwd`."
)
# Hermes desktop GUI — any agent running under the desktop app should know
# it. HERMES_DESKTOP marks the backend powering the chat; HERMES_DESKTOP_TERMINAL
# marks a hermes launched in the embedded terminal pane. Both set by main.cjs.
_truthy = ("1", "true", "yes")
_in_desktop = (os.getenv("HERMES_DESKTOP") or "").strip().lower() in _truthy
_in_desktop_term = (os.getenv("HERMES_DESKTOP_TERMINAL") or "").strip().lower() in _truthy
if _in_desktop or _in_desktop_term:
_desktop_hint = "Runtime surface: you're running inside the Hermes desktop GUI app."
if _in_desktop_term:
_desktop_hint += (
" You're in its embedded terminal pane, beside the GUI chat — the user can "
"select your output (⌥-drag on macOS, Shift-drag elsewhere) and press "
"⌘/Ctrl+L to send it to the chat composer."
)
hints.append(_desktop_hint)
if is_wsl():
hints.append(WSL_ENVIRONMENT_HINT)

View File

@@ -274,7 +274,6 @@ def _platform_asset_name() -> str:
capture_output=True,
text=True,
timeout=2,
stdin=subprocess.DEVNULL,
)
if "musl" in (res.stdout + res.stderr).lower():
libc = "musl"
@@ -526,7 +525,6 @@ def _run_bws_list(
capture_output=True,
text=True,
timeout=_BWS_RUN_TIMEOUT,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(

View File

@@ -74,7 +74,6 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
text=True,
timeout=max(1, int(timeout)),
check=False,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired:
return f"[inline-shell timeout after {timeout}s: {command}]"

View File

@@ -1065,25 +1065,6 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
tool_duration = time.time() - tool_start_time
if agent._should_emit_quiet_tool_messages():
agent._vprint(f" {_get_cute_tool_message_impl('clarify', function_args, tool_duration, result=function_result)}")
elif function_name == "read_terminal":
def _execute(next_args: dict) -> Any:
from tools.read_terminal_tool import read_terminal_tool as _read_terminal_tool
return _read_terminal_tool(
start_line=next_args.get("start_line"),
count=next_args.get("count"),
callback=getattr(agent, "read_terminal_callback", None),
)
function_result, function_args = _run_agent_tool_execution_middleware(
agent,
function_name=function_name,
function_args=function_args,
effective_task_id=effective_task_id,
tool_call_id=getattr(tool_call, "id", "") or "",
execute=_execute,
)
tool_duration = time.time() - tool_start_time
if agent._should_emit_quiet_tool_messages():
agent._vprint(f" {_get_cute_tool_message_impl('read_terminal', function_args, tool_duration, result=function_result)}")
elif function_name == "delegate_task":
tasks_arg = function_args.get("tasks")
if tasks_arg and isinstance(tasks_arg, list):

View File

@@ -378,7 +378,6 @@ def check_codex_binary(
capture_output=True,
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
)
except FileNotFoundError:
return False, (

View File

@@ -72,9 +72,6 @@ class TurnResult:
error: Optional[str] = None # Set if turn ended in a non-recoverable error
turn_id: Optional[str] = None
thread_id: Optional[str] = None
token_usage_last: Optional[dict[str, Any]] = None
token_usage_total: Optional[dict[str, Any]] = None
model_context_window: Optional[int] = None
# Hint to the caller that the underlying codex subprocess is likely
# wedged (turn-level timeout fired, post-tool watchdog tripped, or
# token-refresh failure killed the child). The caller should retire
@@ -504,7 +501,6 @@ class CodexAppServerSession:
pending = self._client.take_notification(timeout=0)
if pending is None:
break
_apply_token_usage_notification(result, pending)
self._track_pending_file_change(pending)
proj = projector.project(pending)
if proj.messages:
@@ -540,8 +536,6 @@ class CodexAppServerSession:
except Exception: # pragma: no cover - display callback
logger.debug("on_event callback raised", exc_info=True)
_apply_token_usage_notification(result, note)
# Track in-progress fileChange items so the approval bridge
# can surface a real change summary when codex requests
# approval (the approval params themselves don't carry the
@@ -808,30 +802,6 @@ class CodexAppServerSession:
return cached
def _apply_token_usage_notification(result: TurnResult, note: dict) -> None:
"""Capture Codex app-server token usage updates for caller accounting.
Codex does not put token usage on turn/completed. It emits a separate
thread/tokenUsage/updated notification containing cumulative totals and
the latest turn breakdown.
"""
if not isinstance(note, dict) or note.get("method") != "thread/tokenUsage/updated":
return
params = note.get("params") or {}
token_usage = params.get("tokenUsage") or {}
if not isinstance(token_usage, dict):
return
last = token_usage.get("last")
total = token_usage.get("total")
if isinstance(last, dict):
result.token_usage_last = dict(last)
if isinstance(total, dict):
result.token_usage_total = dict(total)
window = token_usage.get("modelContextWindow")
if isinstance(window, int) and window > 0:
result.model_context_window = window
def _approval_choice_to_codex_decision(choice: str) -> str:
"""Map Hermes approval choices onto codex's CommandExecutionApprovalDecision
/ FileChangeApprovalDecision wire values.

View File

@@ -13,7 +13,6 @@ DEFAULT_PRICING = {"input": 0.0, "output": 0.0}
_ZERO = Decimal("0")
_ONE_MILLION = Decimal("1000000")
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
CostStatus = Literal["actual", "estimated", "included", "unknown"]
CostSource = Literal[
@@ -571,8 +570,6 @@ def resolve_billing_route(
return BillingRoute(provider="openai-codex", model=model, base_url=base_url or "", billing_mode="subscription_included")
if provider_name == "openrouter" or base_url_host_matches(base_url or "", "openrouter.ai"):
return BillingRoute(provider="openrouter", model=model, base_url=base_url or "", billing_mode="official_models_api")
if provider_name == "nous" or base_url_host_matches(base_url or "", "inference-api.nousresearch.com"):
return BillingRoute(provider="nous", model=model, base_url=base_url or _NOUS_DEFAULT_BASE_URL, billing_mode="official_models_api")
if provider_name == "anthropic":
return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
if provider_name == "openai":

View File

@@ -11,8 +11,7 @@
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"tauri:build:debug": "tauri build --debug",
"typecheck": "tsc -p . --noEmit"
"tauri:build:debug": "tauri build --debug"
},
"dependencies": {
"@nous-research/ui": "0.16.0",
@@ -41,7 +40,7 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"typescript": "^6.0.3",
"typescript": "~5.9.3",
"vite": "^7.3.1"
}
}

View File

@@ -16,8 +16,9 @@
"noUnusedParameters": true,
"esModuleInterop": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
"@/*": ["src/*"]
}
},
"include": ["src"],

View File

@@ -93,7 +93,7 @@ Run before opening a PR (lint may surface pre-existing warnings but must exit cl
```bash
npm run fix
npm run typecheck
npm run type-check
npm run lint
npm run test:desktop:all
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 561 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 561 KiB

After

Width:  |  Height:  |  Size: 674 KiB

View File

@@ -40,15 +40,6 @@ const path = require('node:path')
const https = require('node:https')
const { spawn } = require('node:child_process')
const IS_WINDOWS = process.platform === 'win32'
function hiddenWindowsChildOptions(options = {}) {
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
return options
}
return { ...options, windowsHide: true }
}
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
// Stages flagged needs_user_input=true in the manifest are skipped by the
@@ -293,7 +284,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
const child = spawn(ps, fullArgs, hiddenWindowsChildOptions({
const child = spawn(ps, fullArgs, {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
@@ -301,7 +292,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
// choice rather than re-computing the default.
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
}
}))
})
let stdout = ''
let stderr = ''

View File

@@ -26,11 +26,9 @@ const { fileURLToPath, pathToFileURL } = require('node:url')
const { execFileSync, spawn } = require('node:child_process')
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
const { runBootstrap } = require('./bootstrap-runner.cjs')
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const {
buildPosixCleanupScript,
buildWindowsCleanupScript,
@@ -40,7 +38,6 @@ const {
shouldRemoveAppBundle,
uninstallArgsForMode
} = require('./desktop-uninstall.cjs')
const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./workspace-cwd.cjs')
const {
authModeFromStatus,
buildGatewayWsUrl,
@@ -65,11 +62,9 @@ const {
} = require('./hardening.cjs')
let nodePty = null
let nodePtyDir = null
try {
nodePty = require('node-pty')
nodePtyDir = path.dirname(require.resolve('node-pty/package.json'))
} catch {
// Packaged builds set `files:` in package.json, which excludes node_modules
// from the asar. Workspace dedup also hoists this native dep to the repo
@@ -82,12 +77,10 @@ try {
const path = require('node:path')
const resourcesPath = process.resourcesPath
if (resourcesPath) {
nodePtyDir = path.join(resourcesPath, 'native-deps', 'node-pty')
nodePty = require(nodePtyDir)
nodePty = require(path.join(resourcesPath, 'native-deps', 'node-pty'))
}
} catch {
nodePty = null
nodePtyDir = null
}
}
@@ -107,13 +100,6 @@ const IS_WINDOWS = process.platform === 'win32'
const IS_WSL = isWslEnvironment()
const APP_ROOT = app.getAppPath()
function hiddenWindowsChildOptions(options = {}) {
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
return options
}
return { ...options, windowsHide: true }
}
// Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU
// compositor flicker — accelerated layers can't be presented cleanly over the
// wire, so the window flashes during scroll/streaming/animation. Local
@@ -1113,7 +1099,7 @@ function findSystemPython() {
const out = execFileSync(
'reg',
['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'],
hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
)
// Output format: " (Default) REG_SZ C:\Path\To\Python\"
const match = out.match(/REG_SZ\s+(.+?)\s*$/m)
@@ -1149,10 +1135,10 @@ function findSystemPython() {
if (pyExe) {
for (const version of SUPPORTED_VERSIONS) {
try {
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], hiddenWindowsChildOptions({
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}))
})
const candidate = out.trim()
if (candidate && fileExists(candidate)) return candidate
} catch {
@@ -1287,11 +1273,11 @@ function resolveUpdateRoot() {
function runGit(args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, {
cwd: options.cwd,
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
stdio: ['ignore', 'pipe', 'pipe']
}))
})
let stdout = ''
let stderr = ''
@@ -1501,7 +1487,7 @@ function forceKillProcessTree(pid) {
if (!IS_WINDOWS) return
if (!Number.isInteger(pid) || pid <= 0) return
try {
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], hiddenWindowsChildOptions({ stdio: 'ignore' }))
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
} catch {
// Already gone, or no permission — best effort; the unlock wait below is
// the real gate.
@@ -1687,11 +1673,11 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) {
return new Promise(resolve => {
let child
try {
child = spawn(command, args, hiddenWindowsChildOptions({
child = spawn(command, args, {
cwd,
env: { ...process.env, ...(env || {}) },
stdio: ['ignore', 'pipe', 'pipe']
}))
})
} catch (err) {
resolve({ code: 1, error: err.message })
return
@@ -1962,21 +1948,6 @@ function resolveRendererIndex() {
return candidates[0]
}
// True when `dir` lives inside the packaged app bundle / install tree.
// Packaged Electron's process.cwd() (and npm's INIT_CWD when dev tooling
// leaked into a release build) often resolve here — e.g. win-unpacked on
// Windows — which is exactly where PR #37536 item 16 said we must NOT run.
function isPackagedInstallPath(dir) {
return isPackagedInstallPathUnderRoots(dir, {
isPackaged: IS_PACKAGED,
installRoots: [
APP_ROOT,
path.dirname(process.execPath),
resolveRemovableAppPath(process.execPath, process.platform, process.env)
]
})
}
function resolveHermesCwd() {
// In a packaged build, `process.cwd()` resolves to the install root (e.g.
// `…/win-unpacked` on Windows or `/Applications/Hermes.app/Contents/...`
@@ -1988,7 +1959,7 @@ function resolveHermesCwd() {
const candidates = [
readDefaultProjectDir(),
process.env.HERMES_DESKTOP_CWD,
IS_PACKAGED ? null : process.env.INIT_CWD,
process.env.INIT_CWD,
IS_PACKAGED ? null : process.cwd(),
!IS_PACKAGED ? SOURCE_REPO_ROOT : null,
app.getPath('home')
@@ -1997,37 +1968,12 @@ function resolveHermesCwd() {
for (const candidate of candidates) {
if (!candidate) continue
const resolved = path.resolve(String(candidate))
if (isPackagedInstallPath(resolved)) {
continue
}
if (directoryExists(resolved)) return resolved
}
return app.getPath('home')
}
function sanitizeWorkspaceCwd(cwd) {
const trimmed = typeof cwd === 'string' ? cwd.trim() : ''
if (!trimmed || isPackagedInstallPath(trimmed)) {
return { cwd: resolveHermesCwd(), sanitized: Boolean(trimmed) }
}
try {
const resolved = path.resolve(trimmed)
if (directoryExists(resolved)) {
return { cwd: resolved, sanitized: false }
}
} catch {
// Fall through to the resolved default.
}
return { cwd: resolveHermesCwd(), sanitized: Boolean(trimmed) }
}
// Persisted "Default project directory" — surfaced as a setting in the
// renderer (see app/settings/sessions-settings.tsx). Stored as JSON in
// userData so it survives self-updates without bleeding into the new
@@ -2678,7 +2624,7 @@ function fetchHtmlTitleWithCurl(rawUrl) {
'--raw',
url
]
const child = spawn('curl', args, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'ignore'] }))
const child = spawn('curl', args, { stdio: ['ignore', 'pipe', 'ignore'] })
const chunks = []
let bytes = 0
@@ -3324,18 +3270,14 @@ function setAndPersistZoomLevel(window, zoomLevel) {
const next = clampZoomLevel(zoomLevel)
window.webContents.setZoomLevel(next)
window.webContents
.executeJavaScript(
`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`
)
.executeJavaScript(`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`)
.catch(error => rememberLog(`[zoom] persist failed: ${error?.message || error}`))
}
function restorePersistedZoomLevel(window) {
if (!window || window.isDestroyed()) return
window.webContents
.executeJavaScript(
`(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()`
)
.executeJavaScript(`(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()`)
.then(stored => {
if (stored == null || !window || window.isDestroyed()) return
const level = clampZoomLevel(Number(stored))
@@ -4194,7 +4136,9 @@ async function requestJsonForProfile(profile, path, method, body) {
const conn = await ensureBackend(profile)
const url = `${conn.baseUrl}${path}`
const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }
return conn.authMode === 'oauth' ? fetchJsonViaOauthSession(url, opts) : fetchJson(url, conn.token, opts)
return conn.authMode === 'oauth'
? fetchJsonViaOauthSession(url, opts)
: fetchJson(url, conn.token, opts)
}
async function probeRemoteAuthMode(rawUrl) {
@@ -4268,8 +4212,7 @@ async function testDesktopConnectionConfig(input = {}) {
// The block under test: a per-profile entry or the global remote. Coerce has
// already normalized the URL and resolved token inheritance for the scope.
const block = key ? config.profiles?.[key] || null : config.remote
const wantRemote =
block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block)
const wantRemote = block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block)
// ``/api/status`` is public on every gateway (no creds needed), so a
// reachability test works for local, token, and oauth modes alike — we only
// need a base URL. For a remote config we normalize the URL from the input;
@@ -4352,31 +4295,20 @@ async function teardownPrimaryBackendAndWait() {
const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null
resetHermesConnection()
await waitForBackendExit(dying)
}
async function waitForBackendExit(child, timeoutMs = 5000) {
if (!child) {
return
}
if (child.exitCode !== null || child.signalCode !== null) {
if (!dying) {
return
}
await new Promise(resolve => {
const timer = setTimeout(() => {
try {
if (IS_WINDOWS && Number.isInteger(child.pid)) {
forceKillProcessTree(child.pid)
} else {
child.kill('SIGKILL')
}
dying.kill('SIGKILL')
} catch {
// Already gone.
}
resolve()
}, timeoutMs)
child.once('exit', () => {
}, 5000)
dying.once('exit', () => {
clearTimeout(timer)
resolve()
})
@@ -4498,16 +4430,12 @@ async function spawnPoolBackend(profile, entry) {
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
const child = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
const child = spawn(backend.command, backend.args, {
cwd: hermesCwd,
env: {
...process.env,
HERMES_HOME,
...backend.env,
// Pin the gateway's tool/terminal cwd to the same directory we chose for
// the child process. Inherited TERMINAL_CWD (or a stale config bridge)
// can still point at the install dir even when spawn cwd is home.
TERMINAL_CWD: hermesCwd,
HERMES_DASHBOARD_SESSION_TOKEN: token,
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
@@ -4516,7 +4444,7 @@ async function spawnPoolBackend(profile, entry) {
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
}))
})
entry.process = child
entry.port = port
entry.token = token
@@ -4538,9 +4466,7 @@ async function spawnPoolBackend(profile, entry) {
rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`)
backendPool.delete(profile)
if (!ready) {
rejectStart?.(
new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`)
)
rejectStart?.(new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`))
}
})
@@ -4574,70 +4500,12 @@ function stopPoolBackend(profile) {
}
}
async function teardownPoolBackendAndWait(profile) {
const entry = backendPool.get(profile)
if (!entry) return
backendPool.delete(profile)
if (entry.process && !entry.process.killed) {
try {
entry.process.kill('SIGTERM')
} catch {
// Already gone.
}
}
await waitForBackendExit(entry.process)
}
function stopAllPoolBackends() {
for (const profile of [...backendPool.keys()]) {
stopPoolBackend(profile)
}
}
function profileNameFromDeleteRequest(request) {
if (!request || String(request.method || 'GET').toUpperCase() !== 'DELETE') {
return null
}
const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/)
if (!match) {
return null
}
let raw = ''
try {
raw = decodeURIComponent(match[1])
} catch {
return null
}
const name = raw.trim()
if (!name) {
return null
}
if (name.toLowerCase() === 'default') {
return 'default'
}
return name.toLowerCase()
}
async function prepareProfileDeleteRequest(request) {
const profile = profileNameFromDeleteRequest(request)
if (!profile || profile === 'default' || !PROFILE_NAME_RE.test(profile)) {
return
}
if (profile === primaryProfileKey()) {
writeActiveDesktopProfile('default')
await teardownPrimaryBackendAndWait()
return
}
await teardownPoolBackendAndWait(profile)
}
async function startHermes() {
// Latched-failure short-circuit: once bootstrap has failed in this
// process, every subsequent startHermes() call re-throws the same error
@@ -4698,7 +4566,7 @@ async function startHermes() {
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
rememberLog(`Starting Hermes backend via ${backend.label}`)
hermesProcess = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
hermesProcess = spawn(backend.command, backend.args, {
cwd: hermesCwd,
env: {
...process.env,
@@ -4712,7 +4580,6 @@ async function startHermes() {
// can't reliably do that, so we set it inline for every spawn.
HERMES_HOME,
...backend.env,
TERMINAL_CWD: hermesCwd,
HERMES_DASHBOARD_SESSION_TOKEN: token,
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
@@ -4721,7 +4588,7 @@ async function startHermes() {
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
}))
})
hermesProcess.stdout.on('data', rememberLog)
hermesProcess.stderr.on('data', rememberLog)
@@ -4810,94 +4677,6 @@ async function startHermes() {
return connectionPromise
}
// Shared navigation guards + window chrome wiring applied to every window
// (the primary plus any secondary session windows). Factored out of
// createWindow() so secondary windows can't drift from the main window's
// security posture: external links open in the OS browser, in-app navigation
// stays confined to the dev server / packaged file URL, and the preview /
// devtools / zoom / context-menu affordances behave identically everywhere.
function wireCommonWindowHandlers(win) {
installPreviewShortcut(win)
installDevToolsShortcut(win)
installZoomShortcuts(win)
installContextMenu(win)
win.webContents.setWindowOpenHandler(details => {
openExternalUrl(details.url)
return { action: 'deny' }
})
win.webContents.on('will-navigate', (event, url) => {
if ((DEV_SERVER && url.startsWith(DEV_SERVER)) || (!DEV_SERVER && url.startsWith('file:'))) {
return
}
event.preventDefault()
openExternalUrl(url)
})
}
// Secondary "session windows" — one extra OS window per chat so a user can
// work with multiple chats side by side. The registry guarantees one window
// per sessionId (re-opening focuses the existing window) and self-cleans on
// close. The primary mainWindow is never tracked here. Pure logic + the URL
// builder live in session-windows.cjs so they stay unit-testable.
const sessionWindows = createSessionWindowRegistry()
function focusWindow(win) {
if (!win || win.isDestroyed()) return
if (win.isMinimized()) win.restore()
if (!win.isVisible()) win.show()
win.focus()
}
// Open (or focus) a standalone window for a single chat session.
function createSessionWindow(sessionId) {
return sessionWindows.openOrFocus(sessionId, () => {
const icon = getAppIconPath()
const win = new BrowserWindow({
width: 480,
height: 800,
minWidth: 420,
minHeight: 620,
title: 'Hermes',
titleBarStyle: 'hidden',
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
vibrancy: IS_MAC ? 'sidebar' : undefined,
icon,
backgroundColor: '#f7f7f7',
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true
}
})
if (IS_MAC) {
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
}
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
win.on('enter-full-screen', () => sendWindowStateChanged(true))
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
win.on('leave-full-screen', () => sendWindowStateChanged(false))
wireCommonWindowHandlers(win)
win.loadURL(
buildSessionWindowUrl(sessionId, {
devServer: DEV_SERVER,
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex()
})
)
return win
})
}
function createWindow() {
const icon = getAppIconPath()
mainWindow = new BrowserWindow({
@@ -4958,7 +4737,23 @@ function createWindow() {
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false))
wireCommonWindowHandlers(mainWindow)
installPreviewShortcut(mainWindow)
installDevToolsShortcut(mainWindow)
installZoomShortcuts(mainWindow)
installContextMenu(mainWindow)
mainWindow.webContents.setWindowOpenHandler(details => {
openExternalUrl(details.url)
return { action: 'deny' }
})
mainWindow.webContents.on('will-navigate', (event, url) => {
if ((DEV_SERVER && url.startsWith(DEV_SERVER)) || (!DEV_SERVER && url.startsWith('file:'))) {
return
}
event.preventDefault()
openExternalUrl(url)
})
mainWindow.webContents.on('render-process-gone', (_event, details) => {
rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`)
@@ -5064,15 +4859,6 @@ ipcMain.handle('hermes:backend:touch', async (_event, profile) => {
return { ok: true }
})
ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile))
ipcMain.handle('hermes:window:openSession', async (_event, sessionId) => {
if (typeof sessionId !== 'string' || !sessionId.trim()) {
return { ok: false, error: 'invalid-session-id' }
}
createSessionWindow(sessionId.trim())
return { ok: true }
})
ipcMain.handle('hermes:bootstrap:reset', async () => {
// Renderer's "Reload and retry" path. Clear the latched failure and
// reset connection state so the next startHermes() call restarts the
@@ -5311,19 +5097,17 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) {
let total = (Number(base.total) || 0) - remoteProfiles.reduce((n, p) => n + (profileTotals[p] || 0), 0)
// Swap each remote profile's stale local rows/total for the remote's real ones.
await Promise.all(
remoteProfiles.map(async name => {
const list = await remoteSessionList(name, remoteParams).catch(() => null)
if (!list) {
delete profileTotals[name] // dead remote → drop its stale local total too
return
}
const rows = rowsOf(list)
merged.push(...rows)
profileTotals[name] = Number(list.total) || rows.length
total += profileTotals[name]
})
)
await Promise.all(remoteProfiles.map(async name => {
const list = await remoteSessionList(name, remoteParams).catch(() => null)
if (!list) {
delete profileTotals[name] // dead remote → drop its stale local total too
return
}
const rows = rowsOf(list)
merged.push(...rows)
profileTotals[name] = Number(list.total) || rows.length
total += profileTotals[name]
}))
const recency = s => s?.[order] ?? s?.started_at ?? 0
merged.sort((a, b) => recency(b) - recency(a))
@@ -5340,8 +5124,6 @@ ipcMain.handle('hermes:api', async (_event, request) => {
return rerouted
}
await prepareProfileDeleteRequest(request)
const connection = await ensureBackend(request?.profile)
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
const url = `${connection.baseUrl}${request.path}`
@@ -5489,12 +5271,9 @@ ipcMain.handle('hermes:openExternal', (_event, url) => {
// session spawn (no app restart needed).
ipcMain.handle('hermes:setting:defaultProjectDir:get', async () => ({
dir: readDefaultProjectDir(),
defaultLabel: app.getPath('home'),
resolvedCwd: resolveHermesCwd()
defaultLabel: path.join(app.getPath('home'), 'hermes-projects')
}))
ipcMain.handle('hermes:workspace:sanitize', async (_event, cwd) => sanitizeWorkspaceCwd(cwd))
ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => {
const next = typeof dir === 'string' && dir.trim() ? dir.trim() : null
@@ -5584,121 +5363,22 @@ function findGitRoot(start) {
return null
}
function isExecutableFile(filePath) {
if (!filePath || !path.isAbsolute(filePath)) {
return false
function terminalShellCommand() {
if (IS_WINDOWS) {
return { args: [], command: process.env.COMSPEC || 'cmd.exe' }
}
try {
fs.accessSync(filePath, fs.constants.X_OK)
return true
} catch {
return false
}
}
function posixShellSpec(shellPath) {
const configuredShell = process.env.SHELL || ''
const shellPath =
(path.isAbsolute(configuredShell) && fs.existsSync(configuredShell) && configuredShell) ||
['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => fs.existsSync(candidate)) ||
'/bin/sh'
const shellName = path.basename(shellPath)
const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i']
return { args: interactiveArgs, command: shellPath, name: shellName }
}
let spawnHelperChecked = false
// node-pty execs a `spawn-helper` binary on macOS/Linux to launch the shell in a
// fresh session. The prebuilt that ships in node-pty's `prebuilds/` (and the
// staged copy under resources/native-deps) loses its execute bit through npm
// pack / electron-builder file collection, so every nodePty.spawn() dies with
// "posix_spawnp failed". Restore +x once, lazily, before the first spawn.
function ensureSpawnHelperExecutable() {
if (spawnHelperChecked || IS_WINDOWS || !nodePtyDir) {
return
}
spawnHelperChecked = true
const arch = process.arch
const candidates = [
path.join(nodePtyDir, 'build', 'Release', 'spawn-helper'),
path.join(nodePtyDir, 'prebuilds', `${process.platform}-${arch}`, 'spawn-helper')
]
for (const helper of candidates) {
try {
const mode = fs.statSync(helper).mode
if ((mode & 0o111) !== 0o111) {
fs.chmodSync(helper, mode | 0o755)
}
} catch {
// Not present in this layout (e.g. compiled build vs prebuild); skip.
}
}
}
// Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box;
// prefer it only after PowerShell 7+ (`pwsh`).
function windowsPowerShellPath() {
const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows'
const builtin = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
return isExecutableFile(builtin) ? builtin : findOnPath('powershell.exe')
}
// Map a resolved shell path to its spawn spec, picking interactive flags by
// family: PowerShell drops its logo banner (so the prompt sits flush like the
// POSIX shells), cmd needs nothing, and everything else (zsh/bash/fish/sh…)
// gets POSIX interactive-login flags.
function shellSpecFor(shellPath) {
const name = path.basename(shellPath).toLowerCase()
if (name.startsWith('pwsh') || name.startsWith('powershell')) {
return { args: ['-NoLogo'], command: shellPath, name }
}
if (name.startsWith('cmd')) {
return { args: [], command: shellPath, name }
}
return posixShellSpec(shellPath)
}
// Best installed Windows shell: PowerShell 7+ (`pwsh`), then Windows PowerShell
// 5.1, then comspec/cmd.exe as the universal fallback.
function windowsShellSpec() {
const command =
findOnPath('pwsh.exe') || findOnPath('pwsh') || windowsPowerShellPath() || process.env.COMSPEC || 'cmd.exe'
return shellSpecFor(command)
}
// Resolve the interactive shell for the embedded terminal: an explicit user
// override wins, otherwise auto-detect the best one installed for the platform.
function terminalShellCommand() {
// HERMES_DESKTOP_SHELL is the cross-platform escape hatch (a path or a bare
// name on PATH); $SHELL is honored on POSIX, where it's the user's canonical
// choice, but ignored on Windows, where it's usually a stray MSYS/Git path
// node-pty can't spawn natively.
const override = (process.env.HERMES_DESKTOP_SHELL || (IS_WINDOWS ? '' : process.env.SHELL) || '').trim()
if (override) {
const resolved = isExecutableFile(override) ? override : findOnPath(override)
if (resolved) {
return shellSpecFor(resolved)
}
}
if (IS_WINDOWS) {
return windowsShellSpec()
}
const shellPath = ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => isExecutableFile(candidate))
return posixShellSpec(shellPath || '/bin/sh')
}
function safeTerminalCwd(cwd) {
const candidate = path.resolve(String(cwd || app.getPath('home')))
@@ -5736,11 +5416,6 @@ function terminalShellEnv() {
env.TERM_PROGRAM = 'Hermes'
env.TERM_PROGRAM_VERSION = app.getVersion()
// Let a hermes/--tui launched in this pane know it's embedded in the desktop
// GUI (build_environment_hints surfaces this). Distinct from HERMES_DESKTOP,
// which marks the agent *backend* and gates cron/gateway behavior.
env.HERMES_DESKTOP_TERMINAL = '1'
return env
}
@@ -5812,8 +5487,6 @@ ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.')
}
ensureSpawnHelperExecutable()
const id = crypto.randomUUID()
const { args, command, name } = terminalShellCommand()
const cwd = safeTerminalCwd(payload?.cwd)
@@ -5993,11 +5666,11 @@ async function getUninstallSummary() {
resolve(value)
}
try {
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], hiddenWindowsChildOptions({
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], {
cwd: agentRoot,
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
stdio: ['ignore', 'pipe', 'ignore']
}))
})
child.stdout.on('data', chunk => {
stdout += chunk.toString()
})
@@ -6136,12 +5809,6 @@ ipcMain.handle('hermes:uninstall:run', async (_event, payload) => {
return runDesktopUninstall(String(mode || ''))
})
// Download a VS Code Marketplace extension and return the raw color-theme JSON
// it contributes. No theme code is executed — we only read JSON from the .vsix.
ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketplaceThemes(String(id || '')))
// Search the Marketplace for color-theme extensions (empty query = top installs).
ipcMain.handle('hermes:vscode-theme:search', async (_event, query) => searchMarketplaceThemes(String(query || ''), 20))
app.whenReady().then(() => {
if (IS_MAC) {
@@ -6157,14 +5824,7 @@ app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
// Recreate the primary window if it's gone. Guard on mainWindow directly
// (not just total window count) so a dock click still restores the main
// window when only secondary session windows remain open.
if (!mainWindow || mainWindow.isDestroyed()) {
createWindow()
} else {
focusWindow(mainWindow)
}
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})

View File

@@ -5,7 +5,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'),
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
openSessionWindow: sessionId => ipcRenderer.invoke('hermes:window:openSession', sessionId),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
@@ -42,7 +41,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
sanitizeWorkspaceCwd: cwd => ipcRenderer.invoke('hermes:workspace:sanitize', cwd),
settings: {
getDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:get'),
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
@@ -134,9 +132,5 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
ipcRenderer.on('hermes:updates:progress', listener)
return () => ipcRenderer.removeListener('hermes:updates:progress', listener)
}
},
themes: {
fetchMarketplace: id => ipcRenderer.invoke('hermes:vscode-theme:fetch', id),
searchMarketplace: query => ipcRenderer.invoke('hermes:vscode-theme:search', query)
}
})

View File

@@ -1,86 +0,0 @@
// Secondary "session windows" — one extra OS window per chat so a user can
// work with multiple chats side by side. The pure, Electron-free pieces live
// here so they can be unit-tested with node --test (mirroring how the rest of
// electron/*.cjs splits testable logic out of the main.cjs monolith).
const { pathToFileURL } = require('node:url')
// Build the renderer URL for a secondary window. The renderer uses a
// HashRouter, so the session route lives after the '#'. The `?win=secondary`
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
// treated as the route by HashRouter and would break routeSessionId(). The
// renderer reads the flag from window.location.search to suppress the install /
// onboarding overlays and the global session sidebar.
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath } = {}) {
const route = `#/${encodeURIComponent(sessionId)}`
if (devServer) {
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
return `${base}/?win=secondary${route}`
}
return `${pathToFileURL(rendererIndexPath).toString()}?win=secondary${route}`
}
// A small registry keyed by sessionId that guarantees one window per chat:
// opening a session that already has a live window focuses it instead of
// spawning a duplicate, and a window removes itself from the registry when it
// closes. The actual BrowserWindow construction is injected (the `factory`) so
// this module stays free of Electron and is unit-testable.
function createSessionWindowRegistry() {
const windows = new Map()
function openOrFocus(sessionId, factory) {
const key = typeof sessionId === 'string' ? sessionId.trim() : ''
if (!key) {
return null
}
const existing = windows.get(key)
if (existing && !existing.isDestroyed()) {
// Focus-or-create: never duplicate a window for the same chat.
if (typeof existing.isMinimized === 'function' && existing.isMinimized()) {
existing.restore?.()
}
if (typeof existing.isVisible === 'function' && !existing.isVisible()) {
existing.show?.()
}
existing.focus?.()
return existing
}
const win = factory(key)
if (!win) {
return null
}
windows.set(key, win)
// Self-cleanup on close so the registry never holds a destroyed window.
win.on?.('closed', () => {
if (windows.get(key) === win) {
windows.delete(key)
}
})
return win
}
return {
openOrFocus,
get: key => windows.get(key),
has: key => windows.has(key),
get size() {
return windows.size
}
}
}
module.exports = { buildSessionWindowUrl, createSessionWindowRegistry }

View File

@@ -1,165 +0,0 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
// test fire the 'closed' event, mirroring the slice of the Electron API the
// registry actually touches.
function makeFakeWindow() {
const listeners = {}
const calls = { focus: 0, show: 0, restore: 0 }
let destroyed = false
let minimized = false
let visible = true
return {
on(event, handler) {
listeners[event] = handler
return this
},
emit(event) {
listeners[event]?.()
},
isDestroyed: () => destroyed,
destroy() {
destroyed = true
},
isMinimized: () => minimized,
setMinimized(value) {
minimized = value
},
isVisible: () => visible,
setVisible(value) {
visible = value
},
restore() {
calls.restore += 1
minimized = false
},
show() {
calls.show += 1
visible = true
},
focus() {
calls.focus += 1
},
calls
}
}
test('buildSessionWindowUrl puts the secondary flag before the hash route (dev server)', () => {
const url = buildSessionWindowUrl('abc123', { devServer: 'http://localhost:5173' })
assert.equal(url, 'http://localhost:5173/?win=secondary#/abc123')
})
test('buildSessionWindowUrl avoids a double slash when the dev server has a trailing slash', () => {
const url = buildSessionWindowUrl('abc123', { devServer: 'http://localhost:5173/' })
assert.equal(url, 'http://localhost:5173/?win=secondary#/abc123')
})
test('buildSessionWindowUrl encodes the session id in the hash route', () => {
const url = buildSessionWindowUrl('a b/c', { devServer: 'http://localhost:5173' })
// The query flag must precede the '#' or HashRouter would swallow it as the
// route; the id is URL-encoded so slashes/spaces survive routeSessionId().
assert.equal(url, 'http://localhost:5173/?win=secondary#/a%20b%2Fc')
assert.ok(url.indexOf('?win=secondary') < url.indexOf('#'))
})
test('buildSessionWindowUrl builds a packaged file URL with the flag before the hash', () => {
const url = buildSessionWindowUrl('abc', { rendererIndexPath: '/opt/app/index.html' })
assert.match(url, /^file:\/\/.*index\.html\?win=secondary#\/abc$/)
})
test('registry opens one window per session and focuses on re-open', () => {
const registry = createSessionWindowRegistry()
let built = 0
const win = makeFakeWindow()
const factory = () => {
built += 1
return win
}
const first = registry.openOrFocus('s1', factory)
const second = registry.openOrFocus('s1', factory)
assert.equal(built, 1, 'factory runs once for the same session')
assert.equal(first, second)
assert.equal(registry.size, 1)
assert.equal(win.calls.focus, 1, 'second open focuses the existing window')
})
test('registry restores + shows a minimized/hidden window on re-open', () => {
const registry = createSessionWindowRegistry()
const win = makeFakeWindow()
registry.openOrFocus('s1', () => win)
win.setMinimized(true)
win.setVisible(false)
registry.openOrFocus('s1', () => win)
assert.equal(win.calls.restore, 1)
assert.equal(win.calls.show, 1)
assert.equal(win.calls.focus, 1)
})
test('registry drops the entry when the window closes', () => {
const registry = createSessionWindowRegistry()
const win = makeFakeWindow()
registry.openOrFocus('s1', () => win)
assert.equal(registry.size, 1)
win.emit('closed')
assert.equal(registry.size, 0)
assert.equal(registry.has('s1'), false)
})
test('registry rebuilds a fresh window after the previous one was destroyed', () => {
const registry = createSessionWindowRegistry()
const first = makeFakeWindow()
registry.openOrFocus('s1', () => first)
first.destroy()
let built = 0
const second = makeFakeWindow()
const result = registry.openOrFocus('s1', () => {
built += 1
return second
})
assert.equal(built, 1, 'a destroyed window is replaced, not focused')
assert.equal(result, second)
})
test('registry ignores empty / non-string session ids', () => {
const registry = createSessionWindowRegistry()
let built = 0
const factory = () => {
built += 1
return makeFakeWindow()
}
assert.equal(registry.openOrFocus('', factory), null)
assert.equal(registry.openOrFocus(' ', factory), null)
assert.equal(registry.openOrFocus(null, factory), null)
assert.equal(registry.openOrFocus(42, factory), null)
assert.equal(built, 0)
assert.equal(registry.size, 0)
})
test('registry trims the session id before keying', () => {
const registry = createSessionWindowRegistry()
const win = makeFakeWindow()
registry.openOrFocus(' s1 ', () => win)
assert.equal(registry.has('s1'), true)
})

View File

@@ -1,331 +0,0 @@
'use strict'
/**
* VS Code Marketplace color-theme fetcher (main process).
*
* Resolves an extension's latest version via the (undocumented but stable)
* gallery ExtensionQuery API, downloads the `.vsix` (a zip), and extracts the
* color-theme JSON files it contributes. No theme code is ever executed — we
* only read `package.json` + the referenced `*.json` theme files out of the
* archive and hand their text back to the renderer to convert.
*
* Dependency-free on purpose: a `.vsix` is a plain zip, so we parse the central
* directory and inflate just the entries we need with `zlib`. Avoids pulling a
* zip library into the desktop bundle for a feature this small.
*/
const https = require('node:https')
const zlib = require('node:zlib')
const GALLERY_QUERY_URL = 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery'
const VSIX_ASSET_TYPE = 'Microsoft.VisualStudio.Services.VSIXPackage'
const MAX_VSIX_BYTES = 40 * 1024 * 1024 // 40 MB — themes are tiny; this is paranoia.
const MAX_REDIRECTS = 5
const REQUEST_TIMEOUT_MS = 20_000
const ID_RE = /^[\w-]+\.[\w-]+$/
/** Minimal HTTPS helper with redirect-following, timeout, and a size cap. */
function request(url, { method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {}, redirectsLeft = MAX_REDIRECTS) {
return new Promise((resolve, reject) => {
const req = https.request(url, { method, headers }, res => {
const status = res.statusCode ?? 0
if (status >= 300 && status < 400 && res.headers.location) {
if (redirectsLeft <= 0) {
res.resume()
reject(new Error('Too many redirects.'))
return
}
const next = new URL(res.headers.location, url).toString()
res.resume()
// Redirects to the CDN are plain GETs (drop the POST body).
resolve(request(next, { method: 'GET', headers: { 'User-Agent': headers['User-Agent'] }, maxBytes }, redirectsLeft - 1))
return
}
if (status < 200 || status >= 300) {
res.resume()
reject(new Error(`Request failed (${status}) for ${url}`))
return
}
const chunks = []
let total = 0
res.on('data', chunk => {
total += chunk.length
if (total > maxBytes) {
req.destroy()
reject(new Error('Response exceeded the size limit.'))
return
}
chunks.push(chunk)
})
res.on('end', () => resolve(Buffer.concat(chunks)))
})
req.on('error', reject)
req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('Request timed out.')))
if (body) {
req.write(body)
}
req.end()
})
}
/** Resolve `{ displayName, vsixUrl }` for the latest version of `id`. */
async function resolveExtension(id) {
const json = await queryGallery({
// FilterType 7 = ExtensionName (the full publisher.extension id).
filters: [{ criteria: [{ filterType: 7, value: id }], pageNumber: 1, pageSize: 1 }],
// Flags: IncludeFiles | IncludeVersionProperties | IncludeAssetUri |
// IncludeCategoryAndTags | IncludeLatestVersionOnly = 914.
flags: 914
})
const extension = json?.results?.[0]?.extensions?.[0]
if (!extension) {
throw new Error(`Extension "${id}" was not found on the Marketplace.`)
}
const version = extension.versions?.[0]
if (!version) {
throw new Error(`Extension "${id}" has no published versions.`)
}
const asset = (version.files ?? []).find(file => file.assetType === VSIX_ASSET_TYPE)
const vsixUrl = asset?.source
if (!vsixUrl) {
throw new Error(`Could not find a downloadable package for "${id}".`)
}
return { displayName: extension.displayName || id, vsixUrl }
}
/** POST an ExtensionQuery payload and return the parsed gallery response. */
async function queryGallery(payload, { maxBytes = 4 * 1024 * 1024 } = {}) {
const body = JSON.stringify(payload)
const raw = await request(GALLERY_QUERY_URL, {
method: 'POST',
headers: {
Accept: 'application/json;api-version=3.0-preview.1',
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
'User-Agent': 'Hermes-Desktop'
},
body,
maxBytes
})
return JSON.parse(raw.toString('utf8'))
}
/**
* Search the Marketplace for color-theme extensions. With an empty query this
* returns the most-installed themes; with a query it's a full-text search
* scoped to the Themes category. Returns lightweight cards (no download).
*/
/**
* The "Themes" category also contains file-icon and product-icon themes (the
* gallery has no color-only category). We can't see an extension's actual
* contributions without downloading it, so filter the obvious icon packs out by
* tag + name/description. Color themes that also ship icons are rare; worst case
* a user installs them by exact id from settings.
*/
function looksLikeIconTheme(extension) {
const tags = (extension.tags ?? []).map(tag => String(tag).toLowerCase())
if (tags.includes('icon-theme') || tags.includes('product-icon-theme')) {
return true
}
const text = `${extension.displayName ?? ''} ${extension.shortDescription ?? ''}`.toLowerCase()
return /\b(icon theme|file icons?|product icons?|icon pack|fileicons)\b/.test(text)
}
async function searchMarketplaceThemes(query, limit = 20) {
const text = String(query || '').trim()
const pageSize = Math.min(Math.max(Number(limit) || 20, 1), 50)
// FilterType: 8=Target, 5=Category, 10=SearchText, 12=ExcludeWithFlags.
const criteria = [
{ filterType: 8, value: 'Microsoft.VisualStudio.Code' },
{ filterType: 5, value: 'Themes' },
{ filterType: 12, value: '4096' } // Exclude unpublished (Unpublished = 0x1000).
]
if (text) {
criteria.push({ filterType: 10, value: text })
}
const json = await queryGallery({
// Over-fetch so the icon-theme filter below still leaves a full page.
filters: [{ criteria, pageNumber: 1, pageSize: Math.min(pageSize * 2, 50), sortBy: 4, sortOrder: 0 }],
// IncludeStatistics (0x100) | IncludeLatestVersionOnly (0x200) | IncludeCategoryAndTags (0x4).
flags: 772
})
const extensions = json?.results?.[0]?.extensions ?? []
return extensions
.filter(extension => !looksLikeIconTheme(extension))
.slice(0, pageSize)
.map(extension => {
const publisherName = extension.publisher?.publisherName ?? ''
const installStat = (extension.statistics ?? []).find(stat => stat.statisticName === 'install')
return {
extensionId: `${publisherName}.${extension.extensionName}`,
displayName: extension.displayName || extension.extensionName,
publisher: extension.publisher?.displayName || publisherName,
description: extension.shortDescription || '',
installs: Math.round(installStat?.value ?? 0)
}
})
}
// ─── Minimal zip reader ─────────────────────────────────────────────────────
function findEndOfCentralDirectory(buf) {
// EOCD signature 0x06054b50, scanning back from the end (comment is rare).
for (let i = buf.length - 22; i >= 0; i--) {
if (buf.readUInt32LE(i) === 0x06054b50) {
return i
}
}
throw new Error('Not a valid zip archive (no end-of-central-directory).')
}
/** Parse the central directory into a name → record map. */
function readCentralDirectory(buf) {
const eocd = findEndOfCentralDirectory(buf)
const count = buf.readUInt16LE(eocd + 10)
let offset = buf.readUInt32LE(eocd + 16)
const records = new Map()
for (let i = 0; i < count; i++) {
if (buf.readUInt32LE(offset) !== 0x02014b50) {
break
}
const method = buf.readUInt16LE(offset + 10)
const compressedSize = buf.readUInt32LE(offset + 20)
const nameLen = buf.readUInt16LE(offset + 28)
const extraLen = buf.readUInt16LE(offset + 30)
const commentLen = buf.readUInt16LE(offset + 32)
const localOffset = buf.readUInt32LE(offset + 42)
const name = buf.toString('utf8', offset + 46, offset + 46 + nameLen)
records.set(name, { method, compressedSize, localOffset })
offset += 46 + nameLen + extraLen + commentLen
}
return records
}
/** Inflate a single entry to a string. */
function extractEntry(buf, record) {
// The local header's name/extra lengths can differ from the central record,
// so re-read them here to locate the compressed payload.
if (buf.readUInt32LE(record.localOffset) !== 0x04034b50) {
throw new Error('Corrupt zip: bad local file header.')
}
const nameLen = buf.readUInt16LE(record.localOffset + 26)
const extraLen = buf.readUInt16LE(record.localOffset + 28)
const dataStart = record.localOffset + 30 + nameLen + extraLen
const data = buf.subarray(dataStart, dataStart + record.compressedSize)
// 0 = stored, 8 = deflate. Theme files are one or the other.
return record.method === 0 ? data.toString('utf8') : zlib.inflateRawSync(data).toString('utf8')
}
/** Normalize a package.json theme path to its zip entry name. */
function themeEntryName(themePath) {
const clean = String(themePath).replace(/^\.\//, '').replace(/^\//, '')
return `extension/${clean}`
}
/** Extract every contributed color theme from a `.vsix` buffer. */
function extractThemes(vsixBuffer) {
const records = readCentralDirectory(vsixBuffer)
const pkgRecord = records.get('extension/package.json')
if (!pkgRecord) {
throw new Error('Package manifest missing from the extension.')
}
const pkg = JSON.parse(extractEntry(vsixBuffer, pkgRecord))
const contributed = pkg?.contributes?.themes
if (!Array.isArray(contributed) || contributed.length === 0) {
return []
}
const themes = []
for (const entry of contributed) {
if (!entry?.path) {
continue
}
const record = records.get(themeEntryName(entry.path))
if (!record) {
continue
}
try {
themes.push({
label: entry.label || entry.id || pkg.displayName || pkg.name || 'VS Code Theme',
uiTheme: entry.uiTheme,
contents: extractEntry(vsixBuffer, record)
})
} catch {
// Skip an entry we can't inflate rather than failing the whole install.
}
}
return themes
}
/**
* Public entry: resolve, download, and extract color themes for `id`
* (`publisher.extension`). Returns `{ extensionId, displayName, themes }`.
*/
async function fetchMarketplaceThemes(id) {
const trimmed = String(id || '').trim()
if (!ID_RE.test(trimmed)) {
throw new Error('Expected a Marketplace id like "publisher.extension".')
}
const { displayName, vsixUrl } = await resolveExtension(trimmed)
const vsix = await request(vsixUrl, { headers: { 'User-Agent': 'Hermes-Desktop' } })
const themes = extractThemes(vsix)
return { extensionId: trimmed, displayName, themes }
}
module.exports = {
fetchMarketplaceThemes,
searchMarketplaceThemes,
extractThemes,
readCentralDirectory,
__testing: { themeEntryName, looksLikeIconTheme }
}

View File

@@ -1,113 +0,0 @@
'use strict'
const assert = require('node:assert')
const test = require('node:test')
const { __testing, extractThemes, readCentralDirectory } = require('./vscode-marketplace.cjs')
// Build a minimal zip with stored (uncompressed) entries so the test controls
// the bytes exactly — exercises the central-directory reader + theme extraction
// without a deflate dependency.
function makeZip(entries) {
const locals = []
const centrals = []
let offset = 0
for (const { name, data } of entries) {
const nameBuf = Buffer.from(name, 'utf8')
const body = Buffer.from(data, 'utf8')
const local = Buffer.alloc(30 + nameBuf.length)
local.writeUInt32LE(0x04034b50, 0)
local.writeUInt16LE(0, 8) // method: stored
local.writeUInt32LE(body.length, 18) // compressed size
local.writeUInt32LE(body.length, 22) // uncompressed size
local.writeUInt16LE(nameBuf.length, 26)
nameBuf.copy(local, 30)
locals.push(local, body)
const central = Buffer.alloc(46 + nameBuf.length)
central.writeUInt32LE(0x02014b50, 0)
central.writeUInt16LE(0, 10) // method: stored
central.writeUInt32LE(body.length, 20)
central.writeUInt32LE(body.length, 24)
central.writeUInt16LE(nameBuf.length, 28)
central.writeUInt32LE(offset, 42) // local header offset
nameBuf.copy(central, 46)
centrals.push(central)
offset += local.length + body.length
}
const centralStart = offset
const centralBuf = Buffer.concat(centrals)
const eocd = Buffer.alloc(22)
eocd.writeUInt32LE(0x06054b50, 0)
eocd.writeUInt16LE(entries.length, 8)
eocd.writeUInt16LE(entries.length, 10)
eocd.writeUInt32LE(centralBuf.length, 12)
eocd.writeUInt32LE(centralStart, 16)
return Buffer.concat([...locals, centralBuf, eocd])
}
test('readCentralDirectory finds every entry', () => {
const zip = makeZip([
{ name: 'extension/package.json', data: '{}' },
{ name: 'extension/themes/x.json', data: '{}' }
])
const records = readCentralDirectory(zip)
assert.ok(records.has('extension/package.json'))
assert.ok(records.has('extension/themes/x.json'))
})
test('extractThemes reads contributed color themes (resolving ./ paths)', () => {
const pkg = JSON.stringify({
name: 'theme-dracula',
displayName: 'Dracula',
contributes: {
themes: [{ label: 'Dracula', uiTheme: 'vs-dark', path: './themes/dracula.json' }]
}
})
const themeJson = JSON.stringify({ name: 'Dracula', type: 'dark', colors: { 'editor.background': '#282a36' } })
const zip = makeZip([
{ name: 'extension/package.json', data: pkg },
{ name: 'extension/themes/dracula.json', data: themeJson }
])
const themes = extractThemes(zip)
assert.strictEqual(themes.length, 1)
assert.strictEqual(themes[0].label, 'Dracula')
assert.strictEqual(themes[0].uiTheme, 'vs-dark')
assert.match(themes[0].contents, /editor\.background/)
})
test('extractThemes returns empty when the extension contributes no themes', () => {
const zip = makeZip([{ name: 'extension/package.json', data: JSON.stringify({ name: 'x', contributes: {} }) }])
assert.deepStrictEqual(extractThemes(zip), [])
})
test('extractThemes throws when the manifest is missing', () => {
const zip = makeZip([{ name: 'extension/other.txt', data: 'hi' }])
assert.throws(() => extractThemes(zip), /manifest missing/i)
})
test('looksLikeIconTheme filters icon/product-icon packs out of theme search', () => {
const { looksLikeIconTheme } = __testing
// Tagged contribution points are the strongest signal.
assert.strictEqual(looksLikeIconTheme({ tags: ['theme', 'icon-theme'] }), true)
assert.strictEqual(looksLikeIconTheme({ tags: ['product-icon-theme'] }), true)
// Name/description fallback for packs that don't tag themselves.
assert.strictEqual(looksLikeIconTheme({ displayName: 'Material Icon Theme' }), true)
assert.strictEqual(looksLikeIconTheme({ shortDescription: 'A pack of file icons.' }), true)
// Real color themes survive.
assert.strictEqual(looksLikeIconTheme({ displayName: 'Dracula Official', tags: ['theme', 'color-theme'] }), false)
assert.strictEqual(looksLikeIconTheme({ displayName: 'One Dark Pro' }), false)
})

View File

@@ -1,54 +0,0 @@
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const ELECTRON_DIR = __dirname
function readElectronFile(name) {
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8')
}
function requireHiddenChildOptions(source, needle) {
const index = source.indexOf(needle)
assert.notEqual(index, -1, `missing call site: ${needle}`)
const snippet = source.slice(index, index + 700)
assert.match(
snippet,
/hiddenWindowsChildOptions\(/,
`expected ${needle} to wrap child-process options with hiddenWindowsChildOptions`
)
}
test('desktop background child processes opt into hidden Windows consoles', () => {
const source = readElectronFile('main.cjs')
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
requireHiddenChildOptions(source, 'execFileSync(pyExe')
requireHiddenChildOptions(source, 'spawn(resolveGitBinary()')
requireHiddenChildOptions(source, "execFileSync('taskkill'")
requireHiddenChildOptions(source, 'spawn(command, args')
requireHiddenChildOptions(source, "spawn('curl'")
requireHiddenChildOptions(source, 'spawn(backend.command, backend.args')
requireHiddenChildOptions(source, 'hermesProcess = spawn(backend.command, backend.args')
requireHiddenChildOptions(source, "spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary']")
})
test('intentional or interactive desktop child processes stay documented', () => {
const source = readElectronFile('main.cjs')
assert.match(source, /windowsHide: false/)
assert.match(source, /nodePty\.spawn\(command, args/)
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
})
test('bootstrap PowerShell runner hides Windows console children', () => {
const source = readElectronFile('bootstrap-runner.cjs')
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
requireHiddenChildOptions(source, 'spawn(ps, fullArgs')
})

View File

@@ -1,38 +0,0 @@
const path = require('node:path')
/** True when `dir` lives inside a packaged app bundle / install tree. */
function isPackagedInstallPath(dir, { installRoots, isPackaged }) {
if (!isPackaged || !dir) {
return false
}
let resolved
try {
resolved = path.resolve(String(dir))
} catch {
return false
}
const roots = new Set(
(installRoots ?? [])
.filter(Boolean)
.map(candidate => path.resolve(String(candidate)))
)
for (const root of roots) {
if (resolved === root) {
return true
}
const rel = path.relative(root, resolved)
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
return true
}
}
return false
}
module.exports = { isPackagedInstallPath }

View File

@@ -1,45 +0,0 @@
/**
* Tests for electron/workspace-cwd.cjs.
*
* Run with: node --test electron/workspace-cwd.test.cjs
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const path = require('node:path')
const { isPackagedInstallPath } = require('./workspace-cwd.cjs')
const installRoot = path.resolve('/opt/Hermes')
test('isPackagedInstallPath returns false when not packaged', () => {
assert.equal(
isPackagedInstallPath(installRoot, { isPackaged: false, installRoots: [installRoot] }),
false
)
})
test('isPackagedInstallPath flags the install root itself', () => {
assert.equal(
isPackagedInstallPath(installRoot, { isPackaged: true, installRoots: [installRoot] }),
true
)
})
test('isPackagedInstallPath flags paths nested under the install root', () => {
const nested = path.join(installRoot, 'resources', 'app.asar')
assert.equal(
isPackagedInstallPath(nested, { isPackaged: true, installRoots: [installRoot] }),
true
)
})
test('isPackagedInstallPath ignores paths outside the install root', () => {
const homeProject = path.resolve('/home/user/projects/demo')
assert.equal(
isPackagedInstallPath(homeProject, { isPackaged: true, installRoots: [installRoot] }),
false
)
})

View File

@@ -3,6 +3,7 @@ import typescriptEslint from '@typescript-eslint/eslint-plugin'
import typescriptParser from '@typescript-eslint/parser'
import perfectionist from 'eslint-plugin-perfectionist'
import reactPlugin from 'eslint-plugin-react'
import reactCompiler from 'eslint-plugin-react-compiler'
import hooksPlugin from 'eslint-plugin-react-hooks'
import unusedImports from 'eslint-plugin-unused-imports'
import globals from 'globals'
@@ -46,6 +47,7 @@ export default [
'custom-rules': customRules,
perfectionist,
react: reactPlugin,
'react-compiler': reactCompiler,
'react-hooks': hooksPlugin,
'unused-imports': unusedImports
},
@@ -96,6 +98,7 @@ export default [
'perfectionist/sort-jsx-props': ['error', { order: 'asc', type: 'natural' }],
'perfectionist/sort-named-exports': ['error', { order: 'asc', type: 'natural' }],
'perfectionist/sort-named-imports': ['error', { order: 'asc', type: 'natural' }],
'react-compiler/react-compiler': 'warn',
'react-hooks/exhaustive-deps': 'warn',
'react-hooks/rules-of-hooks': 'error',
'unused-imports/no-unused-imports': 'error'

View File

@@ -35,8 +35,8 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/windows-child-process.test.cjs",
"typecheck": "tsc -p . --noEmit",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' 'vite.config.ts'",
@@ -103,19 +103,20 @@
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/hast": "^3.0.4",
"@types/node": "^24.12.0",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"@vitejs/plugin-react": "^6.0.1",
"concurrently": "^10.0.3",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^40.9.3",
"electron-builder": "^26.8.1",
"eslint": "^9.39.4",
"eslint-plugin-perfectionist": "^5.9.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^16.5.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -3,9 +3,8 @@ import { useStore } from '@nanostores/react'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { AlertCircle, FileText, FolderOpen, ImageIcon, Link, Loader2, Terminal } from '@/lib/icons'
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
@@ -32,9 +31,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
const c = t.composer
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
const cwd = useStore($currentCwd)
const isUploading = attachment.uploadState === 'uploading'
const hasUploadError = attachment.uploadState === 'error'
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal'
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
async function openPreview() {
@@ -62,15 +59,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
throw new Error(c.couldNotPreview(attachment.label))
}
// We already hold the image bytes (the card thumbnail) — render those
// directly so a screenshot/clipboard image previews even when its only
// on-disk copy is a transient path the renderer can't re-read.
const withBytes =
attachment.kind === 'image' && attachment.previewUrl
? { ...preview, dataUrl: attachment.previewUrl, previewKind: 'image' as const }
: preview
setCurrentSessionPreviewTarget(withBytes, 'manual', target)
setCurrentSessionPreviewTarget(preview, 'manual', target)
} catch (error) {
notifyError(error, c.previewUnavailable)
}
@@ -80,51 +69,30 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
<Tip label={attachment.path || attachment.detail || attachment.label}>
<div className="group/attachment relative min-w-0 shrink-0">
<button
aria-busy={isUploading || undefined}
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
className={cn(
'flex max-w-56 items-center gap-2 rounded-2xl border bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.18)] transition-colors disabled:cursor-default',
hasUploadError
? 'border-destructive/45 hover:border-destructive/60'
: 'border-border/60 hover:border-primary/35 hover:bg-accent/45'
)}
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
disabled={!canPreview}
onClick={() => void openPreview()}
type="button"
>
<span className="relative grid size-8 shrink-0 place-items-center overflow-hidden rounded-lg border border-border/55 bg-muted/35 text-muted-foreground">
{attachment.previewUrl && attachment.kind === 'image' ? (
<img
alt={attachment.label}
className="size-full object-cover"
draggable={false}
src={attachment.previewUrl}
/>
) : (
{attachment.previewUrl && attachment.kind === 'image' ? (
<img
alt={attachment.label}
className="size-8 shrink-0 border border-border/70 object-cover"
draggable={false}
src={attachment.previewUrl}
/>
) : (
<span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground">
<Icon className="size-3.5" />
)}
{isUploading && (
<span className="absolute inset-0 grid place-items-center bg-background/60 backdrop-blur-[1px]">
<Loader2 className="size-3.5 animate-spin text-foreground/75" />
</span>
)}
{hasUploadError && (
<span className="absolute inset-0 grid place-items-center bg-destructive/15">
<AlertCircle className="size-3.5 text-destructive" />
</span>
)}
</span>
</span>
)}
<span className="min-w-0">
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
{attachment.label}
</span>
{detail && (
<span
className={cn(
'block truncate text-[0.62rem] leading-3.5',
hasUploadError ? 'text-destructive/80' : 'text-muted-foreground/65'
)}
>
<span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">
{detail}
</span>
)}

View File

@@ -4,7 +4,6 @@ import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, Layers3, Loader2, Square, SteeringWheel } from '@/lib/icons'
import { formatCombo } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import type { ConversationStatus } from './hooks/use-voice-conversation'
@@ -63,7 +62,6 @@ export function ComposerControls({
}) {
const { t } = useI18n()
const c = t.composer
const steerLabel = `${c.steer} (${formatCombo('mod+enter')})`
if (conversation.active) {
return <ConversationPill {...conversation} disabled={disabled} />
@@ -75,9 +73,9 @@ export function ComposerControls({
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
{canSteer && (
<Tip label={steerLabel}>
<Tip label={c.steer}>
<Button
aria-label={steerLabel}
aria-label={c.steer}
className={GHOST_ICON_BTN}
disabled={disabled}
onClick={onSteer}

View File

@@ -1,189 +0,0 @@
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { useRef, useState } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
// No global setupFiles registers auto-cleanup, so unmount between tests —
// otherwise a second render() leaks the first editor and getByTestId('editor')
// matches multiple nodes.
afterEach(cleanup)
// Faithful mirror of index.tsx's Enter wiring (handleEditorKeyDown's Enter
// branch + submitDraft), driven through REAL DOM keydown events on a
// contentEditable.
//
// Regression repro for #39630: pressing Enter right after typing (fast typing /
// IME) did nothing. The composer state (`draft` from useAuiState) and its
// derived `hasComposerPayload` lag the DOM by a render, so the keydown handler
// read empty state and either dropped the message, drained a queued prompt
// instead of sending, or (while busy) refused to queue. The fix reads the live
// editor text — `hasLivePayload` in the handler and a DOM re-sync at the top of
// submitDraft — so the just-typed text always wins.
//
// We model the race deterministically the way the IME repro does: mutate the
// editor's textContent WITHOUT firing an input event, so the React `draft`
// state stays stale while the DOM already holds the text.
function Harness({
busy = false,
queued = [],
onSubmit,
onQueue,
onCancel,
onDrain
}: {
busy?: boolean
queued?: readonly string[]
onSubmit: (text: string) => void
onQueue: (text: string) => void
onCancel: () => void
onDrain: () => void
}) {
const editorRef = useRef<HTMLDivElement>(null)
const draftRef = useRef('')
// Mirrors `useAuiState(s => s.composer.text)` — updated only via setText, so
// it lags the DOM until React re-renders (the source of the bug).
const [draft, setDraft] = useState('')
const attachments: unknown[] = []
const composerPlainText = (el: HTMLElement) => el.textContent ?? ''
const setText = (next: string) => {
draftRef.current = next
setDraft(next)
}
const submitDraft = () => {
const editor = editorRef.current
if (editor) {
const domText = composerPlainText(editor)
if (domText !== draftRef.current) {
draftRef.current = domText
setDraft(domText)
}
}
const text = draftRef.current
const payloadPresent = text.trim().length > 0 || attachments.length > 0
if (busy) {
if (payloadPresent) {
onQueue(text)
} else {
onCancel()
}
} else if (!payloadPresent && queued.length > 0) {
onDrain()
} else if (payloadPresent) {
onSubmit(text)
}
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
if (!busy && !hasLivePayload && queued.length > 0) {
onDrain()
return
}
if (busy && !hasLivePayload) {
return
}
submitDraft()
}
}
// `draft` is read so the lint/compiler treats the stale-state mirror as live;
// the assertions prove the handler never relies on it.
void draft
return (
<div
contentEditable
data-testid="editor"
onInput={event => setText(composerPlainText(event.currentTarget))}
onKeyDown={handleKeyDown}
ref={editorRef}
suppressContentEditableWarning
/>
)
}
describe('composer Enter submit — live DOM vs stale composer state (#39630)', () => {
it('sends the just-typed text on Enter even when composer state has not synced', async () => {
const onSubmit = vi.fn()
const { getByTestId } = render(
<Harness onCancel={vi.fn()} onDrain={vi.fn()} onQueue={vi.fn()} onSubmit={onSubmit} />
)
const editor = getByTestId('editor')
// Fast typing: the DOM has the text but NO input event fired, so `draft`
// state is still empty (the exact stale-state race).
await act(async () => {
editor.textContent = 'hello world'
fireEvent.keyDown(editor, { key: 'Enter' })
})
expect(onSubmit).toHaveBeenCalledWith('hello world')
})
it('queues a fast-typed message while busy instead of draining the queue or cancelling', async () => {
const onQueue = vi.fn()
const onDrain = vi.fn()
const onCancel = vi.fn()
const { getByTestId } = render(
<Harness busy onCancel={onCancel} onDrain={onDrain} onQueue={onQueue} onSubmit={vi.fn()} queued={['queued-1']} />
)
const editor = getByTestId('editor')
await act(async () => {
editor.textContent = 'urgent follow-up'
fireEvent.keyDown(editor, { key: 'Enter' })
})
expect(onQueue).toHaveBeenCalledWith('urgent follow-up')
expect(onDrain).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
})
it('treats an empty Enter while busy as a no-op (never an accidental Stop)', async () => {
const onCancel = vi.fn()
const onSubmit = vi.fn()
const onQueue = vi.fn()
const { getByTestId } = render(
<Harness busy onCancel={onCancel} onDrain={vi.fn()} onQueue={onQueue} onSubmit={onSubmit} />
)
const editor = getByTestId('editor')
await act(async () => {
editor.textContent = ''
fireEvent.keyDown(editor, { key: 'Enter' })
})
expect(onCancel).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
expect(onQueue).not.toHaveBeenCalled()
})
it('drains the next queued prompt on Enter when idle with a truly empty editor', async () => {
const onDrain = vi.fn()
const onSubmit = vi.fn()
const { getByTestId } = render(
<Harness onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
)
const editor = getByTestId('editor')
await act(async () => {
editor.textContent = ''
fireEvent.keyDown(editor, { key: 'Enter' })
})
expect(onDrain).toHaveBeenCalledTimes(1)
expect(onSubmit).not.toHaveBeenCalled()
})
})

View File

@@ -43,7 +43,7 @@ import {
import { $gatewayState, $messages } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../hooks/use-composer-actions'
import { extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions'
import { AttachmentList } from './attachments'
import { ContextMenu } from './context-menu'
@@ -64,7 +64,7 @@ import { useVoiceConversation } from './hooks/use-voice-conversation'
import { useVoiceRecorder } from './hooks/use-voice-recorder'
import {
dragHasAttachments,
droppedFileInlineRefs,
droppedFileInlineRef,
type InlineRefInput,
insertInlineRefsIntoEditor
} from './inline-refs'
@@ -814,16 +814,7 @@ export function ChatBar({
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
// Decide from the DOM, not React state. `hasComposerPayload` is derived
// from the AUI composer state, which lags the latest keystroke by a
// render, so on fast typing / IME the just-typed text isn't in state yet.
// Without the live read, a real message typed while prompts are queued
// would drain the queue instead of sending. submitDraft() re-syncs and
// sends the live editor text.
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
if (!busy && !hasLivePayload && queuedPrompts.length > 0) {
if (!busy && !hasComposerPayload && queuedPrompts.length > 0) {
void drainNextQueued()
return
@@ -831,10 +822,7 @@ export function ChatBar({
// Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc),
// never a stray Enter after sending. With a payload, submitDraft queues it.
// Gate on the live DOM payload (not the render-lagged composer state) so a
// message typed fast / via IME while busy still reaches submitDraft() and
// gets queued instead of being mistaken for an empty Enter.
if (busy && !hasLivePayload) {
if (busy && !hasComposerPayload) {
return
}
@@ -931,25 +919,24 @@ export function ChatBar({
return
}
// In-app drags (project tree / gutter) are workspace-relative paths the
// gateway resolves directly, so they stay inline @file:/@line: refs. OS
// drops are absolute local paths a remote gateway can't read (and images
// need byte upload for vision), so route them through the upload pipeline.
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
const refs = droppedFileInlineRefs(inAppRefs, cwd)
if (Array.from(event.dataTransfer.types || []).includes(HERMES_PATHS_MIME)) {
const refs = candidates
.map(candidate => droppedFileInlineRef(candidate, cwd))
.filter((ref): ref is string => Boolean(ref))
if (refs.length && insertInlineRefs(refs)) {
triggerHaptic('selection')
if (insertInlineRefs(refs)) {
triggerHaptic('selection')
}
return
}
if (osDrops.length) {
void Promise.resolve(onAttachDroppedItems(osDrops)).then(attached => {
if (attached) {
triggerHaptic('selection')
requestMainFocus()
}
})
}
void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => {
if (attached) {
triggerHaptic('selection')
requestMainFocus()
}
})
}
const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
@@ -969,7 +956,11 @@ export function ChatBar({
const candidates = extractDroppedFiles(event.dataTransfer)
if (!candidates.length) {
const refs = candidates
.map(candidate => droppedFileInlineRef(candidate, cwd))
.filter((ref): ref is string => Boolean(ref))
if (!refs.length) {
return
}
@@ -977,27 +968,9 @@ export function ChatBar({
event.stopPropagation()
resetDragState()
// Dropping straight onto the text box used to inline-ref *every* file —
// including OS/Finder drops, whose absolute local path a remote gateway
// can't read and whose image bytes never reached vision. Split by origin:
// in-app drags stay inline refs; OS drops go through the upload pipeline.
// (When no upload handler is wired, fall back to inline refs for all.)
const attach = onAttachDroppedItems
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
const refs = droppedFileInlineRefs(attach ? inAppRefs : candidates, cwd)
if (refs.length && insertInlineRefs(refs)) {
if (insertInlineRefs(refs)) {
triggerHaptic('selection')
}
if (attach && osDrops.length) {
void Promise.resolve(attach(osDrops)).then(attached => {
if (attached) {
triggerHaptic('selection')
requestMainFocus()
}
})
}
}
const clearDraft = useCallback(() => {
@@ -1239,26 +1212,6 @@ export function ChatBar({
}, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps
const submitDraft = () => {
// Source the text from the DOM editor, not React state. The AUI composer
// state (`draft`) and the derived `hasComposerPayload` lag the DOM by a
// render, so on fast typing or IME composition the final keystroke(s) may
// not have synced yet — reading state here drops the message (Enter looks
// like it does nothing; typing a trailing space only "fixes" it because the
// extra input event forces a state sync). draftRef is updated on every
// input event; refresh it from the editor once more to also cover an
// in-flight keystroke that hasn't fired its input event yet.
const editor = editorRef.current
if (editor) {
const domText = composerPlainText(editor)
if (domText !== draftRef.current) {
draftRef.current = domText
aui.composer().setText(domText)
}
}
const text = draftRef.current
const payloadPresent = text.trim().length > 0 || attachments.length > 0
if (queueEdit) {
exitQueuedEdit('save')
} else if (busy) {
@@ -1269,12 +1222,12 @@ export function ChatBar({
// 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(text.trim())) {
const submitted = text
if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
const submitted = draft
triggerHaptic('submit')
clearDraft()
void onSubmit(submitted)
} else if (payloadPresent) {
} else if (hasComposerPayload) {
queueCurrentDraft()
} else {
// Stop button (the only way to reach here while busy with an empty
@@ -1282,10 +1235,10 @@ export function ChatBar({
triggerHaptic('cancel')
void Promise.resolve(onCancel())
}
} else if (!payloadPresent && queuedPrompts.length > 0) {
} else if (!hasComposerPayload && queuedPrompts.length > 0) {
void drainNextQueued()
} else if (payloadPresent) {
const submitted = text
} else if (draft.trim() || attachments.length > 0) {
const submitted = draft
triggerHaptic('submit')
resetBrowseState(sessionId)
clearDraft()

View File

@@ -83,12 +83,6 @@ export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null
return `@${kind}:${formatRefValue(rel)}`
}
/** Resolve a batch of drops to their inline `@file:`/`@line:`/`@folder:` refs,
* dropping any that carry no path. */
export function droppedFileInlineRefs(candidates: DroppedFile[], cwd: string | null | undefined): string[] {
return candidates.map(candidate => droppedFileInlineRef(candidate, cwd)).filter((ref): ref is string => Boolean(ref))
}
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
if (!refs.length) {
return null

View File

@@ -1,57 +0,0 @@
import { describe, expect, it } from 'vitest'
import { type DroppedFile, partitionDroppedFiles } from './use-composer-actions'
// A Finder/Explorer drop carries a native File handle; an in-app drag (project
// tree, gutter line ref) is path-only. The split decides whether a drop becomes
// an inline @file: ref (in-app, workspace-relative, gateway-resolvable) or goes
// through the upload pipeline (OS drop — absolute local path a remote gateway
// can't read, plus image bytes for vision).
const osDrop = (path: string): DroppedFile => ({ file: new File(['x'], path.split('/').pop() || 'f'), path })
const inAppRef = (path: string, extra: Partial<DroppedFile> = {}): DroppedFile => ({ path, ...extra })
describe('partitionDroppedFiles', () => {
it('routes File-bearing OS drops to osDrops and path-only in-app drags to inAppRefs', () => {
const finderPdf = osDrop('/Users/mahmoud/Downloads/DEVIS_signed.pdf')
const projectFile = inAppRef('src/index.ts')
const { inAppRefs, osDrops } = partitionDroppedFiles([finderPdf, projectFile])
expect(osDrops).toEqual([finderPdf])
expect(inAppRefs).toEqual([projectFile])
})
it('treats an OS screenshot drop as an upload target (so it gets byte upload + vision)', () => {
const screenshot = osDrop('/var/folders/tmp/Screenshot 2026-06-09.png')
const { inAppRefs, osDrops } = partitionDroppedFiles([screenshot])
expect(osDrops).toEqual([screenshot])
expect(inAppRefs).toEqual([])
})
it('keeps gutter line-range drags inline (no File handle)', () => {
const lineRef = inAppRef('src/app.ts', { line: 10, lineEnd: 20 })
const { inAppRefs, osDrops } = partitionDroppedFiles([lineRef])
expect(osDrops).toEqual([])
expect(inAppRefs).toEqual([lineRef])
})
it('splits a mixed drop and preserves order within each group', () => {
const a = inAppRef('a.ts')
const b = osDrop('/abs/b.pdf')
const c = inAppRef('c.ts')
const d = osDrop('/abs/d.png')
const { inAppRefs, osDrops } = partitionDroppedFiles([a, b, c, d])
expect(inAppRefs).toEqual([a, c])
expect(osDrops).toEqual([b, d])
})
it('returns empty groups for an empty drop', () => {
expect(partitionDroppedFiles([])).toEqual({ inAppRefs: [], osDrops: [] })
})
})

View File

@@ -33,7 +33,7 @@ function blobExtension(blob: Blob): string {
return (mime && BLOB_MIME_EXTENSION[mime]) || '.png'
}
export function isImagePath(filePath: string): boolean {
function isImagePath(filePath: string): boolean {
return IMAGE_EXTENSION_PATTERN.test(filePath)
}
@@ -181,35 +181,6 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
return result
}
/**
* Split dropped entries by origin. OS/Finder drops carry a native `File`
* handle; in-app drags (project tree, gutter line refs) are path-only.
*
* The distinction is load-bearing: an in-app path is workspace-relative and
* resolves on the gateway as-is, so it stays an inline `@file:`/`@line:` ref.
* An OS drop is an absolute path on *this* machine — the gateway can't read it
* in remote mode, and an image needs its bytes uploaded to get vision either
* way. So OS drops must go through the attachment/upload pipeline rather than
* leaking a local path into the prompt text.
*/
export function partitionDroppedFiles(candidates: DroppedFile[]): {
osDrops: DroppedFile[]
inAppRefs: DroppedFile[]
} {
const osDrops: DroppedFile[] = []
const inAppRefs: DroppedFile[] = []
for (const candidate of candidates) {
if (candidate.file) {
osDrops.push(candidate)
} else {
inAppRefs.push(candidate)
}
}
return { osDrops, inAppRefs }
}
interface ComposerActionsOptions {
activeSessionId: string | null
currentCwd: string

View File

@@ -49,9 +49,9 @@ import { ChatDropOverlay } from './chat-drop-overlay'
import { ChatSwapOverlay } from './chat-swap-overlay'
import { ChatBar, ChatBarFallback } from './composer'
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
import { droppedFileInlineRef, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
import type { ChatBarState } from './composer/types'
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
import type { DroppedFile } from './hooks/use-composer-actions'
import { useFileDropZone } from './hooks/use-file-drop-zone'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
@@ -126,10 +126,7 @@ function ChatHeader({
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div
className="min-w-0 flex-1"
style={{
maxWidth:
'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)'
}}
style={{ maxWidth: 'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)' }}
>
<SessionActionsMenu
align="start"
@@ -302,25 +299,19 @@ export function ChatView({
})
// Drop files anywhere in the conversation area, not just on the composer
// input. In-app drags (project tree / gutter) carry workspace-relative paths
// the gateway resolves directly, so they stay inline `@file:` refs. OS/Finder
// drops carry absolute local paths that don't exist on a remote gateway (and
// images need byte upload for vision), so route them through the attachment
// pipeline — otherwise the local path leaks into the prompt verbatim.
// input — appending the same inline `@file:` ref chips the composer drop
// produces (vs. attachment cards) so both surfaces behave identically.
const onDropFiles = useCallback(
(candidates: DroppedFile[]) => {
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
const refs = droppedFileInlineRefs(inAppRefs, currentCwd)
const refs = candidates
.map(candidate => droppedFileInlineRef(candidate, currentCwd))
.filter((ref): ref is string => Boolean(ref))
if (refs.length) {
requestComposerInsert(refs.join(' '), { mode: 'inline', target: 'main' })
}
if (osDrops.length) {
void onAttachDroppedItems(osDrops)
}
},
[currentCwd, onAttachDroppedItems]
[currentCwd]
)
// Dropping a sidebar session inserts an @session link the agent can resolve

View File

@@ -446,9 +446,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
try {
if (isImage) {
// Prefer bytes the caller already handed us (a pasted/dropped
// screenshot) over re-reading a path that may be transient/unreadable.
const dataUrl = target.dataUrl || (await window.hermesDesktop.readFileDataUrl(filePath))
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
if (active) {
setState({ dataUrl, loading: false })
@@ -486,7 +484,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
return () => {
active = false
}
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.dataUrl, target.language])
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
if (state.loading) {
return <PageLoader label={t.preview.loading} />

View File

@@ -14,8 +14,6 @@ import type { CronJob } from '@/types/hermes'
import { jobState, jobTitle, STATE_DOT } from '../../cron/job-state'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import { SidebarLoadMoreRow } from './load-more-row'
const INACTIVE_STATES = new Set(['completed', 'disabled', 'error', 'paused'])
// Recent runs shown in the inline quick-peek — enough to glance at history
@@ -26,11 +24,6 @@ const PEEK_RUN_LIMIT = 5
// open peek so a freshly-fired run shows up within a few seconds.
const PEEK_POLL_INTERVAL_MS = 8000
// Keep the section compact: show a few jobs up front, reveal more in larger
// steps on demand (mirrors the messaging sections in the sidebar).
const INITIAL_VISIBLE_JOBS = 3
const LOAD_MORE_STEP = 10
const relativeFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' })
// Localized "in 5 min" / "2 hr ago" without hand-rolled strings — picks the
@@ -40,25 +33,17 @@ function relativeTime(targetMs: number, nowMs: number): string {
const abs = Math.abs(diff)
const sign = diff < 0 ? -1 : 1
if (abs < 60_000) {
return relativeFmt.format(sign * Math.round(abs / 1000), 'second')
}
if (abs < 60_000) {return relativeFmt.format(sign * Math.round(abs / 1000), 'second')}
if (abs < 3_600_000) {
return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute')
}
if (abs < 3_600_000) {return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute')}
if (abs < 86_400_000) {
return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour')
}
if (abs < 86_400_000) {return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour')}
return relativeFmt.format(sign * Math.round(abs / 86_400_000), 'day')
}
function nextRunMs(job: CronJob): null | number {
if (!job.next_run_at) {
return null
}
if (!job.next_run_at) {return null}
const ms = Date.parse(job.next_run_at)
@@ -69,9 +54,7 @@ function nextRunMs(job: CronJob): null | number {
// the timestamp is what tells them apart. Compact (no year, no seconds) for the
// narrow sidebar.
function formatRunTime(seconds?: null | number): string {
if (!seconds) {
return '—'
}
if (!seconds) {return '—'}
const date = new Date(seconds * 1000)
@@ -107,15 +90,11 @@ export function SidebarCronJobsSection({
const [nowMs, setNowMs] = useState(() => Date.now())
// Single-open inline peek so the section stays scannable.
const [peekJobId, setPeekJobId] = useState<null | string>(null)
// Rows revealed so far; starts compact, grows in steps via "load more".
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_JOBS)
// One clock for the whole section (rows are pure) so the countdowns tick
// without re-rendering the rest of the sidebar. Only runs while expanded.
useEffect(() => {
if (!open) {
return
}
if (!open) {return}
const id = window.setInterval(() => setNowMs(Date.now()), 1000)
@@ -129,25 +108,17 @@ export function SidebarCronJobsSection({
const an = nextRunMs(a)
const bn = nextRunMs(b)
if (an !== null && bn !== null && an !== bn) {
return an - bn
}
if (an !== null && bn !== null && an !== bn) {return an - bn}
if (an === null && bn !== null) {
return 1
}
if (an === null && bn !== null) {return 1}
if (an !== null && bn === null) {
return -1
}
if (an !== null && bn === null) {return -1}
return jobTitle(a).localeCompare(jobTitle(b))
})
}, [jobs])
const cap = Math.min(visibleCount, max)
const shown = sorted.slice(0, cap)
const hiddenCount = Math.min(sorted.length, max) - shown.length
const shown = sorted.slice(0, max)
// When capped, signal "50+" rather than implying the list is complete.
const countLabel = jobs.length > max ? `${max}+` : String(jobs.length)
@@ -168,7 +139,7 @@ export function SidebarCronJobsSection({
</button>
</div>
{open && (
<SidebarGroupContent className="flex max-h-72 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75 compact:max-h-none compact:overflow-visible">
<SidebarGroupContent className="flex max-h-72 shrink-0 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75">
{shown.map(job => (
<CronJobSidebarRow
expanded={peekJobId === job.id}
@@ -181,12 +152,6 @@ export function SidebarCronJobsSection({
onTrigger={() => onTriggerJob(job.id)}
/>
))}
{hiddenCount > 0 && (
<SidebarLoadMoreRow
onClick={() => setVisibleCount(count => count + LOAD_MORE_STEP)}
step={Math.min(LOAD_MORE_STEP, hiddenCount)}
/>
)}
</SidebarGroupContent>
)}
</SidebarGroup>
@@ -216,7 +181,11 @@ function CronJobSidebarRow({
const next = nextRunMs(job)
const label = jobTitle(job)
const meta = INACTIVE_STATES.has(state) ? (c.states[state] ?? state) : next !== null ? relativeTime(next, nowMs) : '—'
const meta = INACTIVE_STATES.has(state)
? (c.states[state] ?? state)
: next !== null
? relativeTime(next, nowMs)
: '—'
return (
<div>
@@ -288,7 +257,13 @@ function CronJobSidebarRow({
)
}
function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (sessionId: string) => void }) {
function CronJobSidebarRuns({
jobId,
onOpenRun
}: {
jobId: string
onOpenRun: (sessionId: string) => void
}) {
const { t } = useI18n()
const c = t.cron
const selectedSessionId = useStore($selectedStoredSessionId)
@@ -300,22 +275,16 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s
const load = () =>
getCronJobRuns(jobId, PEEK_RUN_LIMIT)
.then(result => {
if (!cancelled) {
setRuns(result)
}
if (!cancelled) {setRuns(result)}
})
.catch(() => {
if (!cancelled) {
setRuns(prev => prev ?? [])
}
if (!cancelled) {setRuns(prev => prev ?? [])}
})
void load()
const intervalId = window.setInterval(() => {
if (document.visibilityState === 'visible') {
void load()
}
if (document.visibilityState === 'visible') {void load()}
}, PEEK_POLL_INTERVAL_MS)
return () => {

View File

@@ -48,7 +48,6 @@ import {
$pinnedSessionIds,
$sidebarAgentsGrouped,
$sidebarCronOpen,
$sidebarMessagingOpenIds,
$sidebarOpen,
$sidebarOverlayMounted,
$sidebarPinsOpen,
@@ -65,7 +64,6 @@ import {
setSidebarSessionOrderIds,
setSidebarWorkspaceOrderIds,
SIDEBAR_SESSIONS_PAGE_SIZE,
toggleSidebarMessagingOpen,
unpinSession
} from '@/store/layout'
import {
@@ -78,9 +76,6 @@ import {
} from '@/store/profile'
import {
$cronSessions,
$messagingPlatformTotals,
$messagingSessions,
$messagingTruncated,
$selectedStoredSessionId,
$sessionProfileTotals,
$sessions,
@@ -95,19 +90,12 @@ import { SidebarPanelLabel } from '../../shell/sidebar-label'
import type { SidebarNavItem } from '../../types'
import { SidebarCronJobsSection } from './cron-jobs-section'
import { SidebarLoadMoreRow } from './load-more-row'
import { ProfileRail } from './profile-switcher'
import { SidebarSessionRow } from './session-row'
import { VirtualSessionList } from './virtual-session-list'
const VIRTUALIZE_THRESHOLD = 25
// Non-session groups (messaging platforms) stay compact: show a few rows up
// front, reveal more in larger steps on demand. Keeps a busy platform from
// dominating the sidebar before the user asks to see it.
const NON_SESSION_INITIAL_ROWS = 3
const NON_SESSION_LOAD_STEP = 10
// Render the modifier key the user actually presses on this platform. The
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
// else) in desktop-controller.tsx, but the hint should match muscle memory.
@@ -136,16 +124,7 @@ const WORKSPACE_PAGE = 5
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
const PROFILE_INITIAL_PAGE = 5
const GROUP_DND_ID_PREFIX = 'group:'
// Two modes via the `compact` height variant (styles.css):
// tall → each section is shrink-0, capped, its own scroller; Sessions is flex-1.
// compact → COMPACT_FLAT drops the caps so the whole stack scrolls as one.
// Sections stay shrink-0 so none can be squeezed below its content and bleed onto
// the next — the flexbox `min-height: auto` overlap trap that caused the bug.
const COMPACT_FLAT = 'compact:max-h-none compact:overflow-visible'
// A non-session group's scroll body: own scroller when tall, flattened when compact.
const GROUP_BODY = cn('overflow-y-auto overscroll-contain', COMPACT_FLAT)
const LOCAL_SESSION_SOURCES = new Set(['cli', 'desktop', 'local', 'tui'])
const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`
@@ -162,25 +141,24 @@ function orderByIds<T>(items: T[], getId: (item: T) => string, orderIds: string[
const byId = new Map(items.map(item => [getId(item), item]))
const seen = new Set<string>()
const ordered: T[] = []
const out: T[] = []
for (const id of orderIds) {
const item = byId.get(id)
if (item) {
ordered.push(item)
out.push(item)
seen.add(id)
}
}
// Items missing from the persisted order are new since it was last
// reconciled. Callers pass recency-sorted lists (newest first), so surface
// these at the TOP instead of burying them beneath the saved order —
// otherwise a brand-new session sinks to the bottom of the sidebar and reads
// as "my latest session never showed up".
const fresh = items.filter(item => !seen.has(getId(item)))
for (const item of items) {
if (!seen.has(getId(item))) {
out.push(item)
}
}
return fresh.length ? [...fresh, ...ordered] : ordered
return out
}
function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
@@ -193,15 +171,17 @@ function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
}
const current = new Set(currentIds)
const retained = orderIds.filter(id => current.has(id))
const retainedSet = new Set(retained)
const next = orderIds.filter(id => current.has(id))
const known = new Set(next)
// New ids (absent from the saved order) are the newest sessions/groups; keep
// them ahead of the persisted order so fresh activity surfaces at the top of
// the sidebar rather than being appended to the bottom.
const fresh = currentIds.filter(id => !retainedSet.has(id))
for (const id of currentIds) {
if (!known.has(id)) {
next.push(id)
known.add(id)
}
}
return [...fresh, ...retained]
return next
}
function sameIds(left: string[], right: string[]) {
@@ -271,6 +251,43 @@ function workspaceGroupsFor(
return [...groups.values()]
}
function sourceSessionGroupsFor(sessions: SessionInfo[]): {
localSessions: SessionInfo[]
sourceGroups: SidebarSessionGroup[]
} {
const groups = new Map<string, SidebarSessionGroup>()
const localSessions: SessionInfo[] = []
for (const session of sessions) {
const sourceId = normalizeSessionSource(session.source)
if (!sourceId || LOCAL_SESSION_SOURCES.has(sourceId)) {
localSessions.push(session)
continue
}
const label = sessionSourceLabel(sourceId) ?? sourceId
const group = groups.get(sourceId) ?? {
id: `source:${sourceId}`,
label,
mode: 'source',
path: null,
sessions: [],
sourceId
}
group.sessions.push(session)
groups.set(sourceId, group)
}
return {
localSessions,
sourceGroups: [...groups.values()].sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}
}
function useSortableBindings(id: string) {
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
@@ -292,7 +309,6 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
onNavigate: (item: SidebarNavItem) => void
onLoadMoreSessions: () => void
onLoadMoreProfileSessions?: (profile: string) => Promise<void> | void
onLoadMoreMessaging?: (platform: string) => Promise<void> | void
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onArchiveSession: (sessionId: string) => void
@@ -306,7 +322,6 @@ export function ChatSidebar({
onNavigate,
onLoadMoreSessions,
onLoadMoreProfileSessions,
onLoadMoreMessaging,
onResumeSession,
onDeleteSession,
onArchiveSession,
@@ -330,9 +345,6 @@ export function ChatSidebar({
const sessions = useStore($sessions)
const cronSessions = useStore($cronSessions)
const cronJobs = useStore($cronJobs)
const messagingSessions = useStore($messagingSessions)
const messagingPlatformTotals = useStore($messagingPlatformTotals)
const messagingTruncated = useStore($messagingTruncated)
const sessionsLoading = useStore($sessionsLoading)
const sessionsTotal = useStore($sessionsTotal)
const sessionProfileTotals = useStore($sessionProfileTotals)
@@ -352,10 +364,6 @@ export function ChatSidebar({
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})
const [messagingLoadMorePending, setMessagingLoadMorePending] = useState<Record<string, boolean>>({})
const messagingOpenIds = useStore($sidebarMessagingOpenIds)
// Per-platform count of rows currently revealed (starts at NON_SESSION_INITIAL_ROWS).
const [messagingVisible, setMessagingVisible] = useState<Record<string, number>>({})
const searchInputRef = useRef<HTMLInputElement>(null)
const trimmedQuery = searchQuery.trim()
@@ -521,12 +529,24 @@ export function ChatSidebar({
[unpinnedAgentSessions, agentOrderIds]
)
// Recents are local-only: messaging-platform sessions are fetched as their
// own slice ($messagingSessions) and rendered in self-managed per-platform
// sections below, so there is no source-grouping magic to untangle here.
const { localSessions: localAgentSessions, sourceGroups } = useMemo(
() => sourceSessionGroupsFor(agentSessions),
[agentSessions]
)
const orderedSourceGroups = useMemo(
() => orderByIds(sourceGroups, g => g.id, workspaceOrderIds),
[sourceGroups, workspaceOrderIds]
)
const agentGroups = useMemo(
() => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
[agentSessions, s.noWorkspace, workspaceOrderIds]
() =>
orderByIds(
workspaceGroupsFor(localAgentSessions, s.noWorkspace, { preserveSessionOrder: sourceGroups.length > 0 }),
g => g.id,
workspaceOrderIds
),
[localAgentSessions, s.noWorkspace, sourceGroups.length, workspaceOrderIds]
)
const loadMoreForProfileGroup = useCallback(
@@ -544,76 +564,6 @@ export function ChatSidebar({
[onLoadMoreProfileSessions]
)
const loadMoreForMessaging = useCallback(
(platform: string) => {
if (!onLoadMoreMessaging) {
return
}
setMessagingLoadMorePending(prev => ({ ...prev, [platform]: true }))
void Promise.resolve(onLoadMoreMessaging(platform))
.catch(() => undefined)
.finally(() => setMessagingLoadMorePending(({ [platform]: _done, ...rest }) => rest))
},
[onLoadMoreMessaging]
)
// Reveal another batch of a platform's rows; fetch from the backend too if we
// run past what's loaded and more remain on disk.
const revealMoreMessaging = (platform: string, loaded: number, hasMore: boolean) => {
const next = (messagingVisible[platform] ?? NON_SESSION_INITIAL_ROWS) + NON_SESSION_LOAD_STEP
setMessagingVisible(prev => ({ ...prev, [platform]: next }))
if (next > loaded && hasMore) {
loadMoreForMessaging(platform)
}
}
// Each messaging platform is its own self-managed section: split the
// separately-fetched messaging slice by source, newest platform first, rows
// within a platform by recency. Per-platform totals (when a "load more" has
// resolved them) drive the count + whether more remain on disk.
const messagingGroups = useMemo<MessagingSection[]>(() => {
if (!messagingSessions.length) {
return []
}
const bySource = new Map<string, SessionInfo[]>()
for (const session of messagingSessions) {
const sourceId = normalizeSessionSource(session.source)
if (!sourceId) {
continue
}
const list = bySource.get(sourceId) ?? []
list.push(session)
bySource.set(sourceId, list)
}
return [...bySource.entries()]
.map(([sourceId, list]) => {
const ordered = [...list].sort((a, b) => sessionTime(b) - sessionTime(a))
const known = messagingPlatformTotals[sourceId]
const total = Math.max(ordered.length, known ?? 0)
return {
// Known exact total → more exist iff total exceeds loaded; otherwise
// the seed fetch was capped, so assume more until a per-platform load
// resolves the count.
hasMore: known != null ? known > ordered.length : messagingTruncated,
label: sessionSourceLabel(sourceId) ?? sourceId,
sessions: ordered,
sourceId,
total
}
})
.sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}, [messagingSessions, messagingPlatformTotals, messagingTruncated])
// ALL-profiles view: one collapsible group per profile, color on the header
// (not on every row). Default profile floats to the top, the rest alpha.
const profileGroups = useMemo<SidebarSessionGroup[] | undefined>(() => {
@@ -660,34 +610,37 @@ export function ChatSidebar({
sessionProfileTotals
])
const displayAgentSessions = agentSessions
const displayAgentSessions = sourceGroups.length ? localAgentSessions : agentSessions
// Pagination is scope-aware. In "All profiles" mode it tracks the global
// unified set. When scoped to one profile it must compare that profile's own
// loaded rows against that profile's total — otherwise a huge default profile
// keeps "Load more" stuck on while you browse a small one (the aggregator's
// total sums every profile). Per-profile totals come from the aggregator
// (children excluded); fall back to the global total / loaded count.
const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length
const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]
const displayAgentGroups = useMemo(() => {
if (orderedSourceGroups.length) {
const localGroups = agentsGrouped
? agentGroups
: localAgentSessions.length
? [
{
id: 'local-sessions',
label: 'Local',
mode: 'workspace' as const,
path: null,
sessions: localAgentSessions
}
]
: []
const knownSessionTotal = Math.max(
showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
loadedSessionCount
)
return orderByIds([...orderedSourceGroups, ...localGroups], g => g.id, workspaceOrderIds)
}
const hasMoreSessions = knownSessionTotal > loadedSessionCount
const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
const displayAgentGroups = showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
// The recents list owns its own (virtualized) scroll container only when it's a
// long flat list. In that case it must keep its scroller even in short mode, so
// we don't flatten it (flattening would defeat virtualization). Short flat lists
// and grouped views flatten into the single outer scroll instead.
const recentsVirtualizes = !displayAgentGroups?.length && displayAgentSessions.length >= VIRTUALIZE_THRESHOLD
return showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
}, [
agentGroups,
agentsGrouped,
localAgentSessions,
orderedSourceGroups,
profileGroups,
showAllProfiles,
workspaceOrderIds
])
useEffect(() => {
if (!displayAgentGroups?.length || showAllProfiles) {
@@ -708,6 +661,25 @@ export function ChatSidebar({
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
// Pagination is scope-aware. In "All profiles" mode it tracks the global
// unified set. When scoped to one profile it must compare that profile's own
// loaded rows against that profile's total — otherwise a huge default profile
// keeps "Load more" stuck on while you browse a small one (the aggregator's
// total sums every profile). Per-profile totals come from the aggregator
// (children excluded); fall back to the global total / loaded count.
const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length
const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]
const knownSessionTotal = Math.max(
showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
loadedSessionCount
)
const hasMoreSessions = knownSessionTotal > loadedSessionCount
const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) {
return
@@ -820,7 +792,9 @@ export function ChatSidebar({
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
{contentVisible && (
<>
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
<span className="min-w-0 flex-1 truncate">
{s.nav[item.id] ?? item.label}
</span>
{isNewSession && (
<KbdGroup
className={cn('ml-auto', newSessionKbdFlash && 'opacity-100!')}
@@ -849,191 +823,135 @@ export function ChatSidebar({
</div>
)}
{contentVisible && showSessionSections && (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75">
{trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
emptyState={
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
{s.noMatch(trimmedQuery)}
</div>
}
label={s.results}
labelMeta={String(searchResults.length)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onToggle={() => undefined}
onTogglePin={pinSession}
open
pinned={false}
rootClassName="min-h-32 flex-1 overflow-hidden p-0"
sessions={searchResults}
workingSessionIdSet={workingSessionIdSet}
/>
{contentVisible && showSessionSections && trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
emptyState={
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
{s.noMatch(trimmedQuery)}
</div>
}
label={s.results}
labelMeta={String(searchResults.length)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onToggle={() => undefined}
onTogglePin={pinSession}
open
pinned={false}
rootClassName="min-h-0 flex-1 p-0"
sessions={searchResults}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{contentVisible && showSessionSections && !trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
dndSensors={dndSensors}
emptyState={<SidebarPinnedEmptyState />}
label={s.pinned}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onReorder={handlePinnedDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarPinsOpen(!pinsOpen)}
onTogglePin={unpinSession}
open={pinsOpen}
pinned
rootClassName="shrink-0 p-0 pb-1"
sessions={pinnedSessions}
sortable={pinnedSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{contentVisible && showSessionSections && !trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName={cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75',
// Separate profile sections clearly in the ALL view; rows inside
// each group keep their own tight gap-px rhythm.
showAllProfiles ? 'gap-3' : 'gap-px'
)}
dndSensors={dndSensors}
emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />}
footer={
// Hide "load more" only when workspace-grouped (those groups page
// themselves). ALL-profiles now pages per-profile from each profile
// header; the global footer only applies to non-ALL views.
!showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
<SidebarLoadMoreRow
loading={sessionsLoading}
onClick={onLoadMoreSessions}
step={Math.min(SIDEBAR_SESSIONS_PAGE_SIZE, remainingSessionCount)}
/>
) : null
}
forceEmptyState={showSessionSkeletons}
groups={displayAgentGroups}
headerAction={
// Always reserve the icon-xs (size-6) slot so the header keeps the
// same height whether or not the toggle renders — otherwise the
// "Sessions" label jumps when switching to the ALL-profiles view.
// Grouping operates on unpinned recents; if everything is pinned
// the toggle does nothing, and it's irrelevant in the ALL-profiles
// view (always grouped by profile), so hide the button (not the slot).
<div className="grid size-6 shrink-0 place-items-center">
{!showAllProfiles && localAgentSessions.length > 0 ? (
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
<Button
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
className={cn(
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
</Tip>
) : null}
</div>
}
label={s.sessions}
labelMeta={recentsMeta}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
onReorder={showAllProfiles ? undefined : handleAgentDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
onTogglePin={pinSession}
open={agentsOpen}
pinned={false}
rootClassName="min-h-0 flex-1 p-0"
sessions={displayAgentSessions}
sortable={!showAllProfiles && agentSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{!trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName={cn('flex max-h-44 flex-col gap-px rounded-lg pb-2 pt-1', GROUP_BODY)}
dndSensors={dndSensors}
emptyState={<SidebarPinnedEmptyState />}
label={s.pinned}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onReorder={handlePinnedDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarPinsOpen(!pinsOpen)}
onTogglePin={unpinSession}
open={pinsOpen}
pinned
rootClassName="shrink-0 p-0 pb-1"
sessions={pinnedSessions}
sortable={pinnedSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{!trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName={cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75',
// Separate profile sections clearly in the ALL view; rows inside
// each group keep their own tight gap-px rhythm.
showAllProfiles ? 'gap-3' : 'gap-px',
// Flatten into the single scroll when compact — unless this is the
// virtualized long list, which must keep its own scroller.
!recentsVirtualizes && COMPACT_FLAT
)}
dndSensors={dndSensors}
emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />}
footer={
// Hide "load more" only when workspace-grouped (those groups page
// themselves). ALL-profiles now pages per-profile from each profile
// header; the global footer only applies to non-ALL views.
!showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
<SidebarLoadMoreRow
loading={sessionsLoading}
onClick={onLoadMoreSessions}
step={Math.min(SIDEBAR_SESSIONS_PAGE_SIZE, remainingSessionCount)}
/>
) : null
}
forceEmptyState={showSessionSkeletons}
groups={displayAgentGroups}
headerAction={
// Always reserve the icon-xs (size-6) slot so the header keeps the
// same height whether or not the toggle renders — otherwise the
// "Sessions" label jumps when switching to the ALL-profiles view.
// Grouping operates on unpinned recents; if everything is pinned
// the toggle does nothing, and it's irrelevant in the ALL-profiles
// view (always grouped by profile), so hide the button (not the slot).
<div className="grid size-6 shrink-0 place-items-center">
{!showAllProfiles && agentSessions.length > 0 ? (
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
<Button
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
className={cn(
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
</Tip>
) : null}
</div>
}
label={s.sessions}
labelMeta={recentsMeta}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
onReorder={showAllProfiles ? undefined : handleAgentDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
onTogglePin={pinSession}
open={agentsOpen}
pinned={false}
rootClassName={cn(
'min-h-32 flex-1 overflow-hidden p-0',
!recentsVirtualizes && 'compact:min-h-0 compact:flex-none compact:overflow-visible'
)}
sessions={displayAgentSessions}
sortable={!showAllProfiles && agentSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{!trimmedQuery &&
messagingGroups.map(group => {
const visible = messagingVisible[group.sourceId] ?? NON_SESSION_INITIAL_ROWS
const shownSessions = group.sessions.slice(0, visible)
// More to show if rows are hidden behind the cap, or the backend
// still has older threads on disk.
const canRevealMore = visible < group.sessions.length || group.hasMore
return (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName={cn('flex max-h-56 flex-col gap-px pb-1.75', GROUP_BODY)}
emptyState={null}
footer={
canRevealMore ? (
<SidebarLoadMoreRow
loading={Boolean(messagingLoadMorePending[group.sourceId])}
onClick={() => revealMoreMessaging(group.sourceId, group.sessions.length, group.hasMore)}
step={Math.min(NON_SESSION_LOAD_STEP, Math.max(0, group.total - shownSessions.length))}
/>
) : null
}
key={group.sourceId}
label={group.label}
labelIcon={
<PlatformAvatar
className="size-4 rounded-[4px] text-[0.5625rem] [&_svg]:size-3"
platformId={group.sourceId}
platformName={group.label}
/>
}
labelMeta={countLabel(group.sessions.length, group.total)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onToggle={() => toggleSidebarMessagingOpen(group.sourceId)}
onTogglePin={pinSession}
open={messagingOpenIds.includes(group.sourceId)}
pinned={false}
rootClassName="shrink-0 p-0"
sessions={shownSessions}
workingSessionIdSet={workingSessionIdSet}
/>
)
})}
{!trimmedQuery && cronJobs.length > 0 && (
<SidebarCronJobsSection
jobs={cronJobs}
label={s.cronJobs}
onManageJob={onManageCronJob}
onOpenRun={onResumeSession}
onToggle={() => setSidebarCronOpen(!cronOpen)}
onTriggerJob={onTriggerCronJob}
open={cronOpen}
/>
)}
</div>
{contentVisible && !trimmedQuery && cronJobs.length > 0 && (
<SidebarCronJobsSection
jobs={cronJobs}
label={s.cronJobs}
onManageJob={onManageCronJob}
onOpenRun={onResumeSession}
onToggle={() => setSidebarCronOpen(!cronOpen)}
onTriggerJob={onTriggerCronJob}
open={cronOpen}
/>
)}
{contentVisible && !showSessionSections && <div className="min-h-0 flex-1" />}
@@ -1054,10 +972,9 @@ interface SidebarSectionHeaderProps {
onToggle: () => void
action?: React.ReactNode
meta?: React.ReactNode
icon?: React.ReactNode
}
function SidebarSectionHeader({ label, open, onToggle, action, meta, icon }: SidebarSectionHeaderProps) {
function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSectionHeaderProps) {
return (
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
<button
@@ -1065,7 +982,6 @@ function SidebarSectionHeader({ label, open, onToggle, action, meta, icon }: Sid
onClick={onToggle}
type="button"
>
{icon}
<SidebarPanelLabel>{label}</SidebarPanelLabel>
{meta && <SidebarCount>{meta}</SidebarCount>}
<DisclosureCaret
@@ -1128,14 +1044,6 @@ interface SidebarSessionGroup {
totalCount?: number
}
interface MessagingSection {
sourceId: string
label: string
sessions: SessionInfo[]
total: number
hasMore: boolean
}
interface SidebarSessionsSectionProps {
label: string
open: boolean
@@ -1157,7 +1065,6 @@ interface SidebarSessionsSectionProps {
footer?: React.ReactNode
groups?: SidebarSessionGroup[]
labelMeta?: React.ReactNode
labelIcon?: React.ReactNode
sortable?: boolean
onReorder?: (event: DragEndEvent) => void
dndSensors?: ReturnType<typeof useSensors>
@@ -1184,7 +1091,6 @@ function SidebarSessionsSection({
footer,
groups,
labelMeta,
labelIcon,
sortable = false,
onReorder,
dndSensors
@@ -1275,7 +1181,6 @@ function SidebarSessionsSection({
inner = (
<VirtualSessionList
activeSessionId={activeSessionId}
className={contentClassName}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
@@ -1304,14 +1209,7 @@ function SidebarSessionsSection({
return (
<SidebarGroup className={rootClassName}>
<SidebarSectionHeader
action={headerAction}
icon={labelIcon}
label={label}
meta={labelMeta}
onToggle={onToggle}
open={open}
/>
<SidebarSectionHeader action={headerAction} label={label} meta={labelMeta} onToggle={onToggle} open={open} />
{open && (
<SidebarGroupContent className={resolvedContentClassName}>
{body}
@@ -1500,3 +1398,30 @@ interface SortableSessionRowProps {
function SortableSidebarSessionRow(props: SortableSessionRowProps) {
return <SidebarSessionRow {...props} {...useSortableBindings(props.session.id)} />
}
interface SidebarLoadMoreRowProps {
loading: boolean
onClick: () => void
step: number
}
function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) {
const { t } = useI18n()
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
return (
<button
className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
disabled={loading}
onClick={onClick}
type="button"
>
{/* Seat the icon in the same w-3.5 column session rows use for their dot
so the chevron + label line up with the rows above. */}
<span className="grid w-3.5 shrink-0 place-items-center">
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
</span>
<span>{label}</span>
</button>
)
}

View File

@@ -1,30 +0,0 @@
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
interface SidebarLoadMoreRowProps {
step: number
onClick: () => void
loading?: boolean
}
// "Load N more" affordance shared by the recents, messaging, and cron sections.
// The chevron sits in the same w-3.5 column the rows use for their dot, so it
// lines up with the list above.
export function SidebarLoadMoreRow({ step, onClick, loading = false }: SidebarLoadMoreRowProps) {
const { t } = useI18n()
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
return (
<button
className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
disabled={loading}
onClick={onClick}
type="button"
>
<span className="grid w-3.5 shrink-0 place-items-center">
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
</span>
<span>{label}</span>
</button>
)
}

View File

@@ -83,9 +83,8 @@ const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, trans
// Arc-Spaces-style profile rail at the sidebar foot: a default↔all toggle pinned
// left, the colored named profiles scrolling between, and Manage pinned right.
// The active profile pops in its own color — the "where am I" cue. Single-
// profile users see the "+" (create their first profile) and the Manage
// overflow (edit the default profile's SOUL.md); the colored named squares
// and the default↔all toggle only appear once a second profile exists.
// profile users see only the "+" (create their first profile); everything else
// appears once a second profile exists.
export function ProfileRail() {
const { t } = useI18n()
const p = t.profiles
@@ -269,11 +268,9 @@ export function ProfileRail() {
</Tip>
</div>
{/* Always reachable, even with only the default profile: the manage
overlay is the only place to edit a profile's SOUL.md, and a
single-profile user must be able to edit the default's persona
without first creating a throwaway second profile. */}
<ProfilePill active={false} glyph="ellipsis" label={p.manageProfiles} onSelect={() => navigate(PROFILES_ROUTE)} />
{multiProfile && (
<ProfilePill active={false} glyph="ellipsis" label={p.manageProfiles} onSelect={() => navigate(PROFILES_ROUTE)} />
)}
{/* Land in the new profile on a fresh chat (selectProfile triggers the
new-session reset), not stuck on the session you were just in. */}

View File

@@ -21,7 +21,6 @@ import { triggerHaptic } from '@/lib/haptics'
import { exportSession } from '@/lib/session-export'
import { notify, notifyError } from '@/store/notifications'
import { setSessions } from '@/store/session'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
interface SessionActions {
sessionId: string
@@ -69,19 +68,6 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
void writeClipboardText(sessionId).catch(err => notifyError(err, r.copyIdFailed))
}
},
...(canOpenSessionWindow()
? [
{
disabled: !sessionId,
icon: 'link-external',
label: r.newWindow,
onSelect: () => {
triggerHaptic('selection')
void openSessionInNewWindow(sessionId)
}
}
]
: []),
{
disabled: !sessionId,
icon: 'cloud-download',

View File

@@ -2,18 +2,14 @@ import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
@@ -71,11 +67,6 @@ export function SidebarSessionRow({
const title = sessionTitle(session)
const age = formatAge(session.last_active || session.started_at, r)
const handleLabel = `Reorder ${title}`
// A handed-off session's live source is local, but it originated on a
// messaging platform — surface that origin as a small badge so e.g. a
// Telegram thread continued here still reads as Telegram.
const handoffSource = handoffOriginSource(session.handoff_state, session.handoff_platform)
const handoffLabel = handoffSource ? sessionSourceLabel(handoffSource) ?? handoffSource : null
// Subscribe per-row (the leaf) instead of drilling a set through the list —
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
// session is waiting on the user.
@@ -133,15 +124,11 @@ export function SidebarSessionRow({
return
}
// ⌘-click (mac) / ⌃-click (win/linux) pops the chat into its own
// window — the universal "open in a new window" gesture. Archive
// lives in the row's ⋯ and right-click menus. Falls through to a
// normal resume when standalone windows aren't available (web embed).
if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
if (event.metaKey || event.ctrlKey) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
void openSessionInNewWindow(session.id)
onArchive()
return
}
@@ -192,15 +179,6 @@ export function SidebarSessionRow({
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
)}
{handoffSource && handoffLabel ? (
<Tip label={r.handoffOrigin(handoffLabel)}>
<PlatformAvatar
className="size-4 rounded-[4px] text-[0.5rem] [&_svg]:size-2.5"
platformId={handoffSource}
platformName={handoffLabel}
/>
</Tip>
) : null}
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</span>

View File

@@ -4,10 +4,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
import { setTerminalTakeover } from '@/app/right-sidebar/store'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { KbdGroup } from '@/components/ui/kbd'
import { getHermesConfigRecord, listSessions } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
@@ -15,11 +12,11 @@ import {
Activity,
Archive,
BarChart3,
Check,
ChevronLeft,
ChevronRight,
Clock,
Cpu,
Download,
Globe,
type IconComponent,
Info,
@@ -33,18 +30,13 @@ import {
Settings,
Settings2,
Sun,
Terminal,
Users,
Wrench,
Zap
} from '@/lib/icons'
import { comboTokens } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { $bindings } from '@/store/keybinds'
import { luminance } from '@/themes/color'
import { type ThemeMode, useTheme } from '@/themes/context'
import { isUserTheme, resolveTheme } from '@/themes/user-themes'
import {
AGENTS_ROUTE,
@@ -62,11 +54,8 @@ import { FIELD_LABELS, SECTIONS } from '../settings/constants'
import { fieldCopyForSchemaKey } from '../settings/field-copy'
import { prettyName } from '../settings/helpers'
import { MarketplaceThemePage } from './marketplace-theme-page'
interface PaletteItem {
/** Keybind action id — its live combo renders as a hotkey hint. */
action?: string
active?: boolean
icon: IconComponent
id: string
/** Keep the palette open after running (live-preview pickers like theme/mode). */
@@ -80,16 +69,10 @@ interface PaletteItem {
}
interface PaletteGroup {
/** Optional: a headingless group renders as a bare action row (e.g. the
* "Install theme…" entry pinned atop the theme picker). */
heading?: string
heading: string
items: PaletteItem[]
}
// Nested page → its parent, so Back / Esc step up one level instead of closing
// the palette. Pages absent here go straight back to the root list.
const PAGE_PARENTS: Record<string, string> = { 'install-theme': 'theme' }
/** A nested page reachable from a root item via `to`. */
interface PalettePage {
groups: PaletteGroup[]
@@ -103,22 +86,6 @@ interface SessionEntry {
title: string
}
// cmdk defaults to fuzzy subsequence scoring, so "color" matches anything with
// c…o…l…o…r scattered across it. Use case-insensitive multi-term substring
// matching instead: every typed word must literally appear in the item's
// value/keywords, which keeps results tight and predictable.
const paletteFilter = (value: string, search: string, keywords?: string[]): number => {
const needle = search.trim().toLowerCase()
if (!needle) {
return 1
}
const haystack = `${value} ${keywords?.join(' ') ?? ''}`.toLowerCase()
return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0
}
type SessionRow = Awaited<ReturnType<typeof listSessions>>['sessions'][number]
const toSessionEntry = (session: SessionRow): SessionEntry => ({
@@ -179,32 +146,11 @@ const THEME_MODES: ReadonlyArray<{ icon: IconComponent; mode: ThemeMode }> = [
{ icon: Monitor, mode: 'system' }
]
// Which Light/Dark groups a theme belongs in. Built-ins render in both modes
// (the engine synthesises the missing side). Imported VS Code themes only carry
// the variant(s) the extension shipped — a single dark theme like Dracula lives
// under Dark only, while a GitHub/Solarized family (light + dark) lives in both.
function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {
if (!isUserTheme(name)) {
return true
}
const resolved = resolveTheme(name)
if (!resolved) {
return true
}
const background = target === 'dark' ? (resolved.darkColors ?? resolved.colors).background : resolved.colors.background
return target === 'dark' ? luminance(background) <= 0.5 : luminance(background) > 0.5
}
export function CommandPalette() {
const { t } = useI18n()
const open = useStore($commandPaletteOpen)
const bindings = useStore($bindings)
const navigate = useNavigate()
const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme()
const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme()
const [search, setSearch] = useState('')
const [page, setPage] = useState<string | null>(null)
@@ -248,19 +194,10 @@ export function CommandPalette() {
}, [open])
const go = useCallback((path: string) => () => navigate(path), [navigate])
// Step up one nested page (or back to the root list), clearing the filter so
// the parent page doesn't reopen mid-search.
const goBack = useCallback(() => {
setSearch('')
setPage(prev => (prev ? (PAGE_PARENTS[prev] ?? null) : null))
}, [])
const settingsSectionLabel = useCallback(
(section: (typeof SECTIONS)[number]) => t.settings.sections[section.id] ?? section.label,
[t.settings.sections]
)
const configFieldLabel = useCallback(
(key: string) =>
fieldCopyForSchemaKey(t.settings.fieldLabels, key) ??
@@ -277,61 +214,20 @@ export function CommandPalette() {
{
heading: cc.goTo,
items: [
{ icon: Plus, id: 'nav-new', keywords: ['chat', 'create'], label: cc.nav.newChat.title, run: go(NEW_CHAT_ROUTE) },
{ icon: Settings, id: 'nav-settings', label: cc.nav.settings.title, run: go(SETTINGS_ROUTE) },
{
action: 'session.new',
icon: Plus,
id: 'nav-new',
keywords: ['chat', 'create'],
label: cc.nav.newChat.title,
run: go(NEW_CHAT_ROUTE)
},
{
action: 'view.showTerminal',
icon: Terminal,
id: 'nav-terminal',
keywords: ['terminal', 'shell', 'console'],
label: t.keybinds.actions['view.showTerminal'],
run: () => setTerminalTakeover(true)
},
{
action: 'nav.settings',
icon: Settings,
id: 'nav-settings',
label: cc.nav.settings.title,
run: go(SETTINGS_ROUTE)
},
{
action: 'nav.skills',
icon: Wrench,
id: 'nav-skills',
keywords: ['tools', 'toolsets'],
label: cc.nav.skills.title,
run: go(SKILLS_ROUTE)
},
{
action: 'nav.messaging',
icon: MessageCircle,
id: 'nav-messaging',
label: cc.nav.messaging.title,
run: go(MESSAGING_ROUTE)
},
{
action: 'nav.artifacts',
icon: Package,
id: 'nav-artifacts',
label: cc.nav.artifacts.title,
run: go(ARTIFACTS_ROUTE)
},
{
action: 'nav.cron',
icon: Clock,
id: 'nav-cron',
keywords: ['schedule', 'jobs'],
label: t.shell.statusbar.cron,
run: go(CRON_ROUTE)
},
{ action: 'nav.profiles', icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) },
{ action: 'nav.agents', icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }
{ icon: MessageCircle, id: 'nav-messaging', label: cc.nav.messaging.title, run: go(MESSAGING_ROUTE) },
{ icon: Package, id: 'nav-artifacts', label: cc.nav.artifacts.title, run: go(ARTIFACTS_ROUTE) },
{ icon: Clock, id: 'nav-cron', keywords: ['schedule', 'jobs'], label: t.shell.statusbar.cron, run: go(CRON_ROUTE) },
{ icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) },
{ icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }
]
},
{
@@ -477,40 +373,24 @@ export function CommandPalette() {
theme: {
title: t.settings.appearance.themeTitle,
placeholder: t.settings.appearance.themeDesc,
groups: [
// Pinned at the top: drills into the Marketplace browser.
{
items: [
{
icon: Download,
id: 'theme-install',
keywords: ['install', 'marketplace', 'vscode', 'vs code', 'download', 'new', 'color'],
label: t.commandCenter.installTheme.title,
to: 'install-theme'
}
]
},
// Built-ins and imported families list under the mode(s) they support;
// picking sets skin + mode at once. A multi-variant import (GitHub,
// Solarized) appears in both groups and switches variants with the mode.
...(['light', 'dark'] as const).map(groupMode => ({
heading: groupMode === 'light' ? t.settings.modeOptions.light.label : t.settings.modeOptions.dark.label,
items: availableThemes
.filter(theme => themeSupportsMode(theme.name, groupMode))
.map(theme => ({
active: themeName === theme.name && resolvedMode === groupMode,
icon: groupMode === 'light' ? Sun : Moon,
id: `theme-${theme.name}-${groupMode}`,
keepOpen: true,
keywords: ['theme', 'appearance', 'palette', groupMode, theme.label, theme.description ?? ''],
label: theme.label,
run: () => {
setTheme(theme.name)
setMode(groupMode)
}
}))
// Skins aren't inherently light/dark — the same skin renders in either
// mode. Group by appearance so picking an entry sets skin + mode at
// once, and keep the palette open so each pick previews live.
groups: (['light', 'dark'] as const).map(groupMode => ({
heading: groupMode === 'light' ? t.settings.modeOptions.light.label : t.settings.modeOptions.dark.label,
items: availableThemes.map(theme => ({
active: themeName === theme.name && resolvedMode === groupMode,
icon: groupMode === 'light' ? Sun : Moon,
id: `theme-${theme.name}-${groupMode}`,
keepOpen: true,
keywords: ['theme', 'appearance', 'palette', groupMode, theme.label, theme.description ?? ''],
label: theme.label,
run: () => {
setTheme(theme.name)
setMode(groupMode)
}
}))
]
}))
},
'color-mode': {
title: t.settings.appearance.colorMode,
@@ -519,6 +399,7 @@ export function CommandPalette() {
{
heading: t.settings.appearance.colorMode,
items: THEME_MODES.map(entry => ({
active: mode === entry.mode,
icon: entry.icon,
id: `mode-${entry.mode}`,
keepOpen: true,
@@ -528,16 +409,9 @@ export function CommandPalette() {
}))
}
]
},
// Server-driven page: items come from the Marketplace, rendered by
// <MarketplaceThemePage> (loader + live search + per-row install).
'install-theme': {
title: t.commandCenter.installTheme.title,
placeholder: t.commandCenter.installTheme.placeholder,
groups: []
}
}),
[availableThemes, resolvedMode, setMode, setTheme, t, themeName]
[availableThemes, mode, resolvedMode, setMode, setTheme, t, themeName]
)
const activePage = page ? subPages[page] : null
@@ -562,22 +436,17 @@ export function CommandPalette() {
return (
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
<DialogPrimitive.Portal>
{/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
<DialogPrimitive.Overlay className="fixed inset-0 z-[200]" />
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/15 backdrop-blur-[1px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
aria-describedby={undefined}
className={cn(
HUD_POSITION,
HUD_SURFACE,
'z-[210] w-[min(34rem,calc(100vw-2rem))] overflow-hidden duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95'
)}
className="fixed left-1/2 top-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
>
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
<Command className="bg-transparent" filter={paletteFilter} loop>
<Command className="bg-transparent" loop>
{activePage && (
<button
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={goBack}
onClick={() => setPage(null)}
type="button"
>
<ChevronLeft className="size-3.5" />
@@ -587,7 +456,6 @@ export function CommandPalette() {
</button>
)}
<CommandInput
className={HUD_TEXT}
onKeyDown={event => {
if (!activePage) {
return
@@ -598,45 +466,38 @@ export function CommandPalette() {
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
event.preventDefault()
event.stopPropagation()
goBack()
setPage(null)
}
}}
onValueChange={setSearch}
placeholder={placeholder}
value={search}
/>
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
{page === 'install-theme' ? (
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
) : (
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
)}
{visibleGroups.map((group, index) => (
<CommandList className="max-h-[min(24rem,60vh)]">
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
{visibleGroups.map(group => (
<CommandGroup
className={HUD_HEADING}
className="**:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-[0.6875rem] **:[[cmdk-group-heading]]:text-muted-foreground/70"
heading={group.heading}
key={group.heading ?? `palette-group-${index}`}
key={group.heading}
>
{group.items.map(item => {
const Icon = item.icon
const combo = item.action ? bindings[item.action]?.[0] : undefined
const keys = combo ? comboTokens(combo) : null
return (
<CommandItem
className={cn(HUD_ITEM, HUD_TEXT)}
className="gap-2.5"
key={item.id}
keywords={item.keywords}
onSelect={() => handleSelect(item)}
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{item.label}</span>
{keys && <KbdGroup className="ml-auto" keys={keys} />}
{item.to && (
<ChevronRight
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !keys && 'ml-auto')}
/>
{item.to ? (
<ChevronRight className="ml-auto size-4 shrink-0 text-muted-foreground/70" />
) : (
<Check className={cn('ml-auto size-4 text-foreground', !item.active && 'invisible')} />
)}
</CommandItem>
)

View File

@@ -1,157 +0,0 @@
/**
* Cmd-K "Install theme…" page.
*
* Browses the VS Code Marketplace for color themes: an empty query shows the
* most-installed themes, typing runs a live (debounced) search against the
* Marketplace. Selecting a row downloads + converts + installs it via the same
* pipeline as the settings importer, then activates it — and stays open so the
* user can grab several.
*/
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud'
import type { DesktopMarketplaceSearchItem } from '@/global'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Download, Loader2, Palette } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { installVscodeThemeFromMarketplace } from '@/themes/install'
const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 })
function useDebounced<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const handle = setTimeout(() => setDebounced(value), delayMs)
return () => clearTimeout(handle)
}, [value, delayMs])
return debounced
}
interface MarketplaceThemePageProps {
search: string
/** Activate a freshly installed theme by slug. */
onPickTheme: (name: string) => void
}
export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePageProps) {
const { t } = useI18n()
const copy = t.commandCenter.installTheme
const debouncedSearch = useDebounced(search.trim(), 300)
const [installingId, setInstallingId] = useState<string | null>(null)
const [installed, setInstalled] = useState<Record<string, true>>({})
const [installError, setInstallError] = useState<string | null>(null)
const query = useQuery({
queryKey: ['marketplace-themes', debouncedSearch],
queryFn: () => window.hermesDesktop?.themes?.searchMarketplace(debouncedSearch) ?? Promise.resolve([]),
staleTime: 5 * 60 * 1000
})
const install = async (item: DesktopMarketplaceSearchItem) => {
if (installingId) {
return
}
setInstallingId(item.extensionId)
setInstallError(null)
try {
const theme = await installVscodeThemeFromMarketplace(item.extensionId)
triggerHaptic('crisp')
setInstalled(prev => ({ ...prev, [item.extensionId]: true }))
onPickTheme(theme.name)
} catch (error) {
setInstallError(error instanceof Error ? error.message : copy.error)
} finally {
setInstallingId(null)
}
}
if (query.isLoading) {
return <Status icon={<Loader2 className="size-3.5 animate-spin" />} text={copy.loading} />
}
if (query.isError) {
return <Status text={copy.error} tone="error" />
}
const results = query.data ?? []
if (results.length === 0) {
return <Status text={copy.empty} />
}
return (
<div role="listbox">
{installError && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{installError}</p>}
{results.map(item => {
const busy = installingId === item.extensionId
const done = installed[item.extensionId]
return (
<button
className={cn(
'flex w-full items-start rounded-md text-left transition-colors hover:bg-(--chrome-action-hover) disabled:opacity-60 aria-disabled:opacity-60',
HUD_ITEM,
HUD_TEXT
)}
disabled={Boolean(installingId) && !busy}
key={item.extensionId}
onClick={() => void install(item)}
onMouseDown={event => event.preventDefault()}
role="option"
type="button"
>
<Palette className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">{item.displayName}</span>
<span className="truncate text-[0.6875rem] text-muted-foreground/80">
{item.publisher}
{item.installs > 0 ? ` · ${copy.installs(compactNumber.format(item.installs))}` : ''}
</span>
</span>
<span className="ml-auto mt-0.5 flex shrink-0 items-center gap-1 text-[0.6875rem] text-muted-foreground">
{busy ? (
<>
<Loader2 className="size-3 animate-spin" />
{copy.installing}
</>
) : done ? (
<>
<Check className="size-3 text-(--ui-green)" />
{copy.installed}
</>
) : (
<>
<Download className="size-3" />
{copy.install}
</>
)}
</span>
</button>
)
})}
</div>
)
}
function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) {
return (
<div
className={cn(
'flex items-center justify-center gap-2 px-2 py-6 text-xs',
tone === 'error' ? 'text-(--ui-red)' : 'text-muted-foreground'
)}
>
{icon}
{text}
</div>
)
}

View File

@@ -14,12 +14,6 @@ import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import {
isMessagingSource,
LOCAL_SESSION_SOURCE_IDS,
MESSAGING_SESSION_SOURCE_IDS,
normalizeSessionSource
} from '../lib/session-source'
import { setCronFocusJobId, setCronJobs } from '../store/cron'
import {
$panesFlipped,
@@ -50,14 +44,12 @@ import {
$currentCwd,
$freshDraftReady,
$gatewayState,
$messagingSessions,
$selectedStoredSessionId,
$sessions,
$workingSessionIds,
CRON_SECTION_LIMIT,
getRecentlySettledSessionIds,
mergeSessionPage,
MESSAGING_SECTION_LIMIT,
sessionPinId,
setAwaitingResponse,
setBusy,
@@ -67,16 +59,12 @@ import {
setCurrentModel,
setCurrentProvider,
setMessages,
setMessagingPlatformTotals,
setMessagingSessions,
setMessagingTruncated,
setSessionProfileTotals,
setSessions,
setSessionsLoading,
setSessionsTotal
} from '../store/session'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
import { isSecondaryWindow } from '../store/windows'
import { ChatView } from './chat'
import { useComposerActions } from './chat/hooks/use-composer-actions'
@@ -98,7 +86,6 @@ import { RightSidebarPane } from './right-sidebar'
import { $terminalTakeover } from './right-sidebar/store'
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
import { SessionSwitcher } from './session-switcher'
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
import { useCwdActions } from './session/hooks/use-cwd-actions'
import { useHermesConfig } from './session/hooks/use-hermes-config'
@@ -134,22 +121,11 @@ const SkillsView = lazy(async () => ({ default: (await import('./skills')).Skill
// this cadence while the app is open + visible so new runs surface promptly
// instead of waiting for the next user-triggered refreshSessions().
const CRON_POLL_INTERVAL_MS = 30_000
// The recents list is local-only: cron rows have their own section, and each
// messaging platform (telegram, discord, …) is fetched separately into its own
// self-managed sidebar section (refreshMessagingSessions). Excluding both here
// keeps "Load more" paging through interactive local chats instead of
// interleaving gateway threads that bury them.
const SIDEBAR_EXCLUDED_SOURCES = ['cron', ...MESSAGING_SESSION_SOURCE_IDS]
// The messaging slice is the inverse: drop cron + every local source so only
// external-platform conversations remain, then split per platform in the UI.
const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS]
// Cheap signature compare so the poll only swaps the atom (and re-renders the
// sidebar) when the visible cron rows actually changed.
function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
if (a.length !== b.length) {
return false
}
if (a.length !== b.length) {return false}
return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title)
}
@@ -225,7 +201,7 @@ export function DesktopController() {
toggleCommandCenter
} = useOverlayRouting()
const terminalSidebarOpen = chatOpen && terminalTakeover
const terminalTakeoverActive = chatOpen && terminalTakeover
const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
const statusbarItemGroups = useGroupRegistry<StatusbarItem>()
@@ -304,51 +280,6 @@ export function DesktopController() {
}
}, [])
// Messaging-platform sessions as their own slice, fetched separately from
// local recents so each platform renders a self-managed section and never
// competes with local chats for the recents page budget. One combined fetch
// seeds every platform; the sidebar splits the rows per source.
const refreshMessagingSessions = useCallback(async () => {
try {
const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', {
excludeSources: MESSAGING_EXCLUDED_SOURCES
})
// Drop any non-messaging source the broad exclude didn't catch (custom
// sources) — those stay in local recents, not a platform section.
const rows = result.sessions.filter(s => isMessagingSource(s.source))
setMessagingSessions(prev => (sameCronSignature(prev, rows) ? prev : rows))
// Hit the cap → at least one platform may have more on disk than loaded,
// so platform sections offer their own per-platform "load more".
setMessagingTruncated(result.sessions.length >= MESSAGING_SECTION_LIMIT)
} catch {
// Non-fatal: the messaging sections just stay empty/stale.
}
}, [])
// Page a single platform's section independently (mirrors the per-profile
// pager): fetch that source's next window and merge it back in place, leaving
// every other platform's rows untouched. Resolves the platform's exact total.
const loadMoreMessagingForPlatform = useCallback(async (platform: string) => {
const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform
const loaded = $messagingSessions.get().filter(inPlatform).length
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', 'all', {
source: platform
})
const incoming = result.sessions.filter(s => normalizeSessionSource(s.source) === platform)
setMessagingSessions(prev => [
...prev.filter(s => !inPlatform(s)),
...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep())
])
const total = result.total ?? incoming.length
setMessagingPlatformTotals(prev => ({ ...prev, [platform]: Math.max(total, incoming.length) }))
}, [])
// Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created
// synchronously (agent tool call or the cron UI), so refreshing here right
// after an agent turn surfaces a new job immediately; the interval poll keeps
@@ -385,7 +316,7 @@ export function DesktopController() {
const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
excludeSources: SIDEBAR_EXCLUDED_SOURCES
excludeSources: ['cron']
})
if (refreshSessionsRequestRef.current === requestId) {
@@ -401,8 +332,7 @@ export function DesktopController() {
void refreshCronSessions()
void refreshCronJobs()
void refreshMessagingSessions()
}, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
}, [profileScope, refreshCronSessions, refreshCronJobs])
const loadMoreSessions = useCallback(() => {
bumpSessionsLimit()
@@ -417,15 +347,12 @@ export function DesktopController() {
const loaded = $sessions.get().filter(inKey).length
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, {
excludeSources: SIDEBAR_EXCLUDED_SOURCES
excludeSources: ['cron']
})
const keep = sessionsToKeep(key)
setSessions(prev => [
...prev.filter(s => !inKey(s)),
...mergeSessionPage(prev.filter(inKey), result.sessions, keep)
])
setSessions(prev => [...prev.filter(s => !inKey(s)), ...mergeSessionPage(prev.filter(inKey), result.sessions, keep)])
const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length
setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) }))
@@ -686,19 +613,19 @@ export function DesktopController() {
submitText,
transcribeVoiceAudio
} = usePromptActions({
activeSessionId,
activeSessionIdRef,
branchCurrentSession: branchInNewChat,
busyRef,
createBackendSessionForSend,
handleSkinCommand,
refreshSessions,
requestGateway,
selectedStoredSessionIdRef,
startFreshSessionDraft,
sttEnabled,
updateSessionState
})
activeSessionId,
activeSessionIdRef,
branchCurrentSession: branchInNewChat,
busyRef,
createBackendSessionForSend,
handleSkinCommand,
refreshSessions,
requestGateway,
selectedStoredSessionIdRef,
startFreshSessionDraft,
sttEnabled,
updateSessionState
})
useGatewayBoot({
handleGatewayEvent: handleDesktopGatewayEvent,
@@ -724,14 +651,10 @@ export function DesktopController() {
// in the background (advancing next-run/state and creating runs), so poll the
// job list on an interval (and on tab re-focus) while connected.
useEffect(() => {
if (gatewayState !== 'open') {
return
}
if (gatewayState !== 'open') {return}
const tick = () => {
if (document.visibilityState === 'visible') {
void refreshCronJobs()
}
if (document.visibilityState === 'visible') {void refreshCronJobs()}
}
const intervalId = window.setInterval(tick, CRON_POLL_INTERVAL_MS)
@@ -761,7 +684,6 @@ export function DesktopController() {
const { leftStatusbarItems, statusbarItems } = useStatusbarItems({
agentsOpen,
chatOpen,
commandCenterOpen,
extraLeftItems: statusbarItemGroups.flat.left,
extraRightItems: statusbarItemGroups.flat.right,
@@ -782,7 +704,6 @@ export function DesktopController() {
currentView={currentView}
onArchiveSession={sessionId => void archiveSession(sessionId)}
onDeleteSession={sessionId => void removeSession(sessionId)}
onLoadMoreMessaging={loadMoreMessagingForPlatform}
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
onLoadMoreSessions={loadMoreSessions}
onManageCronJob={jobId => {
@@ -800,34 +721,27 @@ export function DesktopController() {
/>
)
// One PTY-backed terminal mounted forever; <TerminalSlot /> placeholders decide
// where it shows. Lives in main's stacking context (not the root overlay layer)
// so pane resize handles still paint above it. Toggling never rebuilds the shell.
const mainOverlays = (
<PersistentTerminal cwd={currentCwd} onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
)
const overlays = (
<>
{!isSecondaryWindow() && <DesktopInstallOverlay />}
{!isSecondaryWindow() && (
<DesktopOnboardingOverlay
enabled={gatewayState === 'open'}
onCompleted={() => {
void refreshHermesConfig()
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
requestGateway={requestGateway}
/>
)}
<DesktopInstallOverlay />
{/* One PTY-backed terminal mounted forever; <TerminalSlot /> placeholders
decide where it shows. Toggling fullscreen never rebuilds the shell. */}
<PersistentTerminal cwd={currentCwd} onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
<DesktopOnboardingOverlay
enabled={gatewayState === 'open'}
onCompleted={() => {
void refreshHermesConfig()
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
requestGateway={requestGateway}
/>
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
<UpdatesOverlay />
<GatewayConnectingOverlay />
<BootFailureOverlay />
<CommandPalette />
<SessionSwitcher />
{settingsOpen && (
<Suspense fallback={null}>
@@ -915,6 +829,12 @@ export function DesktopController() {
/>
)
const takeoverTerminalView = (
<div className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background) pt-(--titlebar-height)">
<TerminalSlot />
</div>
)
// Flipped layout mirrors the default: sessions sidebar → right, file
// browser + preview rail → left. Same panes, swapped sides.
const sidebarSide = panesFlipped ? 'right' : 'left'
@@ -959,56 +879,33 @@ export function DesktopController() {
</Pane>
)
const terminalPane = (
<Pane
defaultOpen
disabled={!terminalSidebarOpen}
divider
id="terminal-sidebar"
key="terminal-sidebar"
maxWidth="80vw"
minWidth="22vw"
resizable
side={railSide}
width="42vw"
>
<div className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--ui-editor-surface-background) pt-(--titlebar-height)">
<TerminalSlot />
</div>
</Pane>
)
return (
<AppShell
leftStatusbarItems={leftStatusbarItems}
leftTitlebarTools={titlebarToolGroups.flat.left}
mainOverlays={mainOverlays}
onOpenSettings={openSettings}
overlays={overlays}
previewPaneOpen={chatOpen && Boolean(previewTarget || filePreviewTarget)}
statusbarItems={statusbarItems}
terminalPaneOpen={terminalSidebarOpen}
titlebarTools={titlebarToolGroups.flat.right}
>
{!isSecondaryWindow() && (
<Pane
forceCollapsed={narrowViewport}
hoverReveal
id="chat-sidebar"
maxWidth={SIDEBAR_MAX_WIDTH}
minWidth={SIDEBAR_DEFAULT_WIDTH}
onOverlayActiveChange={setSidebarOverlayMounted}
resizable
side={sidebarSide}
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
>
{sidebar}
</Pane>
)}
<Pane
disabled={terminalTakeoverActive}
forceCollapsed={narrowViewport}
hoverReveal
id="chat-sidebar"
maxWidth={SIDEBAR_MAX_WIDTH}
minWidth={SIDEBAR_DEFAULT_WIDTH}
onOverlayActiveChange={setSidebarOverlayMounted}
resizable
side={sidebarSide}
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
>
{sidebar}
</Pane>
<PaneMain>
<Routes>
<Route element={chatView} index />
<Route element={chatView} path=":sessionId" />
<Route element={terminalTakeoverActive ? takeoverTerminalView : chatView} index />
<Route element={terminalTakeoverActive ? takeoverTerminalView : chatView} path=":sessionId" />
<Route
element={
<Suspense fallback={null}>
@@ -1045,13 +942,11 @@ export function DesktopController() {
</PaneMain>
{/*
Order within a side maps to column order. Default (rail on the right):
main | terminal | preview | file-browser. Flipped (rail on the left):
mirror to file-browser | preview | terminal | main so terminal stays
adjacent to the chat.
main | preview | file-browser. Flipped (rail on the left): mirror it to
file-browser | preview | main so preview stays adjacent to the chat.
*/}
{panesFlipped ? fileBrowserPane : terminalPane}
{previewPane}
{panesFlipped ? terminalPane : fileBrowserPane}
{panesFlipped ? fileBrowserPane : previewPane}
{panesFlipped ? previewPane : fileBrowserPane}
</AppShell>
)
}

View File

@@ -1,22 +0,0 @@
// Shared chrome for the top-center floating HUDs (command palette + session
// switcher). They pin just under the title bar, centered, and lean on a crisp
// border + shadow to separate from the app — no dimming/blurring backdrop.
// Each caller layers on its own z-index, width, and overflow.
export const HUD_POSITION = 'fixed left-1/2 top-3 -translate-x-1/2'
// Matches the app's borderless-overlay surface (dialog, keybind panel, …):
// hairline `--stroke-nous` paired with the soft `--shadow-nous` float.
export const HUD_SURFACE = 'rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous'
// One row/text size for both HUDs (compact — two notches under `text-sm`).
export const HUD_TEXT = 'text-xs'
// Shared item layout + padding for both HUDs. Tight vertical rhythm so rows
// don't feel chunky; overrides the shadcn `CommandItem` default (`px-2 py-1.5`).
export const HUD_ITEM = 'gap-2 px-2 py-1'
// Section headings styled like the sidebar panel labels: brand-tinted, uppercase,
// tightly tracked — plain text, no sticky chrome bar. Targets the cmdk group
// heading via the universal-descendant variant.
export const HUD_HEADING =
'**:[[cmdk-group-heading]]:static **:[[cmdk-group-heading]]:bg-transparent **:[[cmdk-group-heading]]:px-2.5 **:[[cmdk-group-heading]]:pb-1 **:[[cmdk-group-heading]]:pt-2.5 **:[[cmdk-group-heading]]:text-[0.64rem] **:[[cmdk-group-heading]]:font-semibold **:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-[0.16em] **:[[cmdk-group-heading]]:text-(--theme-primary)'

View File

@@ -29,7 +29,6 @@ import {
$connection,
$sessions,
$workingSessionIds,
ensureDefaultWorkspaceCwd,
setConnection,
setSessionsLoading
} from '@/store/session'
@@ -352,7 +351,6 @@ export function useGatewayBoot({
message: translateNow('boot.steps.loadingSettings'),
progress: 97
})
await ensureDefaultWorkspaceCwd()
await callbacksRef.current.refreshHermesConfig()
if (cancelled) {

View File

@@ -1,10 +1,10 @@
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { setRightSidebarTab } from '@/app/right-sidebar/store'
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
import { matchesQuery } from '@/hooks/use-media-query'
import { PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
import { PROFILE_SLOT_COUNT } from '@/lib/keybinds/actions'
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
import { toggleCommandPalette } from '@/store/command-palette'
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
@@ -18,25 +18,13 @@ import {
toggleSidebarOpen
} from '@/store/layout'
import {
$newChatProfile,
cycleProfile,
requestProfileCreate,
switchProfileToSlot,
switchToDefaultProfile,
toggleShowAllProfiles
} from '@/store/profile'
import { setModelPickerOpen } from '@/store/session'
import {
$switcherOpen,
closeSwitcher,
commitOnCtrlUp,
onSwitcherTabDown,
onSwitcherTabUp,
openOrAdvanceSwitcher,
slotSessionId,
switcherActive,
switcherJustClosed
} from '@/store/session-switcher'
import { $activeSessionId, $sessions, setModelPickerOpen } from '@/store/session'
import { useTheme } from '@/themes/context'
import { requestComposerFocus } from '../chat/composer/focus'
@@ -72,7 +60,6 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
// Keep the latest closures without re-subscribing the listener.
const handlersRef = useRef<HandlerMap>({})
const commitSwitcherRef = useRef<() => void>(() => {})
const profileSwitchHandlers: HandlerMap = {}
@@ -80,32 +67,26 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
profileSwitchHandlers[`profile.switch.${slot}`] = () => switchProfileToSlot(slot)
}
const goToSession = (sessionId: null | string) => {
if (sessionId) {
navigate(sessionRoute(sessionId))
// Move to the adjacent session in recency order, wrapping at the ends.
const cycleSession = (direction: 1 | -1) => {
const sessions = $sessions.get()
if (sessions.length < 2) {
return
}
const current = sessions.findIndex(session => session.id === $activeSessionId.get())
const start = current === -1 ? (direction === 1 ? -1 : 0) : current
const next = sessions[(start + direction + sessions.length) % sessions.length]
if (next) {
navigate(sessionRoute(next.id))
}
}
// ^N jumps straight to the Nth recent session and dismisses the switcher.
const sessionSlotHandlers: HandlerMap = {}
for (let slot = 1; slot <= SESSION_SLOT_COUNT; slot += 1) {
sessionSlotHandlers[`session.slot.${slot}`] = () => {
closeSwitcher()
goToSession(slotSessionId(slot))
}
}
commitSwitcherRef.current = () => goToSession(commitOnCtrlUp())
const stepSession = (direction: 1 | -1) => {
onSwitcherTabDown()
goToSession(openOrAdvanceSwitcher(direction))
}
const showFiles = () => {
const showRightSidebarTab = (tab: 'files' | 'terminal') => {
setFileBrowserOpen(true)
setTerminalTakeover(false)
setRightSidebarTab(tab)
}
handlersRef.current = {
@@ -125,16 +106,11 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'nav.agents': () => navigate(AGENTS_ROUTE),
'session.new': () => {
// Match the sidebar New Session button. A plain keyboard new chat should
// target the current live profile, not a stale per-profile quick-create
// selection from a prior action.
$newChatProfile.set(null)
deps.startFreshSession()
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
},
'session.next': () => stepSession(1),
'session.prev': () => stepSession(-1),
...sessionSlotHandlers,
'session.next': () => cycleSession(1),
'session.prev': () => cycleSession(-1),
'session.focusSearch': requestSessionSearchFocus,
'session.togglePin': deps.toggleSelectedPin,
@@ -152,8 +128,8 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
toggleFileBrowserOpen()
}
},
'view.showFiles': showFiles,
'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()),
'view.showFiles': () => showRightSidebarTab('files'),
'view.showTerminal': () => showRightSidebarTab('terminal'),
'view.flipPanes': togglePanesFlipped,
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
@@ -194,16 +170,6 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
return
}
// While the session switcher is up, Esc abandons it (stay put) before any
// combo dispatch — ⌃Tab keeps stepping through the existing handler.
if (switcherActive() && event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
closeSwitcher()
return
}
const combo = comboFromEvent(event)
if (!combo) {
@@ -230,39 +196,8 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
handler()
}
// Mac-app-switcher commit: lifting Ctrl with the overlay open lands on the
// highlighted session. A window blur (Cmd+Tab away mid-switch) cancels so
// the overlay never gets stranded waiting for a keyup that never comes.
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Tab') {
onSwitcherTabUp()
}
if (event.key === 'Control') {
commitSwitcherRef.current()
}
}
const onBlur = () => switcherActive() && closeSwitcher()
// Swallow trailing contextmenu after Ctrl+click commit (Electron main menu).
const onContextMenu = (event: MouseEvent) => {
if ($switcherOpen.get() || switcherJustClosed()) {
event.preventDefault()
event.stopPropagation()
}
}
window.addEventListener('keydown', onKeyDown, { capture: true })
window.addEventListener('keyup', onKeyUp, { capture: true })
window.addEventListener('blur', onBlur)
window.addEventListener('contextmenu', onContextMenu, { capture: true })
return () => {
window.removeEventListener('keydown', onKeyDown, { capture: true })
window.removeEventListener('keyup', onKeyUp, { capture: true })
window.removeEventListener('blur', onBlur)
window.removeEventListener('contextmenu', onContextMenu, { capture: true })
}
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [])
}

View File

@@ -4,20 +4,22 @@ import type { ReactNode } from 'react'
import { ErrorBoundary } from '@/components/error-boundary'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
import { $currentBranch, $currentCwd } from '@/store/session'
import { SidebarPanelLabel } from '../shell/sidebar-label'
import { ProjectTree } from './files/tree'
import { useProjectTree } from './files/use-project-tree'
import { $rightSidebarTab, $terminalTakeover, type RightSidebarTabId, setRightSidebarTab } from './store'
import { TerminalSlot } from './terminal/persistent'
interface RightSidebarPaneProps {
onActivateFile: (path: string) => void
@@ -25,10 +27,24 @@ interface RightSidebarPaneProps {
onChangeCwd: (path: string) => Promise<void> | void
}
interface RightSidebarTab {
icon: string
id: RightSidebarTabId
labelKey: 'files' | 'terminal'
}
const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
{ id: 'files', labelKey: 'files', icon: 'list-tree' },
{ id: 'terminal', labelKey: 'terminal', icon: 'terminal' }
]
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
const { t } = useI18n()
const r = t.rightSidebar
const activeTab = useStore($rightSidebarTab)
const terminalTakeover = useStore($terminalTakeover)
const panesFlipped = useStore($panesFlipped)
const currentBranch = useStore($currentBranch).trim()
const currentCwd = useStore($currentCwd).trim()
const hasCwd = currentCwd.length > 0
@@ -52,6 +68,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
} = useProjectTree(currentCwd)
const canCollapse = Object.values(openState).some(Boolean)
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
const chooseFolder = async () => {
const selected = await window.hermesDesktop?.selectPaths({
@@ -80,6 +97,8 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
}
}
const tabs = terminalTakeover ? RIGHT_SIDEBAR_TABS.filter(tab => tab.id !== 'terminal') : RIGHT_SIDEBAR_TABS
return (
<aside
aria-label={r.aria}
@@ -90,29 +109,85 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
)}
>
<FilesystemTab
canCollapse={canCollapse}
collapseNonce={collapseNonce}
cwd={currentCwd}
cwdName={cwdName}
data={data}
error={rootError}
hasCwd={hasCwd}
loading={rootLoading}
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onChangeFolder={chooseFolder}
onCollapseAll={collapseAll}
onLoadChildren={loadChildren}
onNodeOpenChange={setNodeOpen}
onPreviewFile={previewFile}
onRefresh={() => void refreshRoot()}
openState={openState}
/>
<RightSidebarChrome activeTab={effectiveTab} branch={currentBranch} tabs={tabs} />
{effectiveTab === 'terminal' ? (
<TerminalSlot />
) : (
<FilesystemTab
canCollapse={canCollapse}
collapseNonce={collapseNonce}
cwd={currentCwd}
cwdName={cwdName}
data={data}
error={rootError}
hasCwd={hasCwd}
loading={rootLoading}
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onChangeFolder={chooseFolder}
onCollapseAll={collapseAll}
onLoadChildren={loadChildren}
onNodeOpenChange={setNodeOpen}
onPreviewFile={previewFile}
onRefresh={() => void refreshRoot()}
openState={openState}
/>
)}
</aside>
)
}
function RightSidebarChrome({
activeTab,
branch,
tabs
}: {
activeTab: RightSidebarTabId
branch: string
tabs: readonly RightSidebarTab[]
}) {
const { t } = useI18n()
const r = t.rightSidebar
return (
<header className="shrink-0 bg-transparent text-[0.75rem]">
<div className="flex items-center gap-2 px-2.5 py-1">
<nav aria-label={r.panelsAria} className="flex min-w-0 items-center gap-1">
{tabs.map(tab => {
const label = r[tab.labelKey]
return (
<Tip key={tab.id} label={label}>
<Button
aria-label={label}
aria-pressed={tab.id === activeTab}
className={cn(
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
)}
onClick={() => setRightSidebarTab(tab.id)}
size="icon-xs"
variant="ghost"
>
<Codicon name={tab.icon} size="0.875rem" />
</Button>
</Tip>
)
})}
</nav>
{branch && (
<span className="ml-auto flex min-w-0 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary)">
<Codicon className="shrink-0" name="git-branch" size="0.75rem" />
<span className="truncate">{branch}</span>
</span>
)}
</div>
</header>
)
}
interface FilesystemTabProps extends FileTreeBodyProps {
canCollapse: boolean
cwdName: string

View File

@@ -2,10 +2,14 @@ import { atom } from 'nanostores'
import { persistBoolean, storedBoolean } from '@/lib/storage'
export type RightSidebarTabId = 'files' | 'git' | 'terminal' | 'web'
const TAKEOVER_KEY = 'hermes.desktop.terminalTakeover'
export const $rightSidebarTab = atom<RightSidebarTabId>('files')
export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false))
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
export const setRightSidebarTab = (tab: RightSidebarTabId) => $rightSidebarTab.set(tab)
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)

View File

@@ -1,65 +0,0 @@
import type { Terminal } from '@xterm/xterm'
// Serialized view of the in-app terminal, handed to the agent's `read_terminal`
// tool. Line indices are absolute into xterm's buffer (0 = oldest scrollback
// line), so the agent can page with start_line/count against `total_lines`.
export interface TerminalReadResult {
total_lines: number
start: number
end: number
viewport_rows: number
cursor_row: number
text: string
}
export interface TerminalReadOptions {
start?: number
count?: number
}
type Reader = (opts: TerminalReadOptions) => TerminalReadResult
// The persistent terminal is a singleton (one xterm mounted forever), so a
// module-level slot is enough — set while the session is live, cleared on
// dispose. The gateway `terminal.read.request` handler reads through this.
let activeReader: Reader | null = null
export function setActiveTerminalReader(reader: Reader | null): void {
activeReader = reader
}
export function readActiveTerminal(opts: TerminalReadOptions = {}): TerminalReadResult | null {
return activeReader ? activeReader(opts) : null
}
export function makeTerminalReader(term: Terminal): Reader {
return ({ start, count }) => {
const buf = term.buffer.active
const total = buf.length
const rows = term.rows
// Default window = the visible screen; baseY is the viewport's top row.
const from = Math.max(0, Math.min(start ?? buf.baseY, total))
const to = Math.max(from, Math.min(from + Math.max(1, count ?? rows), total))
const lines: string[] = []
// translateToString(true) right-trims and resolves wide chars, dropping SGR
// colors — exactly what the agent wants.
for (let i = from; i < to; i += 1) {
lines.push(buf.getLine(i)?.translateToString(true) ?? '')
}
while (lines.length && !lines[lines.length - 1].trim()) {
lines.pop()
}
return {
total_lines: total,
start: from,
end: to,
viewport_rows: rows,
cursor_row: buf.baseY + buf.cursorY,
text: lines.join('\n')
}
}
}

View File

@@ -1,5 +1,7 @@
import '@xterm/xterm/css/xterm.css'
import { useStore } from '@nanostores/react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
@@ -7,7 +9,7 @@ import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import { setTerminalTakeover } from '../store'
import { $terminalTakeover, setRightSidebarTab, setTerminalTakeover } from '../store'
import { addSelectionShortcutLabel } from './selection'
import { useTerminalSession } from './use-terminal-session'
@@ -19,32 +21,41 @@ interface TerminalTabProps {
export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
const { t } = useI18n()
const { addSelectionToChat, hostRef, selection, selectionStyle, shellName, status } = useTerminalSession({
cwd,
onAddSelectionToChat
})
const label = t.rightSidebar.terminalHide
const takeover = useStore($terminalTakeover)
const label = takeover ? t.rightSidebar.terminalSplit : t.rightSidebar.terminalFocus
const toggleTakeover = () => {
// Pre-select the Terminal tab so the slot is ready to host us on return.
if (takeover) {
setRightSidebarTab('terminal')
}
setTerminalTakeover(!takeover)
}
return (
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
<div className="flex h-8 shrink-0 items-center gap-2 px-2.5">
<SidebarPanelLabel className="text-(--ui-text-secondary)!">{shellName}</SidebarPanelLabel>
<SidebarPanelLabel className="text-white!">{shellName}</SidebarPanelLabel>
<Tip label={label}>
<Button
aria-label={label}
className="ml-auto size-6 rounded-md text-(--ui-text-secondary)!"
onClick={() => setTerminalTakeover(false)}
className="ml-auto size-6 rounded-md text-white!"
onClick={toggleTakeover}
size="icon"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.875rem" />
<Codicon name={takeover ? 'screen-normal' : 'screen-full'} size="0.875rem" />
</Button>
</Tip>
</div>
<div className="relative min-h-0 flex-1 bg-(--ui-editor-surface-background) p-2">
<div className="relative min-h-0 flex-1 bg-[#002b36] p-2">
{status === 'starting' && (
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
<Loader
@@ -73,13 +84,12 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
</Button>
</div>
)}
{/* Outer div paints terminal inset; inner div is the xterm host so the
canvas sizes to the content area and p-2 stays as terminal padding.
Screen/viewport inherit the live skin surface so the terminal blends
with the app and follows light/dark; the xterm canvas itself is
painted the resolved surface color in use-terminal-session. */}
{/* Outer div paints the dark inset; inner div is the xterm host so the
canvas sizes to the *content* area and p-2 shows as terminal padding.
Forcing screen/viewport bg avoids xterm's default black peeking
through the unused pixels below the last full row. */}
<div
className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-(--ui-editor-surface-background)! [&_.xterm-viewport]:bg-(--ui-editor-surface-background)!"
className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-[#002b36]! [&_.xterm-viewport]:bg-[#002b36]!"
ref={hostRef}
/>
</div>

View File

@@ -2,6 +2,8 @@ import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { TERMINAL_BG } from './selection'
import { TerminalTab } from './index'
/**
@@ -105,9 +107,7 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
visibility: visible ? 'visible' : 'hidden',
pointerEvents: visible ? 'auto' : 'none',
zIndex: 4,
// Match the live skin surface so the header strip (transparent) and body
// read as one cohesive pane instead of revealing a near-black slab behind.
backgroundColor: 'var(--ui-editor-surface-background)',
backgroundColor: TERMINAL_BG,
contain: 'layout size paint'
}

View File

@@ -1,101 +1,38 @@
import type { ITheme, Terminal } from '@xterm/xterm'
import type { CSSProperties } from 'react'
import type { DesktopTerminalPalette } from '@/themes/types'
// Solarized-derived palette, but with bright ANSI 815 promoted to real
// accent variants instead of Schoonover's UI grays. Hermes' TUI skins (gold,
// crimson, ...) emit bright SGR codes that would otherwise wash out to gray.
// We always render the dark canvas — the app's light surfaces can't host the
// default skin without dropping below readable contrast.
export const TERMINAL_BG = '#002b36'
// VS Code's default integrated-terminal palette (terminalColorRegistry.ts) — a
// fixed table per theme type, not luminance-derived. Light/dark diverge on
// purpose so each stays legible (e.g. mustard yellow on white).
const DARK_THEME: ITheme = {
background: '#1e1e1e',
foreground: '#cccccc',
cursor: '#cccccc',
cursorAccent: '#1e1e1e',
selectionBackground: '#264f7866',
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#e5e5e5'
const THEME: ITheme = {
background: TERMINAL_BG,
foreground: '#839496',
cursor: '#93a1a1',
cursorAccent: TERMINAL_BG,
selectionBackground: '#586e7555',
black: '#073642',
red: '#dc322f',
green: '#859900',
yellow: '#b58900',
blue: '#268bd2',
magenta: '#d33682',
cyan: '#2aa198',
white: '#eee8d5',
brightBlack: '#586e75',
brightRed: '#f25c54',
brightGreen: '#b3d437',
brightYellow: '#f7c948',
brightBlue: '#5fb3ff',
brightMagenta: '#ff6ab4',
brightCyan: '#5cd9c8',
brightWhite: '#fdf6e3'
}
const LIGHT_THEME: ITheme = {
background: '#ffffff',
foreground: '#333333',
cursor: '#333333',
cursorAccent: '#ffffff',
selectionBackground: '#add6ff80',
black: '#000000',
red: '#cd3131',
green: '#00bc00',
yellow: '#949800',
blue: '#0451a5',
magenta: '#bc05bc',
cyan: '#0598bc',
white: '#555555',
brightBlack: '#666666',
brightRed: '#cd3131',
brightGreen: '#14ce14',
brightYellow: '#b5ba00',
brightBlue: '#0451a5',
brightMagenta: '#bc05bc',
brightCyan: '#0598bc',
brightWhite: '#a5a5a5'
}
// Palette by painted mode, optionally overlaid with an imported theme's ANSI
// palette (Solarized terminal for the Solarized skin, etc.). `palette` only
// fills the slots it defines, so a partial import keeps the mode defaults for
// the rest. `background` is a fallback only — withSurface swaps in the live skin
// surface at runtime (keeping transparency); minimumContrastRatio keeps colors
// crisp against it.
export function terminalTheme(mode: 'light' | 'dark', palette?: DesktopTerminalPalette): ITheme {
const base = mode === 'dark' ? DARK_THEME : LIGHT_THEME
if (!palette) {
return base
}
const overlay = { ...base } as Record<string, string>
for (const [slot, value] of Object.entries(palette)) {
if (value) {
overlay[slot] = value
}
}
return overlay as ITheme
}
// Resolve --ui-editor-surface-background (a color-mix on the skin seed) to a
// concrete rgb for the WebGL renderer + contrast clamp. Custom props don't
// resolve via getComputedStyle, so probe a real background-color. Read AFTER
// applyTheme repaints (mount / rAF post-change) or it lags a frame behind.
export function resolveSurfaceColor(fallback: string): string {
if (typeof document === 'undefined' || !document.body) {
return fallback
}
const probe = document.createElement('span')
probe.style.cssText =
'position:absolute;visibility:hidden;pointer-events:none;background-color:var(--ui-editor-surface-background)'
document.body.appendChild(probe)
const resolved = getComputedStyle(probe).backgroundColor
probe.remove()
return resolved && resolved !== 'rgba(0, 0, 0, 0)' ? resolved : fallback
}
export const terminalTheme = (): ITheme => THEME
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')

View File

@@ -3,20 +3,12 @@ import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { WebglAddon } from '@xterm/addon-webgl'
import { Terminal } from '@xterm/xterm'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { triggerHaptic } from '@/lib/haptics'
import { useTheme } from '@/themes/context'
import { makeTerminalReader, setActiveTerminalReader } from './buffer'
import {
isAddSelectionShortcut,
resolveSurfaceColor,
terminalSelectionAnchor,
terminalSelectionLabel,
terminalTheme
} from './selection'
import { isAddSelectionShortcut, terminalSelectionAnchor, terminalSelectionLabel, terminalTheme } from './selection'
type TerminalStatus = 'closed' | 'open' | 'starting'
@@ -72,29 +64,10 @@ function stripEscapeSequences(data: string) {
return text
}
// Keep only the ANSI escape sequences from a chunk, dropping printable text. Lets
// us apply control codes (e.g. a clear-screen) while discarding boot spacers and
// zsh's reverse-video "%" partial-line marker.
function keepEscapeSequences(data: string) {
let index = 0
let out = ''
function isStartupSpacer(data: string) {
const text = stripEscapeSequences(data).replace(/[\s\r\n]/g, '')
while (index < data.length) {
if (data.charCodeAt(index) === 0x1b) {
const sequence = readEscapeSequence(data, index)
if (sequence) {
out += sequence
index += sequence.length
continue
}
}
index += 1
}
return out
return text === '' || text === '%'
}
function stripInitialPromptGap(data: string) {
@@ -122,14 +95,6 @@ interface UseTerminalSessionOptions {
onAddSelectionToChat: (text: string, label?: string) => void
}
// Bind the palette to the live skin surface so the terminal blends with the app
// (and the contrast clamp has a real background to work against).
function withSurface(theme: ReturnType<typeof terminalTheme>) {
const surface = resolveSurfaceColor(theme.background ?? '#ffffff')
return { ...theme, background: surface, cursorAccent: surface }
}
function transferHasDropCandidates(t: DataTransfer): boolean {
if (t.types?.includes(HERMES_PATHS_MIME)) {
return true
@@ -219,21 +184,8 @@ function quotePathForShell(path: string, shellName: string): string {
}
export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSessionOptions) {
// Key off renderedMode (the painted surface type), not resolvedMode (the
// clicked switch) — a skin can keep a light surface in "dark" mode, and we
// must match the surface or the ANSI palette inverts against it. themeName
// re-resolves the canvas surface on skin switches (same mode, new tint).
const { renderedMode, theme, themeName } = useTheme()
// Adopt the skin's ANSI palette when it ships one (imported VS Code themes do),
// matched to the painted variant; built-in skins carry none, so the terminal
// keeps its VS Code defaults. withSurface still owns the background, so this
// never touches transparency.
const ansiPalette = renderedMode === 'dark' ? (theme.darkTerminal ?? theme.terminal) : theme.terminal
const activeTheme = useMemo(() => terminalTheme(renderedMode, ansiPalette), [renderedMode, ansiPalette])
const initialThemeRef = useRef(activeTheme)
const hostRef = useRef<HTMLDivElement | null>(null)
const termRef = useRef<Terminal | null>(null)
const webglRef = useRef<WebglAddon | null>(null)
const sessionIdRef = useRef<string | null>(null)
const shellNameRef = useRef('shell')
const selectionLabelRef = useRef('')
@@ -248,26 +200,19 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
onAddSelectionToChatRef.current = onAddSelectionToChat
}, [onAddSelectionToChat])
// Live selection at call time. A redraw-heavy TUI (spinners, clocks) outruns
// onSelectionChange, so trust xterm directly — fall back to the native
// selection — rather than the cached ref / React state.
const readSelection = useCallback(
() => termRef.current?.getSelection() || window.getSelection()?.toString() || '',
[]
)
const addSelectionToChat = useCallback(() => {
const selectedText = readSelection() || selectionRef.current
const selectedText = selectionRef.current || termRef.current?.getSelection() || ''
const label =
selectionLabelRef.current ||
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
const trimmed = selectedText.trim()
if (!trimmed) {
return
}
const label =
selectionLabelRef.current ||
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
onAddSelectionToChatRef.current(trimmed, label)
termRef.current?.clearSelection()
selectionRef.current = ''
@@ -275,14 +220,15 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
setSelection('')
setSelectionStyle(null)
triggerHaptic('selection')
}, [readSelection])
}, [])
// Always listen — gating on the React selection state misses selections the
// TUI redraw races. Only swallow ⌘/Ctrl+L when there's text to send, else it
// must reach the shell as clear-screen.
useEffect(() => {
if (!selection.trim()) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
if (!isAddSelectionShortcut(event) || !readSelection().trim()) {
if (!isAddSelectionShortcut(event)) {
return
}
@@ -294,7 +240,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [addSelectionToChat, readSelection])
}, [addSelectionToChat, selection])
useEffect(() => {
const host = hostRef.current
@@ -318,19 +264,9 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
fontFamily: "'SF Mono', 'Menlo', 'Cascadia Code', 'JetBrains Mono', monospace",
fontSize: 11,
lineHeight: 1.12,
// Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag
// can't select — ⌥-drag (macOS) / Shift-drag (else) forces a native
// selection over mouse-mode apps, which ⌘/Ctrl+L then sends to chat.
macOptionClickForcesSelection: true,
macOptionIsMeta: true,
// VS Code/Cursor's secret sauce: terminal.integrated.minimumContrastRatio
// defaults to 4.5 there. xterm defaults to 1 (off), which paints the raw
// saturated ANSI palette — vivid green/cyan on white reads as candy.
// Clamping to 4.5:1 darkens/lightens foregrounds against the background
// at render time, matching the muted ink-like look of their terminal.
minimumContrastRatio: 4.5,
scrollback: 1000,
theme: withSurface(initialThemeRef.current)
theme: terminalTheme()
})
const fit = new FitAddon()
@@ -340,10 +276,18 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
term.loadAddon(new Unicode11Addon())
term.loadAddon(new WebLinksAddon())
term.unicode.activeVersion = '11'
term.open(host)
term.focus()
// Let the GUI chat agent read this pane via the `read_terminal` tool: the
// gateway's terminal.read.request handler serializes the buffer through this.
setActiveTerminalReader(makeTerminalReader(term))
// WebGL renderer matches the dashboard ChatPage path; xterm's default DOM
// renderer paints SGR via CSS classes that visibly mute against our skins.
try {
const webgl = new WebglAddon()
webgl.onContextLoss(() => webgl.dispose())
term.loadAddon(webgl)
} catch (err) {
console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
}
const onDragOver = (e: DragEvent) => {
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
@@ -384,75 +328,6 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
host.removeEventListener('drop', onDrop)
})
// A fresh prompt should sit at the top. Every resize SIGWINCHes the shell,
// which reprints its prompt and can leave stale blank rows above it. While
// the session is pristine (nothing run yet) we ask the shell to clear +
// redraw via Ctrl-L (\f) after the resize settles. Ctrl-L preserves
// multi-line prompts (term.clear() would drop all but the cursor row) and we
// stop the moment real output exists, so command scrollback is never wiped.
let promptPristine = true
let gapCleanupTimer = 0
// While armed, strip leading blank rows so the prompt lands at the very top
// (no starship `add_newline` gap). Re-armed before each Ctrl-L redraw so the
// resize cleanup doesn't reintroduce the blank line.
let stripLeading = true
const armedWrite = (data: string) => {
if (!stripLeading) {
term.write(data)
return
}
const next = stripInitialPromptGap(data)
const visible = stripEscapeSequences(next).replace(/[\s%]/g, '')
if (!visible) {
// Spacer / lone clear-screen / zsh `%` marker: apply control codes but
// drop the blank text and stay armed so the prompt still lands at top.
const controls = keepEscapeSequences(next)
if (controls) {
term.write(controls)
}
return
}
stripLeading = false
term.write(next)
}
const scheduleGapCleanup = () => {
if (!promptPristine) {
return
}
if (gapCleanupTimer) {
window.clearTimeout(gapCleanupTimer)
}
gapCleanupTimer = window.setTimeout(() => {
gapCleanupTimer = 0
const id = sessionIdRef.current
if (disposed || !id || !promptPristine) {
return
}
stripLeading = true
void terminalApi.write(id, '\f')
term.clearSelection()
}, 120)
}
cleanup.push(() => {
if (gapCleanupTimer) {
window.clearTimeout(gapCleanupTimer)
}
})
const fitAndResize = () => {
if (disposed || !host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
return
@@ -469,7 +344,6 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
if (id && (lastSentSize?.cols !== term.cols || lastSentSize?.rows !== term.rows)) {
lastSentSize = { cols: term.cols, rows: term.rows }
void terminalApi.resize(id, { cols: term.cols, rows: term.rows })
scheduleGapCleanup()
}
}
@@ -506,12 +380,6 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
const id = sessionIdRef.current
if (id) {
// Once the user submits a line, real output may follow — stop the
// pristine-prompt gap cleanup so we never clear command scrollback.
if (promptPristine && data.includes('\r')) {
promptPristine = false
}
void terminalApi.write(id, data)
}
})
@@ -528,88 +396,87 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
cleanup.push(() => selectionDisposable.dispose())
const startSession = () =>
void terminalApi
.start({ cols: term.cols, cwd, rows: term.rows })
.then(session => {
if (disposed) {
void terminalApi.dispose(session.id)
term.attachCustomKeyEventHandler(event => {
if (event.type !== 'keydown') {
return true
}
return
}
if (isAddSelectionShortcut(event) && term.hasSelection()) {
event.preventDefault()
addSelectionToChat()
sessionIdRef.current = session.id
lastSentSize = { cols: term.cols, rows: term.rows }
shellNameRef.current = session.shell || 'shell'
setShellName(session.shell || 'shell')
return false
}
const initial = term.hasSelection() ? term.getSelection() : ''
selectionRef.current = initial
selectionLabelRef.current = initial ? terminalSelectionLabel(term, shellNameRef.current, initial) : ''
return true
})
setStatus('open')
fitAndResize()
cleanup.push(
terminalApi.onData(session.id, armedWrite),
terminalApi.onExit(session.id, ({ code, signal }) => {
setStatus('closed')
term.write(`\r\n[terminal exited${signal ? `: ${signal}` : code !== null ? `: ${code}` : ''}]\r\n`)
})
)
void terminalApi
.start({ cols: term.cols, cwd, rows: term.rows })
.then(session => {
if (disposed) {
void terminalApi.dispose(session.id)
window.requestAnimationFrame(() => {
fitAndResize()
term.clearSelection() // drop any selection painted over transient boot rows
term.focus()
return
}
sessionIdRef.current = session.id
lastSentSize = { cols: term.cols, rows: term.rows }
shellNameRef.current = session.shell || 'shell'
setShellName(session.shell || 'shell')
if (term.hasSelection()) {
const currentSelection = term.getSelection()
selectionRef.current = currentSelection
selectionLabelRef.current = terminalSelectionLabel(term, shellNameRef.current, currentSelection)
} else {
selectionRef.current = ''
selectionLabelRef.current = ''
}
setStatus('open')
let wrotePromptContent = false
cleanup.push(
terminalApi.onData(session.id, data => {
if (wrotePromptContent) {
term.write(data)
return
}
if (isStartupSpacer(data)) {
return
}
const next = stripInitialPromptGap(data)
if (next) {
wrotePromptContent = true
term.write(next)
}
}),
terminalApi.onExit(session.id, sessionExit => {
const { code, signal } = sessionExit
setStatus('closed')
term.write(`\r\n[terminal exited${signal ? `: ${signal}` : code !== null ? `: ${code}` : ''}]\r\n`)
})
)
window.requestAnimationFrame(() => {
fitAndResize()
term.focus()
})
.catch(error => {
setStatus('closed')
term.write(`Terminal failed to start: ${error instanceof Error ? error.message : String(error)}\r\n`)
})
// Open + fit + start only once webfonts settle. Fitting with fallback metrics
// picks the wrong row count, the shell boots at that size, then the real font
// loads -> refit -> SIGWINCH -> the shell reprints its prompt lower, leaving
// stale blank rows (and a stray selection) above it.
const mount = () => {
if (disposed || !host.isConnected) {
return
}
term.open(host)
term.focus()
// WebGL renderer matches the dashboard ChatPage path; xterm's default DOM
// renderer paints SGR via CSS classes that visibly mute against our skins.
try {
const webgl = new WebglAddon()
webgl.onContextLoss(() => {
webgl.dispose()
webglRef.current = null
})
term.loadAddon(webgl)
webglRef.current = webgl
} catch (err) {
console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
}
fitAndResize()
startSession()
}
const fonts = typeof document !== 'undefined' ? document.fonts : undefined
if (fonts?.ready) {
void fonts.ready.then(mount, mount)
} else {
mount()
}
})
.catch(error => {
setStatus('closed')
term.write(`Terminal failed to start: ${error instanceof Error ? error.message : String(error)}\r\n`)
})
return () => {
disposed = true
cleanup.forEach(run => run())
setActiveTerminalReader(null)
const id = sessionIdRef.current
sessionIdRef.current = null
@@ -620,34 +487,12 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
term.dispose()
termRef.current = null
webglRef.current = null
shellNameRef.current = 'shell'
selectionRef.current = ''
selectionLabelRef.current = ''
}
}, [addSelectionToChat, cwd])
useEffect(() => {
const term = termRef.current
if (!term) {
return
}
// Re-resolve the surface in a rAF: ThemeProvider's applyTheme repaints the
// CSS vars in a sibling effect that runs after this one, so reading now
// would lag a mode behind. By the next frame the vars are current.
const raf = requestAnimationFrame(() => {
term.options.theme = withSurface(activeTheme)
// The WebGL renderer caches glyph colors in a texture atlas, so a
// light/dark switch leaves already-drawn cells stale until the atlas is
// cleared. No-op for the DOM fallback.
webglRef.current?.clearTextureAtlas()
})
return () => cancelAnimationFrame(raf)
}, [activeTheme, themeName])
return {
addSelectionToChat,
hostRef,

View File

@@ -1,107 +0,0 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router-dom'
import { sessionTitle } from '@/lib/chat-runtime'
import { cn } from '@/lib/utils'
import { $attentionSessionIds, $workingSessionIds } from '@/store/session'
import { $switcherIndex, $switcherOpen, $switcherSessions, closeSwitcher } from '@/store/session-switcher'
import { HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from './floating-hud'
import { sessionRoute } from './routes'
// Compact session-switcher HUD — keyboard-driven from `use-keybinds`, rows
// clickable via mousedown (Ctrl+click on macOS). No Dialog: Tab stays global.
export function SessionSwitcher() {
const open = useStore($switcherOpen)
const sessions = useStore($switcherSessions)
const index = useStore($switcherIndex)
const working = useStore($workingSessionIds)
const attention = useStore($attentionSessionIds)
const navigate = useNavigate()
const activeRef = useRef<HTMLDivElement>(null)
useEffect(() => {
activeRef.current?.scrollIntoView({ block: 'nearest' })
}, [index, open])
if (!open || sessions.length === 0) {
return null
}
const workingIds = new Set(working)
const attentionIds = new Set(attention)
const pick = (sessionId: string) => {
closeSwitcher()
navigate(sessionRoute(sessionId))
}
return createPortal(
<>
{/* Transparent click-catcher: click-away closes, but no dim/blur. */}
<div
className="fixed inset-0 z-[219]"
onMouseDown={e => {
e.preventDefault()
closeSwitcher()
}}
/>
<div
className={cn(
HUD_POSITION,
HUD_SURFACE,
'dt-portal-scrollbar z-[220] max-h-[min(22rem,64vh)] w-[min(19rem,calc(100vw-2rem))] select-none overflow-y-auto p-1'
)}
>
{sessions.map((session, i) => {
const selected = i === index
return (
<div
className={cn(
'flex cursor-pointer items-center rounded leading-tight',
HUD_ITEM,
HUD_TEXT,
selected ? 'bg-accent text-accent-foreground' : 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background)'
)}
key={session.id}
onMouseDown={e => {
e.preventDefault()
pick(session.id)
}}
ref={selected ? activeRef : undefined}
>
<SwitcherDot attention={attentionIds.has(session.id)} working={workingIds.has(session.id)} />
<span className="min-w-0 flex-1 truncate">{sessionTitle(session)}</span>
{i < 9 && (
<span
className={cn(
'shrink-0 font-mono text-[0.625rem] tabular-nums',
selected ? 'text-accent-foreground/70' : 'text-(--ui-text-quaternary)'
)}
>
{i + 1}
</span>
)}
</div>
)
})}
</div>
</>,
document.body
)
}
function SwitcherDot({ attention, working }: { attention: boolean; working: boolean }) {
return (
<span
className={cn(
'size-1 shrink-0 rounded-full',
attention ? 'bg-amber-400' : working ? 'animate-pulse bg-(--ui-accent)' : 'bg-(--ui-text-quaternary)/50'
)}
/>
)
}

View File

@@ -1,7 +1,6 @@
import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
import {
appendAssistantTextPart,
appendReasoningPart,
@@ -19,7 +18,6 @@ import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
import { triggerHaptic } from '@/lib/haptics'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setClarifyRequest } from '@/store/clarify'
import { $gateway } from '@/store/gateway'
import { notify } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
@@ -908,21 +906,6 @@ export function useMessageStream({
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
}
} else if (event.type === 'terminal.read.request') {
// read_terminal tool: serialize the renderer's xterm buffer and answer
// immediately (Python blocks on the respond). Empty text = no live pane.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
if (requestId) {
const start = typeof payload?.start === 'number' ? payload.start : undefined
const count = typeof payload?.count === 'number' ? payload.count : undefined
const result = readActiveTerminal({ start, count })
void $gateway.get()?.request('terminal.read.respond', {
request_id: requestId,
text: result ? JSON.stringify(result) : ''
})
}
} else if (event.type === 'error') {
const errorMessage = payload?.message || 'Hermes reported an error'
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)

View File

@@ -1,13 +1,12 @@
import { cleanup, render, waitFor } from '@testing-library/react'
import { cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { useEffect } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $composerAttachments, type ComposerAttachment } from '@/store/composer'
import { $connection, $sessions, setSessions } from '@/store/session'
import { $sessions, setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { uploadComposerAttachment, usePromptActions } from './use-prompt-actions'
import { usePromptActions } from './use-prompt-actions'
vi.mock('@/hermes', () => ({
getProfiles: vi.fn(async () => ({ profiles: [] })),
@@ -43,10 +42,7 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
interface HarnessHandle {
steerPrompt: (text: string) => Promise<boolean>
submitText: (
text: string,
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
) => Promise<boolean>
submitText: (text: string, options?: { attachments?: never[]; fromQueue?: boolean }) => Promise<boolean>
}
function Harness({
@@ -54,20 +50,16 @@ function Harness({
onReady,
onSeedState,
refreshSessions,
requestGateway,
storedSessionId
requestGateway
}: {
busyRef?: MutableRefObject<boolean>
onReady: (handle: HarnessHandle) => void
onSeedState?: (state: Record<string, unknown>) => void
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
storedSessionId?: null | string
}) {
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = {
current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId
}
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const localBusyRef = busyRef ?? { current: false }
const actions = usePromptActions({
@@ -322,433 +314,3 @@ describe('usePromptActions steerPrompt', () => {
expect(requestGateway).not.toHaveBeenCalled()
})
})
describe('usePromptActions file attachment sync', () => {
afterEach(() => {
cleanup()
$connection.set(null)
vi.restoreAllMocks()
})
function fileAttachment(): ComposerAttachment {
return {
id: 'file:report.txt',
kind: 'file',
label: 'report.txt',
path: '/Users/alice/Downloads/report.txt',
refText: '@file:`/Users/alice/Downloads/report.txt`'
}
}
it('uploads file bytes via file.attach on a remote gateway and submits the rewritten ref', async () => {
// Remote gateway can't read the client-disk path, so the desktop must upload
// the bytes and submit the workspace-relative ref the gateway hands back —
// not the original /Users/... path (which would dead-end as "outside the
// allowed workspace").
$connection.set({ mode: 'remote' } as never)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl: vi.fn(async () => 'data:text/plain;base64,aGVsbG8=') }
})
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'file.attach') {
return {
attached: true,
path: '/remote/work/.hermes/desktop-attachments/report.txt',
ref_text: '@file:.hermes/desktop-attachments/report.txt',
uploaded: true
} as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
const ok = await handle!.submitText('convert this to epub', { attachments: [fileAttachment()] })
expect(ok).toBe(true)
expect(calls.map(c => c.method)).toEqual(['file.attach', 'prompt.submit'])
expect(calls[0]?.params).toMatchObject({
session_id: RUNTIME_SESSION_ID,
path: '/Users/alice/Downloads/report.txt',
name: 'report.txt',
data_url: 'data:text/plain;base64,aGVsbG8='
})
expect(calls[1]?.params).toEqual({
session_id: RUNTIME_SESSION_ID,
text: '@file:.hermes/desktop-attachments/report.txt\n\nconvert this to epub'
})
})
it('passes a path-less @file: ref straight through (no path = nothing to upload)', async () => {
// Submit-layer contract: only attachments that carry a `path` are upload
// candidates. A path-less ref (an @-mention/context ref or pasted text)
// has no bytes to send, so syncAttachments leaves it untouched and the ref
// reaches the gateway as-is — correct for workspace-relative refs.
//
// The MahmoudR drag-drop bug (a Finder PDF that became a local-path text
// ref in remote mode) is fixed upstream at the DROP layer: OS drops now
// carry a path and route through the upload pipeline instead of becoming a
// path-less inline ref. See partitionDroppedFiles in use-composer-actions.
$connection.set({ mode: 'remote' } as never)
const readFileDataUrl = vi.fn(async () => 'data:application/pdf;base64,JVBERi0=')
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl }
})
const pathlessRef: ComposerAttachment = {
id: 'file:devis',
kind: 'file',
label: 'DEVIS_signed.pdf',
// NOTE: no `path` field — only the pre-baked local @file: ref.
refText: '@file:`/Users/mahmoud/Downloads/DEVIS_signed.pdf`'
}
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
const ok = await handle!.submitText('read this file', { attachments: [pathlessRef] })
expect(ok).toBe(true)
// No path → no file.attach, no byte read: the ref passes through unchanged.
expect(calls.map(c => c.method)).toEqual(['prompt.submit'])
expect(readFileDataUrl).not.toHaveBeenCalled()
expect(calls[0]?.params?.text).toContain('@file:`/Users/mahmoud/Downloads/DEVIS_signed.pdf`')
})
it('passes the path directly via file.attach in local mode (no byte upload)', async () => {
$connection.set({ mode: 'local' } as never)
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'file.attach') {
return { attached: true, ref_text: '@file:data/report.txt', uploaded: false } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
const ok = await handle!.submitText('summarize', { attachments: [fileAttachment()] })
expect(ok).toBe(true)
expect(calls[0]?.method).toBe('file.attach')
// Local mode sends no data_url — the gateway shares this disk.
expect(calls[0]?.params).not.toHaveProperty('data_url')
expect(calls[1]).toEqual({
method: 'prompt.submit',
params: { session_id: RUNTIME_SESSION_ID, text: '@file:data/report.txt\n\nsummarize' }
})
})
})
describe('usePromptActions eager-upload races', () => {
beforeEach(() => {
setSessions(() => [sessionInfo()])
$composerAttachments.set([])
})
afterEach(() => {
cleanup()
$composerAttachments.set([])
$connection.set(null)
vi.restoreAllMocks()
})
it('joins an in-flight eager upload at submit instead of staging the file twice', async () => {
// Drop-then-immediately-Enter: the drop kicks off an eager file.attach; if
// submit doesn't join it, both calls stage the file and leave a duplicate
// under .hermes/desktop-attachments/. Submit must await the in-flight upload
// and reuse its gateway-side ref.
$connection.set({ mode: 'remote' } as never)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl: vi.fn(async () => 'data:application/pdf;base64,JVBERi0=') }
})
let releaseAttach: () => void = () => {}
const methods: string[] = []
const requestGateway = vi.fn(async (method: string) => {
methods.push(method)
if (method === 'file.attach') {
// Block until released so submit runs while the upload is in flight.
await new Promise<void>(resolve => {
releaseAttach = resolve
})
return { attached: true, ref_text: '@file:.hermes/desktop-attachments/doc.pdf', uploaded: true } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
await waitFor(() => expect(handle).not.toBeNull())
// Drop a file → the eager effect fires file.attach and blocks on it.
$composerAttachments.set([{ id: 'file:doc.pdf', kind: 'file', label: 'doc.pdf', path: '/Users/me/doc.pdf' }])
await waitFor(() => expect(methods.filter(m => m === 'file.attach').length).toBe(1))
// Submit reads the store, sees the upload in flight, and joins it.
const submitting = handle!.submitText('here you go')
releaseAttach()
expect(await submitting).toBe(true)
// Exactly one file.attach (submit reused the eager result), then the send.
expect(methods.filter(m => m === 'file.attach').length).toBe(1)
expect(methods).toContain('prompt.submit')
})
})
describe('usePromptActions sleep/wake session recovery', () => {
const STORED_SESSION_ID = 'stored-db-xyz789'
const RECOVERED_SESSION_ID = 'rt-recovered-456'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('resumes the stored session and retries once when prompt.submit reports "session not found"', async () => {
// After sleep/wake the gateway's in-memory session table is cleared, so the
// first prompt.submit with the stale runtime id fails. The hook resumes the
// durable stored id (which survives gateway restarts), gets a fresh live id,
// and retries the send transparently.
const calls: { method: string; params?: Record<string, unknown> }[] = []
let submitAttempts = 0
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'prompt.submit') {
submitAttempts += 1
if (submitAttempts === 1) {
throw new Error('session not found')
}
return {} as never
}
if (method === 'session.resume') {
return { session_id: RECOVERED_SESSION_ID } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_ID}
/>
)
const ok = await handle!.submitText('message after wake')
expect(ok).toBe(true)
// First submit (stale id) → session.resume (stored id) → retry submit (fresh id).
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
})
it('surfaces the original error (no resume) when the failure is not "session not found"', async () => {
const calls: string[] = []
const states: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string) => {
calls.push(method)
if (method === 'prompt.submit') {
throw new Error('session busy')
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
onSeedState={s => states.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_ID}
/>
)
// submitText swallows the error into an inline bubble and returns false.
expect(await handle!.submitText('message')).toBe(false)
// No resume attempt for a non-recoverable error.
expect(calls).not.toContain('session.resume')
})
it('surfaces "session not found" (no resume) when there is no stored session id', async () => {
const calls: string[] = []
const requestGateway = vi.fn(async (method: string) => {
calls.push(method)
if (method === 'prompt.submit') {
throw new Error('session not found')
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={null}
/>
)
// With a null stored ref, the `&& selectedStoredSessionIdRef.current` guard
// short-circuits — no resume is attempted and the error surfaces normally.
expect(await handle!.submitText('message')).toBe(false)
expect(calls).not.toContain('session.resume')
})
})
describe('usePromptActions eager attachment upload (drop-time)', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
$connection.set(null)
$composerAttachments.set([])
})
it('uploads a dropped file the moment it lands (active session) and rewrites the chip with the gateway ref', async () => {
// A Finder drop adds a chip with a local path but no attachedSessionId. With
// a session already open, the hook should stage it right away — so the send
// is instant and the card can show a spinner while bytes upload — instead of
// waiting for submit.
$connection.set({ mode: 'remote' } as never)
const readFileDataUrl = vi.fn(async () => 'data:application/pdf;base64,JVBERi0=')
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { readFileDataUrl } })
const calls: string[] = []
const requestGateway = vi.fn(async (method: string) => {
calls.push(method)
if (method === 'file.attach') {
return { attached: true, ref_text: '@file:.hermes/desktop-attachments/DEVIS_signed.pdf', uploaded: true } as never
}
return {} as never
})
$composerAttachments.set([
{ id: 'file:devis', kind: 'file', label: 'DEVIS_signed.pdf', path: '/Users/mahmoud/Downloads/DEVIS_signed.pdf' }
])
render(<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
await waitFor(() => expect(calls).toContain('file.attach'))
await waitFor(() => expect($composerAttachments.get()[0]?.attachedSessionId).toBe(RUNTIME_SESSION_ID))
const chip = $composerAttachments.get()[0]!
expect(chip.refText).toBe('@file:.hermes/desktop-attachments/DEVIS_signed.pdf')
expect(chip.uploadState).toBeUndefined()
expect(readFileDataUrl).toHaveBeenCalledWith('/Users/mahmoud/Downloads/DEVIS_signed.pdf')
})
it('flags the chip uploadState=error when the eager upload fails, keeping the path so submit can retry', async () => {
$connection.set({ mode: 'remote' } as never)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl: vi.fn(async () => 'data:application/pdf;base64,JVBERi0=') }
})
const requestGateway = vi.fn(async (method: string) => {
if (method === 'file.attach') {
throw new Error('[Errno 13] Permission denied')
}
return {} as never
})
$composerAttachments.set([{ id: 'file:x', kind: 'file', label: 'x.pdf', path: '/abs/x.pdf' }])
render(<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
await waitFor(() => expect($composerAttachments.get()[0]?.uploadState).toBe('error'))
expect($composerAttachments.get()[0]?.attachedSessionId).toBeUndefined()
expect($composerAttachments.get()[0]?.path).toBe('/abs/x.pdf')
})
it('does not eagerly re-upload a chip already attached to this session', async () => {
$connection.set({ mode: 'remote' } as never)
const requestGateway = vi.fn(async () => ({}) as never)
$composerAttachments.set([
{
id: 'file:done',
kind: 'file',
label: 'done.pdf',
path: '/abs/done.pdf',
refText: '@file:data/done.pdf',
attachedSessionId: RUNTIME_SESSION_ID
}
])
render(<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
await Promise.resolve()
expect(requestGateway).not.toHaveBeenCalledWith('file.attach', expect.anything())
})
})
describe('uploadComposerAttachment remote read failures', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('turns the raw 16MB IPC cap error into a friendly remote-gateway message', async () => {
// electron/hardening.cjs rejects the readFileDataUrl IPC with this exact
// shape when a file exceeds DATA_URL_READ_MAX_BYTES.
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
readFileDataUrl: vi.fn(async () => {
throw new Error('File preview failed: file is too large (20971520 bytes; limit 16777216 bytes).')
})
}
})
const requestGateway = vi.fn(async () => ({}) as never)
await expect(
uploadComposerAttachment(
{ id: 'file:big', kind: 'file', label: 'huge.csv', path: '/abs/huge.csv' },
{ remote: true, requestGateway, sessionId: RUNTIME_SESSION_ID }
)
).rejects.toThrow('huge.csv is too large to upload to the remote gateway (max 16 MB).')
// The cap is hit before any gateway round-trip.
expect(requestGateway).not.toHaveBeenCalled()
})
it('passes non-cap read errors through unchanged', async () => {
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
readFileDataUrl: vi.fn(async () => {
throw new Error('ENOENT: no such file')
})
}
})
await expect(
uploadComposerAttachment(
{ id: 'file:gone', kind: 'file', label: 'gone.csv', path: '/abs/gone.csv' },
{ remote: true, requestGateway: vi.fn(async () => ({}) as never), sessionId: RUNTIME_SESSION_ID }
)
).rejects.toThrow('ENOENT: no such file')
})
})

View File

@@ -1,12 +1,11 @@
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { type MutableRefObject, useCallback } from 'react'
import { getProfiles, transcribeAudio } from '@/hermes'
import { translateNow, type Translations, useI18n } from '@/i18n'
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import {
optimisticAttachmentRef,
attachmentDisplayText,
parseCommandDispatch,
parseSlashCommand,
pathLabel,
@@ -25,11 +24,10 @@ import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setSessionYolo } from '@/lib/yolo-session'
import {
$composerAttachments,
addComposerAttachment,
clearComposerAttachments,
type ComposerAttachment,
setComposerAttachmentUploadState,
terminalContextBlocksFromDraft,
updateComposerAttachment
terminalContextBlocksFromDraft
} from '@/store/composer'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
@@ -49,7 +47,6 @@ import {
import type {
ClientSessionState,
FileAttachResponse,
ImageAttachResponse,
SessionSteerResponse,
SessionTitleResponse,
@@ -106,136 +103,6 @@ async function readImageForRemoteAttach(
return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null
}
// Read a non-image file as a data URL for upload via file.attach. Returns null
// when the desktop bridge can't read the file (e.g. it was moved/deleted).
async function readFileDataUrlForAttach(filePath: string): Promise<string | null> {
const reader = window.hermesDesktop?.readFileDataUrl
if (!reader) {
return null
}
const dataUrl = await reader(filePath)
return dataUrl || null
}
// The readFileDataUrl IPC base64-loads the whole file into memory and is
// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.cjs, which
// rejects with a raw "file is too large (N bytes; limit M bytes)" string. In
// remote mode every attachment's bytes go through that read, so a big file
// surfaces that internal message verbatim in the failure toast. Translate it
// into a friendly "too large to upload to the remote gateway" line, parsing the
// limit out of the message so it tracks the real cap. Non-cap errors pass
// through unchanged.
function friendlyRemoteAttachError(err: unknown, label: string): Error {
const message = err instanceof Error ? err.message : String(err)
if (!/too large/i.test(message)) {
return err instanceof Error ? err : new Error(message)
}
const limitBytes = Number(message.match(/limit (\d+) bytes/)?.[1])
const cap = Number.isFinite(limitBytes) && limitBytes > 0 ? ` (max ${Math.floor(limitBytes / (1024 * 1024))} MB)` : ''
return new Error(`${label} is too large to upload to the remote gateway${cap}.`)
}
type GatewayRequest = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
/**
* Stage one file/image attachment into the session workspace and return the
* attachment rewritten with the gateway-side ref. Images upload their bytes in
* remote mode (so vision works) and pass the path locally; non-image files
* upload bytes remotely and pass the path locally. Throws on failure so callers
* can surface an error. Shared by submit-time sync, the eager drop-time upload,
* and the message-edit composer drop — keep them in lockstep.
*/
export async function uploadComposerAttachment(
attachment: ComposerAttachment,
opts: { remote: boolean; requestGateway: GatewayRequest; sessionId: string }
): Promise<ComposerAttachment> {
const { remote, requestGateway, sessionId } = opts
const path = attachment.path ?? ''
const label = attachment.label || pathLabel(path)
if (attachment.kind === 'image') {
let result: ImageAttachResponse
if (remote) {
let payload: Awaited<ReturnType<typeof readImageForRemoteAttach>>
try {
payload = await readImageForRemoteAttach(path)
} catch (err) {
throw friendlyRemoteAttachError(err, label)
}
if (!payload) {
throw new Error(`Could not read ${label}`)
}
result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
session_id: sessionId,
content_base64: payload.contentBase64,
filename: payload.filename
})
} else {
result = await requestGateway<ImageAttachResponse>('image.attach', {
path,
session_id: sessionId
})
}
if (!result.attached) {
throw new Error(result.message || `Could not attach ${label}`)
}
const attachedPath = result.path || path
return {
...attachment,
attachedSessionId: sessionId,
label: attachedPath ? pathLabel(attachedPath) : attachment.label,
path: attachedPath,
uploadState: undefined
}
}
// Non-image file.
let dataUrl: string | null = null
if (remote) {
try {
dataUrl = await readFileDataUrlForAttach(path)
} catch (err) {
throw friendlyRemoteAttachError(err, label)
}
if (!dataUrl) {
throw new Error(`Could not read ${label}`)
}
}
const result = await requestGateway<FileAttachResponse>('file.attach', {
name: label,
path,
session_id: sessionId,
...(dataUrl ? { data_url: dataUrl } : {})
})
if (!result.attached || !result.ref_text) {
throw new Error(result.message || `Could not attach ${label}`)
}
return {
...attachment,
attachedSessionId: sessionId,
refText: result.ref_text,
uploadState: undefined
}
}
interface PromptActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
@@ -345,168 +212,101 @@ export function usePromptActions({
[selectedStoredSessionIdRef, updateSessionState]
)
// In-flight drop-time eager uploads, keyed by attachment id. Submit joins
// these before re-uploading so a drop-then-immediately-Enter can't fire
// file.attach twice and stage duplicate copies on the gateway.
const eagerUploadInFlight = useRef<Map<string, Promise<void>>>(new Map())
const syncAttachmentsForSubmit = useCallback(
const syncImageAttachmentsForSubmit = useCallback(
async (
sessionId: string,
attachments: ComposerAttachment[],
options: { updateComposerAttachments?: boolean } = {}
): Promise<ComposerAttachment[]> => {
) => {
const updateComposerAttachments = options.updateComposerAttachments ?? true
const images = attachments.filter(attachment => attachment.kind === 'image' && attachment.path)
const remote = $connection.get()?.mode === 'remote'
const synced: ComposerAttachment[] = []
for (const original of attachments) {
let attachment = original
// Join a drop-time eager upload still in flight for this attachment
// before deciding anything — otherwise submit and the eager task both
// call file.attach and stage duplicate files. After it settles, take the
// store's updated copy (its gateway ref, or its failure) over the stale
// pre-upload snapshot.
const inFlight = eagerUploadInFlight.current.get(attachment.id)
if (inFlight) {
await inFlight
attachment = $composerAttachments.get().find(item => item.id === attachment.id) ?? attachment
}
// Already-synced or pathless refs (terminal, url, etc.) pass through.
// A drop-time eager upload may already have staged this one (matching
// attachedSessionId) — don't re-upload it.
if (!attachment.path || attachment.attachedSessionId === sessionId) {
synced.push(attachment)
for (const attachment of images) {
if (attachment.attachedSessionId === sessionId) {
continue
}
if (attachment.kind === 'image' || attachment.kind === 'file') {
const nextAttachment = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })
let result: ImageAttachResponse
// Update-only: never resurrect a chip the user removed mid-upload.
if (updateComposerAttachments) {
updateComposerAttachment(nextAttachment)
if (remote) {
// The gateway is on another machine — it can't read attachment.path
// (a path on THIS disk). Upload the bytes via image.attach_bytes.
const payload = attachment.path ? await readImageForRemoteAttach(attachment.path) : null
if (!payload) {
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
throw new Error(`Could not read ${label}`)
}
synced.push(nextAttachment)
continue
result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
session_id: sessionId,
content_base64: payload.contentBase64,
filename: payload.filename
})
} else {
result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
}
synced.push(attachment)
}
if (!result.attached) {
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
throw new Error(result.message || `Could not attach ${label}`)
}
return synced
const attachedPath = result.path || attachment.path
if (updateComposerAttachments) {
addComposerAttachment({
...attachment,
id: attachment.id,
label: attachedPath ? pathLabel(attachedPath) : attachment.label,
path: attachedPath,
attachedSessionId: sessionId
})
}
}
},
[requestGateway]
)
// Stage a freshly dropped file as soon as it lands (when a session already
// exists), so the upload runs while the user is still typing rather than
// stalling the send. The card shows a spinner via `uploadState`; on success
// the chip carries its gateway-side ref so submit skips re-uploading.
//
// Images are intentionally NOT eager-uploaded: attachImagePath adds the chip
// and then fills in `previewUrl` (the base64 thumbnail) on a second tick, so
// an eager upload would race that write — clobbering the thumbnail and
// swapping `path` to a gateway path the local preview can't read. Images are
// small and still byte-upload at submit via image.attach_bytes.
const eagerlyUploadAttachment = useCallback(
async (sessionId: string, attachment: ComposerAttachment) => {
const remote = $connection.get()?.mode === 'remote'
setComposerAttachmentUploadState(attachment.id, 'uploading')
try {
// Update-only: if the user removed the chip while this was uploading,
// don't resurrect it — just drop the staged result on the floor.
updateComposerAttachment(await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId }))
} catch (err) {
// Leave the chip in place so submit-time sync can retry (or the user can
// remove it) and flag the card; also toast so a hard failure (unreadable
// file, gateway perms) isn't swallowed while the user keeps typing.
setComposerAttachmentUploadState(attachment.id, 'error')
notifyError(err, copy.dropFiles)
}
},
[copy.dropFiles, requestGateway]
)
const composerAttachments = useStore($composerAttachments)
useEffect(() => {
if (!activeSessionId) {
return
}
for (const attachment of composerAttachments) {
const needsUpload =
attachment.kind === 'file' &&
Boolean(attachment.path) &&
!attachment.attachedSessionId &&
!attachment.uploadState &&
!eagerUploadInFlight.current.has(attachment.id)
if (!needsUpload) {
continue
}
const task = eagerlyUploadAttachment(activeSessionId, attachment).finally(() =>
eagerUploadInFlight.current.delete(attachment.id)
)
eagerUploadInFlight.current.set(attachment.id, task)
}
}, [activeSessionId, composerAttachments, eagerlyUploadAttachment])
const submitPromptText = useCallback(
async (rawText: string, options?: SubmitTextOptions) => {
const visibleText = rawText.trim()
const usingComposerAttachments = !options?.attachments
const attachments = options?.attachments ?? $composerAttachments.get()
const contextRefs = attachments
.map(a => a.refText)
.filter(Boolean)
.join('\n')
const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n')
const hasImage = attachments.some(a => a.kind === 'image')
const attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
// Refs are recomputed after sync (file.attach rewrites @file: refs to
// workspace-relative paths the remote gateway can resolve). Seed the
// optimistic message with the pre-sync refs, then rewrite once synced.
// Images use their base64 preview so the thumbnail renders inline without
// a (remote-mode 403-prone) /api/media fetch — see optimisticAttachmentRef.
let attachmentRefs = attachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r))
const buildContextText = (atts: ComposerAttachment[]): string => {
const contextRefs = atts
.map(a => a.refText)
.filter(Boolean)
.join('\n')
return (
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
(atts.some(a => a.kind === 'image') ? 'What do you see in this image?' : '')
)
}
const text =
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
(hasImage ? 'What do you see in this image?' : '')
// Queue drains fire on the busy→false settle edge, where busyRef (synced
// from $busy by a separate effect) may still read true — honoring it would
// bounce the drained send. The drain lock serializes them; the user path
// keeps the guard so a stray Enter mid-turn can't double-submit.
const hasSendable = Boolean(visibleText || terminalContextBlocks || attachments.length || hasImage)
if (!hasSendable || (!options?.fromQueue && busyRef.current)) {
if (!text || (!options?.fromQueue && busyRef.current)) {
return false
}
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const buildUserMessage = (): ChatMessage => ({
const userMessage: ChatMessage = {
id: optimisticId,
role: 'user',
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
attachmentRefs
})
}
const releaseBusy = () => {
setMutableRef(busyRef, false)
@@ -523,7 +323,7 @@ export function usePromptActions({
...state,
messages: state.messages.some(m => m.id === optimisticId)
? state.messages
: [...state.messages, buildUserMessage()],
: [...state.messages, userMessage],
busy: true,
awaitingResponse: true,
pendingBranchGroup: null,
@@ -536,18 +336,6 @@ export function usePromptActions({
selectedStoredSessionIdRef.current
)
// After sync rewrites refs, refresh the optimistic message in place so the
// transcript shows the resolved @file: ref rather than the local path.
const rewriteOptimistic = (sid: string) =>
updateSessionState(
sid,
state => ({
...state,
messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message))
}),
selectedStoredSessionIdRef.current
)
const dropOptimistic = (sid: null | string) => {
if (!sid) {
setMessages(current => current.filter(m => m.id !== optimisticId))
@@ -578,7 +366,7 @@ export function usePromptActions({
if (sessionId) {
seedOptimistic(sessionId)
} else {
setMessages(current => [...current, buildUserMessage()])
setMessages(current => [...current, userMessage])
}
if (!sessionId) {
@@ -604,47 +392,10 @@ export function usePromptActions({
}
try {
const syncedAttachments = await syncAttachmentsForSubmit(sessionId, attachments, {
await syncImageAttachmentsForSubmit(sessionId, attachments, {
updateComposerAttachments: usingComposerAttachments
})
// Rewrite the optimistic message + prompt text with the synced refs so
// the gateway receives @file: paths that resolve in its workspace.
// (Images keep their inline base64 preview — see optimisticAttachmentRef.)
attachmentRefs = syncedAttachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r))
rewriteOptimistic(sessionId)
const text = buildContextText(syncedAttachments)
// On sleep/wake the gateway's in-memory session may have been cleared
// while the desktop app still holds the old session ID. Detect this,
// resume the stored session to re-register it, and retry once.
let submitErr: unknown = null
try {
await requestGateway('prompt.submit', { session_id: sessionId, text })
} catch (firstErr) {
const firstMsg = firstErr instanceof Error ? firstErr.message : String(firstErr)
if (/session not found/i.test(firstMsg) && selectedStoredSessionIdRef.current) {
// Re-register the session in the gateway and get a fresh live ID.
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current
})
const recoveredId = resumed?.session_id
if (recoveredId) {
activeSessionIdRef.current = recoveredId
await requestGateway('prompt.submit', { session_id: recoveredId, text })
} else {
submitErr = firstErr
}
} else {
submitErr = firstErr
}
}
if (submitErr !== null) {
throw submitErr
}
await requestGateway('prompt.submit', { session_id: sessionId, text })
if (usingComposerAttachments) {
clearComposerAttachments()
@@ -691,7 +442,7 @@ export function usePromptActions({
createBackendSessionForSend,
requestGateway,
selectedStoredSessionIdRef,
syncAttachmentsForSubmit,
syncImageAttachmentsForSubmit,
updateSessionState
]
)

View File

@@ -84,60 +84,6 @@ describe('useRouteResume', () => {
expect(resumeSession).not.toHaveBeenCalled()
})
it('self-heals a stranded routed session (null selected/active, same pathname, not a fresh draft)', () => {
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-1' }
const creatingSessionRef = { current: false }
const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-1' }
const { rerender } = render(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).not.toHaveBeenCalled()
// A create/stream race nulls selected/active but the route stays on the
// session and freshDraftReady is false (NOT a new-chat transition).
activeSessionIdRef.current = null
selectedStoredSessionIdRef.current = null
rerender(
<RouteResumeHarness
activeSessionId={null}
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId={null}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
})
it('resumes when pathname changes to a routed session', () => {
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
@@ -187,72 +133,4 @@ describe('useRouteResume', () => {
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-2', true)
})
it('resumes the selected route again when the gateway reconnects', () => {
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-1' }
const creatingSessionRef = { current: false }
const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-1' }
const { rerender } = render(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).not.toHaveBeenCalled()
rerender(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="closed"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
rerender(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
})
})

View File

@@ -56,19 +56,13 @@ export function useRouteResume({
startFreshSessionDraft
}: RouteResumeOptions) {
const lastPathnameRef = useRef<string | null>(null)
const seenGatewayStateRef = useRef(false)
const wasGatewayOpenRef = useRef(false)
useEffect(() => {
const gatewayOpen = gatewayState === 'open'
const pathnameChanged = lastPathnameRef.current !== locationPathname
// Fire only on a genuine closed->open transition (a reconnect). seenGatewayStateRef
// stays false until the first effect run, so a session that mounts with the gateway
// already open is not mistaken for "became open" and does not double-resume with the
// pathname-driven initial resume below.
const gatewayBecameOpen = seenGatewayStateRef.current && !wasGatewayOpenRef.current && gatewayOpen
const gatewayBecameOpen = !wasGatewayOpenRef.current && gatewayOpen
lastPathnameRef.current = locationPathname
seenGatewayStateRef.current = true
wasGatewayOpenRef.current = gatewayOpen
if (currentView !== 'chat' || !gatewayOpen) {
@@ -83,33 +77,12 @@ export function useRouteResume({
Boolean(cachedRuntime) &&
cachedRuntime === activeSessionIdRef.current
// Self-heal a desynced view: the route points at a session that isn't the
// loaded one. A create/stream race can leave selected/active null while
// the route stays on /:sid (symptom: brand-new chat shows "Thinking" then
// an empty transcript even though the turn completed and persisted). The
// pathname didn't change, so the normal gate would skip and the view stays
// stuck empty forever. selectedStoredSessionIdRef is set synchronously at
// resume entry, so this can't loop; the resume's cached fast-path restores
// the already-streamed messages without a refetch.
//
// Crucially this must NOT fire during a /:sid -> /new transition, where
// startFreshSessionDraft nulls selected/active one render before the
// pathname flips to / (same null+/:sid signature). freshDraftReady is the
// discriminator: it's true while heading into a blank new chat, false when
// genuinely stranded on a routed session.
const stuckOnRoutedSession = routedSessionId !== selectedStoredSessionIdRef.current && !freshDraftReady
// Resume when the route meaningfully changed, the gateway just opened, or
// we're stranded on a routed session that never loaded. The first two
// guard against a transient /:sid re-resume during "new chat" state clears
// Resume only when the route meaningfully changed (or gateway just opened).
// This avoids a transient /:sid re-resume during "new chat" state clears
// before the pathname updates from /:sid -> /.
const shouldResume = pathnameChanged || gatewayBecameOpen || stuckOnRoutedSession
const shouldResume = pathnameChanged || gatewayBecameOpen
// On a reconnect (gatewayBecameOpen) re-resume even when the route looks
// `alreadyActive`: the cached runtime id can be stale once the gateway
// rebinds/reaps the session on its side, and trusting it strands Desktop on
// a dead id ("session not found"). Otherwise keep skipping when already active.
if ((gatewayBecameOpen || !alreadyActive) && shouldResume && !creatingSessionRef.current) {
if (!alreadyActive && shouldResume && !creatingSessionRef.current) {
void resumeSession(routedSessionId, true)
}

View File

@@ -20,7 +20,6 @@ import {
$sessions,
$yoloActive,
getRememberedWorkspaceCwd,
workspaceCwdForNewSession,
sessionPinId,
setActiveSessionId,
setAwaitingResponse,
@@ -312,9 +311,8 @@ export function useSessionActions({
})
setSessionStartedAt(null)
setTurnStartedAt(null)
// New chats start in the configured default project dir when set,
// otherwise the sticky last-used workspace (PR #37586).
setCurrentCwd(workspaceCwdForNewSession())
// New chats inherit the current workspace.
setCurrentCwd(getRememberedWorkspaceCwd())
setCurrentBranch('')
clearComposerDraft()
clearComposerAttachments()
@@ -335,7 +333,7 @@ export function useSessionActions({
// Route the new chat to the chosen profile's backend (null = primary,
// so single-profile users are unaffected).
await ensureGatewayProfile($newChatProfile.get())
const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession()
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
// Pass the owning profile so a new chat under a non-launch profile (global
// remote mode) builds its agent + persists against THAT profile's home/db.
const newChatProfile = $newChatProfile.get()

View File

@@ -150,29 +150,6 @@ export function useSessionStateCache({
pendingViewStateRef.current = { sessionId, state }
// Terminal / attention transitions (turn finished, error, or the agent is
// now waiting on the user) MUST reach the view immediately. Electron
// throttles `requestAnimationFrame` to ~0 while the window is
// backgrounded, occluded, or unfocused, so an RAF-deferred flush can be
// stranded in `pendingViewStateRef` indefinitely — that's the "new chat
// stuck on Thinking until I refocus / F5" bug. Flush these synchronously
// (cancelling any in-flight RAF, since we're about to publish the latest
// state anyway). The plain busy heartbeat stays RAF-batched: that
// coalescing exists only to keep periodic `session.info` updates from
// churning `$messages` and jerking the scroll position while reading.
const isCriticalTransition = !state.busy || state.needsInput
if (isCriticalTransition) {
if (viewSyncRafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(viewSyncRafRef.current)
viewSyncRafRef.current = null
}
flushPendingViewState()
return
}
if (viewSyncRafRef.current !== null) {
return
}

View File

@@ -1,23 +1,21 @@
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { LanguageSwitcher } from '@/components/language-switcher'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
import { Check, Palette } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { useTheme } from '@/themes/context'
import { installVscodeThemeFromMarketplace } from '@/themes/install'
import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes'
import { BUILTIN_THEMES } from '@/themes/presets'
import { MODE_OPTIONS } from './constants'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
function ThemePreview({ name }: { name: string }) {
const t = resolveTheme(name)
const t = BUILTIN_THEMES[name]
if (!t) {
return null
@@ -56,81 +54,6 @@ function ThemePreview({ name }: { name: string }) {
)
}
function VscodeThemeInstaller() {
const { t } = useI18n()
const { setTheme } = useTheme()
const a = t.settings.appearance
const [id, setId] = useState('')
const [busy, setBusy] = useState(false)
const [status, setStatus] = useState<{ kind: 'error' | 'success'; text: string } | null>(null)
const install = async () => {
const trimmed = id.trim()
if (!trimmed || busy) {
return
}
setBusy(true)
setStatus(null)
try {
const theme = await installVscodeThemeFromMarketplace(trimmed)
triggerHaptic('crisp')
setTheme(theme.name)
setStatus({ kind: 'success', text: a.installed(theme.label) })
setId('')
} catch (error) {
setStatus({ kind: 'error', text: error instanceof Error ? error.message : a.installError })
} finally {
setBusy(false)
}
}
return (
<div className="mt-3">
<div className="flex flex-wrap items-center gap-2">
<input
className="min-w-0 flex-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 font-mono text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
disabled={busy}
onChange={event => {
setId(event.target.value)
setStatus(null)
}}
onKeyDown={event => {
if (event.key === 'Enter') {
void install()
}
}}
placeholder={a.installPlaceholder}
spellCheck={false}
value={id}
/>
<button
className="inline-flex items-center gap-1.5 rounded-lg border border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] font-medium transition hover:bg-(--chrome-action-hover) disabled:opacity-50"
disabled={busy || !id.trim()}
onClick={() => void install()}
type="button"
>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
{busy ? a.installing : a.installButton}
</button>
</div>
{status && (
<p
className={cn(
'mt-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height)',
status.kind === 'error' ? 'text-(--ui-red)' : 'text-(--ui-text-tertiary)'
)}
>
{status.text}
</p>
)}
</div>
)
}
export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
@@ -189,62 +112,40 @@ export function AppearanceSettings() {
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name
const removable = isUserTheme(theme.name)
return (
<div className="group relative" key={theme.name}>
<button
className={cn(
'w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
</div>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
</button>
{removable && (
<button
aria-label={a.removeTheme}
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
onClick={() => {
triggerHaptic('crisp')
removeUserTheme(theme.name)
// Re-normalize off the now-missing skin → default.
if (active) {
setTheme(theme.name)
}
}}
title={a.removeTheme}
type="button"
>
<Trash2 className="size-3.5" />
</button>
<button
className={cn(
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
</div>
key={theme.name}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
</div>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
</button>
)
})}
</div>
<VscodeThemeInstaller />
{showProfileNote && (
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.themeProfileNote(activeProfileName)}

View File

@@ -8,7 +8,7 @@ import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import { applyConfiguredDefaultProjectDir, ensureDefaultWorkspaceCwd, setSessions } from '@/store/session'
import { setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
@@ -196,7 +196,6 @@ function DefaultProjectDirSetting() {
setDir(result.dir)
setFallback(result.defaultLabel)
applyConfiguredDefaultProjectDir(result.dir)
})
return () => {
@@ -222,8 +221,7 @@ function DefaultProjectDirSetting() {
const result = await settings.setDefaultProjectDir(picked.dir)
setDir(result.dir)
applyConfiguredDefaultProjectDir(result.dir)
notify({ durationMs: 4_000, kind: 'success', message: s.defaultDirUpdated })
notify({ durationMs: 2_000, kind: 'success', message: s.defaultDirUpdated })
} catch (err) {
notifyError(err, s.updateDirFailed)
} finally {
@@ -243,8 +241,6 @@ function DefaultProjectDirSetting() {
try {
await settings.setDefaultProjectDir(null)
setDir(null)
applyConfiguredDefaultProjectDir(null)
await ensureDefaultWorkspaceCwd()
} catch (err) {
notifyError(err, s.clearDirFailed)
} finally {
@@ -272,7 +268,7 @@ function DefaultProjectDirSetting() {
)}
</div>
}
description={dir || s.defaultsTo(fallback || '~')}
description={dir || s.defaultsTo(fallback || '~/hermes-projects')}
title={dir ? dir : s.notSet}
/>
</div>

View File

@@ -16,7 +16,6 @@ import {
} from '@/store/layout'
import { $paneWidthOverride } from '@/store/panes'
import { $connection } from '@/store/session'
import { isSecondaryWindow } from '@/store/windows'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
@@ -29,19 +28,9 @@ interface AppShellProps {
children: ReactNode
leftStatusbarItems?: readonly StatusbarItem[]
leftTitlebarTools?: readonly TitlebarTool[]
// Fixed-position overlays that must share <main>'s stacking context so pane
// resize handles (z-20) paint above them. The persistent terminal lives here:
// hoisting it to the root `overlays` layer (sibling of <main>, z above z-3)
// would cover every pane's drag handle.
mainOverlays?: ReactNode
onOpenSettings: () => void
overlays?: ReactNode
// Rails that sit at the window's left edge in the flipped layout but never
// force-collapse to hover-reveal overlays — so they cover the top-left traffic
// lights (and zero the titlebar inset) even below the collapse breakpoint.
previewPaneOpen?: boolean
statusbarItems?: readonly StatusbarItem[]
terminalPaneOpen?: boolean
titlebarTools?: readonly TitlebarTool[]
}
@@ -64,12 +53,9 @@ export function AppShell({
children,
leftStatusbarItems,
leftTitlebarTools,
mainOverlays,
onOpenSettings,
overlays,
previewPaneOpen = false,
statusbarItems,
terminalPaneOpen = false,
titlebarTools
}: AppShellProps) {
const sidebarOpen = useStore($sidebarOpen)
@@ -89,17 +75,10 @@ export function AppShell({
// The inset clears the top-left titlebar buttons when nothing covers the
// window's left edge. Default layout: the sessions sidebar sits there.
// Flipped layout: the file browser does instead. Both force-collapse to a
// hover-reveal overlay (0px track) below the collapse breakpoint, so the edge
// is uncovered there regardless of their stored open state. A standalone
// session window renders no sidebar at all, so its edge is always uncovered.
const collapsibleLeftPaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen
// The terminal + preview rails never force-collapse, so when they're the
// leftmost open pane (flipped layout) they cover the edge even when narrow.
const persistentLeftPaneOpen = panesFlipped && (terminalPaneOpen || previewPaneOpen)
const leftEdgePaneOpen =
!isSecondaryWindow() && ((!narrowViewport && collapsibleLeftPaneOpen) || persistentLeftPaneOpen)
// Flipped layout: the file browser does instead. Below the collapse
// breakpoint both rails are force-collapsed (hover-reveal overlay), so the
// edge is uncovered regardless of their stored open state.
const leftEdgePaneOpen = !narrowViewport && (panesFlipped ? fileBrowserOpen : sidebarOpen)
const titlebarContentInset = leftEdgePaneOpen
? 0
@@ -178,11 +157,6 @@ export function AppShell({
{children}
</PaneShell>
{/* Fixed overlays scoped to main's stacking context (terminal). Rendered
after PaneShell so it paints over pane content, but its z stays under
the panes' z-20 resize handles, keeping every pane resizable. */}
{mainOverlays}
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
</main>

View File

@@ -3,7 +3,6 @@ import type { ReactNode } from 'react'
import { useCallback, useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { useI18n } from '@/i18n'
import {
@@ -15,7 +14,6 @@ import {
Hash,
Loader2,
Sparkles,
Terminal,
Zap,
ZapFilled
} from '@/lib/icons'
@@ -58,7 +56,6 @@ import type { StatusbarItem, StatusbarSelectModifiers } from '../statusbar-contr
interface StatusbarItemsOptions {
agentsOpen: boolean
chatOpen: boolean
commandCenterOpen: boolean
extraLeftItems: readonly StatusbarItem[]
extraRightItems: readonly StatusbarItem[]
@@ -76,7 +73,6 @@ interface StatusbarItemsOptions {
export function useStatusbarItems({
agentsOpen,
chatOpen,
commandCenterOpen,
extraLeftItems,
extraRightItems,
@@ -94,7 +90,6 @@ export function useStatusbarItems({
const { t } = useI18n()
const copy = t.shell.statusbar
const activeSessionId = useStore($activeSessionId)
const terminalTakeover = useStore($terminalTakeover)
const yoloActive = useStore($yoloActive)
const busy = useStore($busy)
const currentFastMode = useStore($currentFastMode)
@@ -447,21 +442,11 @@ export function useStatusbarItems({
variant: 'action' as const
})
},
{
className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`,
hidden: !chatOpen,
icon: <Terminal className="size-3.5" />,
id: 'terminal',
onSelect: () => setTerminalTakeover(!$terminalTakeover.get()),
title: terminalTakeover ? copy.hideTerminal : copy.showTerminal,
variant: 'action'
},
clientVersionItem,
...(backendVersionItem ? [backendVersionItem] : [])
],
[
busy,
chatOpen,
contextBar,
contextUsage,
copy,
@@ -472,7 +457,6 @@ export function useStatusbarItems({
modelMenuContent,
sessionStartedAt,
showYoloToggle,
terminalTakeover,
toggleYolo,
turnStartedAt,
clientVersionItem,

View File

@@ -27,20 +27,6 @@ export interface ImageDetachResponse {
count?: number
}
export interface FileAttachResponse {
attached?: boolean
message?: string
// Gateway-side absolute path the file was staged to.
path?: string
// Workspace-relative path used to build ref_text.
ref_path?: string
// Rewritten @file: ref that resolves on the gateway (workspace-relative).
ref_text?: string
// True when bytes/host file were copied into the session workspace.
uploaded?: boolean
name?: string
}
export interface SlashExecResponse {
output?: string
warning?: string

View File

@@ -494,9 +494,11 @@ export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: Markdo
const MarkdownTextImpl = () => {
return (
<DeferStreamingText>
<MarkdownTextSurface />
</DeferStreamingText>
<SmoothStreamingText>
<DeferStreamingText>
<MarkdownTextSurface />
</DeferStreamingText>
</SmoothStreamingText>
)
}

View File

@@ -164,27 +164,6 @@ function assistantMultiReasoningMessage(texts: string[]): ThreadMessage {
} as ThreadMessage
}
function assistantSeparatedReasoningMessage(): ThreadMessage {
return {
id: 'assistant-reasoning-separated-1',
role: 'assistant',
content: [
{ type: 'reasoning', text: ' Complete first thought.', status: { type: 'complete' } },
{ type: 'text', text: 'Interim answer.' },
{ type: 'reasoning', text: ' Streaming second thought.', status: { type: 'running' } }
],
status: { type: 'running' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function assistantTodoMessage(
todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }>,
running = true
@@ -706,18 +685,6 @@ describe('assistant-ui streaming renderer', () => {
expect(reasoningParts[1]?.textContent).toBe('Second thought.')
})
it('does not reopen an earlier completed thinking group when a later group is running', () => {
const { container } = render(<RunningMessageHarness message={assistantSeparatedReasoningMessage()} />)
const disclosures = container.querySelectorAll('[data-slot="aui_thinking-disclosure"]')
expect(disclosures.length).toBe(2)
expect(disclosures[0].querySelector('button')?.getAttribute('aria-expanded')).toBe('false')
expect(disclosures[1].querySelector('button')?.getAttribute('aria-expanded')).toBe('true')
expect(container.textContent).not.toContain('Complete first thought.')
expect(container.textContent).toContain('Interim answer.')
})
it('renders live todo rows during a running turn', () => {
const { container } = render(
<TodoHarness

View File

@@ -37,12 +37,7 @@ import {
} from '@/app/chat/composer/focus'
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
import {
dragHasAttachments,
droppedFileInlineRefs,
type InlineRefInput,
insertInlineRefsIntoEditor
} from '@/app/chat/composer/inline-refs'
import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from '@/app/chat/composer/inline-refs'
import {
composerPlainText,
placeCaretEnd,
@@ -52,8 +47,7 @@ import {
} from '@/app/chat/composer/rich-editor'
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
import { extractDroppedFiles, HERMES_PATHS_MIME, isImagePath, partitionDroppedFiles } from '@/app/chat/hooks/use-composer-actions'
import { uploadComposerAttachment } from '@/app/session/hooks/use-prompt-actions'
import { extractDroppedFiles, HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
@@ -82,7 +76,6 @@ import { Loader } from '@/components/ui/loader'
import type { HermesGateway } from '@/hermes'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runtime'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { LinkifiedText } from '@/lib/external-link'
import { triggerHaptic } from '@/lib/haptics'
@@ -91,9 +84,7 @@ import { extractPreviewTargets } from '@/lib/preview-targets'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
import { $connection } from '@/store/session'
import { $voicePlayback } from '@/store/voice-playback'
type ThreadLoadingState = 'response' | 'session'
@@ -477,9 +468,7 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
s =>
s.thread.isRunning &&
s.message.status?.type === 'running' &&
s.message.parts
.slice(Math.max(0, startIndex), endIndex + 1)
.some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
s.message.parts.slice(Math.max(0, startIndex)).some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
)
// A reasoning group with no actual text is pure noise — drop the whole
@@ -722,14 +711,8 @@ function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
// edit composer render the same bubble surface (rounded glass card);
// they only differ in border weight, cursor, and padding-right (the
// read-only view reserves room for the restore icon).
//
// no-drag: sticky bubbles park at --sticky-human-top (~4px), sliding under the
// titlebar's [-webkit-app-region:drag] strips (app-shell.tsx). Electron resolves
// drag regions at the compositor level — z-index and pointer-events don't help —
// so without the carve-out, clicking a stuck bubble drags the window instead of
// opening the edit composer.
const USER_BUBBLE_BASE_CLASS =
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left'
const USER_ACTION_ICON_BUTTON_CLASS =
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
@@ -979,10 +962,6 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top')
const [focusRequestId, setFocusRequestId] = useState(0)
const [submitting, setSubmitting] = useState(false)
// True while OS-drop files are being staged/uploaded into the session. Blocks
// submit and shows a spinner so confirming the edit can't race the async
// upload and drop the gateway-side ref before it lands in the draft.
const [staging, setStaging] = useState(false)
const expanded = draft.includes('\n')
const canSubmit = draft.trim().length > 0
const at = useAtCompletions({ cwd, gateway, sessionId })
@@ -1199,14 +1178,18 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
[aui, closeTrigger, refreshTrigger, requestEditFocus, trigger]
)
const insertRefStrings = useCallback(
(refs: InlineRefInput[]) => {
const insertDroppedRefs = useCallback(
(candidates: ReturnType<typeof extractDroppedFiles>) => {
const editor = editorRef.current
if (!editor || refs.length === 0) {
if (!editor) {
return false
}
const refs = candidates
.map(candidate => droppedFileInlineRef(candidate, cwd))
.filter((ref): ref is string => Boolean(ref))
const nextDraft = insertInlineRefsIntoEditor(editor, refs)
if (nextDraft === null) {
@@ -1219,60 +1202,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
return true
},
[aui, requestEditFocus]
)
const insertDroppedRefs = useCallback(
(candidates: ReturnType<typeof extractDroppedFiles>) => insertRefStrings(droppedFileInlineRefs(candidates, cwd)),
[cwd, insertRefStrings]
)
// OS/Finder drops carry an absolute path on THIS machine — the gateway can't
// read it in remote mode, and an image needs its bytes uploaded for vision.
// Stage each through the same file.attach/image.attach_bytes pipeline the main
// composer uses, then insert the *gateway-side* ref the agent can resolve —
// never the raw local path (the MahmoudR remote-attach bug, which the main
// composer fixes but this edit composer used to reproduce).
const uploadOsDropRefs = useCallback(
async (osDrops: ReturnType<typeof extractDroppedFiles>): Promise<InlineRefInput[]> => {
if (!gateway || !sessionId) {
// No session to stage into — best-effort inline refs (matches old path).
return droppedFileInlineRefs(osDrops, cwd)
}
const remote = $connection.get()?.mode === 'remote'
const requestGateway = <T,>(method: string, params?: Record<string, unknown>) => gateway.request<T>(method, params)
const refs: InlineRefInput[] = []
for (const candidate of osDrops) {
const path = candidate.path || ''
if (!path) {
continue
}
const kind: ComposerAttachment['kind'] =
candidate.file?.type.startsWith('image/') || isImagePath(candidate.file?.name || path) ? 'image' : 'file'
try {
const uploaded = await uploadComposerAttachment(
{ detail: path, id: attachmentId(kind, path), kind, label: pathLabel(path), path },
{ remote, requestGateway, sessionId }
)
const ref = attachmentDisplayText(uploaded)
if (ref) {
refs.push(ref)
}
} catch (err) {
notifyError(err, t.desktop.dropFiles)
}
}
return refs
},
[cwd, gateway, sessionId, t.desktop.dropFiles]
[aui, cwd, requestEditFocus]
)
const resetDragState = useCallback(() => {
@@ -1326,25 +1256,9 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
event.stopPropagation()
resetDragState()
// In-app drags (project tree / gutter) are workspace-relative paths that
// resolve on the gateway as-is, so they stay inline refs. OS drops need to
// be staged + uploaded first, then their gateway-side ref is inserted.
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
if (insertDroppedRefs(inAppRefs)) {
if (insertDroppedRefs(candidates)) {
triggerHaptic('selection')
}
if (osDrops.length) {
setStaging(true)
void uploadOsDropRefs(osDrops)
.then(refs => {
if (insertRefStrings(refs)) {
triggerHaptic('selection')
}
})
.finally(() => setStaging(false))
}
}
const handleInput = (event: FormEvent<HTMLDivElement>) => {
@@ -1375,7 +1289,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
const submitEdit = (editor: HTMLDivElement) => {
const nextDraft = syncDraftFromEditor(editor)
if (submitting || staging || !nextDraft.trim()) {
if (submitting || !nextDraft.trim()) {
return
}
@@ -1532,19 +1446,10 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
suppressContentEditableWarning
/>
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
{staging && (
<span
className="pointer-events-none absolute bottom-2 left-2 inline-flex items-center gap-1 rounded-full bg-background/80 px-1.5 py-0.5 text-[0.62rem] text-muted-foreground backdrop-blur-[1px]"
data-slot="aui_edit-staging"
>
<Loader2Icon className="size-3 animate-spin" />
{copy.attachingFile}
</span>
)}
<button
aria-label={copy.sendEdited}
className={cn('absolute right-2 bottom-2 size-5', USER_ACTION_ICON_BUTTON_CLASS)}
disabled={!canSubmit || submitting || staging}
disabled={!canSubmit || submitting}
onClick={() => {
const editor = editorRef.current

View File

@@ -13,9 +13,9 @@ import { DisclosureRow } from '@/components/chat/disclosure-row'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { FadeText } from '@/components/ui/fade-text'
import { ToolIcon } from '@/components/ui/tool-icon'
import { useI18n } from '@/i18n'
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
@@ -136,7 +136,7 @@ function ToolGlyph({ copy, icon, status }: { copy: ToolStatusCopy; icon?: string
const node = status ? (
statusGlyph(status, copy)
) : icon ? (
<ToolIcon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
<Codicon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
) : null
return node ? <span className={TOOL_HEADER_GLYPH_WRAP_CLASS}>{node}</span> : null

View File

@@ -1,141 +0,0 @@
import { ExportedMessageRepository } from '@assistant-ui/core/internal'
// Clicking a user bubble must open the inline edit composer — through the
// app's incremental external-store runtime (which reimplements capability
// resolution, incl. `edit: onEdit !== undefined`) and the stock runtime.
//
// Note: this covers the React/runtime wiring only. The Electron-level failure
// mode (titlebar -webkit-app-region:drag swallowing clicks on *stuck* sticky
// bubbles) is not reproducible in jsdom — see USER_BUBBLE_BASE_CLASS's no-drag
// carve-out in thread.tsx.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { Thread } from './thread'
const createdAt = new Date('2026-05-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
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() {}
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)
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'edit me please' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistantMessage(): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
// Mirrors chat/index.tsx: incremental runtime + messageRepository + onEdit.
function IncrementalHarness({ onEdit }: { onEdit: () => Promise<void> }) {
const repository = ExportedMessageRepository.fromArray([userMessage(), assistantMessage()])
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
messageRepository: repository,
isRunning: false,
setMessages: () => {},
onNew: async () => {},
onEdit,
onCancel: async () => {},
onReload: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
// Control: stock external store runtime.
function StockHarness({ onEdit }: { onEdit: () => Promise<void> }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistantMessage()],
isRunning: false,
onNew: async () => {},
onEdit
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
describe('click-to-edit user message', () => {
it('opens the edit composer with the incremental runtime', async () => {
const { container } = render(<IncrementalHarness onEdit={async () => {}} />)
const bubble = await screen.findByRole('button', { name: 'Edit message' })
fireEvent.click(bubble)
await waitFor(() => {
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
})
})
it('opens the edit composer with the stock runtime', async () => {
const { container } = render(<StockHarness onEdit={async () => {}} />)
const bubble = await screen.findByRole('button', { name: 'Edit message' })
fireEvent.click(bubble)
await waitFor(() => {
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
})
})
})

View File

@@ -30,8 +30,6 @@ export interface PaneProps {
children?: ReactNode
className?: string
defaultOpen?: boolean
/** Paints a persistent hairline on the resize edge (not just the hover sash) so the pane boundary is always visible. */
divider?: boolean
/** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */
disabled?: boolean
/** Like disabled, but keeps hoverReveal alive — collapses the track without writing to the store (e.g. narrow window). */
@@ -96,35 +94,19 @@ const remPx = () =>
? 16
: Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16
const viewportPx = () => (typeof window === 'undefined' ? 1280 : window.innerWidth)
// Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem" | "Nvw" | "N%") to
// pixels for drag clamping. Viewport units resolve against the current window width.
// Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem") to pixels for drag clamping.
function widthToPx(value: WidthValue | undefined) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined
}
const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem|vw|%)?$/)
const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem)?$/)
if (!match) {
return undefined
}
const n = Number.parseFloat(match[1])
switch (match[2]) {
case 'rem':
return n * remPx()
case 'vw':
case '%':
return (n * viewportPx()) / 100
default:
return n
}
return Number.parseFloat(match[1]) * (match[2] === 'rem' ? remPx() : 1)
}
function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement {
@@ -235,7 +217,6 @@ export function Pane({
children,
className,
defaultOpen = true,
divider = false,
disabled = false,
hoverReveal = false,
id,
@@ -428,7 +409,6 @@ export function Pane({
role="separator"
tabIndex={0}
>
{divider && <span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-(--ui-stroke-secondary)" />}
<span className="absolute inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2 bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100" />
</div>
)}

View File

@@ -1,65 +0,0 @@
import type * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
// Solid (filled) glyphs for in-thread tool rows. Codicons are an outline icon
// *font*, so an outline glyph has no separate fillable region — a filled look
// can't be derived from it (stroke-thickening just bolds the outline). To get
// the Cursor-style filled tool icons we render dedicated solid SVG paths,
// keyed by the same names used in `TOOL_META` (tool-fallback-model.ts).
//
// Paths are Phosphor Icons (MIT) "fill" weight, 256×256 viewBox. Inlining the
// path data mirrors the existing precedent in `directive-text.tsx`.
const TOOL_ICON_PATHS: Record<string, string> = {
diff: 'M118.18,213.08c-.11.14-.24.27-.36.4l-.16.18-.17.15a4.83,4.83,0,0,1-.42.37,3.92,3.92,0,0,1-.32.25l-.3.22-.38.23a2.91,2.91,0,0,1-.3.17l-.37.19-.34.15-.36.13a2.84,2.84,0,0,1-.38.13l-.36.1c-.14,0-.26.07-.4.09l-.42.07-.35.05a7,7,0,0,1-.79,0H64a8,8,0,0,1,0-16H92.69L55,162.34a23.85,23.85,0,0,1-7-17V95a32,32,0,1,1,16,0v50.38A8,8,0,0,0,66.34,151L104,188.69V160a8,8,0,0,1,16,0v48a7,7,0,0,1,0,.8c0,.11,0,.21,0,.32s0,.3-.07.46a2.83,2.83,0,0,1-.09.37c0,.13-.06.26-.1.39s-.08.23-.12.35l-.14.39-.15.31c-.06.13-.12.27-.19.4s-.11.18-.16.28l-.24.39-.21.28ZM208,161V110.63a23.85,23.85,0,0,0-7-17L163.31,56H192a8,8,0,0,0,0-16H143.82l-.6,0c-.14,0-.28,0-.41.06l-.37,0-.43.11-.33.08-.4.14-.34.13-.35.16-.36.18a3.14,3.14,0,0,0-.31.18c-.12.07-.25.14-.36.22a3.55,3.55,0,0,0-.31.23,3.81,3.81,0,0,0-.32.24c-.15.12-.28.24-.42.37l-.17.15-.16.18c-.12.13-.25.26-.36.4l-.26.35-.21.28-.24.39c-.05.1-.11.19-.16.28s-.13.27-.19.4l-.15.31-.14.39c0,.12-.09.23-.12.35s-.07.26-.1.39a2.83,2.83,0,0,0-.09.37c0,.16,0,.31-.07.46s0,.21-.05.32a7,7,0,0,0,0,.8V96a8,8,0,0,0,16,0V67.31L189.66,105a8,8,0,0,1,2.34,5.66V161a32,32,0,1,0,16,0Z',
edit: 'M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM192,108.68,147.31,64l24-24L216,84.68Z',
eye: 'M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,168a40,40,0,1,1,40-40A40,40,0,0,1,128,168Z',
file: 'M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM152,88V44l44,44Z',
'file-media':
'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM156,88a12,12,0,1,1-12,12A12,12,0,0,1,156,88Zm60,112H40V160.69l46.34-46.35a8,8,0,0,1,11.32,0h0L165,181.66a8,8,0,0,0,11.32-11.32l-17.66-17.65L173,138.34a8,8,0,0,1,11.31,0L216,170.07V200Z',
files:
'M213.66,66.34l-40-40A8,8,0,0,0,168,24H88A16,16,0,0,0,72,40V56H56A16,16,0,0,0,40,72V216a16,16,0,0,0,16,16H168a16,16,0,0,0,16-16V200h16a16,16,0,0,0,16-16V72A8,8,0,0,0,213.66,66.34ZM136,192H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm0-32H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm64,24H184V104a8,8,0,0,0-2.34-5.66l-40-40A8,8,0,0,0,136,56H88V40h76.69L200,75.31Z',
globe:
'M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm78.36,64H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM216,128a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM128,43a115.27,115.27,0,0,1,26,45H102A115.11,115.11,0,0,1,128,43ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48Zm50.35,61.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z',
question:
'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,168a12,12,0,1,1,12-12A12,12,0,0,1,128,192Zm8-48.72V144a8,8,0,0,1-16,0v-8a8,8,0,0,1,8-8c13.23,0,24-9,24-20s-10.77-20-24-20-24,9-24,20v4a8,8,0,0,1-16,0v-4c0-19.85,17.94-36,40-36s40,16.15,40,36C168,125.38,154.24,139.93,136,143.28Z',
search:
'M168,112a56,56,0,1,1-56-56A56,56,0,0,1,168,112Zm61.66,117.66a8,8,0,0,1-11.32,0l-50.06-50.07a88,88,0,1,1,11.32-11.31l50.06,50.06A8,8,0,0,1,229.66,229.66ZM112,184a72,72,0,1,0-72-72A72.08,72.08,0,0,0,112,184Z',
terminal:
'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm-91,94.25-40,32a8,8,0,1,1-10-12.5L107.19,128,75,102.25a8,8,0,1,1,10-12.5l40,32a8,8,0,0,1,0,12.5ZM176,168H136a8,8,0,0,1,0-16h40a8,8,0,0,1,0,16Z',
tools:
'M232,96a72,72,0,0,1-100.94,66L79,222.22c-.12.14-.26.29-.39.42a32,32,0,0,1-45.26-45.26c.14-.13.28-.27.43-.39L94,124.94a72.07,72.07,0,0,1,83.54-98.78,8,8,0,0,1,3.93,13.19L144,80l5.66,26.35L176,112l40.65-37.52a8,8,0,0,1,13.19,3.93A72.6,72.6,0,0,1,232,96Z',
watch:
'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm56,112H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48a8,8,0,0,1,0,16Z'
}
export interface ToolIconProps {
className?: string
name: string
size?: number | string
}
/** Filled tool glyph. Falls back to the outline codicon font for any name not
* covered by the solid set so new tools still render an icon. */
export function ToolIcon({ className, name, size = '0.875rem' }: ToolIconProps) {
const path = TOOL_ICON_PATHS[name]
if (!path) {
return <Codicon className={className} name={name} size={size} />
}
const dimension: React.CSSProperties = { height: size, width: size }
return (
<svg
aria-hidden="true"
className={cn('shrink-0', className)}
fill="currentColor"
style={dimension}
viewBox="0 0 256 256"
>
<path d={path} />
</svg>
)
}

View File

@@ -18,10 +18,6 @@ declare global {
// reaper spares it while its chat is active.
touchBackend: (profile?: string | null) => Promise<{ ok: boolean }>
getGatewayWsUrl: (profile?: null | string) => Promise<string>
// Open (or focus) a standalone OS window for a single chat session so
// the user can work with multiple chats side by side. Returns ok:false
// with an error code when the sessionId is empty/invalid.
openSessionWindow: (sessionId: string) => Promise<{ ok: boolean; error?: string }>
getBootProgress: () => Promise<DesktopBootProgress>
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
@@ -55,9 +51,8 @@ declare global {
setPreviewShortcutActive?: (active: boolean) => void
openExternal: (url: string) => Promise<void>
fetchLinkTitle: (url: string) => Promise<string>
sanitizeWorkspaceCwd: (cwd?: null | string) => Promise<{ cwd: string; sanitized: boolean }>
settings: {
getDefaultProjectDir: () => Promise<{ defaultLabel: string; dir: null | string; resolvedCwd: string }>
getDefaultProjectDir: () => Promise<{ defaultLabel: string; dir: null | string }>
pickDefaultProjectDir: () => Promise<{ canceled: boolean; dir: null | string }>
setDefaultProjectDir: (dir: null | string) => Promise<{ dir: null | string }>
}
@@ -97,40 +92,10 @@ declare global {
summary: () => Promise<DesktopUninstallSummary>
run: (mode: DesktopUninstallMode) => Promise<DesktopUninstallResult>
}
themes: {
// Download a VS Code Marketplace extension and return the raw color
// theme files it contributes. The renderer converts + persists them.
fetchMarketplace: (id: string) => Promise<DesktopMarketplaceThemeResult>
// Search the Marketplace for color-theme extensions. An empty query
// returns the most-installed themes.
searchMarketplace: (query: string) => Promise<DesktopMarketplaceSearchItem[]>
}
}
}
}
export interface DesktopMarketplaceSearchItem {
extensionId: string
displayName: string
publisher: string
description: string
installs: number
}
export interface DesktopMarketplaceThemeFile {
label: string
/** VS Code's `uiTheme` for this entry (vs-dark / vs / hc-black). */
uiTheme?: string
/** Raw theme JSON (JSONC) text, parsed + converted by the renderer. */
contents: string
}
export interface DesktopMarketplaceThemeResult {
extensionId: string
displayName: string
themes: DesktopMarketplaceThemeFile[]
}
export interface HermesTerminalSession {
cwd: string
id: string

View File

@@ -179,15 +179,6 @@ export const en: Translations = {
'session.new': 'New session',
'session.next': 'Next session',
'session.prev': 'Previous session',
'session.slot.1': 'Switch to recent session 1',
'session.slot.2': 'Switch to recent session 2',
'session.slot.3': 'Switch to recent session 3',
'session.slot.4': 'Switch to recent session 4',
'session.slot.5': 'Switch to recent session 5',
'session.slot.6': 'Switch to recent session 6',
'session.slot.7': 'Switch to recent session 7',
'session.slot.8': 'Switch to recent session 8',
'session.slot.9': 'Switch to recent session 9',
'session.focusSearch': 'Search sessions',
'session.togglePin': 'Pin / unpin current session',
'composer.focus': 'Focus composer',
@@ -302,17 +293,7 @@ export const en: Translations = {
technicalDesc: 'Include raw tool args/results and low-level details.',
themeTitle: 'Theme',
themeDesc: 'Desktop palettes only. The selected mode is applied on top.',
themeProfileNote: profile => `Saved for the ${profile} profile — each profile keeps its own theme.`,
installTitle: 'Install from VS Code',
installDesc:
'Paste a Marketplace extension id (e.g. dracula-theme.theme-dracula) to convert its color theme into a desktop palette.',
installPlaceholder: 'publisher.extension',
installButton: 'Install',
installing: 'Installing…',
installError: 'Could not install that theme.',
installed: name => `Installed “${name}”.`,
removeTheme: 'Remove theme',
importedBadge: 'Imported'
themeProfileNote: profile => `Saved for the ${profile} profile — each profile keeps its own theme.`
},
fieldLabels: FIELD_LABELS,
fieldDescriptions: FIELD_DESCRIPTIONS,
@@ -529,7 +510,7 @@ export const en: Translations = {
defaultDirTitle: 'Default project directory',
defaultDirDesc:
'New sessions start in this folder unless you pick another. Leave it unset to use your home directory.',
defaultDirUpdated: 'Default project directory updated — start a new chat (Ctrl/⌘+N) for it to take effect',
defaultDirUpdated: 'Default project directory updated',
defaultsTo: label => `Defaults to ${label}.`,
change: 'Change',
choose: 'Choose',
@@ -646,17 +627,6 @@ export const en: Translations = {
settings: 'Settings',
changeTheme: 'Change theme...',
changeColorMode: 'Change color mode...',
installTheme: {
title: 'Install theme...',
placeholder: 'Search the VS Code Marketplace...',
loading: 'Searching the Marketplace...',
error: 'Could not reach the Marketplace.',
empty: 'No matching themes.',
install: 'Install',
installing: 'Installing...',
installed: 'Installed',
installs: count => `${count} installs`
},
settingsFields: 'Settings fields',
mcpServers: 'MCP servers',
archivedChats: 'Archived chats',
@@ -1105,14 +1075,12 @@ export const en: Translations = {
export: 'Export',
rename: 'Rename',
archive: 'Archive',
newWindow: 'New window',
copyIdFailed: 'Could not copy session ID',
actionsFor: title => `Actions for ${title}`,
sessionActions: 'Session actions',
sessionRunning: 'Session running',
needsInput: 'Needs your input',
waitingForAnswer: 'Waiting for your answer',
handoffOrigin: platform => `Handed off from ${platform}`,
renamed: 'Renamed',
renameFailed: 'Rename failed',
renameTitle: 'Rename session',
@@ -1151,7 +1119,7 @@ export const en: Translations = {
],
startVoice: 'Start voice conversation',
queueMessage: 'Queue message',
steer: 'Steer the current run',
steer: 'Steer the current run (⌘⏎)',
stop: 'Stop',
send: 'Send',
speaking: 'Speaking',
@@ -1492,8 +1460,6 @@ export const en: Translations = {
branch: branch => `branch ${branch}`,
closeCommandCenter: 'Close Command Center',
openCommandCenter: 'Open Command Center',
showTerminal: 'Show terminal',
hideTerminal: 'Hide terminal',
gateway: 'Gateway',
gatewayReady: 'ready',
gatewayNeedsSetup: 'needs setup',
@@ -1549,7 +1515,8 @@ export const en: Translations = {
tryAgain: 'Try again',
loadingTree: 'Loading file tree',
loadingFiles: 'Loading files',
terminalHide: 'Hide terminal',
terminalFocus: 'Focus terminal view',
terminalSplit: 'Return to split view',
addToChat: 'Add to chat'
},
@@ -1654,8 +1621,7 @@ export const en: Translations = {
restoreCheckpoint: 'Restore checkpoint',
restoreNext: 'Restore next checkpoint',
goForward: 'Go forward',
sendEdited: 'Send edited message',
attachingFile: 'Attaching…'
sendEdited: 'Send edited message'
},
approval: {
gatewayDisconnected: 'Hermes gateway is not connected',

View File

@@ -216,16 +216,7 @@ export const ja = defineLocale({
technicalDesc: '生のツール引数、結果、低レベルの詳細を含めます。',
themeTitle: 'テーマ',
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。',
themeProfileNote: profile => `${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`,
installTitle: 'VS Code から導入',
installDesc: 'Marketplace の拡張機能 ID例: dracula-theme.theme-draculaを貼り付けると、その配色テーマをデスクトップ用パレットに変換します。',
installPlaceholder: 'publisher.extension',
installButton: 'インストール',
installing: 'インストール中…',
installError: 'そのテーマをインストールできませんでした。',
installed: name => `${name}」をインストールしました。`,
removeTheme: 'テーマを削除',
importedBadge: 'インポート済み'
themeProfileNote: profile => `${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`
},
fieldLabels: defineFieldCopy({
model: 'デフォルトモデル',
@@ -771,17 +762,6 @@ export const ja = defineLocale({
settings: '設定',
changeTheme: 'テーマを変更...',
changeColorMode: 'カラーモードを変更...',
installTheme: {
title: 'テーマをインストール...',
placeholder: 'VS Code Marketplace を検索...',
loading: 'Marketplace を検索中...',
error: 'Marketplace に接続できませんでした。',
empty: '一致するテーマがありません。',
install: 'インストール',
installing: 'インストール中...',
installed: 'インストール済み',
installs: count => `${count} 回インストール`
},
settingsFields: '設定フィールド',
mcpServers: 'MCP サーバー',
archivedChats: 'アーカイブ済みチャット',
@@ -1238,14 +1218,12 @@ export const ja = defineLocale({
export: 'エクスポート',
rename: '名前を変更',
archive: 'アーカイブ',
newWindow: '新しいウィンドウ',
copyIdFailed: 'セッション ID をコピーできませんでした',
actionsFor: title => `${title} のアクション`,
sessionActions: 'セッションアクション',
sessionRunning: 'セッション実行中',
needsInput: '入力が必要です',
waitingForAnswer: '回答を待っています',
handoffOrigin: platform => `${platform} から引き継ぎ`,
renamed: '名前を変更しました',
renameFailed: '名前の変更に失敗しました',
renameTitle: 'セッションの名前を変更',
@@ -1625,8 +1603,6 @@ export const ja = defineLocale({
branch: branch => `ブランチ ${branch}`,
closeCommandCenter: 'コマンドセンターを閉じる',
openCommandCenter: 'コマンドセンターを開く',
showTerminal: 'ターミナルを表示',
hideTerminal: 'ターミナルを非表示',
gateway: 'ゲートウェイ',
gatewayReady: '準備完了',
gatewayNeedsSetup: '設定が必要',
@@ -1682,7 +1658,8 @@ export const ja = defineLocale({
tryAgain: '再試行',
loadingTree: 'ファイルツリーを読み込み中',
loadingFiles: 'ファイルを読み込み中',
terminalHide: 'ターミナルを非表示',
terminalFocus: 'ターミナルビューにフォーカス',
terminalSplit: '分割ビューに戻る',
addToChat: 'チャットに追加'
},
@@ -1788,8 +1765,7 @@ export const ja = defineLocale({
restoreCheckpoint: 'チェックポイントを復元',
restoreNext: '次のチェックポイントに戻す',
goForward: '進む',
sendEdited: '編集済みメッセージを送信',
attachingFile: '添付中…'
sendEdited: '編集済みメッセージを送信'
},
approval: {
gatewayDisconnected: 'Hermes ゲートウェイが接続されていません',

View File

@@ -220,15 +220,6 @@ export interface Translations {
themeTitle: string
themeDesc: string
themeProfileNote: (profile: string) => string
installTitle: string
installDesc: string
installPlaceholder: string
installButton: string
installing: string
installError: string
installed: (name: string) => string
removeTheme: string
importedBadge: string
}
fieldLabels: Record<string, string>
fieldDescriptions: Record<string, string>
@@ -543,17 +534,6 @@ export interface Translations {
settings: string
changeTheme: string
changeColorMode: string
installTheme: {
title: string
placeholder: string
loading: string
error: string
empty: string
install: string
installing: string
installed: string
installs: (count: string) => string
}
settingsFields: string
mcpServers: string
archivedChats: string
@@ -852,14 +832,12 @@ export interface Translations {
export: string
rename: string
archive: string
newWindow: string
copyIdFailed: string
actionsFor: (title: string) => string
sessionActions: string
sessionRunning: string
needsInput: string
waitingForAnswer: string
handoffOrigin: (platform: string) => string
renamed: string
renameFailed: string
renameTitle: string
@@ -1154,8 +1132,6 @@ export interface Translations {
branch: (branch: string) => string
closeCommandCenter: string
openCommandCenter: string
showTerminal: string
hideTerminal: string
gateway: string
gatewayReady: string
gatewayNeedsSetup: string
@@ -1211,7 +1187,8 @@ export interface Translations {
tryAgain: string
loadingTree: string
loadingFiles: string
terminalHide: string
terminalFocus: string
terminalSplit: string
addToChat: string
}
@@ -1315,7 +1292,6 @@ export interface Translations {
restoreNext: string
goForward: string
sendEdited: string
attachingFile: string
}
approval: {
gatewayDisconnected: string

View File

@@ -210,16 +210,7 @@ export const zhHant = defineLocale({
technicalDesc: '包含原始工具參數、結果與底層細節。',
themeTitle: '主題',
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。',
themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。`,
installTitle: '從 VS Code 安裝',
installDesc: '貼上 Marketplace 擴充功能 ID例如 dracula-theme.theme-dracula將其配色主題轉換為桌面調色盤。',
installPlaceholder: 'publisher.extension',
installButton: '安裝',
installing: '安裝中…',
installError: '無法安裝該主題。',
installed: name => `已安裝「${name}」。`,
removeTheme: '移除主題',
importedBadge: '已匯入'
themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。`
},
fieldLabels: defineFieldCopy({
model: '預設模型',
@@ -754,17 +745,6 @@ export const zhHant = defineLocale({
settings: '設定',
changeTheme: '變更主題...',
changeColorMode: '變更色彩模式...',
installTheme: {
title: '安裝主題...',
placeholder: '搜尋 VS Code Marketplace...',
loading: '正在搜尋 Marketplace...',
error: '無法連接到 Marketplace。',
empty: '沒有符合的主題。',
install: '安裝',
installing: '安裝中...',
installed: '已安裝',
installs: count => `${count} 次安裝`
},
settingsFields: '設定欄位',
mcpServers: 'MCP 伺服器',
archivedChats: '已封存聊天',
@@ -1204,14 +1184,12 @@ export const zhHant = defineLocale({
export: '匯出',
rename: '重新命名',
archive: '封存',
newWindow: '新視窗',
copyIdFailed: '無法複製工作階段 ID',
actionsFor: title => `${title} 的動作`,
sessionActions: '工作階段動作',
sessionRunning: '工作階段執行中',
needsInput: '需要您的輸入',
waitingForAnswer: '等待您的回答',
handoffOrigin: platform => `${platform} 轉接`,
renamed: '已重新命名',
renameFailed: '重新命名失敗',
renameTitle: '重新命名工作階段',
@@ -1586,8 +1564,6 @@ export const zhHant = defineLocale({
branch: branch => `分支 ${branch}`,
closeCommandCenter: '關閉命令中心',
openCommandCenter: '開啟命令中心',
showTerminal: '顯示終端機',
hideTerminal: '隱藏終端機',
gateway: '閘道',
gatewayReady: '就緒',
gatewayNeedsSetup: '需要設定',
@@ -1643,7 +1619,8 @@ export const zhHant = defineLocale({
tryAgain: '重試',
loadingTree: '正在載入檔案樹',
loadingFiles: '正在載入檔案',
terminalHide: '隱藏終端機',
terminalFocus: '聚焦終端機檢視',
terminalSplit: '返回分割檢視',
addToChat: '新增至聊天'
},
@@ -1749,8 +1726,7 @@ export const zhHant = defineLocale({
restoreCheckpoint: '還原檢查點',
restoreNext: '還原至下一個檢查點',
goForward: '前進',
sendEdited: '傳送編輯後的訊息',
attachingFile: '正在附加…'
sendEdited: '傳送編輯後的訊息'
},
approval: {
gatewayDisconnected: 'Hermes 閘道未連線',

View File

@@ -175,15 +175,6 @@ export const zh: Translations = {
'session.new': '新建会话',
'session.next': '下一个会话',
'session.prev': '上一个会话',
'session.slot.1': '切换到最近会话 1',
'session.slot.2': '切换到最近会话 2',
'session.slot.3': '切换到最近会话 3',
'session.slot.4': '切换到最近会话 4',
'session.slot.5': '切换到最近会话 5',
'session.slot.6': '切换到最近会话 6',
'session.slot.7': '切换到最近会话 7',
'session.slot.8': '切换到最近会话 8',
'session.slot.9': '切换到最近会话 9',
'session.focusSearch': '搜索会话',
'session.togglePin': '固定/取消固定当前会话',
'composer.focus': '聚焦输入框',
@@ -297,16 +288,7 @@ export const zh: Translations = {
technicalDesc: '包含原始工具参数/结果及底层细节。',
themeTitle: '主题',
themeDesc: '仅桌面端调色板。所选模式叠加其上。',
themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。`,
installTitle: '从 VS Code 安装',
installDesc: '粘贴 Marketplace 扩展 ID例如 dracula-theme.theme-dracula将其配色主题转换为桌面调色板。',
installPlaceholder: 'publisher.extension',
installButton: '安装',
installing: '安装中…',
installError: '无法安装该主题。',
installed: name => `已安装「${name}」。`,
removeTheme: '移除主题',
importedBadge: '已导入'
themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。`
},
fieldLabels: defineFieldCopy({
model: '默认模型',
@@ -838,17 +820,6 @@ export const zh: Translations = {
settings: '设置',
changeTheme: '更改主题...',
changeColorMode: '更改颜色模式...',
installTheme: {
title: '安装主题...',
placeholder: '搜索 VS Code Marketplace...',
loading: '正在搜索 Marketplace...',
error: '无法连接到 Marketplace。',
empty: '没有匹配的主题。',
install: '安装',
installing: '安装中...',
installed: '已安装',
installs: count => `${count} 次安装`
},
settingsFields: '设置字段',
mcpServers: 'MCP 服务器',
archivedChats: '已归档对话',
@@ -1291,14 +1262,12 @@ export const zh: Translations = {
export: '导出',
rename: '重命名',
archive: '归档',
newWindow: '新窗口',
copyIdFailed: '无法复制会话 ID',
actionsFor: title => `${title} 的操作`,
sessionActions: '会话操作',
sessionRunning: '会话运行中',
needsInput: '需要你输入',
waitingForAnswer: '正在等待你的回答',
handoffOrigin: platform => `${platform} 转接`,
renamed: '已重命名',
renameFailed: '重命名失败',
renameTitle: '重命名会话',
@@ -1337,7 +1306,7 @@ export const zh: Translations = {
],
startVoice: '开始语音对话',
queueMessage: '排队消息',
steer: '引导当前运行',
steer: '引导当前运行 (⌘⏎)',
stop: '停止',
send: '发送',
speaking: '讲话中',
@@ -1672,8 +1641,6 @@ export const zh: Translations = {
branch: branch => `分支 ${branch}`,
closeCommandCenter: '关闭命令中心',
openCommandCenter: '打开命令中心',
showTerminal: '显示终端',
hideTerminal: '隐藏终端',
gateway: '网关',
gatewayReady: '就绪',
gatewayNeedsSetup: '需要设置',
@@ -1729,7 +1696,8 @@ export const zh: Translations = {
tryAgain: '重试',
loadingTree: '正在加载文件树',
loadingFiles: '正在加载文件',
terminalHide: '隐藏终端',
terminalFocus: '聚焦终端视图',
terminalSplit: '返回分栏视图',
addToChat: '添加到对话'
},
@@ -1833,8 +1801,7 @@ export const zh: Translations = {
restoreCheckpoint: '恢复检查点',
restoreNext: '恢复下一个检查点',
goForward: '前进',
sendEdited: '发送编辑后的消息',
attachingFile: '正在附加…'
sendEdited: '发送编辑后的消息'
},
approval: {
gatewayDisconnected: 'Hermes 网关未连接',

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