Compare commits
1 Commits
hermes/her
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc60cbfeb5 |
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 428 KiB |
2
.github/workflows/deploy-site.yml
vendored
@@ -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
|
||||
|
||||
|
||||
2
.github/workflows/docs-site-checks.yml
vendored
@@ -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
|
||||
|
||||
|
||||
48
.github/workflows/tests.yml
vendored
@@ -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: |
|
||||
|
||||
25
.github/workflows/typecheck.yml
vendored
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
|
||||
29
Dockerfile
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
@@ -1571,15 +1511,6 @@ def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if ptype == "input_text":
|
||||
block: Dict[str, Any] = {"type": "text", "text": part.get("text", "")}
|
||||
elif ptype == "text":
|
||||
# A stored Anthropic text block. Rebuild from whitelisted fields only —
|
||||
# SDK response text blocks carry output-only siblings (parsed_output,
|
||||
# citations=None) that the Messages INPUT schema rejects with HTTP 400
|
||||
# "Extra inputs are not permitted". Do NOT dict(part) it verbatim.
|
||||
block = {"type": "text", "text": part.get("text", "")}
|
||||
cits = part.get("citations")
|
||||
if isinstance(cits, list) and cits:
|
||||
block["citations"] = cits
|
||||
elif ptype in {"image_url", "input_image"}:
|
||||
image_value = part.get("image_url", {})
|
||||
url = image_value.get("url", "") if isinstance(image_value, dict) else str(image_value or "")
|
||||
@@ -1694,58 +1625,6 @@ def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Strip output-only fields from a stored Anthropic content block so it is
|
||||
valid as REQUEST input on replay.
|
||||
|
||||
The SDK response objects carry output-only attributes that the Messages
|
||||
*input* schema forbids ("Extra inputs are not permitted"): text blocks get
|
||||
``parsed_output``/``citations`` (when null), tool_use blocks get ``caller``,
|
||||
etc. ``normalize_response`` captured blocks verbatim via ``_to_plain_data``,
|
||||
so these leak back as input on the next turn → HTTP 400.
|
||||
|
||||
Whitelist per type (NOT a blacklist) so future SDK output-only fields can't
|
||||
reintroduce the bug. Returns a clean block, or None to drop it.
|
||||
"""
|
||||
if not isinstance(b, dict):
|
||||
return None
|
||||
btype = b.get("type")
|
||||
if btype == "text":
|
||||
out: Dict[str, Any] = {"type": "text", "text": b.get("text", "")}
|
||||
# citations is input-valid ONLY when it's a non-empty list; the SDK
|
||||
# emits citations=None on responses, which the input schema rejects.
|
||||
cits = b.get("citations")
|
||||
if isinstance(cits, list) and cits:
|
||||
out["citations"] = cits
|
||||
if isinstance(b.get("cache_control"), dict):
|
||||
out["cache_control"] = b["cache_control"]
|
||||
return out
|
||||
if btype == "thinking":
|
||||
out = {"type": "thinking", "thinking": b.get("thinking", "")}
|
||||
if b.get("signature"):
|
||||
out["signature"] = b["signature"]
|
||||
return out
|
||||
if btype == "redacted_thinking":
|
||||
# Only valid with its data payload; drop if missing.
|
||||
return {"type": "redacted_thinking", "data": b["data"]} if b.get("data") else None
|
||||
if btype == "tool_use":
|
||||
out = {
|
||||
"type": "tool_use",
|
||||
"id": _sanitize_tool_id(b.get("id", "")),
|
||||
"name": b.get("name", ""),
|
||||
"input": b.get("input", {}),
|
||||
}
|
||||
if isinstance(b.get("cache_control"), dict):
|
||||
out["cache_control"] = b["cache_control"]
|
||||
return out
|
||||
if btype == "image":
|
||||
src = b.get("source")
|
||||
return {"type": "image", "source": src} if isinstance(src, dict) else None
|
||||
# Unknown/unsupported block type on the input path — drop rather than risk
|
||||
# another "Extra inputs are not permitted".
|
||||
return None
|
||||
|
||||
|
||||
def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Convert an assistant message to Anthropic content blocks.
|
||||
|
||||
@@ -1753,55 +1632,6 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
|
||||
reasoning_content injection for Kimi/DeepSeek endpoints.
|
||||
"""
|
||||
content = m.get("content", "")
|
||||
# Anthropic interleaved-thinking fast path: when this turn carries a
|
||||
# verbatim, order-preserving block list (set by normalize_response only
|
||||
# for turns that interleave SIGNED thinking with tool_use), replay it.
|
||||
# Each block is run through _sanitize_replay_block to strip output-only
|
||||
# SDK fields (parsed_output, caller, citations=None, …) that the Messages
|
||||
# INPUT schema forbids — replaying them verbatim caused HTTP 400 "Extra
|
||||
# inputs are not permitted" (text.parsed_output). Block ORDER is preserved
|
||||
# (the reason this channel exists); only forbidden sibling fields are
|
||||
# dropped, leaving thinking signatures and tool_use id/name/input intact.
|
||||
ordered_blocks = m.get("anthropic_content_blocks")
|
||||
if isinstance(ordered_blocks, list) and ordered_blocks:
|
||||
# Re-source each tool_use input from the stored tool_calls map rather
|
||||
# than the captured block. The ordered-blocks list captures tool_use
|
||||
# input from the RAW API response (normalize_response), which is NOT
|
||||
# credential-redacted; tool_calls[].function.arguments IS redacted at
|
||||
# storage time (build_assistant_message, #19798). Replaying the raw
|
||||
# block input would resurrect a secret the model inlined into a tool
|
||||
# call (e.g. terminal(command="curl -H 'Authorization: Bearer sk-...'")
|
||||
# onto the wire, even though the same value is redacted everywhere else
|
||||
# in history. Keying by sanitized tool id preserves interleave order
|
||||
# (the reason this channel exists) while swapping in the redacted
|
||||
# input. Adapted from #36071 (replay-time tool-input re-sourcing).
|
||||
redacted_input_by_id: Dict[str, Any] = {}
|
||||
for tc in m.get("tool_calls", []) or []:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
fn = tc.get("function", {}) or {}
|
||||
raw_args = fn.get("arguments", "{}")
|
||||
try:
|
||||
parsed_args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
parsed_args = {}
|
||||
redacted_input_by_id[_sanitize_tool_id(tc.get("id", ""))] = parsed_args
|
||||
replayed: List[Dict[str, Any]] = []
|
||||
for b in ordered_blocks:
|
||||
clean = _sanitize_replay_block(b)
|
||||
if clean is None:
|
||||
continue
|
||||
if clean.get("type") == "tool_use":
|
||||
# Override raw (un-redacted) input with the redacted copy when
|
||||
# we have one for this id; fall back to the sanitized block
|
||||
# input only if the tool_call is missing (shape mismatch).
|
||||
redacted = redacted_input_by_id.get(clean.get("id", ""))
|
||||
if redacted is not None:
|
||||
clean["input"] = redacted
|
||||
replayed.append(clean)
|
||||
if replayed:
|
||||
return {"role": "assistant", "content": replayed}
|
||||
|
||||
blocks = _extract_preserved_thinking_blocks(m)
|
||||
if content:
|
||||
if isinstance(content, list):
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -952,18 +952,6 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
|
||||
if preserved:
|
||||
msg["reasoning_details"] = preserved
|
||||
|
||||
# Anthropic interleaved-thinking replay: when a turn interleaves signed
|
||||
# thinking blocks with tool_use, the parallel reasoning_details +
|
||||
# tool_calls fields lose the cross-type ordering, and reconstruction
|
||||
# front-loads thinking — reordering signed blocks and triggering HTTP 400
|
||||
# ("thinking ... blocks in the latest assistant message cannot be
|
||||
# modified"). Carry the verbatim ordered block list so the adapter can
|
||||
# replay the latest assistant message unchanged. See
|
||||
# agent/transports/anthropic.py and agent/anthropic_adapter.py.
|
||||
ordered_blocks = getattr(assistant_message, "anthropic_content_blocks", None)
|
||||
if ordered_blocks:
|
||||
msg["anthropic_content_blocks"] = ordered_blocks
|
||||
|
||||
# Codex Responses API: preserve encrypted reasoning items for
|
||||
# multi-turn continuity. These get replayed as input on the next turn.
|
||||
codex_items = getattr(assistant_message, "codex_reasoning_items", None)
|
||||
@@ -1710,14 +1698,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
# poll loop uses this to detect stale connections that keep receiving
|
||||
# SSE keep-alive pings but no actual data.
|
||||
last_chunk_time = {"t": time.time()}
|
||||
# Stale-stream patience, shared between the httpx socket read timeout
|
||||
# (built in ``_call_chat_completions`` below) and the stale-stream detector
|
||||
# (computed further down, before the worker thread starts). Initialized
|
||||
# here so the read-timeout builder can floor itself at the stale value and
|
||||
# never fire before the detector. ``None`` until the detector value is
|
||||
# resolved, so the builder degrades to its plain default if it ever runs
|
||||
# first.
|
||||
_stream_stale_timeout = None
|
||||
|
||||
def _fire_first_delta():
|
||||
if not first_delta_fired["done"] and on_first_delta:
|
||||
@@ -1754,26 +1734,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
"Local provider detected (%s) — stream read timeout raised to %.0fs",
|
||||
agent.base_url, _stream_read_timeout,
|
||||
)
|
||||
elif (
|
||||
_stream_read_timeout == 120.0
|
||||
and _stream_stale_timeout is not None
|
||||
and _stream_stale_timeout != float("inf")
|
||||
and _stream_stale_timeout > _stream_read_timeout
|
||||
):
|
||||
# Cloud reasoning models (e.g. Opus) routinely pause mid-stream
|
||||
# for minutes during extended thinking. The stale-stream
|
||||
# detector is deliberately scaled up to tolerate this (180–300s,
|
||||
# see the stale-timeout block below), but the raw httpx socket
|
||||
# read timeout defaulted to a flat 120s and fired *first* —
|
||||
# tearing down a healthy reasoning stream before the stale
|
||||
# detector (which owns retry + diagnostics) could act. Keep the
|
||||
# socket read timeout in step with the detector so it no longer
|
||||
# preempts it.
|
||||
_stream_read_timeout = _stream_stale_timeout
|
||||
logger.debug(
|
||||
"Cloud reasoning stream — read timeout raised to %.0fs to "
|
||||
"match stale-stream detector", _stream_read_timeout,
|
||||
)
|
||||
# Cap connect/pool at 60s even when provider timeout is higher.
|
||||
# connect/pool cover TCP handshake, not model inference.
|
||||
_conn_cap = min(_base_timeout, 60.0) if _provider_timeout_cfg is not None else 30.0
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -2221,54 +2221,30 @@ def run_conversation(
|
||||
print(f"{agent.log_prefix} • Legacy cleanup: hermes config set ANTHROPIC_TOKEN \"\"")
|
||||
print(f"{agent.log_prefix} • Clear stale keys: hermes config set ANTHROPIC_API_KEY \"\"")
|
||||
|
||||
# Thinking block signature recovery.
|
||||
#
|
||||
# ── Thinking block signature recovery ─────────────────
|
||||
# Anthropic signs thinking blocks against the full turn
|
||||
# content. Any upstream mutation (context compression,
|
||||
# content. Any upstream mutation (context compression,
|
||||
# session truncation, message merging) invalidates the
|
||||
# signature and the API replies HTTP 400 ("invalid
|
||||
# signature" or "cannot be modified"). Recovery strips
|
||||
# ``reasoning_details`` so the retry sends no thinking
|
||||
# blocks at all. One-shot per outer loop.
|
||||
#
|
||||
# The strip targets ``api_messages``, which is the
|
||||
# API-call-time list that ``_build_api_kwargs`` consumes
|
||||
# on every retry. ``api_messages`` was populated once at
|
||||
# the start of the turn from shallow copies of
|
||||
# ``messages``, so mutating it does not touch the
|
||||
# canonical store. The previous implementation popped
|
||||
# ``reasoning_details`` from ``messages`` instead, which
|
||||
# had two problems: ``api_messages`` carried its own
|
||||
# reference to the field through the shallow copy, so the
|
||||
# retry's wire payload still included thinking blocks and
|
||||
# the recovery never reached the API; and the mutation
|
||||
# persisted into ``state.db`` through any subsequent
|
||||
# ``_persist_session`` call, permanently corrupting the
|
||||
# conversation. Future turns would replay the stripped
|
||||
# state, hit the same 400, and the agent would terminate
|
||||
# with ``max_retries_exhausted``, often spawning
|
||||
# cascading compaction-ended sessions chained off the
|
||||
# corrupted parent.
|
||||
# signature → HTTP 400. Recovery: strip reasoning_details
|
||||
# from all messages so the next retry sends no thinking
|
||||
# blocks at all. One-shot — don't retry infinitely.
|
||||
if (
|
||||
classified.reason == FailoverReason.thinking_signature
|
||||
and not _retry.thinking_sig_retry_attempted
|
||||
):
|
||||
_retry.thinking_sig_retry_attempted = True
|
||||
_api_stripped = 0
|
||||
for _m in api_messages:
|
||||
if isinstance(_m, dict) and "reasoning_details" in _m:
|
||||
for _m in messages:
|
||||
if isinstance(_m, dict):
|
||||
_m.pop("reasoning_details", None)
|
||||
_api_stripped += 1
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}⚠️ Thinking block signature invalid, "
|
||||
f"stripped reasoning_details from api_messages for retry...",
|
||||
f"{agent.log_prefix}⚠️ Thinking block signature invalid — "
|
||||
f"stripped all thinking blocks, retrying...",
|
||||
force=True,
|
||||
)
|
||||
logger.warning(
|
||||
"%sThinking block signature recovery: stripped "
|
||||
"reasoning_details from %d api_messages "
|
||||
"(canonical messages unchanged)",
|
||||
agent.log_prefix, _api_stripped,
|
||||
"reasoning_details from %d messages",
|
||||
agent.log_prefix, len(messages),
|
||||
)
|
||||
continue
|
||||
|
||||
|
||||
@@ -194,71 +194,17 @@ class AgentNotice:
|
||||
id: Optional[str] = None
|
||||
|
||||
|
||||
# ── is_free_tier_model (local-data-only free-model check) ────────────────────
|
||||
|
||||
|
||||
def is_free_tier_model(model: str, base_url: str = "") -> bool:
|
||||
"""Return True when *model* is a Nous free-tier model, using ONLY local data.
|
||||
|
||||
Two signals, both zero-network:
|
||||
|
||||
1. The ``:free`` suffix — the canonical Nous free SKU marker (e.g.
|
||||
``nvidia/nemotron-3-ultra:free``). Free by construction on the API side
|
||||
(spend is forced to 0 for ``:free`` ids).
|
||||
2. A peek into the in-process pricing cache in ``hermes_cli.models``
|
||||
(populated when the model picker fetched ``/v1/models`` pricing for
|
||||
*base_url*). PEEK ONLY — a cache miss never triggers a fetch. This is
|
||||
CLI/TUI-session best-effort: gateway sessions never run the picker's
|
||||
pricing fetch, so suppression there rests entirely on the ``:free``
|
||||
suffix (which all Nous free SKUs carry).
|
||||
|
||||
Fail-open to False (the depleted notice still shows) on any error: wrongly
|
||||
showing the warning is recoverable noise; wrongly hiding it on a paid model
|
||||
would mask a real billing block.
|
||||
"""
|
||||
if not model:
|
||||
return False
|
||||
if model.endswith(":free"):
|
||||
return True
|
||||
if not base_url:
|
||||
return False
|
||||
try:
|
||||
from hermes_cli.models import _is_model_free, _pricing_cache
|
||||
|
||||
# Mirror get_pricing_for_provider's key normalization: the agent's
|
||||
# Nous base_url is /v1-suffixed (https://inference-api.nousresearch.com/v1)
|
||||
# but the picker keys _pricing_cache on the pre-/v1 root.
|
||||
key = base_url.rstrip("/")
|
||||
if key.endswith("/v1"):
|
||||
key = key[:-3].rstrip("/")
|
||||
pricing = _pricing_cache.get(key)
|
||||
if not pricing:
|
||||
return False
|
||||
return _is_model_free(model, pricing)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ── evaluate_credits_notices (pure reconciliation function) ──────────────────
|
||||
|
||||
|
||||
def evaluate_credits_notices(
|
||||
state: CreditsState,
|
||||
latch: dict,
|
||||
*,
|
||||
model_is_free: bool = False,
|
||||
) -> tuple[list[AgentNotice], list[str]]:
|
||||
"""Reconcile credits notices against the latch. Mutates ``latch`` IN PLACE.
|
||||
|
||||
latch = {"active": set[str], "seen_below_90": bool, "usage_band": Optional[int]}.
|
||||
|
||||
``model_is_free``: True when the session's active model is a Nous free-tier
|
||||
model (see :func:`is_free_tier_model`). Suppresses the ``credits.depleted``
|
||||
notice — a depleted account on a free model can keep inferencing, so the
|
||||
error banner is noise (and confuses free-tier users who never had credits).
|
||||
Suppression does NOT emit the "restored" success notice; that fires only on
|
||||
a genuine ``paid_access`` flip back to True.
|
||||
|
||||
Returns ``(to_show: list[AgentNotice], to_clear: list[str])``.
|
||||
Caller emits to_clear FIRST, then to_show.
|
||||
|
||||
@@ -338,11 +284,7 @@ def evaluate_credits_notices(
|
||||
active.discard("credits.grant_spent")
|
||||
|
||||
# ── depleted ─────────────────────────────────────────────────────────────
|
||||
# Suppressed while the active model is free: inference still works there,
|
||||
# so the error banner would just alarm users (free-tier users especially,
|
||||
# who never had paid credits to "lose").
|
||||
show_depleted = depleted_cond and not model_is_free
|
||||
if show_depleted and "credits.depleted" not in active:
|
||||
if depleted_cond and "credits.depleted" not in active:
|
||||
to_show.append(
|
||||
AgentNotice(
|
||||
text="✕ Credit access paused · run /usage for balance",
|
||||
@@ -353,23 +295,20 @@ def evaluate_credits_notices(
|
||||
)
|
||||
)
|
||||
active.add("credits.depleted")
|
||||
elif "credits.depleted" in active and not show_depleted:
|
||||
elif "credits.depleted" in active and not depleted_cond:
|
||||
to_clear.append("credits.depleted")
|
||||
active.discard("credits.depleted")
|
||||
if not depleted_cond:
|
||||
# Genuine recovery (paid_access flipped back True): also emit the
|
||||
# success notice. A clear caused by switching to a free model while
|
||||
# still depleted must NOT claim access was restored.
|
||||
to_show.append(
|
||||
AgentNotice(
|
||||
text="✓ Credit access restored",
|
||||
level="success",
|
||||
kind="ttl",
|
||||
ttl_ms=CREDITS_RESTORED_TTL_MS,
|
||||
key="credits.restored",
|
||||
id="credits.restored",
|
||||
)
|
||||
# Recovery: also emit the success notice
|
||||
to_show.append(
|
||||
AgentNotice(
|
||||
text="✓ Credit access restored",
|
||||
level="success",
|
||||
kind="ttl",
|
||||
ttl_ms=CREDITS_RESTORED_TTL_MS,
|
||||
key="credits.restored",
|
||||
id="credits.restored",
|
||||
)
|
||||
)
|
||||
|
||||
return (to_show, to_clear)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -858,20 +858,6 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]
|
||||
return False, ""
|
||||
|
||||
|
||||
def _used_free_parallel(result: str | None) -> bool:
|
||||
"""True when a web result came from Parallel's free Search MCP.
|
||||
|
||||
Only the keyless Parallel path tags its result with ``provider="parallel"``;
|
||||
the paid REST path and every other provider omit it. Used to label the tool
|
||||
line "Parallel search" / "Parallel fetch" exactly when the free MCP served
|
||||
the call.
|
||||
"""
|
||||
if not isinstance(result, str) or '"provider"' not in result:
|
||||
return False
|
||||
data = safe_json_loads(result)
|
||||
return isinstance(data, dict) and str(data.get("provider", "")).lower() == "parallel"
|
||||
|
||||
|
||||
def get_cute_tool_message(
|
||||
tool_name: str, args: dict, duration: float, result: str | None = None,
|
||||
) -> str:
|
||||
@@ -909,17 +895,15 @@ def get_cute_tool_message(
|
||||
return f"{line}{failure_suffix}"
|
||||
|
||||
if tool_name == "web_search":
|
||||
verb = "Parallel search" if _used_free_parallel(result) else "search"
|
||||
return _wrap(f"┊ 🔍 {verb:<9} {_trunc(args.get('query', ''), 42)} {dur}")
|
||||
return _wrap(f"┊ 🔍 search {_trunc(args.get('query', ''), 42)} {dur}")
|
||||
if tool_name == "web_extract":
|
||||
verb = "Parallel fetch" if _used_free_parallel(result) else "fetch"
|
||||
urls = args.get("urls", [])
|
||||
if urls:
|
||||
url = urls[0] if isinstance(urls, list) else str(urls)
|
||||
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
|
||||
extra = f" +{len(urls)-1}" if len(urls) > 1 else ""
|
||||
return _wrap(f"┊ 📄 {verb:<9} {_trunc(domain, 35)}{extra} {dur}")
|
||||
return _wrap(f"┊ 📄 {verb:<9} pages {dur}")
|
||||
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
|
||||
return _wrap(f"┊ 📄 fetch pages {dur}")
|
||||
if tool_name == "terminal":
|
||||
return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}")
|
||||
if tool_name == "process":
|
||||
|
||||
@@ -549,32 +549,14 @@ def classify_api_error(
|
||||
should_fallback=True,
|
||||
)
|
||||
|
||||
# Anthropic thinking block recovery (400). Two distinct failure modes,
|
||||
# same recovery (strip all reasoning_details and retry without thinking
|
||||
# blocks — see the thinking_signature handler in conversation_loop.py):
|
||||
# 1. Signature mismatch: a thinking block is signed against the full
|
||||
# turn content; any upstream mutation (context compression, session
|
||||
# truncation, message merging) invalidates the signature.
|
||||
# Pattern: "signature" + "thinking".
|
||||
# 2. Frozen-block mutation: Anthropic rejects any change to the
|
||||
# thinking/redacted_thinking blocks in the *latest* assistant
|
||||
# message — "`thinking` or `redacted_thinking` blocks in the latest
|
||||
# assistant message cannot be modified. These blocks must remain as
|
||||
# they were in the original response." This carries no "signature"
|
||||
# token, so the original pattern missed it and the turn hard-aborted
|
||||
# as a non-retryable client error instead of self-healing.
|
||||
# Pattern: "thinking" + ("cannot be modified" | "must remain as they were").
|
||||
# Anthropic thinking block signature invalid (400).
|
||||
# Don't gate on provider — OpenRouter proxies Anthropic errors, so the
|
||||
# provider may be "openrouter" even though the error is Anthropic-specific.
|
||||
# The combined patterns are unique enough.
|
||||
# The message pattern ("signature" + "thinking") is unique enough.
|
||||
if (
|
||||
status_code == 400
|
||||
and "signature" in error_msg
|
||||
and "thinking" in error_msg
|
||||
and (
|
||||
"signature" in error_msg
|
||||
or "cannot be modified" in error_msg
|
||||
or "must remain as they were" in error_msg
|
||||
)
|
||||
):
|
||||
return _result(
|
||||
FailoverReason.thinking_signature,
|
||||
@@ -984,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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}]"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -84,7 +84,7 @@ class AnthropicTransport(ProviderTransport):
|
||||
to OpenAI finish_reason, and collects reasoning_details in provider_data.
|
||||
"""
|
||||
import json
|
||||
from agent.anthropic_adapter import _to_plain_data, _sanitize_replay_block
|
||||
from agent.anthropic_adapter import _to_plain_data
|
||||
from agent.transports.types import ToolCall
|
||||
|
||||
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
|
||||
@@ -94,40 +94,14 @@ class AnthropicTransport(ProviderTransport):
|
||||
reasoning_parts = []
|
||||
reasoning_details = []
|
||||
tool_calls = []
|
||||
# Verbatim, order-preserving copy of every content block in the turn.
|
||||
# Anthropic signs each thinking block against the turn content that
|
||||
# PRECEDES it at its position; when a turn interleaves thinking and
|
||||
# tool_use (adaptive/interleaved thinking, Claude 4.6+), the parallel
|
||||
# reasoning_details + tool_calls lists below lose that cross-type
|
||||
# ordering. Replaying the latest assistant message in the wrong order
|
||||
# invalidates the signatures -> HTTP 400 "thinking ... blocks in the
|
||||
# latest assistant message cannot be modified". Preserve the exact
|
||||
# block sequence here so the adapter can replay it unchanged. See
|
||||
# tests/agent/test_anthropic_thinking_block_order.py.
|
||||
ordered_blocks = []
|
||||
|
||||
for block in response.content:
|
||||
block_dict = _to_plain_data(block)
|
||||
clean_block = None
|
||||
if isinstance(block_dict, dict):
|
||||
# Sanitize at capture so output-only SDK fields (parsed_output,
|
||||
# caller, citations=None, …) never persist to state.db and leak
|
||||
# back as request input on replay → HTTP 400 "Extra inputs are
|
||||
# not permitted". Defence-in-depth with the replay-side sanitize.
|
||||
clean_block = _sanitize_replay_block(block_dict)
|
||||
if clean_block is not None:
|
||||
ordered_blocks.append(clean_block)
|
||||
if block.type == "text":
|
||||
text_parts.append(block.text)
|
||||
elif block.type in ("thinking", "redacted_thinking"):
|
||||
if block.type == "thinking":
|
||||
reasoning_parts.append(block.thinking)
|
||||
# Use the sanitized block (clean_block) for reasoning_details too,
|
||||
# since _extract_preserved_thinking_blocks replays these on the
|
||||
# non-ordered path. Falls back to raw only if sanitize dropped it.
|
||||
if isinstance(clean_block, dict):
|
||||
reasoning_details.append(clean_block)
|
||||
elif isinstance(block_dict, dict):
|
||||
elif block.type == "thinking":
|
||||
reasoning_parts.append(block.thinking)
|
||||
block_dict = _to_plain_data(block)
|
||||
if isinstance(block_dict, dict):
|
||||
reasoning_details.append(block_dict)
|
||||
elif block.type == "tool_use":
|
||||
name = block.name
|
||||
@@ -156,23 +130,6 @@ class AnthropicTransport(ProviderTransport):
|
||||
provider_data = {}
|
||||
if reasoning_details:
|
||||
provider_data["reasoning_details"] = reasoning_details
|
||||
# Only worth carrying the ordered-blocks channel when the turn
|
||||
# actually interleaves signed thinking with tool_use — that's the
|
||||
# only shape the parallel lists reconstruct incorrectly. A turn that
|
||||
# is purely text, or thinking-then-tools with a single leading
|
||||
# thinking block, replays correctly without it.
|
||||
_has_signed_thinking = any(
|
||||
isinstance(b, dict)
|
||||
and b.get("type") in ("thinking", "redacted_thinking")
|
||||
and (b.get("signature") or b.get("data"))
|
||||
for b in ordered_blocks
|
||||
)
|
||||
_has_tool_use = any(
|
||||
isinstance(b, dict) and b.get("type") == "tool_use"
|
||||
for b in ordered_blocks
|
||||
)
|
||||
if _has_signed_thinking and _has_tool_use:
|
||||
provider_data["anthropic_content_blocks"] = ordered_blocks
|
||||
|
||||
return NormalizedResponse(
|
||||
content="\n".join(text_parts) if text_parts else None,
|
||||
|
||||
@@ -378,7 +378,6 @@ def check_codex_binary(
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return False, (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -121,18 +121,6 @@ class NormalizedResponse:
|
||||
pd = self.provider_data or {}
|
||||
return pd.get("reasoning_details")
|
||||
|
||||
@property
|
||||
def anthropic_content_blocks(self):
|
||||
"""Verbatim, order-preserving Anthropic content blocks for a turn.
|
||||
|
||||
Present only when an Anthropic turn interleaves signed thinking with
|
||||
tool_use — the one shape the parallel reasoning_details + tool_calls
|
||||
lists reconstruct in the wrong order, invalidating thinking-block
|
||||
signatures on replay. See agent/transports/anthropic.py.
|
||||
"""
|
||||
pd = self.provider_data or {}
|
||||
return pd.get("anthropic_content_blocks")
|
||||
|
||||
@property
|
||||
def codex_reasoning_items(self):
|
||||
pd = self.provider_data or {}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
"noUnusedParameters": true,
|
||||
"esModuleInterop": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
Before Width: | Height: | Size: 561 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 561 KiB After Width: | Height: | Size: 674 KiB |
@@ -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 = ''
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 528 KiB After Width: | Height: | Size: 1.1 MiB |
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -3,25 +3,32 @@ import { ComposerPrimitive } from '@assistant-ui/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export const COMPLETION_DRAWER_CLASS = [
|
||||
'absolute bottom-[calc(100%+0.375rem)] left-0 z-50',
|
||||
'w-80 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-xl border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-lg',
|
||||
'absolute bottom-[calc(100%+0.25rem)] left-0 z-50',
|
||||
'w-60 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-lg border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-md',
|
||||
'backdrop-blur-md'
|
||||
].join(' ')
|
||||
|
||||
export const COMPLETION_DRAWER_BELOW_CLASS = [
|
||||
'absolute left-0 top-[calc(100%+0.375rem)] z-50',
|
||||
'w-80 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-xl border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-lg',
|
||||
'absolute left-0 top-[calc(100%+0.25rem)] z-50',
|
||||
'w-60 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-lg border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-md',
|
||||
'backdrop-blur-md'
|
||||
].join(' ')
|
||||
|
||||
export const COMPLETION_DRAWER_ROW_CLASS = [
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1',
|
||||
'w-full min-w-0 text-left text-xs outline-hidden transition-colors',
|
||||
'hover:bg-(--ui-bg-tertiary)',
|
||||
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
|
||||
].join(' ')
|
||||
|
||||
export function ComposerCompletionDrawer({
|
||||
adapter,
|
||||
ariaLabel,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -5,13 +5,6 @@ export interface CompletionEntry {
|
||||
text: string
|
||||
display?: unknown
|
||||
meta?: unknown
|
||||
/** Optional section label (e.g. "Commands", "Skills"). The popover renders a
|
||||
* header whenever this changes between consecutive items, so the fetcher must
|
||||
* emit entries already grouped contiguously. */
|
||||
group?: string
|
||||
/** Optional completion-action id. When set, picking the item runs that action
|
||||
* (e.g. opening an overlay) instead of inserting a chip + waiting for submit. */
|
||||
action?: string
|
||||
}
|
||||
|
||||
export interface CompletionPayload {
|
||||
|
||||
@@ -2,17 +2,12 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-u
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import {
|
||||
type CommandsCatalogLike,
|
||||
desktopSkinSlashCompletions,
|
||||
desktopSlashDescription,
|
||||
type DesktopThemeCommandOption,
|
||||
filterDesktopCommandsCatalog,
|
||||
isDesktopSlashExtensionCommand,
|
||||
isDesktopSlashSuggestion
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
import { $sessions } from '@/store/session'
|
||||
|
||||
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
|
||||
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
|
||||
@@ -21,10 +16,7 @@ interface SlashItemMetadata extends Record<string, string> {
|
||||
command: string
|
||||
display: string
|
||||
meta: string
|
||||
group: string
|
||||
rawText: string
|
||||
/** Completion-action id; empty for ordinary insert-a-chip completions. */
|
||||
action: string
|
||||
}
|
||||
|
||||
function textValue(value: unknown, fallback = ''): string {
|
||||
@@ -46,21 +38,12 @@ function commandText(value: string): string {
|
||||
return value.startsWith('/') ? value : `/${value}`
|
||||
}
|
||||
|
||||
/** How many recent sessions to surface inline before the "Browse all…" entry. */
|
||||
const SESSION_INLINE_LIMIT = 7
|
||||
|
||||
/** Live `/` completions backed by the gateway's `complete.slash` RPC. */
|
||||
export function useSlashCompletions(options: {
|
||||
gateway: HermesGateway | null
|
||||
/** Desktop theme list — `/skin` is owned client-side, so its arg completions
|
||||
* come from here, not the backend (whose skin list is CLI/TUI-only). */
|
||||
skinThemes?: DesktopThemeCommandOption[]
|
||||
activeSkin?: string
|
||||
}): {
|
||||
export function useSlashCompletions(options: { gateway: HermesGateway | null }): {
|
||||
adapter: Unstable_TriggerAdapter
|
||||
loading: boolean
|
||||
} {
|
||||
const { gateway, skinThemes, activeSkin } = options
|
||||
const { gateway } = options
|
||||
const enabled = Boolean(gateway)
|
||||
|
||||
const fetcher = useCallback(
|
||||
@@ -71,136 +54,34 @@ export function useSlashCompletions(options: {
|
||||
|
||||
const text = `/${query}`
|
||||
|
||||
// The desktop owns /skin entirely (client-side theme context). Surface its
|
||||
// theme list inside this single popover instead of a bespoke one, and skip
|
||||
// the backend skin completions (which describe CLI/TUI skins that don't
|
||||
// apply here). Matches once we're past `/skin ` into the arg stage.
|
||||
const skinArg = /^\/skin\s+(.*)$/is.exec(text)
|
||||
|
||||
if (skinArg && skinThemes) {
|
||||
const items = desktopSkinSlashCompletions(skinThemes, activeSkin ?? '', skinArg[1] ?? '').map(entry => ({
|
||||
text: entry.text,
|
||||
display: entry.display,
|
||||
meta: entry.meta,
|
||||
group: 'Themes'
|
||||
}))
|
||||
|
||||
return { items, query }
|
||||
}
|
||||
|
||||
// /resume (and its aliases) completes recent sessions inline — the same
|
||||
// client-side list the picker overlay shows — instead of the backend
|
||||
// (whose /resume opens an interactive TUI picker we can't render here).
|
||||
const sessionArg = /^\/(?:resume|sessions|switch)\s+(.*)$/is.exec(text)
|
||||
|
||||
if (sessionArg) {
|
||||
const needle = (sessionArg[1] ?? '').trim().toLowerCase()
|
||||
|
||||
const matches = (
|
||||
needle
|
||||
? $sessions.get().filter(
|
||||
session =>
|
||||
sessionTitle(session).toLowerCase().includes(needle) ||
|
||||
(session.preview ?? '').toLowerCase().includes(needle) ||
|
||||
session.id.toLowerCase().includes(needle)
|
||||
)
|
||||
: $sessions.get()
|
||||
).slice(0, SESSION_INLINE_LIMIT)
|
||||
|
||||
const items: CompletionEntry[] = matches.map(session => ({
|
||||
text: `/resume ${session.id}`,
|
||||
display: sessionTitle(session),
|
||||
meta: (session.preview ?? '').trim(),
|
||||
group: 'Sessions'
|
||||
}))
|
||||
|
||||
// Trailing "more" affordance (Cursor-style): picking it opens the full
|
||||
// session picker overlay directly. `text` stays a bare `/resume` so that
|
||||
// submitting it (Enter) still opens the overlay if the action is skipped.
|
||||
items.push({
|
||||
text: '/resume',
|
||||
display: 'Browse all sessions…',
|
||||
meta: '',
|
||||
group: 'Sessions',
|
||||
action: 'session-picker'
|
||||
})
|
||||
|
||||
return { items, query }
|
||||
}
|
||||
|
||||
try {
|
||||
if (!query) {
|
||||
const catalog = filterDesktopCommandsCatalog(await gateway.request<CommandsCatalogLike>('commands.catalog'))
|
||||
|
||||
// Prefer the categorized layout so the popover renders section headers
|
||||
// (Session, Tools & Skills, ...). Fall back to the flat list when the
|
||||
// backend didn't categorize.
|
||||
const sections = catalog.categories?.length
|
||||
? catalog.categories
|
||||
: [{ name: '', pairs: catalog.pairs ?? [] }]
|
||||
|
||||
const items = sections.flatMap(section =>
|
||||
section.pairs.map(([command, meta]) => ({
|
||||
text: command,
|
||||
display: command,
|
||||
group: section.name || undefined,
|
||||
meta
|
||||
}))
|
||||
)
|
||||
const items = (catalog.pairs ?? []).map(([command, meta]) => ({
|
||||
text: command,
|
||||
display: command,
|
||||
meta
|
||||
}))
|
||||
|
||||
return { items, query }
|
||||
}
|
||||
|
||||
const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>(
|
||||
'complete.slash',
|
||||
{ text }
|
||||
)
|
||||
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text })
|
||||
|
||||
// Arg-completion items (replace_from > 1) carry just the arg stub —
|
||||
// e.g. complete.slash returns `{text: "alice"}` for `/personality alic`
|
||||
// with replace_from = 14. Rewrite those entries so the popover inserts
|
||||
// the full `/personality alice` token instead of stranding `/alice`.
|
||||
const replaceFrom = typeof result.replace_from === 'number' ? result.replace_from : 1
|
||||
const isArgCompletion = replaceFrom > 1
|
||||
const prefix = isArgCompletion ? text.slice(0, replaceFrom) : ''
|
||||
|
||||
const decorated = (result.items ?? [])
|
||||
.map(item => {
|
||||
if (!isArgCompletion) {
|
||||
return item
|
||||
}
|
||||
|
||||
const argText = typeof item.text === 'string' ? item.text : ''
|
||||
|
||||
return { ...item, text: `${prefix}${argText}` }
|
||||
})
|
||||
.filter(item => isArgCompletion || isDesktopSlashSuggestion(item.text))
|
||||
const items = (result.items ?? [])
|
||||
.filter(item => isDesktopSlashSuggestion(item.text))
|
||||
.map(item => ({
|
||||
...item,
|
||||
// Arg suggestions (e.g. `/handoff <platform>`) live under one
|
||||
// header; otherwise split skills out from built-in commands.
|
||||
group: isArgCompletion ? 'Options' : isDesktopSlashExtensionCommand(item.text) ? 'Skills' : 'Commands',
|
||||
// Arg items carry their own meta (the personality/toolset/platform
|
||||
// blurb). Only command rows get the registry description — looking
|
||||
// one up for `/personality none` would clobber it with the parent
|
||||
// command's text.
|
||||
meta: isArgCompletion ? textValue(item.meta) : desktopSlashDescription(item.text, textValue(item.meta))
|
||||
meta: desktopSlashDescription(item.text, textValue(item.meta))
|
||||
}))
|
||||
|
||||
// Keep each group contiguous so headers render once: Commands before
|
||||
// Skills (stable within a group, preserving backend relevance order).
|
||||
const groupOrder = ['Commands', 'Skills', 'Options']
|
||||
|
||||
const items = isArgCompletion
|
||||
? decorated
|
||||
: [...decorated].sort((a, b) => groupOrder.indexOf(a.group) - groupOrder.indexOf(b.group))
|
||||
|
||||
return { items, query }
|
||||
} catch {
|
||||
return { items: [], query }
|
||||
}
|
||||
},
|
||||
[gateway, skinThemes, activeSkin]
|
||||
[gateway]
|
||||
)
|
||||
|
||||
const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => {
|
||||
@@ -212,8 +93,6 @@ export function useSlashCompletions(options: {
|
||||
command,
|
||||
display,
|
||||
meta,
|
||||
group: textValue(entry.group),
|
||||
action: textValue(entry.action),
|
||||
// Provide rawText so hermesDirectiveFormatter.serialize uses the
|
||||
// direct-insertion path instead of the legacy @type:id fallback.
|
||||
// Without this, the item.id (which includes a "|index" suffix for
|
||||
|
||||
@@ -13,25 +13,17 @@ import {
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text'
|
||||
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$composerAttachments,
|
||||
clearComposerAttachments,
|
||||
clearPersistedComposerDraft,
|
||||
type ComposerAttachment,
|
||||
readPersistedComposerDraft,
|
||||
writePersistedComposerDraft
|
||||
} from '@/store/composer'
|
||||
import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||
import {
|
||||
browseBackward,
|
||||
browseForward,
|
||||
@@ -48,11 +40,10 @@ import {
|
||||
shouldAutoDrainOnSettle,
|
||||
updateQueuedPrompt
|
||||
} from '@/store/composer-queue'
|
||||
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
|
||||
import { $gatewayState, $messages } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { useTheme } from '@/themes'
|
||||
|
||||
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'
|
||||
@@ -73,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'
|
||||
@@ -83,9 +74,9 @@ import {
|
||||
placeCaretEnd,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT,
|
||||
slashChipElement
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
import { SkinSlashPopover } from './skin-slash-popover'
|
||||
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
@@ -104,30 +95,6 @@ const COMPOSER_FADE_BACKGROUND =
|
||||
|
||||
const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
|
||||
|
||||
/** Completion items can carry an `action` (set in use-slash-completions) that
|
||||
* runs a side effect on pick instead of inserting a chip — e.g. the session
|
||||
* picker's "Browse all…" entry opens the overlay. Table-driven so new action
|
||||
* items are a registry row, not a composer branch. */
|
||||
const COMPLETION_ACTIONS: Record<string, () => void> = {
|
||||
'session-picker': () => setSessionPickerOpen(true)
|
||||
}
|
||||
|
||||
/** Map a picked `/` completion to its pill accent. Driven by the completion
|
||||
* group set in use-slash-completions (Skills / Themes / Commands|Options). */
|
||||
function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind {
|
||||
const group = (item.metadata as { group?: unknown } | undefined)?.group
|
||||
|
||||
if (group === 'Skills') {
|
||||
return 'skill'
|
||||
}
|
||||
|
||||
if (group === 'Themes') {
|
||||
return 'theme'
|
||||
}
|
||||
|
||||
return 'command'
|
||||
}
|
||||
|
||||
interface QueueEditState {
|
||||
attachments: ComposerAttachment[]
|
||||
draft: string
|
||||
@@ -137,10 +104,6 @@ interface QueueEditState {
|
||||
|
||||
const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a }))
|
||||
|
||||
// How long the composer waits after the last keystroke before persisting the
|
||||
// draft to localStorage. Scope-change/unmount flushes bypass the delay.
|
||||
const DRAFT_PERSIST_DEBOUNCE_MS = 400
|
||||
|
||||
export function ChatBar({
|
||||
busy,
|
||||
cwd,
|
||||
@@ -171,7 +134,6 @@ export function ChatBar({
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const sessionMessages = useStore($messages)
|
||||
const activeQueueSessionKey = queueSessionKey || sessionId || null
|
||||
const draftPersistenceScope = activeQueueSessionKey || null
|
||||
|
||||
const queuedPrompts = useMemo(
|
||||
() => (activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : []),
|
||||
@@ -183,8 +145,6 @@ export function ChatBar({
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const draftRef = useRef(draft)
|
||||
const previousBusyRef = useRef(busy)
|
||||
const skipNextDraftPersistScopeRef = useRef<string | null>(null)
|
||||
const pendingDraftPersistRef = useRef<{ scope: string | null; value: string } | null>(null)
|
||||
const drainingQueueRef = useRef(false)
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
@@ -202,9 +162,8 @@ export function ChatBar({
|
||||
|
||||
const narrow = useMediaQuery('(max-width: 30rem)')
|
||||
|
||||
const { availableThemes, themeName } = useTheme()
|
||||
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
|
||||
const slash = useSlashCompletions({ activeSkin: themeName, gateway: gateway ?? null, skinThemes: availableThemes })
|
||||
const slash = useSlashCompletions({ gateway: gateway ?? null })
|
||||
|
||||
const stacked = expanded || narrow || tight
|
||||
const trimmedDraft = draft.trim()
|
||||
@@ -212,12 +171,10 @@ export function ChatBar({
|
||||
const canSubmit = busy || hasComposerPayload
|
||||
const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null
|
||||
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
|
||||
|
||||
// Steer only makes sense mid-turn, text-only (the gateway can't carry images
|
||||
// into a tool result) and never for a slash command (those execute inline).
|
||||
const canSteer =
|
||||
busy && !!onSteer && attachments.length === 0 && trimmedDraft.length > 0 && !SLASH_COMMAND_RE.test(trimmedDraft)
|
||||
|
||||
const showHelpHint = draft === '?'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -505,6 +462,12 @@ export function ChatBar({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const selectSkinSlashCommand = (command: string) => {
|
||||
draftRef.current = command
|
||||
aui.composer().setText(command)
|
||||
requestMainFocus()
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||
|
||||
@@ -657,50 +620,16 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// Action items (e.g. "Browse all sessions…") run a side effect instead of
|
||||
// inserting a chip: strip the typed trigger token, then fire the action.
|
||||
const completionAction = (item.metadata as { action?: unknown } | undefined)?.action
|
||||
const runAction = typeof completionAction === 'string' ? COMPLETION_ACTIONS[completionAction] : undefined
|
||||
|
||||
if (runAction) {
|
||||
const current = composerPlainText(editor)
|
||||
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
|
||||
|
||||
renderComposerContents(editor, prefix)
|
||||
placeCaretEnd(editor)
|
||||
draftRef.current = composerPlainText(editor)
|
||||
aui.composer().setText(draftRef.current)
|
||||
closeTrigger()
|
||||
runAction()
|
||||
requestMainFocus()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const serialized = hermesDirectiveFormatter.serialize(item)
|
||||
const starter = serialized.endsWith(':')
|
||||
|
||||
// Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit
|
||||
// it — expand to its options step so the popover shows the inline list, just
|
||||
// as typing `/personality ` by hand would. A serialized value with a space is
|
||||
// already an arg pick (`/personality alice`), so it commits normally.
|
||||
const command = (item.metadata as { command?: string } | undefined)?.command ?? ''
|
||||
|
||||
const expandsToArgs =
|
||||
trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command)
|
||||
|
||||
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
|
||||
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
|
||||
// No pill while expanding — the bare command stays plain text until an arg
|
||||
// is picked, at which point a single pill is emitted for the full command.
|
||||
const slashKind = !expandsToArgs && trigger.kind === '/' ? slashChipKindForItem(item) : null
|
||||
const keepTriggerOpen = starter || expandsToArgs
|
||||
|
||||
const finish = () => {
|
||||
draftRef.current = composerPlainText(editor)
|
||||
aui.composer().setText(draftRef.current)
|
||||
requestMainFocus()
|
||||
keepTriggerOpen ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
|
||||
starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
|
||||
}
|
||||
|
||||
const sel = window.getSelection()
|
||||
@@ -710,20 +639,7 @@ export function ChatBar({
|
||||
|
||||
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
|
||||
const current = composerPlainText(editor)
|
||||
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
|
||||
|
||||
if (slashKind) {
|
||||
// Two-step arg picks (e.g. `/handoff` pill already inserted, now picking
|
||||
// the platform) land here because the caret sits past a contenteditable
|
||||
// chip. Rebuild the prefix and re-emit a single pill for the full command.
|
||||
renderComposerContents(editor, prefix)
|
||||
editor.append(slashChipElement(serialized, slashKind), document.createTextNode(' '))
|
||||
placeCaretEnd(editor)
|
||||
|
||||
return finish()
|
||||
}
|
||||
|
||||
renderComposerContents(editor, `${prefix}${text}`)
|
||||
renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
|
||||
placeCaretEnd(editor)
|
||||
|
||||
return finish()
|
||||
@@ -734,13 +650,8 @@ export function ChatBar({
|
||||
replaceRange.setEnd(node, offset)
|
||||
replaceRange.deleteContents()
|
||||
|
||||
const chip = slashKind
|
||||
? slashChipElement(serialized, slashKind)
|
||||
: directive
|
||||
? refChipElement(directive[1], directive[2])
|
||||
: null
|
||||
|
||||
if (chip) {
|
||||
if (directive) {
|
||||
const chip = refChipElement(directive[1], directive[2])
|
||||
const space = document.createTextNode(' ')
|
||||
const fragment = document.createDocumentFragment()
|
||||
fragment.append(chip, space)
|
||||
@@ -903,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
|
||||
@@ -920,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
|
||||
}
|
||||
|
||||
@@ -1020,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>) => {
|
||||
@@ -1058,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
|
||||
}
|
||||
|
||||
@@ -1066,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(() => {
|
||||
@@ -1111,48 +995,6 @@ export function ChatBar({
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const persisted = readPersistedComposerDraft(draftPersistenceScope)
|
||||
skipNextDraftPersistScopeRef.current = draftPersistenceScope
|
||||
loadIntoComposer(persisted, [])
|
||||
}, [draftPersistenceScope]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (skipNextDraftPersistScopeRef.current === draftPersistenceScope) {
|
||||
skipNextDraftPersistScopeRef.current = null
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Debounce the localStorage write: the composer's per-keystroke path was
|
||||
// deliberately slimmed down (see the draftRef sync comment above), so we
|
||||
// don't touch storage on every keypress. The pending ref below is flushed
|
||||
// on scope change / unmount so a fast session switch can't drop the
|
||||
// trailing keystrokes.
|
||||
pendingDraftPersistRef.current = { scope: draftPersistenceScope, value: draft }
|
||||
|
||||
const handle = window.setTimeout(() => {
|
||||
pendingDraftPersistRef.current = null
|
||||
writePersistedComposerDraft(draftPersistenceScope, draft)
|
||||
}, DRAFT_PERSIST_DEBOUNCE_MS)
|
||||
|
||||
return () => window.clearTimeout(handle)
|
||||
}, [draft, draftPersistenceScope])
|
||||
|
||||
// Flush any pending debounced draft write when leaving a session scope or
|
||||
// unmounting, so the departing session's latest text is always persisted.
|
||||
useEffect(
|
||||
() => () => {
|
||||
const pending = pendingDraftPersistRef.current
|
||||
|
||||
if (pending) {
|
||||
pendingDraftPersistRef.current = null
|
||||
writePersistedComposerDraft(pending.scope, pending.value)
|
||||
}
|
||||
},
|
||||
[draftPersistenceScope]
|
||||
)
|
||||
|
||||
const beginQueuedEdit = (entry: QueuedPromptEntry) => {
|
||||
if (!activeQueueSessionKey || queueEdit) {
|
||||
return
|
||||
@@ -1370,28 +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) {
|
||||
@@ -1402,22 +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 Promise.resolve(onSubmit(submitted)).then(accepted => {
|
||||
if (accepted === false) {
|
||||
loadIntoComposer(submitted, [])
|
||||
writePersistedComposerDraft(draftPersistenceScope, submitted)
|
||||
} else {
|
||||
clearPersistedComposerDraft(draftPersistenceScope)
|
||||
}
|
||||
}).catch(() => {
|
||||
loadIntoComposer(submitted, [])
|
||||
writePersistedComposerDraft(draftPersistenceScope, submitted)
|
||||
})
|
||||
} else if (payloadPresent) {
|
||||
void onSubmit(submitted)
|
||||
} else if (hasComposerPayload) {
|
||||
queueCurrentDraft()
|
||||
} else {
|
||||
// Stop button (the only way to reach here while busy with an empty
|
||||
@@ -1425,26 +1235,15 @@ 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
|
||||
const submittedAttachments = cloneAttachments(attachments)
|
||||
} else if (draft.trim() || attachments.length > 0) {
|
||||
const submitted = draft
|
||||
triggerHaptic('submit')
|
||||
resetBrowseState(sessionId)
|
||||
clearDraft()
|
||||
clearComposerAttachments()
|
||||
void Promise.resolve(onSubmit(submitted, { attachments: submittedAttachments })).then(accepted => {
|
||||
if (accepted === false) {
|
||||
loadIntoComposer(submitted, submittedAttachments)
|
||||
writePersistedComposerDraft(draftPersistenceScope, submitted)
|
||||
} else {
|
||||
clearPersistedComposerDraft(draftPersistenceScope)
|
||||
}
|
||||
}).catch(() => {
|
||||
loadIntoComposer(submitted, submittedAttachments)
|
||||
writePersistedComposerDraft(draftPersistenceScope, submitted)
|
||||
})
|
||||
void onSubmit(submitted, { attachments })
|
||||
}
|
||||
|
||||
focusInput()
|
||||
@@ -1669,6 +1468,7 @@ export function ChatBar({
|
||||
onPick={replaceTriggerWithChip}
|
||||
/>
|
||||
)}
|
||||
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
|
||||
{activeQueueSessionKey && queuedPrompts.length > 0 && (
|
||||
// Out of flow so the queue never inflates the composer's measured
|
||||
// height (that drives thread bottom padding → chat resizes on
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,10 +10,7 @@ import {
|
||||
DIRECTIVE_CHIP_CLASS,
|
||||
directiveIconElement,
|
||||
directiveIconSvg,
|
||||
formatRefValue,
|
||||
slashChipClass,
|
||||
type SlashChipKind,
|
||||
slashIconElement
|
||||
formatRefValue
|
||||
} from '@/components/assistant-ui/directive-text'
|
||||
|
||||
export const RICH_INPUT_SLOT = 'composer-rich-input'
|
||||
@@ -80,24 +77,6 @@ export function refChipElement(kind: string, rawValue: string, displayLabel?: st
|
||||
return chip
|
||||
}
|
||||
|
||||
/** A non-editable pill for a picked slash command (`/skin nous`, `/tropes`).
|
||||
* `data-ref-text` carries the literal command so `composerPlainText` round-trips
|
||||
* it back to the exact text that gets submitted. */
|
||||
export function slashChipElement(command: string, kind: SlashChipKind, label?: string) {
|
||||
const chip = document.createElement('span')
|
||||
const text = document.createElement('span')
|
||||
|
||||
chip.contentEditable = 'false'
|
||||
chip.dataset.refText = command
|
||||
chip.dataset.slashKind = kind
|
||||
chip.className = slashChipClass(kind)
|
||||
text.className = 'truncate'
|
||||
text.textContent = label || command
|
||||
chip.append(slashIconElement(kind), text)
|
||||
|
||||
return chip
|
||||
}
|
||||
|
||||
function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: string) {
|
||||
const lines = text.split('\n')
|
||||
|
||||
|
||||
61
apps/desktop/src/app/chat/composer/skin-slash-popover.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useI18n } from '@/i18n'
|
||||
import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer'
|
||||
|
||||
interface SkinSlashPopoverProps {
|
||||
draft: string
|
||||
onSelect: (command: string) => void
|
||||
}
|
||||
|
||||
export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const { availableThemes, themeName } = useTheme()
|
||||
const match = draft.match(/^\/skin\s+(\S*)$/i)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const items = desktopSkinSlashCompletions(availableThemes, themeName, match[1] ?? '')
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={c.themeSuggestions}
|
||||
className={COMPLETION_DRAWER_CLASS}
|
||||
data-slot="composer-skin-completion-drawer"
|
||||
data-state="open"
|
||||
role="listbox"
|
||||
>
|
||||
<div className="grid gap-0.5 pt-0.5">
|
||||
{items.length === 0 ? (
|
||||
<CompletionDrawerEmpty title={c.noMatchingThemes}>
|
||||
{c.themeTryPre}
|
||||
<span className="font-mono text-foreground/80">/skin list</span>
|
||||
{c.themeTryPost}
|
||||
</CompletionDrawerEmpty>
|
||||
) : (
|
||||
items.map(item => (
|
||||
<button
|
||||
className={COMPLETION_DRAWER_ROW_CLASS}
|
||||
key={item.text}
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
onSelect(item.text)
|
||||
}}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<span className="shrink-0 font-mono font-medium leading-5 text-foreground">{item.display}</span>
|
||||
<span className="min-w-0 truncate leading-5 text-muted-foreground/80">{item.meta}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -22,33 +22,6 @@ describe('detectTrigger', () => {
|
||||
it('returns null for plain text', () => {
|
||||
expect(detectTrigger('hello there')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the slash trigger live while typing args', () => {
|
||||
expect(detectTrigger('/personality ')).toEqual({
|
||||
kind: '/',
|
||||
query: 'personality ',
|
||||
tokenLength: 13
|
||||
})
|
||||
expect(detectTrigger('/personality alic')).toEqual({
|
||||
kind: '/',
|
||||
query: 'personality alic',
|
||||
tokenLength: 17
|
||||
})
|
||||
expect(detectTrigger('/tools enable foo')).toEqual({
|
||||
kind: '/',
|
||||
query: 'tools enable foo',
|
||||
tokenLength: 17
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat file-style paths as slash triggers', () => {
|
||||
expect(detectTrigger('src/foo/bar')).toBeNull()
|
||||
expect(detectTrigger('/path/to/file')).toBeNull()
|
||||
})
|
||||
|
||||
it('still anchors at-mention triggers strictly at the token edge', () => {
|
||||
expect(detectTrigger('@file:path with space')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractClipboardImageBlobs', () => {
|
||||
|
||||
@@ -6,13 +6,7 @@ export interface TriggerState {
|
||||
tokenLength: number
|
||||
}
|
||||
|
||||
// `@` triggers stop at the first whitespace — `@file:path` and `@diff` are
|
||||
// single tokens. `/` triggers keep going so the popover stays live while the
|
||||
// user types args (`/personality alic` → arg completer suggests `alice`).
|
||||
// Restricting the slash command name to `[a-zA-Z][\w-]*` avoids matching file
|
||||
// paths like `src/foo/bar`.
|
||||
const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/
|
||||
const SLASH_TRIGGER_RE = /(?:^|[\s])(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/
|
||||
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
|
||||
|
||||
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
|
||||
export function blobDedupeKey(blob: Blob): string {
|
||||
@@ -103,17 +97,11 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null {
|
||||
}
|
||||
|
||||
export function detectTrigger(textBefore: string): TriggerState | null {
|
||||
const slash = SLASH_TRIGGER_RE.exec(textBefore)
|
||||
const match = TRIGGER_RE.exec(textBefore)
|
||||
|
||||
if (slash) {
|
||||
return { kind: '/', query: slash[2], tokenLength: 1 + slash[2].length }
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const at = AT_TRIGGER_RE.exec(textBefore)
|
||||
|
||||
if (at) {
|
||||
return { kind: '@', query: at[2], tokenLength: 1 + at[2].length }
|
||||
}
|
||||
|
||||
return null
|
||||
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
|
||||
}
|
||||
|
||||
@@ -34,17 +34,9 @@ describe('ComposerTriggerPopover i18n', () => {
|
||||
})
|
||||
|
||||
it('renders localized loading copy for slash commands', () => {
|
||||
renderPopover('/', true)
|
||||
const { container } = renderPopover('/', true)
|
||||
|
||||
// While loading the popover shows only the spinner + loading copy — the
|
||||
// `/help` empty-state hint is reserved for the resolved (not-loading) state.
|
||||
expect(screen.getByText('查找中…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the slash empty-state hint when not loading', () => {
|
||||
const { container } = renderPopover('/')
|
||||
|
||||
expect(screen.getByText('没有匹配项。')).toBeTruthy()
|
||||
expect(container.textContent).toContain('/help')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { Fragment } from 'react'
|
||||
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -9,6 +7,7 @@ import { cn } from '@/lib/utils'
|
||||
import {
|
||||
COMPLETION_DRAWER_BELOW_CLASS,
|
||||
COMPLETION_DRAWER_CLASS,
|
||||
COMPLETION_DRAWER_ROW_CLASS,
|
||||
CompletionDrawerEmpty
|
||||
} from './completion-drawer'
|
||||
|
||||
@@ -24,7 +23,11 @@ const AT_ICON_BY_TYPE: Record<string, string> = {
|
||||
url: 'globe'
|
||||
}
|
||||
|
||||
function atIcon(item: Unstable_TriggerItem) {
|
||||
function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) {
|
||||
if (kind === '/') {
|
||||
return 'terminal'
|
||||
}
|
||||
|
||||
const meta = item.metadata as { rawText?: string } | undefined
|
||||
const raw = meta?.rawText || item.label
|
||||
|
||||
@@ -39,18 +42,6 @@ function atIcon(item: Unstable_TriggerItem) {
|
||||
return AT_ICON_BY_TYPE[item.type] || AT_ICON_BY_TYPE.simple
|
||||
}
|
||||
|
||||
interface RowMeta {
|
||||
display?: string
|
||||
group?: string
|
||||
meta?: string
|
||||
}
|
||||
|
||||
const ROW_BASE_CLASS = [
|
||||
'relative flex w-full cursor-default select-none rounded-md px-2 py-1 text-left',
|
||||
'outline-hidden transition-colors hover:bg-(--ui-bg-tertiary)',
|
||||
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
|
||||
].join(' ')
|
||||
|
||||
interface ComposerTriggerPopoverProps {
|
||||
activeIndex: number
|
||||
items: readonly Unstable_TriggerItem[]
|
||||
@@ -72,9 +63,6 @@ export function ComposerTriggerPopover({
|
||||
}: ComposerTriggerPopoverProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.composer
|
||||
const isSlash = kind === '/'
|
||||
|
||||
let lastGroup: string | undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -85,94 +73,41 @@ export function ComposerTriggerPopover({
|
||||
role="listbox"
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
loading ? (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-(--ui-text-tertiary)">
|
||||
<BrailleSpinner ariaLabel={copy.lookupLoading} className="text-foreground/70" spinner="braille" />
|
||||
<span>{copy.lookupLoading}</span>
|
||||
</div>
|
||||
) : (
|
||||
<CompletionDrawerEmpty title={copy.lookupNoMatches}>
|
||||
{kind === '@' ? (
|
||||
<>
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
|
||||
<span className="font-mono text-foreground/80">@folder:</span>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
|
||||
</>
|
||||
)}
|
||||
</CompletionDrawerEmpty>
|
||||
)
|
||||
<CompletionDrawerEmpty title={loading ? copy.lookupLoading : copy.lookupNoMatches}>
|
||||
{kind === '@' ? (
|
||||
<>
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">@file:</span> {copy.lookupOr}{' '}
|
||||
<span className="font-mono text-foreground/80">@folder:</span>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{copy.lookupTry} <span className="font-mono text-foreground/80">/help</span>.
|
||||
</>
|
||||
)}
|
||||
</CompletionDrawerEmpty>
|
||||
) : (
|
||||
items.map((item, index) => {
|
||||
const meta = item.metadata as RowMeta | undefined
|
||||
const display = meta?.display ?? (isSlash ? `/${item.label}` : item.label)
|
||||
const meta = item.metadata as { display?: string; meta?: string } | undefined
|
||||
const display = meta?.display ?? (kind === '/' ? `/${item.label}` : item.label)
|
||||
const description = meta?.meta || item.description
|
||||
const group = meta?.group?.trim()
|
||||
const showHeader = isSlash && Boolean(group) && group !== lastGroup
|
||||
const isFirstHeader = lastGroup === undefined
|
||||
lastGroup = group || lastGroup
|
||||
const active = index === activeIndex
|
||||
|
||||
return (
|
||||
<Fragment key={item.id}>
|
||||
{showHeader && (
|
||||
<div
|
||||
className={cn(
|
||||
'select-none px-2 pb-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)',
|
||||
isFirstHeader ? 'pt-0.5' : 'pt-2'
|
||||
)}
|
||||
>
|
||||
{group}
|
||||
</div>
|
||||
<button
|
||||
className={cn(COMPLETION_DRAWER_ROW_CLASS, index === activeIndex && 'bg-(--ui-bg-tertiary)')}
|
||||
data-highlighted={index === activeIndex ? '' : undefined}
|
||||
key={item.id}
|
||||
onClick={() => onPick(item)}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
type="button"
|
||||
>
|
||||
<span className="grid size-3.5 shrink-0 place-items-center text-(--ui-text-tertiary)">
|
||||
<Codicon name={completionIcon(kind, item)} size="0.875rem" />
|
||||
</span>
|
||||
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">{display}</span>
|
||||
{description && (
|
||||
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
|
||||
)}
|
||||
<button
|
||||
className={cn(ROW_BASE_CLASS, isSlash ? 'flex-col gap-0' : 'items-center gap-2')}
|
||||
data-highlighted={active ? '' : undefined}
|
||||
onClick={() => onPick(item)}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
type="button"
|
||||
>
|
||||
{isSlash ? (
|
||||
<>
|
||||
{/* Active row (keyboard nav or hover) un-truncates inline so
|
||||
long command names / descriptions stay readable without a
|
||||
floating tooltip. */}
|
||||
<span
|
||||
className={cn(
|
||||
'text-[0.8125rem] font-medium leading-snug text-foreground',
|
||||
active ? 'whitespace-normal break-words' : 'truncate'
|
||||
)}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
{description && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[0.6875rem] leading-snug text-(--ui-text-tertiary)',
|
||||
active ? 'whitespace-normal break-words' : 'truncate'
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="grid size-4 shrink-0 place-items-center text-(--ui-text-tertiary)">
|
||||
<Codicon name={atIcon(item)} size="0.875rem" />
|
||||
</span>
|
||||
<span className="min-w-0 shrink truncate font-mono font-medium leading-5 text-foreground">
|
||||
{display}
|
||||
</span>
|
||||
{description && (
|
||||
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Fragment>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -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: [] })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,8 +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 { SessionPickerOverlay } from './session-picker-overlay'
|
||||
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'
|
||||
@@ -135,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)
|
||||
}
|
||||
@@ -226,7 +201,7 @@ export function DesktopController() {
|
||||
toggleCommandCenter
|
||||
} = useOverlayRouting()
|
||||
|
||||
const terminalSidebarOpen = chatOpen && terminalTakeover
|
||||
const terminalTakeoverActive = chatOpen && terminalTakeover
|
||||
|
||||
const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
|
||||
const statusbarItemGroups = useGroupRegistry<StatusbarItem>()
|
||||
@@ -305,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
|
||||
@@ -386,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) {
|
||||
@@ -402,8 +332,7 @@ export function DesktopController() {
|
||||
|
||||
void refreshCronSessions()
|
||||
void refreshCronJobs()
|
||||
void refreshMessagingSessions()
|
||||
}, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
|
||||
}, [profileScope, refreshCronSessions, refreshCronJobs])
|
||||
|
||||
const loadMoreSessions = useCallback(() => {
|
||||
bumpSessionsLimit()
|
||||
@@ -418,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) }))
|
||||
@@ -687,20 +613,19 @@ export function DesktopController() {
|
||||
submitText,
|
||||
transcribeVoiceAudio
|
||||
} = usePromptActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
branchCurrentSession: branchInNewChat,
|
||||
busyRef,
|
||||
createBackendSessionForSend,
|
||||
handleSkinCommand,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession: resumeSession,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
sttEnabled,
|
||||
updateSessionState
|
||||
})
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
branchCurrentSession: branchInNewChat,
|
||||
busyRef,
|
||||
createBackendSessionForSend,
|
||||
handleSkinCommand,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
sttEnabled,
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
useGatewayBoot({
|
||||
handleGatewayEvent: handleDesktopGatewayEvent,
|
||||
@@ -726,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)
|
||||
@@ -745,13 +666,6 @@ export function DesktopController() {
|
||||
}
|
||||
}, [gatewayState, refreshCronJobs])
|
||||
|
||||
useEffect(() => {
|
||||
if (gatewayState === 'open' && !activeSessionId && freshDraftReady) {
|
||||
void refreshCurrentModel()
|
||||
void refreshHermesConfig()
|
||||
}
|
||||
}, [activeSessionId, freshDraftReady, gatewayState, refreshCurrentModel, refreshHermesConfig])
|
||||
|
||||
useRouteResume({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
@@ -770,7 +684,6 @@ export function DesktopController() {
|
||||
|
||||
const { leftStatusbarItems, statusbarItems } = useStatusbarItems({
|
||||
agentsOpen,
|
||||
chatOpen,
|
||||
commandCenterOpen,
|
||||
extraLeftItems: statusbarItemGroups.flat.left,
|
||||
extraRightItems: statusbarItemGroups.flat.right,
|
||||
@@ -791,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 => {
|
||||
@@ -809,35 +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} />
|
||||
<SessionPickerOverlay onResume={resumeSession} />
|
||||
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
|
||||
<UpdatesOverlay />
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
<CommandPalette />
|
||||
<SessionSwitcher />
|
||||
|
||||
{settingsOpen && (
|
||||
<Suspense fallback={null}>
|
||||
@@ -925,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'
|
||||
@@ -969,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}>
|
||||
@@ -1055,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)'
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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 8–15 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')
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { SessionPickerDialog } from '@/components/session-picker'
|
||||
import { $gatewayState, $selectedStoredSessionId, $sessionPickerOpen, setSessionPickerOpen } from '@/store/session'
|
||||
|
||||
interface SessionPickerOverlayProps {
|
||||
onResume: (storedSessionId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the session picker that `/resume` (and `/sessions`, `/switch`) opens —
|
||||
* the desktop equivalent of the TUI's sessions overlay. Resuming runs through
|
||||
* the same `resumeSession` path the sidebar uses.
|
||||
*/
|
||||
export function SessionPickerOverlay({ onResume }: SessionPickerOverlayProps) {
|
||||
const open = useStore($sessionPickerOpen)
|
||||
const gatewayOpen = useStore($gatewayState) === 'open'
|
||||
const activeStoredSessionId = useStore($selectedStoredSessionId)
|
||||
|
||||
if (!gatewayOpen) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionPickerDialog
|
||||
activeStoredSessionId={activeStoredSessionId}
|
||||
onOpenChange={setSessionPickerOpen}
|
||||
onResume={onResume}
|
||||
open={open}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -633,21 +631,14 @@ export function useMessageStream({
|
||||
const runningChanged = typeof payload?.running === 'boolean'
|
||||
|
||||
if (apply) {
|
||||
const runtimeInfo: Partial<
|
||||
Pick<
|
||||
ClientSessionState,
|
||||
'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'
|
||||
>
|
||||
> = {}
|
||||
const runtimeInfo: { branch?: string; cwd?: string } = {}
|
||||
|
||||
if (modelChanged) {
|
||||
setCurrentModel(payload!.model || '')
|
||||
runtimeInfo.model = payload!.model || ''
|
||||
}
|
||||
|
||||
if (providerChanged) {
|
||||
setCurrentProvider(payload!.provider || '')
|
||||
runtimeInfo.provider = payload!.provider || ''
|
||||
}
|
||||
|
||||
if (typeof payload?.cwd === 'string') {
|
||||
@@ -660,32 +651,32 @@ export function useMessageStream({
|
||||
runtimeInfo.branch = payload.branch
|
||||
}
|
||||
|
||||
if (sessionId && (runtimeInfo.cwd !== undefined || runtimeInfo.branch !== undefined)) {
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
branch: runtimeInfo.branch ?? state.branch,
|
||||
cwd: runtimeInfo.cwd ?? state.cwd
|
||||
}))
|
||||
}
|
||||
|
||||
if (typeof payload?.personality === 'string') {
|
||||
setCurrentPersonality(normalizePersonalityValue(payload.personality))
|
||||
}
|
||||
|
||||
if (typeof payload?.reasoning_effort === 'string') {
|
||||
setCurrentReasoningEffort(payload.reasoning_effort)
|
||||
runtimeInfo.reasoningEffort = payload.reasoning_effort
|
||||
}
|
||||
|
||||
if (typeof payload?.service_tier === 'string') {
|
||||
setCurrentServiceTier(payload.service_tier)
|
||||
runtimeInfo.serviceTier = payload.service_tier
|
||||
}
|
||||
|
||||
if (typeof payload?.fast === 'boolean') {
|
||||
setCurrentFastMode(payload.fast)
|
||||
runtimeInfo.fast = payload.fast
|
||||
}
|
||||
|
||||
if (typeof payload?.yolo === 'boolean') {
|
||||
setYoloActive(payload.yolo)
|
||||
runtimeInfo.yolo = payload.yolo
|
||||
}
|
||||
|
||||
if (sessionId && Object.keys(runtimeInfo).length > 0) {
|
||||
updateSessionState(sessionId, state => ({ ...state, ...runtimeInfo }))
|
||||
}
|
||||
|
||||
if (runningChanged && sessionId) {
|
||||
@@ -915,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)
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getGlobalModelInfo } from '@/hermes'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
setCurrentModel,
|
||||
setCurrentProvider
|
||||
} from '@/store/session'
|
||||
|
||||
import { useModelControls } from './use-model-controls'
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getGlobalModelInfo: vi.fn(),
|
||||
setGlobalModel: vi.fn()
|
||||
}))
|
||||
|
||||
describe('useModelControls.refreshCurrentModel', () => {
|
||||
beforeEach(() => {
|
||||
$activeSessionId.set(null)
|
||||
setCurrentModel('')
|
||||
setCurrentProvider('')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
$activeSessionId.set(null)
|
||||
setCurrentModel('')
|
||||
setCurrentProvider('')
|
||||
})
|
||||
|
||||
it('applies the global model when there is no active runtime session', async () => {
|
||||
vi.mocked(getGlobalModelInfo).mockResolvedValue({
|
||||
model: 'openai/gpt-5.5',
|
||||
provider: 'openai-codex'
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useModelControls({
|
||||
activeSessionId: null,
|
||||
queryClient: new QueryClient(),
|
||||
requestGateway: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
await result.current.refreshCurrentModel()
|
||||
|
||||
expect($currentModel.get()).toBe('openai/gpt-5.5')
|
||||
expect($currentProvider.get()).toBe('openai-codex')
|
||||
})
|
||||
|
||||
it('does not clobber the active session footer state with global model info', async () => {
|
||||
setCurrentModel('deepseek/deepseek-v4-pro')
|
||||
setCurrentProvider('deepseek')
|
||||
$activeSessionId.set('runtime-1')
|
||||
vi.mocked(getGlobalModelInfo).mockResolvedValue({
|
||||
model: 'openai/gpt-5.5',
|
||||
provider: 'openai-codex'
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useModelControls({
|
||||
activeSessionId: 'runtime-1',
|
||||
queryClient: new QueryClient(),
|
||||
requestGateway: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
await result.current.refreshCurrentModel()
|
||||
|
||||
expect($currentModel.get()).toBe('deepseek/deepseek-v4-pro')
|
||||
expect($currentProvider.get()).toBe('deepseek')
|
||||
})
|
||||
})
|
||||
@@ -4,13 +4,7 @@ import { useCallback } from 'react'
|
||||
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
setCurrentModel,
|
||||
setCurrentProvider
|
||||
} from '@/store/session'
|
||||
import { $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
interface ModelSelection {
|
||||
@@ -45,13 +39,6 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
||||
try {
|
||||
const result = await getGlobalModelInfo()
|
||||
|
||||
// A resumed/live session owns the footer model state. Global config
|
||||
// refreshes (gateway boot, profile swap, settings save) must not clobber
|
||||
// the active chat's runtime model/provider in the status bar.
|
||||
if ($activeSessionId.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof result.model === 'string') {
|
||||
setCurrentModel(result.model)
|
||||
}
|
||||
|
||||
@@ -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, useRef } 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,29 +50,17 @@ function Harness({
|
||||
onReady,
|
||||
onSeedState,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession,
|
||||
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>
|
||||
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
|
||||
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 stateRef = useRef({
|
||||
messages: [],
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
interrupted: true
|
||||
} as never)
|
||||
|
||||
const actions = usePromptActions({
|
||||
activeSessionId: RUNTIME_SESSION_ID,
|
||||
@@ -87,14 +71,17 @@ function Harness({
|
||||
handleSkinCommand: () => '',
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession: resumeStoredSession ?? (() => undefined),
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft: () => undefined,
|
||||
sttEnabled: false,
|
||||
updateSessionState: (_sessionId, updater) => {
|
||||
// Seed with interrupted:true so we can prove a fresh submit clears it.
|
||||
const next = updater(stateRef.current) as unknown as Record<string, unknown>
|
||||
stateRef.current = next as never
|
||||
const next = updater({
|
||||
messages: [],
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
interrupted: true
|
||||
} as never) as unknown as Record<string, unknown>
|
||||
onSeedState?.(next)
|
||||
|
||||
return next as never
|
||||
@@ -195,68 +182,6 @@ describe('usePromptActions /title', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions desktop slash pickers', () => {
|
||||
beforeEach(() => {
|
||||
setSessions(() => [sessionInfo({ id: '20260610_120000_abcdef', title: 'Loaded session' })])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('resumes an exact session id even when it is not in the loaded sidebar cache', async () => {
|
||||
const resumeStoredSession = vi.fn(async () => undefined)
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
resumeStoredSession={resumeStoredSession}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.submitText('/resume 20260610_130000_123abc')
|
||||
|
||||
expect(resumeStoredSession).toHaveBeenCalledWith('20260610_130000_123abc')
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
|
||||
})
|
||||
|
||||
it('marks a timed-out handoff as failed so the next attempt can retry', async () => {
|
||||
vi.useFakeTimers()
|
||||
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 === 'handoff.state') {
|
||||
return { state: 'pending' } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
const result = handle!.submitText('/handoff telegram')
|
||||
await vi.advanceTimersByTimeAsync(61_000)
|
||||
await result
|
||||
|
||||
expect(calls.some(call => call.method === 'handoff.request')).toBe(true)
|
||||
expect(calls).toContainEqual({
|
||||
method: 'handoff.fail',
|
||||
params: {
|
||||
error: expect.stringContaining('Timed out'),
|
||||
session_id: RUNTIME_SESSION_ID
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions submit / queue drain semantics', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
@@ -389,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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
$messages,
|
||||
$sessions,
|
||||
$yoloActive,
|
||||
workspaceCwdForNewSession,
|
||||
getRememberedWorkspaceCwd,
|
||||
sessionPinId,
|
||||
setActiveSessionId,
|
||||
setAwaitingResponse,
|
||||
@@ -210,16 +210,14 @@ function patchSessionWorkspace(sessionId: string, cwd: string | undefined) {
|
||||
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
|
||||
}
|
||||
|
||||
function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Partial<
|
||||
Pick<ClientSessionState, 'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'>
|
||||
> | null {
|
||||
function applyRuntimeInfo(
|
||||
info: SessionCreateResponse['info'] | undefined
|
||||
): Partial<Pick<ClientSessionState, 'branch' | 'cwd'>> | null {
|
||||
if (!info) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sessionState: Partial<
|
||||
Pick<ClientSessionState, 'branch' | 'cwd' | 'fast' | 'model' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'>
|
||||
> = {}
|
||||
const sessionState: Partial<Pick<ClientSessionState, 'branch' | 'cwd'>> = {}
|
||||
|
||||
reportBackendContract(info.desktop_contract)
|
||||
|
||||
@@ -229,12 +227,10 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Part
|
||||
|
||||
if (info.model) {
|
||||
setCurrentModel(info.model)
|
||||
sessionState.model = info.model
|
||||
}
|
||||
|
||||
if (info.provider) {
|
||||
setCurrentProvider(info.provider)
|
||||
sessionState.provider = info.provider
|
||||
}
|
||||
|
||||
if (info.cwd) {
|
||||
@@ -253,22 +249,18 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Part
|
||||
|
||||
if (typeof info.reasoning_effort === 'string') {
|
||||
setCurrentReasoningEffort(info.reasoning_effort)
|
||||
sessionState.reasoningEffort = info.reasoning_effort
|
||||
}
|
||||
|
||||
if (typeof info.service_tier === 'string') {
|
||||
setCurrentServiceTier(info.service_tier)
|
||||
sessionState.serviceTier = info.service_tier
|
||||
}
|
||||
|
||||
if (typeof info.fast === 'boolean') {
|
||||
setCurrentFastMode(info.fast)
|
||||
sessionState.fast = info.fast
|
||||
}
|
||||
|
||||
if (typeof info.yolo === 'boolean') {
|
||||
setYoloActive(info.yolo)
|
||||
sessionState.yolo = info.yolo
|
||||
}
|
||||
|
||||
if (info.usage) {
|
||||
@@ -319,15 +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).
|
||||
setCurrentModel('')
|
||||
setCurrentProvider('')
|
||||
setCurrentReasoningEffort('')
|
||||
setCurrentServiceTier('')
|
||||
setCurrentFastMode(false)
|
||||
setYoloActive(false)
|
||||
setCurrentCwd(workspaceCwdForNewSession())
|
||||
// New chats inherit the current workspace.
|
||||
setCurrentCwd(getRememberedWorkspaceCwd())
|
||||
setCurrentBranch('')
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
@@ -348,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()
|
||||
|
||||