Compare commits
143 Commits
fix/plugin
...
desktop-cm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a23bbe3348 | ||
|
|
a72bb03757 | ||
|
|
47e77ae166 | ||
|
|
4c797d0e23 | ||
|
|
189ffe7362 | ||
|
|
2c19208224 | ||
|
|
5718811de0 | ||
|
|
af3c8b80b5 | ||
|
|
70d5d7e39b | ||
|
|
a5c32cdf30 | ||
|
|
15813336cc | ||
|
|
183d86b3e0 | ||
|
|
cd9a9cd8e5 | ||
|
|
5d8c44a393 | ||
|
|
2f19512341 | ||
|
|
f222bd26e7 | ||
|
|
38273676ea | ||
|
|
c1308ebf3f | ||
|
|
fa32af886f | ||
|
|
984e69ff62 | ||
|
|
e80754647c | ||
|
|
298bb93d39 | ||
|
|
eee1da45f0 | ||
|
|
6a30cfca82 | ||
|
|
888bf96025 | ||
|
|
383d44bc9a | ||
|
|
243cada157 | ||
|
|
af978ecb17 | ||
|
|
4eadef18a9 | ||
|
|
099146fedd | ||
|
|
e5580f43c2 | ||
|
|
5a4297a11a | ||
|
|
aea0b7397b | ||
|
|
311900842e | ||
|
|
105625d650 | ||
|
|
2ce3ae3d16 | ||
|
|
19c07c4037 | ||
|
|
ab55008631 | ||
|
|
1c055a4c58 | ||
|
|
095f526b11 | ||
|
|
9ca9697342 | ||
|
|
63a421d4c0 | ||
|
|
e4a1b35a39 | ||
|
|
ea7981eba7 | ||
|
|
f1b8519670 | ||
|
|
f8fd30942c | ||
|
|
1967c590ed | ||
|
|
702f4df194 | ||
|
|
0092015496 | ||
|
|
9caa12f4ec | ||
|
|
4642762289 | ||
|
|
bf7abc2f73 | ||
|
|
d03cdd63eb | ||
|
|
96af61b6ef | ||
|
|
7803cbfbb9 | ||
|
|
45e1689c03 | ||
|
|
fdc90346ea | ||
|
|
f082b4ec5c | ||
|
|
833410e02b | ||
|
|
6b330522e1 | ||
|
|
1770263ccc | ||
|
|
33a5bfa3c4 | ||
|
|
8f73d0d945 | ||
|
|
27a3211579 | ||
|
|
5cf6e28a2f | ||
|
|
b4170f3ac2 | ||
|
|
7df3aa34b1 | ||
|
|
b96bd4808d | ||
|
|
d33965396e | ||
|
|
258d24039f | ||
|
|
ab5f1a1f11 | ||
|
|
8bb6529553 | ||
|
|
29036155ce | ||
|
|
8b84d82227 | ||
|
|
93340fa3c1 | ||
|
|
59ea2f98e6 | ||
|
|
aecdacb11b | ||
|
|
7ffc216bc0 | ||
|
|
218452b050 | ||
|
|
29147afd63 | ||
|
|
b021497bc8 | ||
|
|
891c9a6823 | ||
|
|
72154ad879 | ||
|
|
153060e206 | ||
|
|
4906dcfc25 | ||
|
|
57c6714995 | ||
|
|
a5d05cf30e | ||
|
|
68a997fed4 | ||
|
|
49dd776d8b | ||
|
|
d7886da08c | ||
|
|
02f878ec5a | ||
|
|
8d71c38919 | ||
|
|
46fedef07f | ||
|
|
ba44de06da | ||
|
|
5750d058fa | ||
|
|
1febb08240 | ||
|
|
39b76d9013 | ||
|
|
52f7e24a74 | ||
|
|
b8eede7bda | ||
|
|
967c325da8 | ||
|
|
f6f573ebaa | ||
|
|
ff9c110d5a | ||
|
|
c4811c382f | ||
|
|
c6dc2fcd21 | ||
|
|
f6416f50fc | ||
|
|
92dfd70d6a | ||
|
|
b5421f4ba6 | ||
|
|
d046169646 | ||
|
|
57775e9e16 | ||
|
|
3a74b75217 | ||
|
|
24a934295f | ||
|
|
ffcd9d7ac7 | ||
|
|
be2f739e9a | ||
|
|
72f522d464 | ||
|
|
cb4cc08b0a | ||
|
|
85852b71d8 | ||
|
|
8d99b5bc4f | ||
|
|
a38cc69bcc | ||
|
|
76f89d66de | ||
|
|
f8adefdebf | ||
|
|
dbbd1d4d05 | ||
|
|
e687292eb4 | ||
|
|
c4066091ca | ||
|
|
50ad191a8b | ||
|
|
520b59db16 | ||
|
|
4b073d0906 | ||
|
|
dbf2470d46 | ||
|
|
9fb83eaa2f | ||
|
|
0337658904 | ||
|
|
b58ff93459 | ||
|
|
2130ef68b3 | ||
|
|
637cf94bed | ||
|
|
9351cbafab | ||
|
|
18ead88273 | ||
|
|
dba6380ca6 | ||
|
|
ba622d44e4 | ||
|
|
2c1aaa9cba | ||
|
|
8bb60ff039 | ||
|
|
bddab61bcb | ||
|
|
d1f23bb2d5 | ||
|
|
54318c65b0 | ||
|
|
c1927d2342 | ||
|
|
3705625b74 |
@@ -63,3 +63,45 @@ data/
|
|||||||
# Compose/profile runtime state (bind-mounted; avoid ownership/secret issues)
|
# Compose/profile runtime state (bind-mounted; avoid ownership/secret issues)
|
||||||
hermes-config/
|
hermes-config/
|
||||||
runtime/
|
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
|
||||||
|
|||||||
48
.github/workflows/tests.yml
vendored
@@ -55,15 +55,31 @@ jobs:
|
|||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
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
|
- name: Set up Python 3.11
|
||||||
run: uv python install 3.11
|
run: uv python install 3.11
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
# `uv sync --locked` installs the exact pinned set from uv.lock (and
|
||||||
uv venv .venv --python 3.11
|
# fails if the lock is out of sync with pyproject.toml), giving a
|
||||||
source .venv/bin/activate
|
# reproducible env. It also creates .venv itself, so no separate
|
||||||
uv pip install -e ".[all,dev]"
|
# `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
|
||||||
|
|
||||||
- name: Run tests (slice ${{ matrix.slice }}/6)
|
- name: Run tests (slice ${{ matrix.slice }}/6)
|
||||||
# Per-file isolation via scripts/run_tests_parallel.py: discovers
|
# Per-file isolation via scripts/run_tests_parallel.py: discovers
|
||||||
@@ -161,15 +177,31 @@ jobs:
|
|||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
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
|
- name: Set up Python 3.11
|
||||||
run: uv python install 3.11
|
run: uv python install 3.11
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
# `uv sync --locked` installs the exact pinned set from uv.lock (and
|
||||||
uv venv .venv --python 3.11
|
# fails if the lock is out of sync with pyproject.toml), giving a
|
||||||
source .venv/bin/activate
|
# reproducible env. It also creates .venv itself, so no separate
|
||||||
uv pip install -e ".[all,dev]"
|
# `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
|
||||||
|
|
||||||
- name: Packaged-wheel i18n smoke test
|
- name: Packaged-wheel i18n smoke test
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
6
.gitignore
vendored
@@ -114,6 +114,12 @@ docs/superpowers/*
|
|||||||
# treat it as a local edit and autostash it on every run (#38529).
|
# treat it as a local edit and autostash it on every run (#38529).
|
||||||
.hermes-bootstrap-complete
|
.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,
|
# Tool Search live-test harness output — non-deterministic model transcripts,
|
||||||
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
|
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
|
||||||
scripts/out/
|
scripts/out/
|
||||||
|
|||||||
203
AGENTS.md
@@ -4,6 +4,201 @@ Instructions for AI coding assistants and developers working on the hermes-agent
|
|||||||
|
|
||||||
**Never give up on the right solution.**
|
**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
|
## Development Environment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -302,9 +497,11 @@ A **separate** chat surface from both the classic CLI and the dashboard's embedd
|
|||||||
|
|
||||||
## Adding New Tools
|
## Adding New Tools
|
||||||
|
|
||||||
For most custom or local-only tools, do **not** edit Hermes core. Use the plugin
|
Before adding any tool, settle the footprint question first (see "The
|
||||||
route instead: create `~/.hermes/plugins/<name>/plugin.yaml` and
|
Footprint Ladder" in the Contribution Rubric): most capabilities should NOT
|
||||||
`~/.hermes/plugins/<name>/__init__.py`, then register tools with
|
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
|
||||||
`ctx.register_tool(...)`. Plugin toolsets are discovered automatically and can be
|
`ctx.register_tool(...)`. Plugin toolsets are discovered automatically and can be
|
||||||
enabled or disabled without touching `tools/` or `toolsets.py`.
|
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.
|
# hermes process, the dashboard, and per-profile gateways.
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
|
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# ---------- s6-overlay install ----------
|
# ---------- s6-overlay install ----------
|
||||||
@@ -146,9 +146,9 @@ RUN npm install --prefer-offline --no-audit && \
|
|||||||
#
|
#
|
||||||
# `uv sync --frozen --no-install-project --extra all --extra messaging`
|
# `uv sync --frozen --no-install-project --extra all --extra messaging`
|
||||||
# installs the deps reachable through the composite `[all]` extra
|
# installs the deps reachable through the composite `[all]` extra
|
||||||
# (handpicked set intended for the production image), plus gateway
|
# (handpicked set intended for the production image — excludes `[dev]`),
|
||||||
# messaging adapters that should work in the published image without a
|
# plus gateway messaging adapters that should work in the published image
|
||||||
# first-boot lazy install. We do NOT use `--all-extras`:
|
# without a first-boot lazy install. We do NOT use `--all-extras`:
|
||||||
# that would pull in `[rl]` (atroposlib + tinker + torch + wandb from
|
# that would pull in `[rl]` (atroposlib + tinker + torch + wandb from
|
||||||
# git), `[yc-bench]` (another git dep), and `[termux-all]` (Android
|
# git), `[yc-bench]` (another git dep), and `[termux-all]` (Android
|
||||||
# redundancy), none of which belong in the published container.
|
# redundancy), none of which belong in the published container.
|
||||||
@@ -164,19 +164,30 @@ RUN npm install --prefer-offline --no-audit && \
|
|||||||
# image update and recall/retain then fails with
|
# image update and recall/retain then fails with
|
||||||
# `ModuleNotFoundError: No module named 'hindsight_client'` (#38128).
|
# `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.
|
# The editable link is created after the source copy below.
|
||||||
COPY pyproject.toml uv.lock ./
|
COPY pyproject.toml uv.lock ./
|
||||||
RUN touch ./README.md
|
RUN touch ./README.md
|
||||||
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight
|
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --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
|
||||||
|
|
||||||
# ---------- Source code ----------
|
# ---------- Source code ----------
|
||||||
# .dockerignore excludes node_modules, so the installs above survive.
|
# .dockerignore excludes node_modules, so the installs above survive.
|
||||||
COPY --chown=hermes:hermes . .
|
COPY --chown=hermes:hermes . .
|
||||||
|
|
||||||
# Build browser dashboard and terminal UI assets.
|
|
||||||
RUN cd web && npm run build && \
|
|
||||||
cd ../ui-tui && npm run build
|
|
||||||
|
|
||||||
# ---------- Permissions ----------
|
# ---------- Permissions ----------
|
||||||
# Make install dir world-readable so any HERMES_UID can read it at runtime.
|
# Make install dir world-readable so any HERMES_UID can read it at runtime.
|
||||||
# The venv needs to be traversable too.
|
# The venv needs to be traversable too.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
graft skills
|
graft skills
|
||||||
graft optional-skills
|
graft optional-skills
|
||||||
|
graft optional-mcps
|
||||||
graft locales
|
graft locales
|
||||||
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
|
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
|
||||||
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs
|
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
# Hermes Agent ☤
|
# 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">
|
<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://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>
|
<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,6 +187,7 @@ def init_agent(
|
|||||||
thinking_callback: callable = None,
|
thinking_callback: callable = None,
|
||||||
reasoning_callback: callable = None,
|
reasoning_callback: callable = None,
|
||||||
clarify_callback: callable = None,
|
clarify_callback: callable = None,
|
||||||
|
read_terminal_callback: callable = None,
|
||||||
step_callback: callable = None,
|
step_callback: callable = None,
|
||||||
stream_delta_callback: callable = None,
|
stream_delta_callback: callable = None,
|
||||||
interim_assistant_callback: callable = None,
|
interim_assistant_callback: callable = None,
|
||||||
@@ -417,6 +418,7 @@ def init_agent(
|
|||||||
agent.thinking_callback = thinking_callback
|
agent.thinking_callback = thinking_callback
|
||||||
agent.reasoning_callback = reasoning_callback
|
agent.reasoning_callback = reasoning_callback
|
||||||
agent.clarify_callback = clarify_callback
|
agent.clarify_callback = clarify_callback
|
||||||
|
agent.read_terminal_callback = read_terminal_callback
|
||||||
agent.step_callback = step_callback
|
agent.step_callback = step_callback
|
||||||
agent.stream_delta_callback = stream_delta_callback
|
agent.stream_delta_callback = stream_delta_callback
|
||||||
agent.interim_assistant_callback = interim_assistant_callback
|
agent.interim_assistant_callback = interim_assistant_callback
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ def _ra():
|
|||||||
|
|
||||||
|
|
||||||
AGENT_RUNTIME_POST_HOOK_TOOL_NAMES = frozenset(
|
AGENT_RUNTIME_POST_HOOK_TOOL_NAMES = frozenset(
|
||||||
{"todo", "session_search", "memory", "clarify", "delegate_task"}
|
{"todo", "session_search", "memory", "clarify", "read_terminal", "delegate_task"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1784,6 +1784,17 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
|||||||
),
|
),
|
||||||
next_args,
|
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":
|
elif function_name == "delegate_task":
|
||||||
def _execute(next_args: dict) -> Any:
|
def _execute(next_args: dict) -> Any:
|
||||||
return _finish_agent_tool(agent._dispatch_delegate_task(next_args), next_args)
|
return _finish_agent_tool(agent._dispatch_delegate_task(next_args), next_args)
|
||||||
|
|||||||
@@ -73,20 +73,50 @@ ADAPTIVE_EFFORT_MAP = {
|
|||||||
"minimal": "low",
|
"minimal": "low",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Models that accept the "xhigh" output_config.effort level. Opus 4.7 added
|
# ── Anthropic thinking-mode classification ────────────────────────────
|
||||||
# xhigh as a distinct level between high and max; older adaptive-thinking
|
# Claude 4.6 replaced budget-based extended thinking with *adaptive* thinking,
|
||||||
# models (4.6) reject it with a 400. Keep this substring list in sync with
|
# and 4.7 additionally forbids the manual ``thinking`` block entirely and drops
|
||||||
# the Anthropic migration guide as new model families ship.
|
# temperature/top_p/top_k. Newer Claude releases (4.8, and named models like
|
||||||
_XHIGH_EFFORT_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8")
|
# 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 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")
|
_FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
|
||||||
|
|
||||||
# ── Max output token limits per Anthropic model ───────────────────────
|
# ── Max output token limits per Anthropic model ───────────────────────
|
||||||
@@ -94,6 +124,8 @@ _FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
|
|||||||
# max_tokens as a mandatory field. Previously we hardcoded 16384, which
|
# max_tokens as a mandatory field. Previously we hardcoded 16384, which
|
||||||
# starves thinking-enabled models (thinking tokens count toward the limit).
|
# starves thinking-enabled models (thinking tokens count toward the limit).
|
||||||
_ANTHROPIC_OUTPUT_LIMITS = {
|
_ANTHROPIC_OUTPUT_LIMITS = {
|
||||||
|
# Mythos-class named models (claude-fable-5, …) — 1M context, reasoning
|
||||||
|
"claude-fable": 128_000,
|
||||||
# Claude 4.8
|
# Claude 4.8
|
||||||
"claude-opus-4-8": 128_000,
|
"claude-opus-4-8": 128_000,
|
||||||
# Claude 4.7
|
# Claude 4.7
|
||||||
@@ -208,8 +240,17 @@ def _resolve_anthropic_messages_max_tokens(
|
|||||||
|
|
||||||
|
|
||||||
def _supports_adaptive_thinking(model: str) -> bool:
|
def _supports_adaptive_thinking(model: str) -> bool:
|
||||||
"""Return True for Claude 4.6+ models that support adaptive thinking."""
|
"""Return True for Claude models that use adaptive thinking (4.6+).
|
||||||
return any(v in model for v in _ADAPTIVE_THINKING_SUBSTRINGS)
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
def _supports_xhigh_effort(model: str) -> bool:
|
def _supports_xhigh_effort(model: str) -> bool:
|
||||||
@@ -219,18 +260,33 @@ def _supports_xhigh_effort(model: str) -> bool:
|
|||||||
Pre-4.7 adaptive models (Opus/Sonnet 4.6) only accept low/medium/high/max
|
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
|
and reject xhigh with an HTTP 400. Callers should downgrade xhigh→max
|
||||||
when this returns False.
|
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.
|
||||||
"""
|
"""
|
||||||
return any(v in model for v in _XHIGH_EFFORT_SUBSTRINGS)
|
if not _supports_adaptive_thinking(model):
|
||||||
|
return False
|
||||||
|
m = model.lower()
|
||||||
|
return not any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS)
|
||||||
|
|
||||||
|
|
||||||
def _forbids_sampling_params(model: str) -> bool:
|
def _forbids_sampling_params(model: str) -> bool:
|
||||||
"""Return True for models that 400 on any non-default temperature/top_p/top_k.
|
"""Return True for models that 400 on any non-default temperature/top_p/top_k.
|
||||||
|
|
||||||
Opus 4.7 explicitly rejects sampling parameters; later Claude releases are
|
Opus 4.7 introduced this restriction; later Claude releases follow it.
|
||||||
expected to follow suit. Callers should omit these fields entirely rather
|
Defaults unknown Claude models to forbidding sampling params (the modern
|
||||||
than passing zero/default values (the API rejects anything non-null).
|
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).
|
||||||
"""
|
"""
|
||||||
return any(v in model for v in _NO_SAMPLING_PARAMS_SUBSTRINGS)
|
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)
|
||||||
|
|
||||||
|
|
||||||
def _supports_fast_mode(model: str) -> bool:
|
def _supports_fast_mode(model: str) -> bool:
|
||||||
@@ -821,6 +877,7 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=5,
|
timeout=5,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
logger.debug("Keychain: security command not available or timed out")
|
logger.debug("Keychain: security command not available or timed out")
|
||||||
@@ -1163,7 +1220,10 @@ def run_oauth_setup_token() -> Optional[str]:
|
|||||||
"Install it with: npm install -g @anthropic-ai/claude-code"
|
"Install it with: npm install -g @anthropic-ai/claude-code"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Run interactively — stdin/stdout/stderr inherited so user can interact
|
# 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
|
||||||
try:
|
try:
|
||||||
subprocess.run([claude_path, "setup-token"])
|
subprocess.run([claude_path, "setup-token"])
|
||||||
except (KeyboardInterrupt, EOFError):
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance
|
|||||||
from agent.credential_pool import load_pool
|
from agent.credential_pool import load_pool
|
||||||
from hermes_cli.config import get_hermes_home
|
from hermes_cli.config import get_hermes_home
|
||||||
from hermes_constants import OPENROUTER_BASE_URL
|
from hermes_constants import OPENROUTER_BASE_URL
|
||||||
from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_vars
|
from utils import base_url_host_matches, base_url_hostname, model_forces_max_completion_tokens, normalize_proxy_env_vars
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -4300,13 +4300,15 @@ def get_auxiliary_extra_body() -> dict:
|
|||||||
return _nous_extra_body() if auxiliary_is_nous else {}
|
return _nous_extra_body() if auxiliary_is_nous else {}
|
||||||
|
|
||||||
|
|
||||||
def auxiliary_max_tokens_param(value: int) -> dict:
|
def auxiliary_max_tokens_param(value: int, *, model: Optional[str] = None) -> dict:
|
||||||
"""Return the correct max tokens kwarg for the auxiliary client's provider.
|
"""Return the correct max tokens kwarg for the auxiliary client's provider.
|
||||||
|
|
||||||
OpenRouter and local models use 'max_tokens'. Direct OpenAI with newer
|
OpenRouter and local models use 'max_tokens'. Direct OpenAI with newer
|
||||||
models (gpt-4o, o-series, gpt-5+) requires 'max_completion_tokens'.
|
models (gpt-4o, gpt-4.1, gpt-5+, o-series) requires 'max_completion_tokens'.
|
||||||
The Codex adapter translates max_tokens internally, so we use max_tokens
|
The Codex adapter translates max_tokens internally, so we use max_tokens
|
||||||
for it as well.
|
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``.
|
||||||
"""
|
"""
|
||||||
custom_base = _current_custom_base_url()
|
custom_base = _current_custom_base_url()
|
||||||
or_key = os.getenv("OPENROUTER_API_KEY")
|
or_key = os.getenv("OPENROUTER_API_KEY")
|
||||||
@@ -4316,6 +4318,9 @@ def auxiliary_max_tokens_param(value: int) -> dict:
|
|||||||
and _read_nous_auth() is None
|
and _read_nous_auth() is None
|
||||||
and base_url_hostname(custom_base) in {"api.openai.com", "api.githubcopilot.com"}):
|
and base_url_hostname(custom_base) in {"api.openai.com", "api.githubcopilot.com"}):
|
||||||
return {"max_completion_tokens": value}
|
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}
|
return {"max_tokens": value}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,154 @@ from typing import Any, Dict, List
|
|||||||
logger = logging.getLogger(__name__)
|
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(
|
def run_codex_app_server_turn(
|
||||||
agent,
|
agent,
|
||||||
*,
|
*,
|
||||||
@@ -120,6 +268,8 @@ def run_codex_app_server_turn(
|
|||||||
agent._iters_since_skill = (
|
agent._iters_since_skill = (
|
||||||
getattr(agent, "_iters_since_skill", 0) + turn.tool_iterations
|
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
|
# Now check the skill nudge AFTER iters were incremented — same
|
||||||
# pattern the chat_completions path uses (line ~15432).
|
# pattern the chat_completions path uses (line ~15432).
|
||||||
@@ -164,12 +314,13 @@ def run_codex_app_server_turn(
|
|||||||
return {
|
return {
|
||||||
"final_response": turn.final_text,
|
"final_response": turn.final_text,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"api_calls": 1, # one app-server "turn" maps to one logical API call
|
"api_calls": api_calls,
|
||||||
"completed": not turn.interrupted and turn.error is None,
|
"completed": not turn.interrupted and turn.error is None,
|
||||||
"partial": turn.interrupted or turn.error is not None,
|
"partial": turn.interrupted or turn.error is not None,
|
||||||
"error": turn.error,
|
"error": turn.error,
|
||||||
"codex_thread_id": turn.thread_id,
|
"codex_thread_id": turn.thread_id,
|
||||||
"codex_turn_id": turn.turn_id,
|
"codex_turn_id": turn.turn_id,
|
||||||
|
**usage_result,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -246,7 +246,14 @@ def _expand_file_reference(
|
|||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return f"{ref.raw}: path is not a file", None
|
return f"{ref.raw}: path is not a file", None
|
||||||
if _is_binary_file(path):
|
if _is_binary_file(path):
|
||||||
return f"{ref.raw}: binary files are not supported", None
|
# 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)
|
||||||
|
|
||||||
text = path.read_text(encoding="utf-8")
|
text = path.read_text(encoding="utf-8")
|
||||||
if ref.line_start is not None:
|
if ref.line_start is not None:
|
||||||
@@ -290,6 +297,7 @@ def _expand_git_reference(
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return f"{ref.raw}: git command timed out (30s)", None
|
return f"{ref.raw}: git command timed out (30s)", None
|
||||||
@@ -482,6 +490,7 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
||||||
return None
|
return None
|
||||||
@@ -491,6 +500,30 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
|
|||||||
return files[:limit]
|
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:
|
def _file_metadata(path: Path) -> str:
|
||||||
if _is_binary_file(path):
|
if _is_binary_file(path):
|
||||||
return f"{path.stat().st_size} bytes"
|
return f"{path.stat().st_size} bytes"
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import tempfile
|
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -33,6 +32,7 @@ from typing import Any, Callable, Dict, List, NamedTuple, Optional, Set
|
|||||||
|
|
||||||
from hermes_constants import get_hermes_home
|
from hermes_constants import get_hermes_home
|
||||||
from tools import skill_usage
|
from tools import skill_usage
|
||||||
|
from utils import atomic_json_write
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -97,20 +97,7 @@ def load_state() -> Dict[str, Any]:
|
|||||||
def save_state(data: Dict[str, Any]) -> None:
|
def save_state(data: Dict[str, Any]) -> None:
|
||||||
path = _state_file()
|
path = _state_file()
|
||||||
try:
|
try:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
atomic_json_write(path, data, indent=2, sort_keys=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:
|
except Exception as e:
|
||||||
logger.debug("Failed to save curator state: %s", e, exc_info=True)
|
logger.debug("Failed to save curator state: %s", e, exc_info=True)
|
||||||
|
|
||||||
|
|||||||
@@ -966,6 +966,34 @@ def _classify_400(
|
|||||||
should_fallback=False,
|
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
|
# Context overflow from 400
|
||||||
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
|
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
|
||||||
return result_fn(
|
return result_fn(
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ def _install_npm(
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -310,6 +311,7 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]:
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=600,
|
timeout=600,
|
||||||
env=env,
|
env=env,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -347,6 +349,7 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ DEFAULT_CONTEXT_LENGTHS = {
|
|||||||
# fuzzy-match collisions (e.g. "anthropic/claude-sonnet-4" is a
|
# fuzzy-match collisions (e.g. "anthropic/claude-sonnet-4" is a
|
||||||
# substring of "anthropic/claude-sonnet-4.6").
|
# substring of "anthropic/claude-sonnet-4.6").
|
||||||
# OpenRouter-prefixed models resolve via OpenRouter live API or models.dev.
|
# 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.8": 1000000,
|
"claude-opus-4.8": 1000000,
|
||||||
"claude-opus-4-7": 1000000,
|
"claude-opus-4-7": 1000000,
|
||||||
@@ -968,6 +970,16 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
|
|||||||
# OpenRouter/Nous phrasing of the same condition.
|
# OpenRouter/Nous phrasing of the same condition.
|
||||||
"in the output" in error_lower
|
"in the output" in error_lower
|
||||||
and "maximum context length" 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:
|
if not is_output_cap_error:
|
||||||
return None
|
return None
|
||||||
@@ -999,6 +1011,22 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
|
|||||||
if _available >= 1:
|
if _available >= 1:
|
||||||
return _available
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -1784,10 +1812,43 @@ def get_model_context_length(
|
|||||||
if ctx is not None:
|
if ctx is not None:
|
||||||
save_context_length(model, base_url, ctx)
|
save_context_length(model, base_url, ctx)
|
||||||
return 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:
|
if effective_provider:
|
||||||
from agent.models_dev import lookup_models_dev_context
|
from agent.models_dev import lookup_models_dev_context
|
||||||
ctx = lookup_models_dev_context(effective_provider, model)
|
ctx = lookup_models_dev_context(effective_provider, model)
|
||||||
if ctx:
|
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
|
return ctx
|
||||||
|
|
||||||
# 6. OpenRouter live API metadata — provider-unaware fallback.
|
# 6. OpenRouter live API metadata — provider-unaware fallback.
|
||||||
|
|||||||
@@ -885,6 +885,22 @@ def build_environment_hints() -> str:
|
|||||||
f"`uname -a && whoami && pwd`."
|
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():
|
if is_wsl():
|
||||||
hints.append(WSL_ENVIRONMENT_HINT)
|
hints.append(WSL_ENVIRONMENT_HINT)
|
||||||
|
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ def _platform_asset_name() -> str:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=2,
|
timeout=2,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
if "musl" in (res.stdout + res.stderr).lower():
|
if "musl" in (res.stdout + res.stderr).lower():
|
||||||
libc = "musl"
|
libc = "musl"
|
||||||
@@ -525,6 +526,7 @@ def _run_bws_list(
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=_BWS_RUN_TIMEOUT,
|
timeout=_BWS_RUN_TIMEOUT,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired as exc:
|
except subprocess.TimeoutExpired as exc:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=max(1, int(timeout)),
|
timeout=max(1, int(timeout)),
|
||||||
check=False,
|
check=False,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return f"[inline-shell timeout after {timeout}s: {command}]"
|
return f"[inline-shell timeout after {timeout}s: {command}]"
|
||||||
|
|||||||
@@ -1065,6 +1065,25 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||||||
tool_duration = time.time() - tool_start_time
|
tool_duration = time.time() - tool_start_time
|
||||||
if agent._should_emit_quiet_tool_messages():
|
if agent._should_emit_quiet_tool_messages():
|
||||||
agent._vprint(f" {_get_cute_tool_message_impl('clarify', function_args, tool_duration, result=function_result)}")
|
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":
|
elif function_name == "delegate_task":
|
||||||
tasks_arg = function_args.get("tasks")
|
tasks_arg = function_args.get("tasks")
|
||||||
if tasks_arg and isinstance(tasks_arg, list):
|
if tasks_arg and isinstance(tasks_arg, list):
|
||||||
|
|||||||
@@ -378,6 +378,7 @@ def check_codex_binary(
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return False, (
|
return False, (
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ class TurnResult:
|
|||||||
error: Optional[str] = None # Set if turn ended in a non-recoverable error
|
error: Optional[str] = None # Set if turn ended in a non-recoverable error
|
||||||
turn_id: Optional[str] = None
|
turn_id: Optional[str] = None
|
||||||
thread_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
|
# Hint to the caller that the underlying codex subprocess is likely
|
||||||
# wedged (turn-level timeout fired, post-tool watchdog tripped, or
|
# wedged (turn-level timeout fired, post-tool watchdog tripped, or
|
||||||
# token-refresh failure killed the child). The caller should retire
|
# token-refresh failure killed the child). The caller should retire
|
||||||
@@ -501,6 +504,7 @@ class CodexAppServerSession:
|
|||||||
pending = self._client.take_notification(timeout=0)
|
pending = self._client.take_notification(timeout=0)
|
||||||
if pending is None:
|
if pending is None:
|
||||||
break
|
break
|
||||||
|
_apply_token_usage_notification(result, pending)
|
||||||
self._track_pending_file_change(pending)
|
self._track_pending_file_change(pending)
|
||||||
proj = projector.project(pending)
|
proj = projector.project(pending)
|
||||||
if proj.messages:
|
if proj.messages:
|
||||||
@@ -536,6 +540,8 @@ class CodexAppServerSession:
|
|||||||
except Exception: # pragma: no cover - display callback
|
except Exception: # pragma: no cover - display callback
|
||||||
logger.debug("on_event callback raised", exc_info=True)
|
logger.debug("on_event callback raised", exc_info=True)
|
||||||
|
|
||||||
|
_apply_token_usage_notification(result, note)
|
||||||
|
|
||||||
# Track in-progress fileChange items so the approval bridge
|
# Track in-progress fileChange items so the approval bridge
|
||||||
# can surface a real change summary when codex requests
|
# can surface a real change summary when codex requests
|
||||||
# approval (the approval params themselves don't carry the
|
# approval (the approval params themselves don't carry the
|
||||||
@@ -802,6 +808,30 @@ class CodexAppServerSession:
|
|||||||
return cached
|
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:
|
def _approval_choice_to_codex_decision(choice: str) -> str:
|
||||||
"""Map Hermes approval choices onto codex's CommandExecutionApprovalDecision
|
"""Map Hermes approval choices onto codex's CommandExecutionApprovalDecision
|
||||||
/ FileChangeApprovalDecision wire values.
|
/ FileChangeApprovalDecision wire values.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ DEFAULT_PRICING = {"input": 0.0, "output": 0.0}
|
|||||||
|
|
||||||
_ZERO = Decimal("0")
|
_ZERO = Decimal("0")
|
||||||
_ONE_MILLION = Decimal("1000000")
|
_ONE_MILLION = Decimal("1000000")
|
||||||
|
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
|
||||||
|
|
||||||
CostStatus = Literal["actual", "estimated", "included", "unknown"]
|
CostStatus = Literal["actual", "estimated", "included", "unknown"]
|
||||||
CostSource = Literal[
|
CostSource = Literal[
|
||||||
@@ -570,6 +571,8 @@ def resolve_billing_route(
|
|||||||
return BillingRoute(provider="openai-codex", model=model, base_url=base_url or "", billing_mode="subscription_included")
|
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"):
|
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")
|
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":
|
if provider_name == "anthropic":
|
||||||
return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
|
||||||
if provider_name == "openai":
|
if provider_name == "openai":
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 561 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 361 KiB |
|
Before Width: | Height: | Size: 674 KiB After Width: | Height: | Size: 561 KiB |
@@ -40,6 +40,15 @@ const path = require('node:path')
|
|||||||
const https = require('node:https')
|
const https = require('node:https')
|
||||||
const { spawn } = require('node:child_process')
|
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
|
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
|
||||||
|
|
||||||
// Stages flagged needs_user_input=true in the manifest are skipped by the
|
// Stages flagged needs_user_input=true in the manifest are skipped by the
|
||||||
@@ -284,7 +293,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
|||||||
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
|
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
|
||||||
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
|
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
|
||||||
|
|
||||||
const child = spawn(ps, fullArgs, {
|
const child = spawn(ps, fullArgs, hiddenWindowsChildOptions({
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -292,7 +301,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
|||||||
// choice rather than re-computing the default.
|
// choice rather than re-computing the default.
|
||||||
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
|
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
|
||||||
}
|
}
|
||||||
})
|
}))
|
||||||
|
|
||||||
let stdout = ''
|
let stdout = ''
|
||||||
let stderr = ''
|
let stderr = ''
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ const { fileURLToPath, pathToFileURL } = require('node:url')
|
|||||||
const { execFileSync, spawn } = require('node:child_process')
|
const { execFileSync, spawn } = require('node:child_process')
|
||||||
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||||
|
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
|
||||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||||
|
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||||
const {
|
const {
|
||||||
buildPosixCleanupScript,
|
buildPosixCleanupScript,
|
||||||
buildWindowsCleanupScript,
|
buildWindowsCleanupScript,
|
||||||
@@ -38,6 +40,7 @@ const {
|
|||||||
shouldRemoveAppBundle,
|
shouldRemoveAppBundle,
|
||||||
uninstallArgsForMode
|
uninstallArgsForMode
|
||||||
} = require('./desktop-uninstall.cjs')
|
} = require('./desktop-uninstall.cjs')
|
||||||
|
const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./workspace-cwd.cjs')
|
||||||
const {
|
const {
|
||||||
authModeFromStatus,
|
authModeFromStatus,
|
||||||
buildGatewayWsUrl,
|
buildGatewayWsUrl,
|
||||||
@@ -62,9 +65,11 @@ const {
|
|||||||
} = require('./hardening.cjs')
|
} = require('./hardening.cjs')
|
||||||
|
|
||||||
let nodePty = null
|
let nodePty = null
|
||||||
|
let nodePtyDir = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
nodePty = require('node-pty')
|
nodePty = require('node-pty')
|
||||||
|
nodePtyDir = path.dirname(require.resolve('node-pty/package.json'))
|
||||||
} catch {
|
} catch {
|
||||||
// Packaged builds set `files:` in package.json, which excludes node_modules
|
// Packaged builds set `files:` in package.json, which excludes node_modules
|
||||||
// from the asar. Workspace dedup also hoists this native dep to the repo
|
// from the asar. Workspace dedup also hoists this native dep to the repo
|
||||||
@@ -77,10 +82,12 @@ try {
|
|||||||
const path = require('node:path')
|
const path = require('node:path')
|
||||||
const resourcesPath = process.resourcesPath
|
const resourcesPath = process.resourcesPath
|
||||||
if (resourcesPath) {
|
if (resourcesPath) {
|
||||||
nodePty = require(path.join(resourcesPath, 'native-deps', 'node-pty'))
|
nodePtyDir = path.join(resourcesPath, 'native-deps', 'node-pty')
|
||||||
|
nodePty = require(nodePtyDir)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
nodePty = null
|
nodePty = null
|
||||||
|
nodePtyDir = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +107,13 @@ const IS_WINDOWS = process.platform === 'win32'
|
|||||||
const IS_WSL = isWslEnvironment()
|
const IS_WSL = isWslEnvironment()
|
||||||
const APP_ROOT = app.getAppPath()
|
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
|
// Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU
|
||||||
// compositor flicker — accelerated layers can't be presented cleanly over the
|
// compositor flicker — accelerated layers can't be presented cleanly over the
|
||||||
// wire, so the window flashes during scroll/streaming/animation. Local
|
// wire, so the window flashes during scroll/streaming/animation. Local
|
||||||
@@ -1099,7 +1113,7 @@ function findSystemPython() {
|
|||||||
const out = execFileSync(
|
const out = execFileSync(
|
||||||
'reg',
|
'reg',
|
||||||
['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'],
|
['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'],
|
||||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
||||||
)
|
)
|
||||||
// Output format: " (Default) REG_SZ C:\Path\To\Python\"
|
// Output format: " (Default) REG_SZ C:\Path\To\Python\"
|
||||||
const match = out.match(/REG_SZ\s+(.+?)\s*$/m)
|
const match = out.match(/REG_SZ\s+(.+?)\s*$/m)
|
||||||
@@ -1135,10 +1149,10 @@ function findSystemPython() {
|
|||||||
if (pyExe) {
|
if (pyExe) {
|
||||||
for (const version of SUPPORTED_VERSIONS) {
|
for (const version of SUPPORTED_VERSIONS) {
|
||||||
try {
|
try {
|
||||||
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], {
|
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], hiddenWindowsChildOptions({
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: ['ignore', 'pipe', 'ignore']
|
stdio: ['ignore', 'pipe', 'ignore']
|
||||||
})
|
}))
|
||||||
const candidate = out.trim()
|
const candidate = out.trim()
|
||||||
if (candidate && fileExists(candidate)) return candidate
|
if (candidate && fileExists(candidate)) return candidate
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1273,11 +1287,11 @@ function resolveUpdateRoot() {
|
|||||||
|
|
||||||
function runGit(args, options = {}) {
|
function runGit(args, options = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, {
|
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
|
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
|
||||||
stdio: ['ignore', 'pipe', 'pipe']
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
})
|
}))
|
||||||
|
|
||||||
let stdout = ''
|
let stdout = ''
|
||||||
let stderr = ''
|
let stderr = ''
|
||||||
@@ -1487,7 +1501,7 @@ function forceKillProcessTree(pid) {
|
|||||||
if (!IS_WINDOWS) return
|
if (!IS_WINDOWS) return
|
||||||
if (!Number.isInteger(pid) || pid <= 0) return
|
if (!Number.isInteger(pid) || pid <= 0) return
|
||||||
try {
|
try {
|
||||||
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
|
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], hiddenWindowsChildOptions({ stdio: 'ignore' }))
|
||||||
} catch {
|
} catch {
|
||||||
// Already gone, or no permission — best effort; the unlock wait below is
|
// Already gone, or no permission — best effort; the unlock wait below is
|
||||||
// the real gate.
|
// the real gate.
|
||||||
@@ -1673,11 +1687,11 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) {
|
|||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
let child
|
let child
|
||||||
try {
|
try {
|
||||||
child = spawn(command, args, {
|
child = spawn(command, args, hiddenWindowsChildOptions({
|
||||||
cwd,
|
cwd,
|
||||||
env: { ...process.env, ...(env || {}) },
|
env: { ...process.env, ...(env || {}) },
|
||||||
stdio: ['ignore', 'pipe', 'pipe']
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
})
|
}))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
resolve({ code: 1, error: err.message })
|
resolve({ code: 1, error: err.message })
|
||||||
return
|
return
|
||||||
@@ -1948,6 +1962,21 @@ function resolveRendererIndex() {
|
|||||||
return candidates[0]
|
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() {
|
function resolveHermesCwd() {
|
||||||
// In a packaged build, `process.cwd()` resolves to the install root (e.g.
|
// In a packaged build, `process.cwd()` resolves to the install root (e.g.
|
||||||
// `…/win-unpacked` on Windows or `/Applications/Hermes.app/Contents/...`
|
// `…/win-unpacked` on Windows or `/Applications/Hermes.app/Contents/...`
|
||||||
@@ -1959,7 +1988,7 @@ function resolveHermesCwd() {
|
|||||||
const candidates = [
|
const candidates = [
|
||||||
readDefaultProjectDir(),
|
readDefaultProjectDir(),
|
||||||
process.env.HERMES_DESKTOP_CWD,
|
process.env.HERMES_DESKTOP_CWD,
|
||||||
process.env.INIT_CWD,
|
IS_PACKAGED ? null : process.env.INIT_CWD,
|
||||||
IS_PACKAGED ? null : process.cwd(),
|
IS_PACKAGED ? null : process.cwd(),
|
||||||
!IS_PACKAGED ? SOURCE_REPO_ROOT : null,
|
!IS_PACKAGED ? SOURCE_REPO_ROOT : null,
|
||||||
app.getPath('home')
|
app.getPath('home')
|
||||||
@@ -1968,12 +1997,37 @@ function resolveHermesCwd() {
|
|||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (!candidate) continue
|
if (!candidate) continue
|
||||||
const resolved = path.resolve(String(candidate))
|
const resolved = path.resolve(String(candidate))
|
||||||
|
|
||||||
|
if (isPackagedInstallPath(resolved)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (directoryExists(resolved)) return resolved
|
if (directoryExists(resolved)) return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
return app.getPath('home')
|
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
|
// Persisted "Default project directory" — surfaced as a setting in the
|
||||||
// renderer (see app/settings/sessions-settings.tsx). Stored as JSON in
|
// renderer (see app/settings/sessions-settings.tsx). Stored as JSON in
|
||||||
// userData so it survives self-updates without bleeding into the new
|
// userData so it survives self-updates without bleeding into the new
|
||||||
@@ -2624,7 +2678,7 @@ function fetchHtmlTitleWithCurl(rawUrl) {
|
|||||||
'--raw',
|
'--raw',
|
||||||
url
|
url
|
||||||
]
|
]
|
||||||
const child = spawn('curl', args, { stdio: ['ignore', 'pipe', 'ignore'] })
|
const child = spawn('curl', args, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'ignore'] }))
|
||||||
const chunks = []
|
const chunks = []
|
||||||
let bytes = 0
|
let bytes = 0
|
||||||
|
|
||||||
@@ -3270,14 +3324,18 @@ function setAndPersistZoomLevel(window, zoomLevel) {
|
|||||||
const next = clampZoomLevel(zoomLevel)
|
const next = clampZoomLevel(zoomLevel)
|
||||||
window.webContents.setZoomLevel(next)
|
window.webContents.setZoomLevel(next)
|
||||||
window.webContents
|
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}`))
|
.catch(error => rememberLog(`[zoom] persist failed: ${error?.message || error}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
function restorePersistedZoomLevel(window) {
|
function restorePersistedZoomLevel(window) {
|
||||||
if (!window || window.isDestroyed()) return
|
if (!window || window.isDestroyed()) return
|
||||||
window.webContents
|
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 => {
|
.then(stored => {
|
||||||
if (stored == null || !window || window.isDestroyed()) return
|
if (stored == null || !window || window.isDestroyed()) return
|
||||||
const level = clampZoomLevel(Number(stored))
|
const level = clampZoomLevel(Number(stored))
|
||||||
@@ -4136,9 +4194,7 @@ async function requestJsonForProfile(profile, path, method, body) {
|
|||||||
const conn = await ensureBackend(profile)
|
const conn = await ensureBackend(profile)
|
||||||
const url = `${conn.baseUrl}${path}`
|
const url = `${conn.baseUrl}${path}`
|
||||||
const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }
|
const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }
|
||||||
return conn.authMode === 'oauth'
|
return conn.authMode === 'oauth' ? fetchJsonViaOauthSession(url, opts) : fetchJson(url, conn.token, opts)
|
||||||
? fetchJsonViaOauthSession(url, opts)
|
|
||||||
: fetchJson(url, conn.token, opts)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function probeRemoteAuthMode(rawUrl) {
|
async function probeRemoteAuthMode(rawUrl) {
|
||||||
@@ -4212,7 +4268,8 @@ async function testDesktopConnectionConfig(input = {}) {
|
|||||||
// The block under test: a per-profile entry or the global remote. Coerce has
|
// 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.
|
// already normalized the URL and resolved token inheritance for the scope.
|
||||||
const block = key ? config.profiles?.[key] || null : config.remote
|
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
|
// ``/api/status`` is public on every gateway (no creds needed), so a
|
||||||
// reachability test works for local, token, and oauth modes alike — we only
|
// 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;
|
// need a base URL. For a remote config we normalize the URL from the input;
|
||||||
@@ -4295,20 +4352,31 @@ async function teardownPrimaryBackendAndWait() {
|
|||||||
const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null
|
const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null
|
||||||
resetHermesConnection()
|
resetHermesConnection()
|
||||||
|
|
||||||
if (!dying) {
|
await waitForBackendExit(dying)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForBackendExit(child, timeoutMs = 5000) {
|
||||||
|
if (!child) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise(resolve => {
|
await new Promise(resolve => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
dying.kill('SIGKILL')
|
if (IS_WINDOWS && Number.isInteger(child.pid)) {
|
||||||
|
forceKillProcessTree(child.pid)
|
||||||
|
} else {
|
||||||
|
child.kill('SIGKILL')
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Already gone.
|
// Already gone.
|
||||||
}
|
}
|
||||||
resolve()
|
resolve()
|
||||||
}, 5000)
|
}, timeoutMs)
|
||||||
dying.once('exit', () => {
|
child.once('exit', () => {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
@@ -4430,12 +4498,16 @@ async function spawnPoolBackend(profile, entry) {
|
|||||||
|
|
||||||
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
|
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
|
||||||
|
|
||||||
const child = spawn(backend.command, backend.args, {
|
const child = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
|
||||||
cwd: hermesCwd,
|
cwd: hermesCwd,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
HERMES_HOME,
|
HERMES_HOME,
|
||||||
...backend.env,
|
...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,
|
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||||
// scheduler tick loop (the gateway isn't running under the app).
|
// scheduler tick loop (the gateway isn't running under the app).
|
||||||
@@ -4444,7 +4516,7 @@ async function spawnPoolBackend(profile, entry) {
|
|||||||
},
|
},
|
||||||
shell: backend.shell,
|
shell: backend.shell,
|
||||||
stdio: ['ignore', 'pipe', 'pipe']
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
})
|
}))
|
||||||
entry.process = child
|
entry.process = child
|
||||||
entry.port = port
|
entry.port = port
|
||||||
entry.token = token
|
entry.token = token
|
||||||
@@ -4466,7 +4538,9 @@ async function spawnPoolBackend(profile, entry) {
|
|||||||
rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`)
|
rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`)
|
||||||
backendPool.delete(profile)
|
backendPool.delete(profile)
|
||||||
if (!ready) {
|
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}).`)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -4500,12 +4574,70 @@ 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() {
|
function stopAllPoolBackends() {
|
||||||
for (const profile of [...backendPool.keys()]) {
|
for (const profile of [...backendPool.keys()]) {
|
||||||
stopPoolBackend(profile)
|
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() {
|
async function startHermes() {
|
||||||
// Latched-failure short-circuit: once bootstrap has failed in this
|
// Latched-failure short-circuit: once bootstrap has failed in this
|
||||||
// process, every subsequent startHermes() call re-throws the same error
|
// process, every subsequent startHermes() call re-throws the same error
|
||||||
@@ -4566,7 +4698,7 @@ async function startHermes() {
|
|||||||
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
|
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
|
||||||
rememberLog(`Starting Hermes backend via ${backend.label}`)
|
rememberLog(`Starting Hermes backend via ${backend.label}`)
|
||||||
|
|
||||||
hermesProcess = spawn(backend.command, backend.args, {
|
hermesProcess = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
|
||||||
cwd: hermesCwd,
|
cwd: hermesCwd,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -4580,6 +4712,7 @@ async function startHermes() {
|
|||||||
// can't reliably do that, so we set it inline for every spawn.
|
// can't reliably do that, so we set it inline for every spawn.
|
||||||
HERMES_HOME,
|
HERMES_HOME,
|
||||||
...backend.env,
|
...backend.env,
|
||||||
|
TERMINAL_CWD: hermesCwd,
|
||||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||||
// scheduler tick loop (the gateway isn't running under the app).
|
// scheduler tick loop (the gateway isn't running under the app).
|
||||||
@@ -4588,7 +4721,7 @@ async function startHermes() {
|
|||||||
},
|
},
|
||||||
shell: backend.shell,
|
shell: backend.shell,
|
||||||
stdio: ['ignore', 'pipe', 'pipe']
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
})
|
}))
|
||||||
|
|
||||||
hermesProcess.stdout.on('data', rememberLog)
|
hermesProcess.stdout.on('data', rememberLog)
|
||||||
hermesProcess.stderr.on('data', rememberLog)
|
hermesProcess.stderr.on('data', rememberLog)
|
||||||
@@ -4677,6 +4810,94 @@ async function startHermes() {
|
|||||||
return connectionPromise
|
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() {
|
function createWindow() {
|
||||||
const icon = getAppIconPath()
|
const icon = getAppIconPath()
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
@@ -4737,23 +4958,7 @@ function createWindow() {
|
|||||||
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
||||||
mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false))
|
mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false))
|
||||||
|
|
||||||
installPreviewShortcut(mainWindow)
|
wireCommonWindowHandlers(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) => {
|
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||||
rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`)
|
rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`)
|
||||||
@@ -4859,6 +5064,15 @@ ipcMain.handle('hermes:backend:touch', async (_event, profile) => {
|
|||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile))
|
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 () => {
|
ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||||
// Renderer's "Reload and retry" path. Clear the latched failure and
|
// Renderer's "Reload and retry" path. Clear the latched failure and
|
||||||
// reset connection state so the next startHermes() call restarts the
|
// reset connection state so the next startHermes() call restarts the
|
||||||
@@ -5097,17 +5311,19 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) {
|
|||||||
let total = (Number(base.total) || 0) - remoteProfiles.reduce((n, p) => n + (profileTotals[p] || 0), 0)
|
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.
|
// Swap each remote profile's stale local rows/total for the remote's real ones.
|
||||||
await Promise.all(remoteProfiles.map(async name => {
|
await Promise.all(
|
||||||
const list = await remoteSessionList(name, remoteParams).catch(() => null)
|
remoteProfiles.map(async name => {
|
||||||
if (!list) {
|
const list = await remoteSessionList(name, remoteParams).catch(() => null)
|
||||||
delete profileTotals[name] // dead remote → drop its stale local total too
|
if (!list) {
|
||||||
return
|
delete profileTotals[name] // dead remote → drop its stale local total too
|
||||||
}
|
return
|
||||||
const rows = rowsOf(list)
|
}
|
||||||
merged.push(...rows)
|
const rows = rowsOf(list)
|
||||||
profileTotals[name] = Number(list.total) || rows.length
|
merged.push(...rows)
|
||||||
total += profileTotals[name]
|
profileTotals[name] = Number(list.total) || rows.length
|
||||||
}))
|
total += profileTotals[name]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const recency = s => s?.[order] ?? s?.started_at ?? 0
|
const recency = s => s?.[order] ?? s?.started_at ?? 0
|
||||||
merged.sort((a, b) => recency(b) - recency(a))
|
merged.sort((a, b) => recency(b) - recency(a))
|
||||||
@@ -5124,6 +5340,8 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
|||||||
return rerouted
|
return rerouted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await prepareProfileDeleteRequest(request)
|
||||||
|
|
||||||
const connection = await ensureBackend(request?.profile)
|
const connection = await ensureBackend(request?.profile)
|
||||||
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||||
const url = `${connection.baseUrl}${request.path}`
|
const url = `${connection.baseUrl}${request.path}`
|
||||||
@@ -5271,9 +5489,12 @@ ipcMain.handle('hermes:openExternal', (_event, url) => {
|
|||||||
// session spawn (no app restart needed).
|
// session spawn (no app restart needed).
|
||||||
ipcMain.handle('hermes:setting:defaultProjectDir:get', async () => ({
|
ipcMain.handle('hermes:setting:defaultProjectDir:get', async () => ({
|
||||||
dir: readDefaultProjectDir(),
|
dir: readDefaultProjectDir(),
|
||||||
defaultLabel: path.join(app.getPath('home'), 'hermes-projects')
|
defaultLabel: app.getPath('home'),
|
||||||
|
resolvedCwd: resolveHermesCwd()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
ipcMain.handle('hermes:workspace:sanitize', async (_event, cwd) => sanitizeWorkspaceCwd(cwd))
|
||||||
|
|
||||||
ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => {
|
ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => {
|
||||||
const next = typeof dir === 'string' && dir.trim() ? dir.trim() : null
|
const next = typeof dir === 'string' && dir.trim() ? dir.trim() : null
|
||||||
|
|
||||||
@@ -5363,22 +5584,121 @@ function findGitRoot(start) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function terminalShellCommand() {
|
function isExecutableFile(filePath) {
|
||||||
if (IS_WINDOWS) {
|
if (!filePath || !path.isAbsolute(filePath)) {
|
||||||
return { args: [], command: process.env.COMSPEC || 'cmd.exe' }
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const configuredShell = process.env.SHELL || ''
|
try {
|
||||||
const shellPath =
|
fs.accessSync(filePath, fs.constants.X_OK)
|
||||||
(path.isAbsolute(configuredShell) && fs.existsSync(configuredShell) && configuredShell) ||
|
return true
|
||||||
['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => fs.existsSync(candidate)) ||
|
} catch {
|
||||||
'/bin/sh'
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function posixShellSpec(shellPath) {
|
||||||
const shellName = path.basename(shellPath)
|
const shellName = path.basename(shellPath)
|
||||||
const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i']
|
const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i']
|
||||||
|
|
||||||
return { args: interactiveArgs, command: shellPath, name: shellName }
|
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) {
|
function safeTerminalCwd(cwd) {
|
||||||
const candidate = path.resolve(String(cwd || app.getPath('home')))
|
const candidate = path.resolve(String(cwd || app.getPath('home')))
|
||||||
|
|
||||||
@@ -5416,6 +5736,11 @@ function terminalShellEnv() {
|
|||||||
env.TERM_PROGRAM = 'Hermes'
|
env.TERM_PROGRAM = 'Hermes'
|
||||||
env.TERM_PROGRAM_VERSION = app.getVersion()
|
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
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5487,6 +5812,8 @@ ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
|||||||
throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.')
|
throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureSpawnHelperExecutable()
|
||||||
|
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
const { args, command, name } = terminalShellCommand()
|
const { args, command, name } = terminalShellCommand()
|
||||||
const cwd = safeTerminalCwd(payload?.cwd)
|
const cwd = safeTerminalCwd(payload?.cwd)
|
||||||
@@ -5666,11 +5993,11 @@ async function getUninstallSummary() {
|
|||||||
resolve(value)
|
resolve(value)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], {
|
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], hiddenWindowsChildOptions({
|
||||||
cwd: agentRoot,
|
cwd: agentRoot,
|
||||||
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
|
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
|
||||||
stdio: ['ignore', 'pipe', 'ignore']
|
stdio: ['ignore', 'pipe', 'ignore']
|
||||||
})
|
}))
|
||||||
child.stdout.on('data', chunk => {
|
child.stdout.on('data', chunk => {
|
||||||
stdout += chunk.toString()
|
stdout += chunk.toString()
|
||||||
})
|
})
|
||||||
@@ -5809,6 +6136,12 @@ ipcMain.handle('hermes:uninstall:run', async (_event, payload) => {
|
|||||||
return runDesktopUninstall(String(mode || ''))
|
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(() => {
|
app.whenReady().then(() => {
|
||||||
if (IS_MAC) {
|
if (IS_MAC) {
|
||||||
@@ -5824,7 +6157,14 @@ app.whenReady().then(() => {
|
|||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
// 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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||||||
revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'),
|
revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'),
|
||||||
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
|
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
|
||||||
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', 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'),
|
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
||||||
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
|
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
|
||||||
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
||||||
@@ -41,6 +42,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||||
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
|
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
|
||||||
|
sanitizeWorkspaceCwd: cwd => ipcRenderer.invoke('hermes:workspace:sanitize', cwd),
|
||||||
settings: {
|
settings: {
|
||||||
getDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:get'),
|
getDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:get'),
|
||||||
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
|
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
|
||||||
@@ -132,5 +134,9 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||||||
ipcRenderer.on('hermes:updates:progress', listener)
|
ipcRenderer.on('hermes:updates:progress', listener)
|
||||||
return () => ipcRenderer.removeListener('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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
86
apps/desktop/electron/session-windows.cjs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// 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 }
|
||||||
165
apps/desktop/electron/session-windows.test.cjs
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
331
apps/desktop/electron/vscode-marketplace.cjs
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
'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 }
|
||||||
|
}
|
||||||
113
apps/desktop/electron/vscode-marketplace.test.cjs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
'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)
|
||||||
|
})
|
||||||
54
apps/desktop/electron/windows-child-process.test.cjs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
'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')
|
||||||
|
})
|
||||||
38
apps/desktop/electron/workspace-cwd.cjs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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 }
|
||||||
45
apps/desktop/electron/workspace-cwd.test.cjs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
"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",
|
"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",
|
||||||
"type-check": "tsc -b",
|
"type-check": "tsc -b",
|
||||||
"lint": "eslint src/ electron/",
|
"lint": "eslint src/ electron/",
|
||||||
"lint:fix": "eslint src/ electron/ --fix",
|
"lint:fix": "eslint src/ electron/ --fix",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 528 KiB |
@@ -3,8 +3,9 @@ import { useStore } from '@nanostores/react'
|
|||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import { Tip } from '@/components/ui/tooltip'
|
import { Tip } from '@/components/ui/tooltip'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
|
import { AlertCircle, FileText, FolderOpen, ImageIcon, Link, Loader2, Terminal } from '@/lib/icons'
|
||||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import type { ComposerAttachment } from '@/store/composer'
|
import type { ComposerAttachment } from '@/store/composer'
|
||||||
import { notifyError } from '@/store/notifications'
|
import { notifyError } from '@/store/notifications'
|
||||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||||
@@ -31,7 +32,9 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||||||
const c = t.composer
|
const c = t.composer
|
||||||
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
|
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
|
||||||
const cwd = useStore($currentCwd)
|
const cwd = useStore($currentCwd)
|
||||||
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal'
|
const isUploading = attachment.uploadState === 'uploading'
|
||||||
|
const hasUploadError = attachment.uploadState === 'error'
|
||||||
|
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading
|
||||||
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
|
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
|
||||||
|
|
||||||
async function openPreview() {
|
async function openPreview() {
|
||||||
@@ -59,7 +62,15 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||||||
throw new Error(c.couldNotPreview(attachment.label))
|
throw new Error(c.couldNotPreview(attachment.label))
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentSessionPreviewTarget(preview, 'manual', target)
|
// 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)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifyError(error, c.previewUnavailable)
|
notifyError(error, c.previewUnavailable)
|
||||||
}
|
}
|
||||||
@@ -69,30 +80,51 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||||||
<Tip label={attachment.path || attachment.detail || attachment.label}>
|
<Tip label={attachment.path || attachment.detail || attachment.label}>
|
||||||
<div className="group/attachment relative min-w-0 shrink-0">
|
<div className="group/attachment relative min-w-0 shrink-0">
|
||||||
<button
|
<button
|
||||||
|
aria-busy={isUploading || undefined}
|
||||||
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
|
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
|
||||||
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
|
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'
|
||||||
|
)}
|
||||||
disabled={!canPreview}
|
disabled={!canPreview}
|
||||||
onClick={() => void openPreview()}
|
onClick={() => void openPreview()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{attachment.previewUrl && attachment.kind === 'image' ? (
|
<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">
|
||||||
<img
|
{attachment.previewUrl && attachment.kind === 'image' ? (
|
||||||
alt={attachment.label}
|
<img
|
||||||
className="size-8 shrink-0 border border-border/70 object-cover"
|
alt={attachment.label}
|
||||||
draggable={false}
|
className="size-full object-cover"
|
||||||
src={attachment.previewUrl}
|
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" />
|
<Icon className="size-3.5" />
|
||||||
</span>
|
)}
|
||||||
)}
|
{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 className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
|
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
|
||||||
{attachment.label}
|
{attachment.label}
|
||||||
</span>
|
</span>
|
||||||
{detail && (
|
{detail && (
|
||||||
<span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">
|
<span
|
||||||
|
className={cn(
|
||||||
|
'block truncate text-[0.62rem] leading-3.5',
|
||||||
|
hasUploadError ? 'text-destructive/80' : 'text-muted-foreground/65'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{detail}
|
{detail}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Tip } from '@/components/ui/tooltip'
|
|||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { AudioLines, Layers3, Loader2, Square, SteeringWheel } from '@/lib/icons'
|
import { AudioLines, Layers3, Loader2, Square, SteeringWheel } from '@/lib/icons'
|
||||||
|
import { formatCombo } from '@/lib/keybinds/combo'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import type { ConversationStatus } from './hooks/use-voice-conversation'
|
import type { ConversationStatus } from './hooks/use-voice-conversation'
|
||||||
@@ -62,6 +63,7 @@ export function ComposerControls({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const c = t.composer
|
const c = t.composer
|
||||||
|
const steerLabel = `${c.steer} (${formatCombo('mod+enter')})`
|
||||||
|
|
||||||
if (conversation.active) {
|
if (conversation.active) {
|
||||||
return <ConversationPill {...conversation} disabled={disabled} />
|
return <ConversationPill {...conversation} disabled={disabled} />
|
||||||
@@ -73,9 +75,9 @@ export function ComposerControls({
|
|||||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||||
{canSteer && (
|
{canSteer && (
|
||||||
<Tip label={c.steer}>
|
<Tip label={steerLabel}>
|
||||||
<Button
|
<Button
|
||||||
aria-label={c.steer}
|
aria-label={steerLabel}
|
||||||
className={GHOST_ICON_BTN}
|
className={GHOST_ICON_BTN}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onSteer}
|
onClick={onSteer}
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -43,7 +43,7 @@ import {
|
|||||||
import { $gatewayState, $messages } from '@/store/session'
|
import { $gatewayState, $messages } from '@/store/session'
|
||||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||||
|
|
||||||
import { extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions'
|
import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../hooks/use-composer-actions'
|
||||||
|
|
||||||
import { AttachmentList } from './attachments'
|
import { AttachmentList } from './attachments'
|
||||||
import { ContextMenu } from './context-menu'
|
import { ContextMenu } from './context-menu'
|
||||||
@@ -64,7 +64,7 @@ import { useVoiceConversation } from './hooks/use-voice-conversation'
|
|||||||
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
||||||
import {
|
import {
|
||||||
dragHasAttachments,
|
dragHasAttachments,
|
||||||
droppedFileInlineRef,
|
droppedFileInlineRefs,
|
||||||
type InlineRefInput,
|
type InlineRefInput,
|
||||||
insertInlineRefsIntoEditor
|
insertInlineRefsIntoEditor
|
||||||
} from './inline-refs'
|
} from './inline-refs'
|
||||||
@@ -814,7 +814,16 @@ export function ChatBar({
|
|||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
if (!busy && !hasComposerPayload && queuedPrompts.length > 0) {
|
// 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) {
|
||||||
void drainNextQueued()
|
void drainNextQueued()
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -822,7 +831,10 @@ export function ChatBar({
|
|||||||
|
|
||||||
// Empty Enter while busy is a no-op — interrupting is explicit (Stop/Esc),
|
// 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.
|
// never a stray Enter after sending. With a payload, submitDraft queues it.
|
||||||
if (busy && !hasComposerPayload) {
|
// 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) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -919,24 +931,25 @@ export function ChatBar({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.from(event.dataTransfer.types || []).includes(HERMES_PATHS_MIME)) {
|
// In-app drags (project tree / gutter) are workspace-relative paths the
|
||||||
const refs = candidates
|
// gateway resolves directly, so they stay inline @file:/@line: refs. OS
|
||||||
.map(candidate => droppedFileInlineRef(candidate, cwd))
|
// drops are absolute local paths a remote gateway can't read (and images
|
||||||
.filter((ref): ref is string => Boolean(ref))
|
// need byte upload for vision), so route them through the upload pipeline.
|
||||||
|
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
|
||||||
|
const refs = droppedFileInlineRefs(inAppRefs, cwd)
|
||||||
|
|
||||||
if (insertInlineRefs(refs)) {
|
if (refs.length && insertInlineRefs(refs)) {
|
||||||
triggerHaptic('selection')
|
triggerHaptic('selection')
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => {
|
if (osDrops.length) {
|
||||||
if (attached) {
|
void Promise.resolve(onAttachDroppedItems(osDrops)).then(attached => {
|
||||||
triggerHaptic('selection')
|
if (attached) {
|
||||||
requestMainFocus()
|
triggerHaptic('selection')
|
||||||
}
|
requestMainFocus()
|
||||||
})
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
|
const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
|
||||||
@@ -956,11 +969,7 @@ export function ChatBar({
|
|||||||
|
|
||||||
const candidates = extractDroppedFiles(event.dataTransfer)
|
const candidates = extractDroppedFiles(event.dataTransfer)
|
||||||
|
|
||||||
const refs = candidates
|
if (!candidates.length) {
|
||||||
.map(candidate => droppedFileInlineRef(candidate, cwd))
|
|
||||||
.filter((ref): ref is string => Boolean(ref))
|
|
||||||
|
|
||||||
if (!refs.length) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -968,9 +977,27 @@ export function ChatBar({
|
|||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
resetDragState()
|
resetDragState()
|
||||||
|
|
||||||
if (insertInlineRefs(refs)) {
|
// 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)) {
|
||||||
triggerHaptic('selection')
|
triggerHaptic('selection')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (attach && osDrops.length) {
|
||||||
|
void Promise.resolve(attach(osDrops)).then(attached => {
|
||||||
|
if (attached) {
|
||||||
|
triggerHaptic('selection')
|
||||||
|
requestMainFocus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearDraft = useCallback(() => {
|
const clearDraft = useCallback(() => {
|
||||||
@@ -1212,6 +1239,26 @@ export function ChatBar({
|
|||||||
}, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const submitDraft = () => {
|
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) {
|
if (queueEdit) {
|
||||||
exitQueuedEdit('save')
|
exitQueuedEdit('save')
|
||||||
} else if (busy) {
|
} else if (busy) {
|
||||||
@@ -1222,12 +1269,12 @@ export function ChatBar({
|
|||||||
// busy guard for commands that genuinely need an idle session (skill
|
// busy guard for commands that genuinely need an idle session (skill
|
||||||
// /send directives). Queuing them would make every slash command wait
|
// /send directives). Queuing them would make every slash command wait
|
||||||
// for the current turn to finish, which is how the TUI never behaves.
|
// for the current turn to finish, which is how the TUI never behaves.
|
||||||
if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
|
if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) {
|
||||||
const submitted = draft
|
const submitted = text
|
||||||
triggerHaptic('submit')
|
triggerHaptic('submit')
|
||||||
clearDraft()
|
clearDraft()
|
||||||
void onSubmit(submitted)
|
void onSubmit(submitted)
|
||||||
} else if (hasComposerPayload) {
|
} else if (payloadPresent) {
|
||||||
queueCurrentDraft()
|
queueCurrentDraft()
|
||||||
} else {
|
} else {
|
||||||
// Stop button (the only way to reach here while busy with an empty
|
// Stop button (the only way to reach here while busy with an empty
|
||||||
@@ -1235,10 +1282,10 @@ export function ChatBar({
|
|||||||
triggerHaptic('cancel')
|
triggerHaptic('cancel')
|
||||||
void Promise.resolve(onCancel())
|
void Promise.resolve(onCancel())
|
||||||
}
|
}
|
||||||
} else if (!hasComposerPayload && queuedPrompts.length > 0) {
|
} else if (!payloadPresent && queuedPrompts.length > 0) {
|
||||||
void drainNextQueued()
|
void drainNextQueued()
|
||||||
} else if (draft.trim() || attachments.length > 0) {
|
} else if (payloadPresent) {
|
||||||
const submitted = draft
|
const submitted = text
|
||||||
triggerHaptic('submit')
|
triggerHaptic('submit')
|
||||||
resetBrowseState(sessionId)
|
resetBrowseState(sessionId)
|
||||||
clearDraft()
|
clearDraft()
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null
|
|||||||
return `@${kind}:${formatRefValue(rel)}`
|
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[]) {
|
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
|
||||||
if (!refs.length) {
|
if (!refs.length) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
57
apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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'
|
return (mime && BLOB_MIME_EXTENSION[mime]) || '.png'
|
||||||
}
|
}
|
||||||
|
|
||||||
function isImagePath(filePath: string): boolean {
|
export function isImagePath(filePath: string): boolean {
|
||||||
return IMAGE_EXTENSION_PATTERN.test(filePath)
|
return IMAGE_EXTENSION_PATTERN.test(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +181,35 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
|
|||||||
return result
|
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 {
|
interface ComposerActionsOptions {
|
||||||
activeSessionId: string | null
|
activeSessionId: string | null
|
||||||
currentCwd: string
|
currentCwd: string
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ import { ChatDropOverlay } from './chat-drop-overlay'
|
|||||||
import { ChatSwapOverlay } from './chat-swap-overlay'
|
import { ChatSwapOverlay } from './chat-swap-overlay'
|
||||||
import { ChatBar, ChatBarFallback } from './composer'
|
import { ChatBar, ChatBarFallback } from './composer'
|
||||||
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
|
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
|
||||||
import { droppedFileInlineRef, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
|
import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
|
||||||
import type { ChatBarState } from './composer/types'
|
import type { ChatBarState } from './composer/types'
|
||||||
import type { DroppedFile } from './hooks/use-composer-actions'
|
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
|
||||||
import { useFileDropZone } from './hooks/use-file-drop-zone'
|
import { useFileDropZone } from './hooks/use-file-drop-zone'
|
||||||
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
||||||
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
|
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
|
||||||
@@ -126,7 +126,10 @@ function ChatHeader({
|
|||||||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||||
<div
|
<div
|
||||||
className="min-w-0 flex-1"
|
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
|
<SessionActionsMenu
|
||||||
align="start"
|
align="start"
|
||||||
@@ -299,19 +302,25 @@ export function ChatView({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Drop files anywhere in the conversation area, not just on the composer
|
// Drop files anywhere in the conversation area, not just on the composer
|
||||||
// input — appending the same inline `@file:` ref chips the composer drop
|
// input. In-app drags (project tree / gutter) carry workspace-relative paths
|
||||||
// produces (vs. attachment cards) so both surfaces behave identically.
|
// 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.
|
||||||
const onDropFiles = useCallback(
|
const onDropFiles = useCallback(
|
||||||
(candidates: DroppedFile[]) => {
|
(candidates: DroppedFile[]) => {
|
||||||
const refs = candidates
|
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
|
||||||
.map(candidate => droppedFileInlineRef(candidate, currentCwd))
|
const refs = droppedFileInlineRefs(inAppRefs, currentCwd)
|
||||||
.filter((ref): ref is string => Boolean(ref))
|
|
||||||
|
|
||||||
if (refs.length) {
|
if (refs.length) {
|
||||||
requestComposerInsert(refs.join(' '), { mode: 'inline', target: 'main' })
|
requestComposerInsert(refs.join(' '), { mode: 'inline', target: 'main' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (osDrops.length) {
|
||||||
|
void onAttachDroppedItems(osDrops)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[currentCwd]
|
[currentCwd, onAttachDroppedItems]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Dropping a sidebar session inserts an @session link the agent can resolve
|
// Dropping a sidebar session inserts an @session link the agent can resolve
|
||||||
|
|||||||
@@ -446,7 +446,9 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
|
// 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))
|
||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
setState({ dataUrl, loading: false })
|
setState({ dataUrl, loading: false })
|
||||||
@@ -484,7 +486,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||||||
return () => {
|
return () => {
|
||||||
active = false
|
active = false
|
||||||
}
|
}
|
||||||
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
|
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.dataUrl, target.language])
|
||||||
|
|
||||||
if (state.loading) {
|
if (state.loading) {
|
||||||
return <PageLoader label={t.preview.loading} />
|
return <PageLoader label={t.preview.loading} />
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import type { CronJob } from '@/types/hermes'
|
|||||||
import { jobState, jobTitle, STATE_DOT } from '../../cron/job-state'
|
import { jobState, jobTitle, STATE_DOT } from '../../cron/job-state'
|
||||||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||||
|
|
||||||
|
import { SidebarLoadMoreRow } from './load-more-row'
|
||||||
|
|
||||||
const INACTIVE_STATES = new Set(['completed', 'disabled', 'error', 'paused'])
|
const INACTIVE_STATES = new Set(['completed', 'disabled', 'error', 'paused'])
|
||||||
|
|
||||||
// Recent runs shown in the inline quick-peek — enough to glance at history
|
// Recent runs shown in the inline quick-peek — enough to glance at history
|
||||||
@@ -24,6 +26,11 @@ const PEEK_RUN_LIMIT = 5
|
|||||||
// open peek so a freshly-fired run shows up within a few seconds.
|
// open peek so a freshly-fired run shows up within a few seconds.
|
||||||
const PEEK_POLL_INTERVAL_MS = 8000
|
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' })
|
const relativeFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' })
|
||||||
|
|
||||||
// Localized "in 5 min" / "2 hr ago" without hand-rolled strings — picks the
|
// Localized "in 5 min" / "2 hr ago" without hand-rolled strings — picks the
|
||||||
@@ -33,17 +40,25 @@ function relativeTime(targetMs: number, nowMs: number): string {
|
|||||||
const abs = Math.abs(diff)
|
const abs = Math.abs(diff)
|
||||||
const sign = diff < 0 ? -1 : 1
|
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')
|
return relativeFmt.format(sign * Math.round(abs / 86_400_000), 'day')
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextRunMs(job: CronJob): null | number {
|
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)
|
const ms = Date.parse(job.next_run_at)
|
||||||
|
|
||||||
@@ -54,7 +69,9 @@ function nextRunMs(job: CronJob): null | number {
|
|||||||
// the timestamp is what tells them apart. Compact (no year, no seconds) for the
|
// the timestamp is what tells them apart. Compact (no year, no seconds) for the
|
||||||
// narrow sidebar.
|
// narrow sidebar.
|
||||||
function formatRunTime(seconds?: null | number): string {
|
function formatRunTime(seconds?: null | number): string {
|
||||||
if (!seconds) {return '—'}
|
if (!seconds) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
const date = new Date(seconds * 1000)
|
const date = new Date(seconds * 1000)
|
||||||
|
|
||||||
@@ -90,11 +107,15 @@ export function SidebarCronJobsSection({
|
|||||||
const [nowMs, setNowMs] = useState(() => Date.now())
|
const [nowMs, setNowMs] = useState(() => Date.now())
|
||||||
// Single-open inline peek so the section stays scannable.
|
// Single-open inline peek so the section stays scannable.
|
||||||
const [peekJobId, setPeekJobId] = useState<null | string>(null)
|
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
|
// 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.
|
// without re-rendering the rest of the sidebar. Only runs while expanded.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {return}
|
if (!open) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const id = window.setInterval(() => setNowMs(Date.now()), 1000)
|
const id = window.setInterval(() => setNowMs(Date.now()), 1000)
|
||||||
|
|
||||||
@@ -108,17 +129,25 @@ export function SidebarCronJobsSection({
|
|||||||
const an = nextRunMs(a)
|
const an = nextRunMs(a)
|
||||||
const bn = nextRunMs(b)
|
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))
|
return jobTitle(a).localeCompare(jobTitle(b))
|
||||||
})
|
})
|
||||||
}, [jobs])
|
}, [jobs])
|
||||||
|
|
||||||
const shown = sorted.slice(0, max)
|
const cap = Math.min(visibleCount, max)
|
||||||
|
const shown = sorted.slice(0, cap)
|
||||||
|
const hiddenCount = Math.min(sorted.length, max) - shown.length
|
||||||
// When capped, signal "50+" rather than implying the list is complete.
|
// When capped, signal "50+" rather than implying the list is complete.
|
||||||
const countLabel = jobs.length > max ? `${max}+` : String(jobs.length)
|
const countLabel = jobs.length > max ? `${max}+` : String(jobs.length)
|
||||||
|
|
||||||
@@ -139,7 +168,7 @@ export function SidebarCronJobsSection({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{open && (
|
{open && (
|
||||||
<SidebarGroupContent className="flex max-h-72 shrink-0 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75">
|
<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">
|
||||||
{shown.map(job => (
|
{shown.map(job => (
|
||||||
<CronJobSidebarRow
|
<CronJobSidebarRow
|
||||||
expanded={peekJobId === job.id}
|
expanded={peekJobId === job.id}
|
||||||
@@ -152,6 +181,12 @@ export function SidebarCronJobsSection({
|
|||||||
onTrigger={() => onTriggerJob(job.id)}
|
onTrigger={() => onTriggerJob(job.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{hiddenCount > 0 && (
|
||||||
|
<SidebarLoadMoreRow
|
||||||
|
onClick={() => setVisibleCount(count => count + LOAD_MORE_STEP)}
|
||||||
|
step={Math.min(LOAD_MORE_STEP, hiddenCount)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
)}
|
)}
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
@@ -181,11 +216,7 @@ function CronJobSidebarRow({
|
|||||||
const next = nextRunMs(job)
|
const next = nextRunMs(job)
|
||||||
const label = jobTitle(job)
|
const label = jobTitle(job)
|
||||||
|
|
||||||
const meta = INACTIVE_STATES.has(state)
|
const meta = INACTIVE_STATES.has(state) ? (c.states[state] ?? state) : next !== null ? relativeTime(next, nowMs) : '—'
|
||||||
? (c.states[state] ?? state)
|
|
||||||
: next !== null
|
|
||||||
? relativeTime(next, nowMs)
|
|
||||||
: '—'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -257,13 +288,7 @@ function CronJobSidebarRow({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CronJobSidebarRuns({
|
function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (sessionId: string) => void }) {
|
||||||
jobId,
|
|
||||||
onOpenRun
|
|
||||||
}: {
|
|
||||||
jobId: string
|
|
||||||
onOpenRun: (sessionId: string) => void
|
|
||||||
}) {
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const c = t.cron
|
const c = t.cron
|
||||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||||
@@ -275,16 +300,22 @@ function CronJobSidebarRuns({
|
|||||||
const load = () =>
|
const load = () =>
|
||||||
getCronJobRuns(jobId, PEEK_RUN_LIMIT)
|
getCronJobRuns(jobId, PEEK_RUN_LIMIT)
|
||||||
.then(result => {
|
.then(result => {
|
||||||
if (!cancelled) {setRuns(result)}
|
if (!cancelled) {
|
||||||
|
setRuns(result)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) {setRuns(prev => prev ?? [])}
|
if (!cancelled) {
|
||||||
|
setRuns(prev => prev ?? [])
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
void load()
|
void load()
|
||||||
|
|
||||||
const intervalId = window.setInterval(() => {
|
const intervalId = window.setInterval(() => {
|
||||||
if (document.visibilityState === 'visible') {void load()}
|
if (document.visibilityState === 'visible') {
|
||||||
|
void load()
|
||||||
|
}
|
||||||
}, PEEK_POLL_INTERVAL_MS)
|
}, PEEK_POLL_INTERVAL_MS)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||||||
import { Tip } from '@/components/ui/tooltip'
|
import { Tip } from '@/components/ui/tooltip'
|
||||||
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
|
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
|
import { normalizeCombo } from '@/lib/keybinds/combo'
|
||||||
import { profileColor } from '@/lib/profile-color'
|
import { profileColor } from '@/lib/profile-color'
|
||||||
import { sessionMatchesSearch } from '@/lib/session-search'
|
import { sessionMatchesSearch } from '@/lib/session-search'
|
||||||
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
|
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
|
||||||
@@ -48,6 +49,7 @@ import {
|
|||||||
$pinnedSessionIds,
|
$pinnedSessionIds,
|
||||||
$sidebarAgentsGrouped,
|
$sidebarAgentsGrouped,
|
||||||
$sidebarCronOpen,
|
$sidebarCronOpen,
|
||||||
|
$sidebarMessagingOpenIds,
|
||||||
$sidebarOpen,
|
$sidebarOpen,
|
||||||
$sidebarOverlayMounted,
|
$sidebarOverlayMounted,
|
||||||
$sidebarPinsOpen,
|
$sidebarPinsOpen,
|
||||||
@@ -64,6 +66,7 @@ import {
|
|||||||
setSidebarSessionOrderIds,
|
setSidebarSessionOrderIds,
|
||||||
setSidebarWorkspaceOrderIds,
|
setSidebarWorkspaceOrderIds,
|
||||||
SIDEBAR_SESSIONS_PAGE_SIZE,
|
SIDEBAR_SESSIONS_PAGE_SIZE,
|
||||||
|
toggleSidebarMessagingOpen,
|
||||||
unpinSession
|
unpinSession
|
||||||
} from '@/store/layout'
|
} from '@/store/layout'
|
||||||
import {
|
import {
|
||||||
@@ -76,6 +79,9 @@ import {
|
|||||||
} from '@/store/profile'
|
} from '@/store/profile'
|
||||||
import {
|
import {
|
||||||
$cronSessions,
|
$cronSessions,
|
||||||
|
$messagingPlatformTotals,
|
||||||
|
$messagingSessions,
|
||||||
|
$messagingTruncated,
|
||||||
$selectedStoredSessionId,
|
$selectedStoredSessionId,
|
||||||
$sessionProfileTotals,
|
$sessionProfileTotals,
|
||||||
$sessions,
|
$sessions,
|
||||||
@@ -90,17 +96,23 @@ import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
|||||||
import type { SidebarNavItem } from '../../types'
|
import type { SidebarNavItem } from '../../types'
|
||||||
|
|
||||||
import { SidebarCronJobsSection } from './cron-jobs-section'
|
import { SidebarCronJobsSection } from './cron-jobs-section'
|
||||||
|
import { SidebarLoadMoreRow } from './load-more-row'
|
||||||
import { ProfileRail } from './profile-switcher'
|
import { ProfileRail } from './profile-switcher'
|
||||||
import { SidebarSessionRow } from './session-row'
|
import { SidebarSessionRow } from './session-row'
|
||||||
import { VirtualSessionList } from './virtual-session-list'
|
import { VirtualSessionList } from './virtual-session-list'
|
||||||
|
|
||||||
const VIRTUALIZE_THRESHOLD = 25
|
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
|
// 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
|
// 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.
|
// else) in desktop-controller.tsx, but the hint should match muscle memory.
|
||||||
const NEW_SESSION_KBD: readonly string[] =
|
const NEW_SESSION_KBD: readonly string[] =normalizeCombo('mod+n')
|
||||||
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') ? ['⌘', 'N'] : ['Ctrl', 'N']
|
|
||||||
|
|
||||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||||
{
|
{
|
||||||
@@ -124,7 +136,16 @@ const WORKSPACE_PAGE = 5
|
|||||||
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
|
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
|
||||||
const PROFILE_INITIAL_PAGE = 5
|
const PROFILE_INITIAL_PAGE = 5
|
||||||
const GROUP_DND_ID_PREFIX = 'group:'
|
const GROUP_DND_ID_PREFIX = 'group:'
|
||||||
const LOCAL_SESSION_SOURCES = new Set(['cli', 'desktop', 'local', 'tui'])
|
|
||||||
|
// 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 groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`
|
const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`
|
||||||
|
|
||||||
@@ -141,24 +162,25 @@ function orderByIds<T>(items: T[], getId: (item: T) => string, orderIds: string[
|
|||||||
|
|
||||||
const byId = new Map(items.map(item => [getId(item), item]))
|
const byId = new Map(items.map(item => [getId(item), item]))
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const out: T[] = []
|
const ordered: T[] = []
|
||||||
|
|
||||||
for (const id of orderIds) {
|
for (const id of orderIds) {
|
||||||
const item = byId.get(id)
|
const item = byId.get(id)
|
||||||
|
|
||||||
if (item) {
|
if (item) {
|
||||||
out.push(item)
|
ordered.push(item)
|
||||||
seen.add(id)
|
seen.add(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of items) {
|
// Items missing from the persisted order are new since it was last
|
||||||
if (!seen.has(getId(item))) {
|
// reconciled. Callers pass recency-sorted lists (newest first), so surface
|
||||||
out.push(item)
|
// 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)))
|
||||||
|
|
||||||
return out
|
return fresh.length ? [...fresh, ...ordered] : ordered
|
||||||
}
|
}
|
||||||
|
|
||||||
function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
|
function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
|
||||||
@@ -171,17 +193,15 @@ function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const current = new Set(currentIds)
|
const current = new Set(currentIds)
|
||||||
const next = orderIds.filter(id => current.has(id))
|
const retained = orderIds.filter(id => current.has(id))
|
||||||
const known = new Set(next)
|
const retainedSet = new Set(retained)
|
||||||
|
|
||||||
for (const id of currentIds) {
|
// New ids (absent from the saved order) are the newest sessions/groups; keep
|
||||||
if (!known.has(id)) {
|
// them ahead of the persisted order so fresh activity surfaces at the top of
|
||||||
next.push(id)
|
// the sidebar rather than being appended to the bottom.
|
||||||
known.add(id)
|
const fresh = currentIds.filter(id => !retainedSet.has(id))
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return next
|
return [...fresh, ...retained]
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameIds(left: string[], right: string[]) {
|
function sameIds(left: string[], right: string[]) {
|
||||||
@@ -251,43 +271,6 @@ function workspaceGroupsFor(
|
|||||||
return [...groups.values()]
|
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) {
|
function useSortableBindings(id: string) {
|
||||||
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
|
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
|
||||||
|
|
||||||
@@ -309,6 +292,7 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
|||||||
onNavigate: (item: SidebarNavItem) => void
|
onNavigate: (item: SidebarNavItem) => void
|
||||||
onLoadMoreSessions: () => void
|
onLoadMoreSessions: () => void
|
||||||
onLoadMoreProfileSessions?: (profile: string) => Promise<void> | void
|
onLoadMoreProfileSessions?: (profile: string) => Promise<void> | void
|
||||||
|
onLoadMoreMessaging?: (platform: string) => Promise<void> | void
|
||||||
onResumeSession: (sessionId: string) => void
|
onResumeSession: (sessionId: string) => void
|
||||||
onDeleteSession: (sessionId: string) => void
|
onDeleteSession: (sessionId: string) => void
|
||||||
onArchiveSession: (sessionId: string) => void
|
onArchiveSession: (sessionId: string) => void
|
||||||
@@ -322,6 +306,7 @@ export function ChatSidebar({
|
|||||||
onNavigate,
|
onNavigate,
|
||||||
onLoadMoreSessions,
|
onLoadMoreSessions,
|
||||||
onLoadMoreProfileSessions,
|
onLoadMoreProfileSessions,
|
||||||
|
onLoadMoreMessaging,
|
||||||
onResumeSession,
|
onResumeSession,
|
||||||
onDeleteSession,
|
onDeleteSession,
|
||||||
onArchiveSession,
|
onArchiveSession,
|
||||||
@@ -345,6 +330,9 @@ export function ChatSidebar({
|
|||||||
const sessions = useStore($sessions)
|
const sessions = useStore($sessions)
|
||||||
const cronSessions = useStore($cronSessions)
|
const cronSessions = useStore($cronSessions)
|
||||||
const cronJobs = useStore($cronJobs)
|
const cronJobs = useStore($cronJobs)
|
||||||
|
const messagingSessions = useStore($messagingSessions)
|
||||||
|
const messagingPlatformTotals = useStore($messagingPlatformTotals)
|
||||||
|
const messagingTruncated = useStore($messagingTruncated)
|
||||||
const sessionsLoading = useStore($sessionsLoading)
|
const sessionsLoading = useStore($sessionsLoading)
|
||||||
const sessionsTotal = useStore($sessionsTotal)
|
const sessionsTotal = useStore($sessionsTotal)
|
||||||
const sessionProfileTotals = useStore($sessionProfileTotals)
|
const sessionProfileTotals = useStore($sessionProfileTotals)
|
||||||
@@ -364,6 +352,10 @@ export function ChatSidebar({
|
|||||||
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
|
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
|
||||||
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
|
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
|
||||||
const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})
|
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 searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
const trimmedQuery = searchQuery.trim()
|
const trimmedQuery = searchQuery.trim()
|
||||||
|
|
||||||
@@ -529,24 +521,12 @@ export function ChatSidebar({
|
|||||||
[unpinnedAgentSessions, agentOrderIds]
|
[unpinnedAgentSessions, agentOrderIds]
|
||||||
)
|
)
|
||||||
|
|
||||||
const { localSessions: localAgentSessions, sourceGroups } = useMemo(
|
// Recents are local-only: messaging-platform sessions are fetched as their
|
||||||
() => sourceSessionGroupsFor(agentSessions),
|
// own slice ($messagingSessions) and rendered in self-managed per-platform
|
||||||
[agentSessions]
|
// sections below, so there is no source-grouping magic to untangle here.
|
||||||
)
|
|
||||||
|
|
||||||
const orderedSourceGroups = useMemo(
|
|
||||||
() => orderByIds(sourceGroups, g => g.id, workspaceOrderIds),
|
|
||||||
[sourceGroups, workspaceOrderIds]
|
|
||||||
)
|
|
||||||
|
|
||||||
const agentGroups = useMemo(
|
const agentGroups = useMemo(
|
||||||
() =>
|
() => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
|
||||||
orderByIds(
|
[agentSessions, s.noWorkspace, workspaceOrderIds]
|
||||||
workspaceGroupsFor(localAgentSessions, s.noWorkspace, { preserveSessionOrder: sourceGroups.length > 0 }),
|
|
||||||
g => g.id,
|
|
||||||
workspaceOrderIds
|
|
||||||
),
|
|
||||||
[localAgentSessions, s.noWorkspace, sourceGroups.length, workspaceOrderIds]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const loadMoreForProfileGroup = useCallback(
|
const loadMoreForProfileGroup = useCallback(
|
||||||
@@ -564,6 +544,76 @@ export function ChatSidebar({
|
|||||||
[onLoadMoreProfileSessions]
|
[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
|
// 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.
|
// (not on every row). Default profile floats to the top, the rest alpha.
|
||||||
const profileGroups = useMemo<SidebarSessionGroup[] | undefined>(() => {
|
const profileGroups = useMemo<SidebarSessionGroup[] | undefined>(() => {
|
||||||
@@ -610,56 +660,7 @@ export function ChatSidebar({
|
|||||||
sessionProfileTotals
|
sessionProfileTotals
|
||||||
])
|
])
|
||||||
|
|
||||||
const displayAgentSessions = sourceGroups.length ? localAgentSessions : agentSessions
|
const displayAgentSessions = agentSessions
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
|
|
||||||
return orderByIds([...orderedSourceGroups, ...localGroups], g => g.id, workspaceOrderIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
return showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
|
|
||||||
}, [
|
|
||||||
agentGroups,
|
|
||||||
agentsGrouped,
|
|
||||||
localAgentSessions,
|
|
||||||
orderedSourceGroups,
|
|
||||||
profileGroups,
|
|
||||||
showAllProfiles,
|
|
||||||
workspaceOrderIds
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!displayAgentGroups?.length || showAllProfiles) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = reconcileOrderIds(
|
|
||||||
displayAgentGroups.map(g => g.id),
|
|
||||||
workspaceOrderIds
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!sameIds(next, workspaceOrderIds)) {
|
|
||||||
setSidebarWorkspaceOrderIds(next)
|
|
||||||
}
|
|
||||||
}, [displayAgentGroups, showAllProfiles, workspaceOrderIds])
|
|
||||||
|
|
||||||
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
|
|
||||||
|
|
||||||
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
|
|
||||||
|
|
||||||
// Pagination is scope-aware. In "All profiles" mode it tracks the global
|
// 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
|
// unified set. When scoped to one profile it must compare that profile's own
|
||||||
@@ -680,6 +681,33 @@ export function ChatSidebar({
|
|||||||
|
|
||||||
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
|
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
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!displayAgentGroups?.length || showAllProfiles) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = reconcileOrderIds(
|
||||||
|
displayAgentGroups.map(g => g.id),
|
||||||
|
workspaceOrderIds
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!sameIds(next, workspaceOrderIds)) {
|
||||||
|
setSidebarWorkspaceOrderIds(next)
|
||||||
|
}
|
||||||
|
}, [displayAgentGroups, showAllProfiles, workspaceOrderIds])
|
||||||
|
|
||||||
|
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
|
||||||
|
|
||||||
|
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
|
||||||
|
|
||||||
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
|
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
|
||||||
if (!over || active.id === over.id) {
|
if (!over || active.id === over.id) {
|
||||||
return
|
return
|
||||||
@@ -792,9 +820,7 @@ export function ChatSidebar({
|
|||||||
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
|
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
|
||||||
{contentVisible && (
|
{contentVisible && (
|
||||||
<>
|
<>
|
||||||
<span className="min-w-0 flex-1 truncate">
|
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
|
||||||
{s.nav[item.id] ?? item.label}
|
|
||||||
</span>
|
|
||||||
{isNewSession && (
|
{isNewSession && (
|
||||||
<KbdGroup
|
<KbdGroup
|
||||||
className={cn('ml-auto', newSessionKbdFlash && 'opacity-100!')}
|
className={cn('ml-auto', newSessionKbdFlash && 'opacity-100!')}
|
||||||
@@ -823,135 +849,191 @@ export function ChatSidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{contentVisible && showSessionSections && trimmedQuery && (
|
{contentVisible && showSessionSections && (
|
||||||
<SidebarSessionsSection
|
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75">
|
||||||
activeSessionId={activeSidebarSessionId}
|
{trimmedQuery && (
|
||||||
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
|
<SidebarSessionsSection
|
||||||
emptyState={
|
activeSessionId={activeSidebarSessionId}
|
||||||
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
|
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
|
||||||
{s.noMatch(trimmedQuery)}
|
emptyState={
|
||||||
</div>
|
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
|
||||||
}
|
{s.noMatch(trimmedQuery)}
|
||||||
label={s.results}
|
</div>
|
||||||
labelMeta={String(searchResults.length)}
|
}
|
||||||
onArchiveSession={onArchiveSession}
|
label={s.results}
|
||||||
onDeleteSession={onDeleteSession}
|
labelMeta={String(searchResults.length)}
|
||||||
onResumeSession={onResumeSession}
|
onArchiveSession={onArchiveSession}
|
||||||
onToggle={() => undefined}
|
onDeleteSession={onDeleteSession}
|
||||||
onTogglePin={pinSession}
|
onResumeSession={onResumeSession}
|
||||||
open
|
onToggle={() => undefined}
|
||||||
pinned={false}
|
onTogglePin={pinSession}
|
||||||
rootClassName="min-h-0 flex-1 p-0"
|
open
|
||||||
sessions={searchResults}
|
pinned={false}
|
||||||
workingSessionIdSet={workingSessionIdSet}
|
rootClassName="min-h-32 flex-1 overflow-hidden 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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{contentVisible && !trimmedQuery && cronJobs.length > 0 && (
|
{!trimmedQuery && (
|
||||||
<SidebarCronJobsSection
|
<SidebarSessionsSection
|
||||||
jobs={cronJobs}
|
activeSessionId={activeSidebarSessionId}
|
||||||
label={s.cronJobs}
|
contentClassName={cn('flex max-h-44 flex-col gap-px rounded-lg pb-2 pt-1', GROUP_BODY)}
|
||||||
onManageJob={onManageCronJob}
|
dndSensors={dndSensors}
|
||||||
onOpenRun={onResumeSession}
|
emptyState={<SidebarPinnedEmptyState />}
|
||||||
onToggle={() => setSidebarCronOpen(!cronOpen)}
|
label={s.pinned}
|
||||||
onTriggerJob={onTriggerCronJob}
|
onArchiveSession={onArchiveSession}
|
||||||
open={cronOpen}
|
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 && !showSessionSections && <div className="min-h-0 flex-1" />}
|
{contentVisible && !showSessionSections && <div className="min-h-0 flex-1" />}
|
||||||
@@ -972,9 +1054,10 @@ interface SidebarSectionHeaderProps {
|
|||||||
onToggle: () => void
|
onToggle: () => void
|
||||||
action?: React.ReactNode
|
action?: React.ReactNode
|
||||||
meta?: React.ReactNode
|
meta?: React.ReactNode
|
||||||
|
icon?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSectionHeaderProps) {
|
function SidebarSectionHeader({ label, open, onToggle, action, meta, icon }: SidebarSectionHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
|
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
|
||||||
<button
|
<button
|
||||||
@@ -982,6 +1065,7 @@ function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSe
|
|||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
{icon}
|
||||||
<SidebarPanelLabel>{label}</SidebarPanelLabel>
|
<SidebarPanelLabel>{label}</SidebarPanelLabel>
|
||||||
{meta && <SidebarCount>{meta}</SidebarCount>}
|
{meta && <SidebarCount>{meta}</SidebarCount>}
|
||||||
<DisclosureCaret
|
<DisclosureCaret
|
||||||
@@ -1044,6 +1128,14 @@ interface SidebarSessionGroup {
|
|||||||
totalCount?: number
|
totalCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MessagingSection {
|
||||||
|
sourceId: string
|
||||||
|
label: string
|
||||||
|
sessions: SessionInfo[]
|
||||||
|
total: number
|
||||||
|
hasMore: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface SidebarSessionsSectionProps {
|
interface SidebarSessionsSectionProps {
|
||||||
label: string
|
label: string
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -1065,6 +1157,7 @@ interface SidebarSessionsSectionProps {
|
|||||||
footer?: React.ReactNode
|
footer?: React.ReactNode
|
||||||
groups?: SidebarSessionGroup[]
|
groups?: SidebarSessionGroup[]
|
||||||
labelMeta?: React.ReactNode
|
labelMeta?: React.ReactNode
|
||||||
|
labelIcon?: React.ReactNode
|
||||||
sortable?: boolean
|
sortable?: boolean
|
||||||
onReorder?: (event: DragEndEvent) => void
|
onReorder?: (event: DragEndEvent) => void
|
||||||
dndSensors?: ReturnType<typeof useSensors>
|
dndSensors?: ReturnType<typeof useSensors>
|
||||||
@@ -1091,6 +1184,7 @@ function SidebarSessionsSection({
|
|||||||
footer,
|
footer,
|
||||||
groups,
|
groups,
|
||||||
labelMeta,
|
labelMeta,
|
||||||
|
labelIcon,
|
||||||
sortable = false,
|
sortable = false,
|
||||||
onReorder,
|
onReorder,
|
||||||
dndSensors
|
dndSensors
|
||||||
@@ -1181,6 +1275,7 @@ function SidebarSessionsSection({
|
|||||||
inner = (
|
inner = (
|
||||||
<VirtualSessionList
|
<VirtualSessionList
|
||||||
activeSessionId={activeSessionId}
|
activeSessionId={activeSessionId}
|
||||||
|
className={contentClassName}
|
||||||
onArchiveSession={onArchiveSession}
|
onArchiveSession={onArchiveSession}
|
||||||
onDeleteSession={onDeleteSession}
|
onDeleteSession={onDeleteSession}
|
||||||
onResumeSession={onResumeSession}
|
onResumeSession={onResumeSession}
|
||||||
@@ -1209,7 +1304,14 @@ function SidebarSessionsSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup className={rootClassName}>
|
<SidebarGroup className={rootClassName}>
|
||||||
<SidebarSectionHeader action={headerAction} label={label} meta={labelMeta} onToggle={onToggle} open={open} />
|
<SidebarSectionHeader
|
||||||
|
action={headerAction}
|
||||||
|
icon={labelIcon}
|
||||||
|
label={label}
|
||||||
|
meta={labelMeta}
|
||||||
|
onToggle={onToggle}
|
||||||
|
open={open}
|
||||||
|
/>
|
||||||
{open && (
|
{open && (
|
||||||
<SidebarGroupContent className={resolvedContentClassName}>
|
<SidebarGroupContent className={resolvedContentClassName}>
|
||||||
{body}
|
{body}
|
||||||
@@ -1398,30 +1500,3 @@ interface SortableSessionRowProps {
|
|||||||
function SortableSidebarSessionRow(props: SortableSessionRowProps) {
|
function SortableSidebarSessionRow(props: SortableSessionRowProps) {
|
||||||
return <SidebarSessionRow {...props} {...useSortableBindings(props.session.id)} />
|
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
30
apps/desktop/src/app/chat/sidebar/load-more-row.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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,8 +83,9 @@ const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, trans
|
|||||||
// Arc-Spaces-style profile rail at the sidebar foot: a default↔all toggle pinned
|
// 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.
|
// 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-
|
// The active profile pops in its own color — the "where am I" cue. Single-
|
||||||
// profile users see only the "+" (create their first profile); everything else
|
// profile users see the "+" (create their first profile) and the Manage
|
||||||
// appears once a second profile exists.
|
// overflow (edit the default profile's SOUL.md); the colored named squares
|
||||||
|
// and the default↔all toggle only appear once a second profile exists.
|
||||||
export function ProfileRail() {
|
export function ProfileRail() {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const p = t.profiles
|
const p = t.profiles
|
||||||
@@ -268,9 +269,11 @@ export function ProfileRail() {
|
|||||||
</Tip>
|
</Tip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{multiProfile && (
|
{/* Always reachable, even with only the default profile: the manage
|
||||||
<ProfilePill active={false} glyph="ellipsis" label={p.manageProfiles} onSelect={() => navigate(PROFILES_ROUTE)} />
|
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)} />
|
||||||
|
|
||||||
{/* Land in the new profile on a fresh chat (selectProfile triggers the
|
{/* Land in the new profile on a fresh chat (selectProfile triggers the
|
||||||
new-session reset), not stuck on the session you were just in. */}
|
new-session reset), not stuck on the session you were just in. */}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { triggerHaptic } from '@/lib/haptics'
|
|||||||
import { exportSession } from '@/lib/session-export'
|
import { exportSession } from '@/lib/session-export'
|
||||||
import { notify, notifyError } from '@/store/notifications'
|
import { notify, notifyError } from '@/store/notifications'
|
||||||
import { setSessions } from '@/store/session'
|
import { setSessions } from '@/store/session'
|
||||||
|
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||||
|
|
||||||
interface SessionActions {
|
interface SessionActions {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
@@ -68,6 +69,19 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
|||||||
void writeClipboardText(sessionId).catch(err => notifyError(err, r.copyIdFailed))
|
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,
|
disabled: !sessionId,
|
||||||
icon: 'cloud-download',
|
icon: 'cloud-download',
|
||||||
|
|||||||
@@ -2,14 +2,19 @@ import { useStore } from '@nanostores/react'
|
|||||||
import type * as React from 'react'
|
import type * as React from 'react'
|
||||||
|
|
||||||
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
|
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
|
||||||
|
import { PlatformAvatar } from '@/app/messaging/platform-icon'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
|
import { Tip } from '@/components/ui/tooltip'
|
||||||
import type { SessionInfo } from '@/hermes'
|
import type { SessionInfo } from '@/hermes'
|
||||||
import { type Translations, useI18n } from '@/i18n'
|
import { type Translations, useI18n } from '@/i18n'
|
||||||
import { sessionTitle } from '@/lib/chat-runtime'
|
import { sessionTitle } from '@/lib/chat-runtime'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
|
import { modKey } from '@/lib/keybinds/combo'
|
||||||
|
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { $attentionSessionIds } from '@/store/session'
|
import { $attentionSessionIds } from '@/store/session'
|
||||||
|
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||||
|
|
||||||
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
|
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
|
||||||
|
|
||||||
@@ -67,6 +72,11 @@ export function SidebarSessionRow({
|
|||||||
const title = sessionTitle(session)
|
const title = sessionTitle(session)
|
||||||
const age = formatAge(session.last_active || session.started_at, r)
|
const age = formatAge(session.last_active || session.started_at, r)
|
||||||
const handleLabel = `Reorder ${title}`
|
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 —
|
// 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
|
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
|
||||||
// session is waiting on the user.
|
// session is waiting on the user.
|
||||||
@@ -124,11 +134,15 @@ export function SidebarSessionRow({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.metaKey || event.ctrlKey) {
|
// ⌘-click (mac) / Ctrl-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[modKey] && canOpenSessionWindow()) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
triggerHaptic('selection')
|
triggerHaptic('selection')
|
||||||
onArchive()
|
void openSessionInNewWindow(session.id)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -179,6 +193,15 @@ export function SidebarSessionRow({
|
|||||||
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
|
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
|
||||||
</span>
|
</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">
|
<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}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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 { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||||
|
import { KbdGroup } from '@/components/ui/kbd'
|
||||||
import { getHermesConfigRecord, listSessions } from '@/hermes'
|
import { getHermesConfigRecord, listSessions } from '@/hermes'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { sessionTitle } from '@/lib/chat-runtime'
|
import { sessionTitle } from '@/lib/chat-runtime'
|
||||||
@@ -12,11 +15,11 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
Archive,
|
Archive,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Check,
|
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock,
|
Clock,
|
||||||
Cpu,
|
Cpu,
|
||||||
|
Download,
|
||||||
Globe,
|
Globe,
|
||||||
type IconComponent,
|
type IconComponent,
|
||||||
Info,
|
Info,
|
||||||
@@ -30,13 +33,18 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Settings2,
|
Settings2,
|
||||||
Sun,
|
Sun,
|
||||||
|
Terminal,
|
||||||
Users,
|
Users,
|
||||||
Wrench,
|
Wrench,
|
||||||
Zap
|
Zap
|
||||||
} from '@/lib/icons'
|
} from '@/lib/icons'
|
||||||
|
import { comboTokens } from '@/lib/keybinds/combo'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
|
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 { type ThemeMode, useTheme } from '@/themes/context'
|
||||||
|
import { isUserTheme, resolveTheme } from '@/themes/user-themes'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AGENTS_ROUTE,
|
AGENTS_ROUTE,
|
||||||
@@ -54,8 +62,11 @@ import { FIELD_LABELS, SECTIONS } from '../settings/constants'
|
|||||||
import { fieldCopyForSchemaKey } from '../settings/field-copy'
|
import { fieldCopyForSchemaKey } from '../settings/field-copy'
|
||||||
import { prettyName } from '../settings/helpers'
|
import { prettyName } from '../settings/helpers'
|
||||||
|
|
||||||
|
import { MarketplaceThemePage } from './marketplace-theme-page'
|
||||||
|
|
||||||
interface PaletteItem {
|
interface PaletteItem {
|
||||||
active?: boolean
|
/** Keybind action id — its live combo renders as a hotkey hint. */
|
||||||
|
action?: string
|
||||||
icon: IconComponent
|
icon: IconComponent
|
||||||
id: string
|
id: string
|
||||||
/** Keep the palette open after running (live-preview pickers like theme/mode). */
|
/** Keep the palette open after running (live-preview pickers like theme/mode). */
|
||||||
@@ -69,10 +80,16 @@ interface PaletteItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PaletteGroup {
|
interface PaletteGroup {
|
||||||
heading: string
|
/** Optional: a headingless group renders as a bare action row (e.g. the
|
||||||
|
* "Install theme…" entry pinned atop the theme picker). */
|
||||||
|
heading?: string
|
||||||
items: PaletteItem[]
|
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`. */
|
/** A nested page reachable from a root item via `to`. */
|
||||||
interface PalettePage {
|
interface PalettePage {
|
||||||
groups: PaletteGroup[]
|
groups: PaletteGroup[]
|
||||||
@@ -86,6 +103,22 @@ interface SessionEntry {
|
|||||||
title: string
|
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]
|
type SessionRow = Awaited<ReturnType<typeof listSessions>>['sessions'][number]
|
||||||
|
|
||||||
const toSessionEntry = (session: SessionRow): SessionEntry => ({
|
const toSessionEntry = (session: SessionRow): SessionEntry => ({
|
||||||
@@ -146,11 +179,32 @@ const THEME_MODES: ReadonlyArray<{ icon: IconComponent; mode: ThemeMode }> = [
|
|||||||
{ icon: Monitor, mode: 'system' }
|
{ 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() {
|
export function CommandPalette() {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const open = useStore($commandPaletteOpen)
|
const open = useStore($commandPaletteOpen)
|
||||||
|
const bindings = useStore($bindings)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme()
|
const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme()
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [page, setPage] = useState<string | null>(null)
|
const [page, setPage] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -194,10 +248,19 @@ export function CommandPalette() {
|
|||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const go = useCallback((path: string) => () => navigate(path), [navigate])
|
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(
|
const settingsSectionLabel = useCallback(
|
||||||
(section: (typeof SECTIONS)[number]) => t.settings.sections[section.id] ?? section.label,
|
(section: (typeof SECTIONS)[number]) => t.settings.sections[section.id] ?? section.label,
|
||||||
[t.settings.sections]
|
[t.settings.sections]
|
||||||
)
|
)
|
||||||
|
|
||||||
const configFieldLabel = useCallback(
|
const configFieldLabel = useCallback(
|
||||||
(key: string) =>
|
(key: string) =>
|
||||||
fieldCopyForSchemaKey(t.settings.fieldLabels, key) ??
|
fieldCopyForSchemaKey(t.settings.fieldLabels, key) ??
|
||||||
@@ -214,20 +277,61 @@ export function CommandPalette() {
|
|||||||
{
|
{
|
||||||
heading: cc.goTo,
|
heading: cc.goTo,
|
||||||
items: [
|
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,
|
icon: Wrench,
|
||||||
id: 'nav-skills',
|
id: 'nav-skills',
|
||||||
keywords: ['tools', 'toolsets'],
|
keywords: ['tools', 'toolsets'],
|
||||||
label: cc.nav.skills.title,
|
label: cc.nav.skills.title,
|
||||||
run: go(SKILLS_ROUTE)
|
run: go(SKILLS_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) },
|
action: 'nav.messaging',
|
||||||
{ icon: Clock, id: 'nav-cron', keywords: ['schedule', 'jobs'], label: t.shell.statusbar.cron, run: go(CRON_ROUTE) },
|
icon: MessageCircle,
|
||||||
{ icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) },
|
id: 'nav-messaging',
|
||||||
{ icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }
|
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) }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -373,24 +477,40 @@ export function CommandPalette() {
|
|||||||
theme: {
|
theme: {
|
||||||
title: t.settings.appearance.themeTitle,
|
title: t.settings.appearance.themeTitle,
|
||||||
placeholder: t.settings.appearance.themeDesc,
|
placeholder: t.settings.appearance.themeDesc,
|
||||||
// Skins aren't inherently light/dark — the same skin renders in either
|
groups: [
|
||||||
// mode. Group by appearance so picking an entry sets skin + mode at
|
// Pinned at the top: drills into the Marketplace browser.
|
||||||
// once, and keep the palette open so each pick previews live.
|
{
|
||||||
groups: (['light', 'dark'] as const).map(groupMode => ({
|
items: [
|
||||||
heading: groupMode === 'light' ? t.settings.modeOptions.light.label : t.settings.modeOptions.dark.label,
|
{
|
||||||
items: availableThemes.map(theme => ({
|
icon: Download,
|
||||||
active: themeName === theme.name && resolvedMode === groupMode,
|
id: 'theme-install',
|
||||||
icon: groupMode === 'light' ? Sun : Moon,
|
keywords: ['install', 'marketplace', 'vscode', 'vs code', 'download', 'new', 'color'],
|
||||||
id: `theme-${theme.name}-${groupMode}`,
|
label: t.commandCenter.installTheme.title,
|
||||||
keepOpen: true,
|
to: 'install-theme'
|
||||||
keywords: ['theme', 'appearance', 'palette', groupMode, theme.label, theme.description ?? ''],
|
}
|
||||||
label: theme.label,
|
]
|
||||||
run: () => {
|
},
|
||||||
setTheme(theme.name)
|
// Built-ins and imported families list under the mode(s) they support;
|
||||||
setMode(groupMode)
|
// 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)
|
||||||
|
}
|
||||||
|
}))
|
||||||
}))
|
}))
|
||||||
}))
|
]
|
||||||
},
|
},
|
||||||
'color-mode': {
|
'color-mode': {
|
||||||
title: t.settings.appearance.colorMode,
|
title: t.settings.appearance.colorMode,
|
||||||
@@ -399,7 +519,6 @@ export function CommandPalette() {
|
|||||||
{
|
{
|
||||||
heading: t.settings.appearance.colorMode,
|
heading: t.settings.appearance.colorMode,
|
||||||
items: THEME_MODES.map(entry => ({
|
items: THEME_MODES.map(entry => ({
|
||||||
active: mode === entry.mode,
|
|
||||||
icon: entry.icon,
|
icon: entry.icon,
|
||||||
id: `mode-${entry.mode}`,
|
id: `mode-${entry.mode}`,
|
||||||
keepOpen: true,
|
keepOpen: true,
|
||||||
@@ -409,9 +528,16 @@ 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, mode, resolvedMode, setMode, setTheme, t, themeName]
|
[availableThemes, resolvedMode, setMode, setTheme, t, themeName]
|
||||||
)
|
)
|
||||||
|
|
||||||
const activePage = page ? subPages[page] : null
|
const activePage = page ? subPages[page] : null
|
||||||
@@ -436,17 +562,22 @@ export function CommandPalette() {
|
|||||||
return (
|
return (
|
||||||
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
|
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
|
||||||
<DialogPrimitive.Portal>
|
<DialogPrimitive.Portal>
|
||||||
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/15 backdrop-blur-[1px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
|
{/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
|
||||||
|
<DialogPrimitive.Overlay className="fixed inset-0 z-[200]" />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
aria-describedby={undefined}
|
aria-describedby={undefined}
|
||||||
className="fixed left-1/2 top-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
|
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'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
|
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
|
||||||
<Command className="bg-transparent" loop>
|
<Command className="bg-transparent" filter={paletteFilter} loop>
|
||||||
{activePage && (
|
{activePage && (
|
||||||
<button
|
<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"
|
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||||
onClick={() => setPage(null)}
|
onClick={goBack}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="size-3.5" />
|
<ChevronLeft className="size-3.5" />
|
||||||
@@ -456,6 +587,7 @@ export function CommandPalette() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<CommandInput
|
<CommandInput
|
||||||
|
className={HUD_TEXT}
|
||||||
onKeyDown={event => {
|
onKeyDown={event => {
|
||||||
if (!activePage) {
|
if (!activePage) {
|
||||||
return
|
return
|
||||||
@@ -466,38 +598,45 @@ export function CommandPalette() {
|
|||||||
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
|
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
setPage(null)
|
goBack()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onValueChange={setSearch}
|
onValueChange={setSearch}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={search}
|
value={search}
|
||||||
/>
|
/>
|
||||||
<CommandList className="max-h-[min(24rem,60vh)]">
|
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
|
||||||
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
|
{page === 'install-theme' ? (
|
||||||
{visibleGroups.map(group => (
|
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
|
||||||
|
) : (
|
||||||
|
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
|
||||||
|
)}
|
||||||
|
{visibleGroups.map((group, index) => (
|
||||||
<CommandGroup
|
<CommandGroup
|
||||||
className="**:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-[0.6875rem] **:[[cmdk-group-heading]]:text-muted-foreground/70"
|
className={HUD_HEADING}
|
||||||
heading={group.heading}
|
heading={group.heading}
|
||||||
key={group.heading}
|
key={group.heading ?? `palette-group-${index}`}
|
||||||
>
|
>
|
||||||
{group.items.map(item => {
|
{group.items.map(item => {
|
||||||
const Icon = item.icon
|
const Icon = item.icon
|
||||||
|
const combo = item.action ? bindings[item.action]?.[0] : undefined
|
||||||
|
const keys = combo ? comboTokens(combo) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
className="gap-2.5"
|
className={cn(HUD_ITEM, HUD_TEXT)}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
keywords={item.keywords}
|
keywords={item.keywords}
|
||||||
onSelect={() => handleSelect(item)}
|
onSelect={() => handleSelect(item)}
|
||||||
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
|
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
|
||||||
>
|
>
|
||||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span className="truncate">{item.label}</span>
|
<span className="truncate">{item.label}</span>
|
||||||
{item.to ? (
|
{keys && <KbdGroup className="ml-auto" keys={keys} />}
|
||||||
<ChevronRight className="ml-auto size-4 shrink-0 text-muted-foreground/70" />
|
{item.to && (
|
||||||
) : (
|
<ChevronRight
|
||||||
<Check className={cn('ml-auto size-4 text-foreground', !item.active && 'invisible')} />
|
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !keys && 'ml-auto')}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
)
|
)
|
||||||
|
|||||||
157
apps/desktop/src/app/command-palette/marketplace-theme-page.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* 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,6 +14,12 @@ import { useSkinCommand } from '@/themes/use-skin-command'
|
|||||||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||||
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
|
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
|
||||||
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
|
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 { setCronFocusJobId, setCronJobs } from '../store/cron'
|
||||||
import {
|
import {
|
||||||
$panesFlipped,
|
$panesFlipped,
|
||||||
@@ -44,12 +50,14 @@ import {
|
|||||||
$currentCwd,
|
$currentCwd,
|
||||||
$freshDraftReady,
|
$freshDraftReady,
|
||||||
$gatewayState,
|
$gatewayState,
|
||||||
|
$messagingSessions,
|
||||||
$selectedStoredSessionId,
|
$selectedStoredSessionId,
|
||||||
$sessions,
|
$sessions,
|
||||||
$workingSessionIds,
|
$workingSessionIds,
|
||||||
CRON_SECTION_LIMIT,
|
CRON_SECTION_LIMIT,
|
||||||
getRecentlySettledSessionIds,
|
getRecentlySettledSessionIds,
|
||||||
mergeSessionPage,
|
mergeSessionPage,
|
||||||
|
MESSAGING_SECTION_LIMIT,
|
||||||
sessionPinId,
|
sessionPinId,
|
||||||
setAwaitingResponse,
|
setAwaitingResponse,
|
||||||
setBusy,
|
setBusy,
|
||||||
@@ -59,12 +67,16 @@ import {
|
|||||||
setCurrentModel,
|
setCurrentModel,
|
||||||
setCurrentProvider,
|
setCurrentProvider,
|
||||||
setMessages,
|
setMessages,
|
||||||
|
setMessagingPlatformTotals,
|
||||||
|
setMessagingSessions,
|
||||||
|
setMessagingTruncated,
|
||||||
setSessionProfileTotals,
|
setSessionProfileTotals,
|
||||||
setSessions,
|
setSessions,
|
||||||
setSessionsLoading,
|
setSessionsLoading,
|
||||||
setSessionsTotal
|
setSessionsTotal
|
||||||
} from '../store/session'
|
} from '../store/session'
|
||||||
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
|
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
|
||||||
|
import { isSecondaryWindow } from '../store/windows'
|
||||||
|
|
||||||
import { ChatView } from './chat'
|
import { ChatView } from './chat'
|
||||||
import { useComposerActions } from './chat/hooks/use-composer-actions'
|
import { useComposerActions } from './chat/hooks/use-composer-actions'
|
||||||
@@ -79,6 +91,7 @@ import { CommandPalette } from './command-palette'
|
|||||||
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
|
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
|
||||||
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
|
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
|
||||||
import { useKeybinds } from './hooks/use-keybinds'
|
import { useKeybinds } from './hooks/use-keybinds'
|
||||||
|
import { modKey } from '@/lib/keybinds/combo'
|
||||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
|
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
|
||||||
import { ModelPickerOverlay } from './model-picker-overlay'
|
import { ModelPickerOverlay } from './model-picker-overlay'
|
||||||
import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
||||||
@@ -86,6 +99,7 @@ import { RightSidebarPane } from './right-sidebar'
|
|||||||
import { $terminalTakeover } from './right-sidebar/store'
|
import { $terminalTakeover } from './right-sidebar/store'
|
||||||
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
||||||
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
|
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
|
||||||
|
import { SessionSwitcher } from './session-switcher'
|
||||||
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
|
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
|
||||||
import { useCwdActions } from './session/hooks/use-cwd-actions'
|
import { useCwdActions } from './session/hooks/use-cwd-actions'
|
||||||
import { useHermesConfig } from './session/hooks/use-hermes-config'
|
import { useHermesConfig } from './session/hooks/use-hermes-config'
|
||||||
@@ -121,11 +135,22 @@ const SkillsView = lazy(async () => ({ default: (await import('./skills')).Skill
|
|||||||
// this cadence while the app is open + visible so new runs surface promptly
|
// this cadence while the app is open + visible so new runs surface promptly
|
||||||
// instead of waiting for the next user-triggered refreshSessions().
|
// instead of waiting for the next user-triggered refreshSessions().
|
||||||
const CRON_POLL_INTERVAL_MS = 30_000
|
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
|
// Cheap signature compare so the poll only swaps the atom (and re-renders the
|
||||||
// sidebar) when the visible cron rows actually changed.
|
// sidebar) when the visible cron rows actually changed.
|
||||||
function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
|
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)
|
return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title)
|
||||||
}
|
}
|
||||||
@@ -201,7 +226,7 @@ export function DesktopController() {
|
|||||||
toggleCommandCenter
|
toggleCommandCenter
|
||||||
} = useOverlayRouting()
|
} = useOverlayRouting()
|
||||||
|
|
||||||
const terminalTakeoverActive = chatOpen && terminalTakeover
|
const terminalSidebarOpen = chatOpen && terminalTakeover
|
||||||
|
|
||||||
const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
|
const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
|
||||||
const statusbarItemGroups = useGroupRegistry<StatusbarItem>()
|
const statusbarItemGroups = useGroupRegistry<StatusbarItem>()
|
||||||
@@ -247,7 +272,7 @@ export function DesktopController() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
|
if (event[modKey] && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
closeActiveRightRailTab()
|
closeActiveRightRailTab()
|
||||||
@@ -280,6 +305,51 @@ 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
|
// Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created
|
||||||
// synchronously (agent tool call or the cron UI), so refreshing here right
|
// 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
|
// after an agent turn surfaces a new job immediately; the interval poll keeps
|
||||||
@@ -316,7 +386,7 @@ export function DesktopController() {
|
|||||||
const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
|
const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
|
||||||
|
|
||||||
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
|
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
|
||||||
excludeSources: ['cron']
|
excludeSources: SIDEBAR_EXCLUDED_SOURCES
|
||||||
})
|
})
|
||||||
|
|
||||||
if (refreshSessionsRequestRef.current === requestId) {
|
if (refreshSessionsRequestRef.current === requestId) {
|
||||||
@@ -332,7 +402,8 @@ export function DesktopController() {
|
|||||||
|
|
||||||
void refreshCronSessions()
|
void refreshCronSessions()
|
||||||
void refreshCronJobs()
|
void refreshCronJobs()
|
||||||
}, [profileScope, refreshCronSessions, refreshCronJobs])
|
void refreshMessagingSessions()
|
||||||
|
}, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
|
||||||
|
|
||||||
const loadMoreSessions = useCallback(() => {
|
const loadMoreSessions = useCallback(() => {
|
||||||
bumpSessionsLimit()
|
bumpSessionsLimit()
|
||||||
@@ -347,12 +418,15 @@ export function DesktopController() {
|
|||||||
const loaded = $sessions.get().filter(inKey).length
|
const loaded = $sessions.get().filter(inKey).length
|
||||||
|
|
||||||
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, {
|
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, {
|
||||||
excludeSources: ['cron']
|
excludeSources: SIDEBAR_EXCLUDED_SOURCES
|
||||||
})
|
})
|
||||||
|
|
||||||
const keep = sessionsToKeep(key)
|
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
|
const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length
|
||||||
setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) }))
|
setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) }))
|
||||||
@@ -613,19 +687,19 @@ export function DesktopController() {
|
|||||||
submitText,
|
submitText,
|
||||||
transcribeVoiceAudio
|
transcribeVoiceAudio
|
||||||
} = usePromptActions({
|
} = usePromptActions({
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
activeSessionIdRef,
|
activeSessionIdRef,
|
||||||
branchCurrentSession: branchInNewChat,
|
branchCurrentSession: branchInNewChat,
|
||||||
busyRef,
|
busyRef,
|
||||||
createBackendSessionForSend,
|
createBackendSessionForSend,
|
||||||
handleSkinCommand,
|
handleSkinCommand,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
requestGateway,
|
requestGateway,
|
||||||
selectedStoredSessionIdRef,
|
selectedStoredSessionIdRef,
|
||||||
startFreshSessionDraft,
|
startFreshSessionDraft,
|
||||||
sttEnabled,
|
sttEnabled,
|
||||||
updateSessionState
|
updateSessionState
|
||||||
})
|
})
|
||||||
|
|
||||||
useGatewayBoot({
|
useGatewayBoot({
|
||||||
handleGatewayEvent: handleDesktopGatewayEvent,
|
handleGatewayEvent: handleDesktopGatewayEvent,
|
||||||
@@ -651,10 +725,14 @@ export function DesktopController() {
|
|||||||
// in the background (advancing next-run/state and creating runs), so poll the
|
// 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.
|
// job list on an interval (and on tab re-focus) while connected.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gatewayState !== 'open') {return}
|
if (gatewayState !== 'open') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (document.visibilityState === 'visible') {void refreshCronJobs()}
|
if (document.visibilityState === 'visible') {
|
||||||
|
void refreshCronJobs()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const intervalId = window.setInterval(tick, CRON_POLL_INTERVAL_MS)
|
const intervalId = window.setInterval(tick, CRON_POLL_INTERVAL_MS)
|
||||||
@@ -684,6 +762,7 @@ export function DesktopController() {
|
|||||||
|
|
||||||
const { leftStatusbarItems, statusbarItems } = useStatusbarItems({
|
const { leftStatusbarItems, statusbarItems } = useStatusbarItems({
|
||||||
agentsOpen,
|
agentsOpen,
|
||||||
|
chatOpen,
|
||||||
commandCenterOpen,
|
commandCenterOpen,
|
||||||
extraLeftItems: statusbarItemGroups.flat.left,
|
extraLeftItems: statusbarItemGroups.flat.left,
|
||||||
extraRightItems: statusbarItemGroups.flat.right,
|
extraRightItems: statusbarItemGroups.flat.right,
|
||||||
@@ -704,6 +783,7 @@ export function DesktopController() {
|
|||||||
currentView={currentView}
|
currentView={currentView}
|
||||||
onArchiveSession={sessionId => void archiveSession(sessionId)}
|
onArchiveSession={sessionId => void archiveSession(sessionId)}
|
||||||
onDeleteSession={sessionId => void removeSession(sessionId)}
|
onDeleteSession={sessionId => void removeSession(sessionId)}
|
||||||
|
onLoadMoreMessaging={loadMoreMessagingForPlatform}
|
||||||
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
|
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
|
||||||
onLoadMoreSessions={loadMoreSessions}
|
onLoadMoreSessions={loadMoreSessions}
|
||||||
onManageCronJob={jobId => {
|
onManageCronJob={jobId => {
|
||||||
@@ -721,27 +801,34 @@ 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 = (
|
const overlays = (
|
||||||
<>
|
<>
|
||||||
<DesktopInstallOverlay />
|
{!isSecondaryWindow() && <DesktopInstallOverlay />}
|
||||||
{/* One PTY-backed terminal mounted forever; <TerminalSlot /> placeholders
|
{!isSecondaryWindow() && (
|
||||||
decide where it shows. Toggling fullscreen never rebuilds the shell. */}
|
<DesktopOnboardingOverlay
|
||||||
<PersistentTerminal cwd={currentCwd} onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
|
enabled={gatewayState === 'open'}
|
||||||
<DesktopOnboardingOverlay
|
onCompleted={() => {
|
||||||
enabled={gatewayState === 'open'}
|
void refreshHermesConfig()
|
||||||
onCompleted={() => {
|
void refreshCurrentModel()
|
||||||
void refreshHermesConfig()
|
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||||
void refreshCurrentModel()
|
}}
|
||||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
requestGateway={requestGateway}
|
||||||
}}
|
/>
|
||||||
requestGateway={requestGateway}
|
)}
|
||||||
/>
|
|
||||||
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
||||||
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
|
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
|
||||||
<UpdatesOverlay />
|
<UpdatesOverlay />
|
||||||
<GatewayConnectingOverlay />
|
<GatewayConnectingOverlay />
|
||||||
<BootFailureOverlay />
|
<BootFailureOverlay />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
|
<SessionSwitcher />
|
||||||
|
|
||||||
{settingsOpen && (
|
{settingsOpen && (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
@@ -829,12 +916,6 @@ 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
|
// Flipped layout mirrors the default: sessions sidebar → right, file
|
||||||
// browser + preview rail → left. Same panes, swapped sides.
|
// browser + preview rail → left. Same panes, swapped sides.
|
||||||
const sidebarSide = panesFlipped ? 'right' : 'left'
|
const sidebarSide = panesFlipped ? 'right' : 'left'
|
||||||
@@ -879,33 +960,56 @@ export function DesktopController() {
|
|||||||
</Pane>
|
</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 (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
leftStatusbarItems={leftStatusbarItems}
|
leftStatusbarItems={leftStatusbarItems}
|
||||||
leftTitlebarTools={titlebarToolGroups.flat.left}
|
leftTitlebarTools={titlebarToolGroups.flat.left}
|
||||||
|
mainOverlays={mainOverlays}
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
overlays={overlays}
|
overlays={overlays}
|
||||||
|
previewPaneOpen={chatOpen && Boolean(previewTarget || filePreviewTarget)}
|
||||||
statusbarItems={statusbarItems}
|
statusbarItems={statusbarItems}
|
||||||
|
terminalPaneOpen={terminalSidebarOpen}
|
||||||
titlebarTools={titlebarToolGroups.flat.right}
|
titlebarTools={titlebarToolGroups.flat.right}
|
||||||
>
|
>
|
||||||
<Pane
|
{!isSecondaryWindow() && (
|
||||||
disabled={terminalTakeoverActive}
|
<Pane
|
||||||
forceCollapsed={narrowViewport}
|
forceCollapsed={narrowViewport}
|
||||||
hoverReveal
|
hoverReveal
|
||||||
id="chat-sidebar"
|
id="chat-sidebar"
|
||||||
maxWidth={SIDEBAR_MAX_WIDTH}
|
maxWidth={SIDEBAR_MAX_WIDTH}
|
||||||
minWidth={SIDEBAR_DEFAULT_WIDTH}
|
minWidth={SIDEBAR_DEFAULT_WIDTH}
|
||||||
onOverlayActiveChange={setSidebarOverlayMounted}
|
onOverlayActiveChange={setSidebarOverlayMounted}
|
||||||
resizable
|
resizable
|
||||||
side={sidebarSide}
|
side={sidebarSide}
|
||||||
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
|
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
|
||||||
>
|
>
|
||||||
{sidebar}
|
{sidebar}
|
||||||
</Pane>
|
</Pane>
|
||||||
|
)}
|
||||||
<PaneMain>
|
<PaneMain>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={terminalTakeoverActive ? takeoverTerminalView : chatView} index />
|
<Route element={chatView} index />
|
||||||
<Route element={terminalTakeoverActive ? takeoverTerminalView : chatView} path=":sessionId" />
|
<Route element={chatView} path=":sessionId" />
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
@@ -942,11 +1046,13 @@ export function DesktopController() {
|
|||||||
</PaneMain>
|
</PaneMain>
|
||||||
{/*
|
{/*
|
||||||
Order within a side maps to column order. Default (rail on the right):
|
Order within a side maps to column order. Default (rail on the right):
|
||||||
main | preview | file-browser. Flipped (rail on the left): mirror it to
|
main | terminal | preview | file-browser. Flipped (rail on the left):
|
||||||
file-browser | preview | main so preview stays adjacent to the chat.
|
mirror to file-browser | preview | terminal | main so terminal stays
|
||||||
|
adjacent to the chat.
|
||||||
*/}
|
*/}
|
||||||
{panesFlipped ? fileBrowserPane : previewPane}
|
{panesFlipped ? fileBrowserPane : terminalPane}
|
||||||
{panesFlipped ? previewPane : fileBrowserPane}
|
{previewPane}
|
||||||
|
{panesFlipped ? terminalPane : fileBrowserPane}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
22
apps/desktop/src/app/floating-hud.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// 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,6 +29,7 @@ import {
|
|||||||
$connection,
|
$connection,
|
||||||
$sessions,
|
$sessions,
|
||||||
$workingSessionIds,
|
$workingSessionIds,
|
||||||
|
ensureDefaultWorkspaceCwd,
|
||||||
setConnection,
|
setConnection,
|
||||||
setSessionsLoading
|
setSessionsLoading
|
||||||
} from '@/store/session'
|
} from '@/store/session'
|
||||||
@@ -351,6 +352,7 @@ export function useGatewayBoot({
|
|||||||
message: translateNow('boot.steps.loadingSettings'),
|
message: translateNow('boot.steps.loadingSettings'),
|
||||||
progress: 97
|
progress: 97
|
||||||
})
|
})
|
||||||
|
await ensureDefaultWorkspaceCwd()
|
||||||
await callbacksRef.current.refreshHermesConfig()
|
await callbacksRef.current.refreshHermesConfig()
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { setRightSidebarTab } from '@/app/right-sidebar/store'
|
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||||
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
|
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
|
||||||
import { matchesQuery } from '@/hooks/use-media-query'
|
import { matchesQuery } from '@/hooks/use-media-query'
|
||||||
import { PROFILE_SLOT_COUNT } from '@/lib/keybinds/actions'
|
import { PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
|
||||||
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
|
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
|
||||||
import { toggleCommandPalette } from '@/store/command-palette'
|
import { toggleCommandPalette } from '@/store/command-palette'
|
||||||
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
|
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
|
||||||
@@ -18,13 +18,25 @@ import {
|
|||||||
toggleSidebarOpen
|
toggleSidebarOpen
|
||||||
} from '@/store/layout'
|
} from '@/store/layout'
|
||||||
import {
|
import {
|
||||||
|
$newChatProfile,
|
||||||
cycleProfile,
|
cycleProfile,
|
||||||
requestProfileCreate,
|
requestProfileCreate,
|
||||||
switchProfileToSlot,
|
switchProfileToSlot,
|
||||||
switchToDefaultProfile,
|
switchToDefaultProfile,
|
||||||
toggleShowAllProfiles
|
toggleShowAllProfiles
|
||||||
} from '@/store/profile'
|
} from '@/store/profile'
|
||||||
import { $activeSessionId, $sessions, setModelPickerOpen } from '@/store/session'
|
import { setModelPickerOpen } from '@/store/session'
|
||||||
|
import {
|
||||||
|
$switcherOpen,
|
||||||
|
closeSwitcher,
|
||||||
|
commitOnCtrlUp,
|
||||||
|
onSwitcherTabDown,
|
||||||
|
onSwitcherTabUp,
|
||||||
|
openOrAdvanceSwitcher,
|
||||||
|
slotSessionId,
|
||||||
|
switcherActive,
|
||||||
|
switcherJustClosed
|
||||||
|
} from '@/store/session-switcher'
|
||||||
import { useTheme } from '@/themes/context'
|
import { useTheme } from '@/themes/context'
|
||||||
|
|
||||||
import { requestComposerFocus } from '../chat/composer/focus'
|
import { requestComposerFocus } from '../chat/composer/focus'
|
||||||
@@ -60,6 +72,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||||||
|
|
||||||
// Keep the latest closures without re-subscribing the listener.
|
// Keep the latest closures without re-subscribing the listener.
|
||||||
const handlersRef = useRef<HandlerMap>({})
|
const handlersRef = useRef<HandlerMap>({})
|
||||||
|
const commitSwitcherRef = useRef<() => void>(() => {})
|
||||||
|
|
||||||
const profileSwitchHandlers: HandlerMap = {}
|
const profileSwitchHandlers: HandlerMap = {}
|
||||||
|
|
||||||
@@ -67,26 +80,32 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||||||
profileSwitchHandlers[`profile.switch.${slot}`] = () => switchProfileToSlot(slot)
|
profileSwitchHandlers[`profile.switch.${slot}`] = () => switchProfileToSlot(slot)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move to the adjacent session in recency order, wrapping at the ends.
|
const goToSession = (sessionId: null | string) => {
|
||||||
const cycleSession = (direction: 1 | -1) => {
|
if (sessionId) {
|
||||||
const sessions = $sessions.get()
|
navigate(sessionRoute(sessionId))
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showRightSidebarTab = (tab: 'files' | 'terminal') => {
|
// ^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 = () => {
|
||||||
setFileBrowserOpen(true)
|
setFileBrowserOpen(true)
|
||||||
setRightSidebarTab(tab)
|
setTerminalTakeover(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
handlersRef.current = {
|
handlersRef.current = {
|
||||||
@@ -106,11 +125,16 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||||||
'nav.agents': () => navigate(AGENTS_ROUTE),
|
'nav.agents': () => navigate(AGENTS_ROUTE),
|
||||||
|
|
||||||
'session.new': () => {
|
'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()
|
deps.startFreshSession()
|
||||||
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
|
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
|
||||||
},
|
},
|
||||||
'session.next': () => cycleSession(1),
|
'session.next': () => stepSession(1),
|
||||||
'session.prev': () => cycleSession(-1),
|
'session.prev': () => stepSession(-1),
|
||||||
|
...sessionSlotHandlers,
|
||||||
'session.focusSearch': requestSessionSearchFocus,
|
'session.focusSearch': requestSessionSearchFocus,
|
||||||
'session.togglePin': deps.toggleSelectedPin,
|
'session.togglePin': deps.toggleSelectedPin,
|
||||||
|
|
||||||
@@ -128,8 +152,8 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||||||
toggleFileBrowserOpen()
|
toggleFileBrowserOpen()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'view.showFiles': () => showRightSidebarTab('files'),
|
'view.showFiles': showFiles,
|
||||||
'view.showTerminal': () => showRightSidebarTab('terminal'),
|
'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()),
|
||||||
'view.flipPanes': togglePanesFlipped,
|
'view.flipPanes': togglePanesFlipped,
|
||||||
|
|
||||||
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
|
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
|
||||||
@@ -170,6 +194,16 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||||||
return
|
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)
|
const combo = comboFromEvent(event)
|
||||||
|
|
||||||
if (!combo) {
|
if (!combo) {
|
||||||
@@ -196,8 +230,39 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||||||
handler()
|
handler()
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
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 })
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,22 +4,20 @@ import type { ReactNode } from 'react'
|
|||||||
import { ErrorBoundary } from '@/components/error-boundary'
|
import { ErrorBoundary } from '@/components/error-boundary'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import { useI18n } from '@/i18n'
|
|
||||||
import { Loader } from '@/components/ui/loader'
|
import { Loader } from '@/components/ui/loader'
|
||||||
import { Tip } from '@/components/ui/tooltip'
|
import { Tip } from '@/components/ui/tooltip'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { $panesFlipped } from '@/store/layout'
|
import { $panesFlipped } from '@/store/layout'
|
||||||
import { notifyError } from '@/store/notifications'
|
import { notifyError } from '@/store/notifications'
|
||||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||||
import { $currentBranch, $currentCwd } from '@/store/session'
|
import { $currentCwd } from '@/store/session'
|
||||||
|
|
||||||
import { SidebarPanelLabel } from '../shell/sidebar-label'
|
import { SidebarPanelLabel } from '../shell/sidebar-label'
|
||||||
|
|
||||||
import { ProjectTree } from './files/tree'
|
import { ProjectTree } from './files/tree'
|
||||||
import { useProjectTree } from './files/use-project-tree'
|
import { useProjectTree } from './files/use-project-tree'
|
||||||
import { $rightSidebarTab, $terminalTakeover, type RightSidebarTabId, setRightSidebarTab } from './store'
|
|
||||||
import { TerminalSlot } from './terminal/persistent'
|
|
||||||
|
|
||||||
interface RightSidebarPaneProps {
|
interface RightSidebarPaneProps {
|
||||||
onActivateFile: (path: string) => void
|
onActivateFile: (path: string) => void
|
||||||
@@ -27,24 +25,10 @@ interface RightSidebarPaneProps {
|
|||||||
onChangeCwd: (path: string) => Promise<void> | void
|
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) {
|
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const r = t.rightSidebar
|
const r = t.rightSidebar
|
||||||
const activeTab = useStore($rightSidebarTab)
|
|
||||||
const terminalTakeover = useStore($terminalTakeover)
|
|
||||||
const panesFlipped = useStore($panesFlipped)
|
const panesFlipped = useStore($panesFlipped)
|
||||||
const currentBranch = useStore($currentBranch).trim()
|
|
||||||
const currentCwd = useStore($currentCwd).trim()
|
const currentCwd = useStore($currentCwd).trim()
|
||||||
const hasCwd = currentCwd.length > 0
|
const hasCwd = currentCwd.length > 0
|
||||||
|
|
||||||
@@ -68,7 +52,6 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
|||||||
} = useProjectTree(currentCwd)
|
} = useProjectTree(currentCwd)
|
||||||
|
|
||||||
const canCollapse = Object.values(openState).some(Boolean)
|
const canCollapse = Object.values(openState).some(Boolean)
|
||||||
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
|
|
||||||
|
|
||||||
const chooseFolder = async () => {
|
const chooseFolder = async () => {
|
||||||
const selected = await window.hermesDesktop?.selectPaths({
|
const selected = await window.hermesDesktop?.selectPaths({
|
||||||
@@ -97,8 +80,6 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs = terminalTakeover ? RIGHT_SIDEBAR_TABS.filter(tab => tab.id !== 'terminal') : RIGHT_SIDEBAR_TABS
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-label={r.aria}
|
aria-label={r.aria}
|
||||||
@@ -109,85 +90,29 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
|||||||
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
|
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<RightSidebarChrome activeTab={effectiveTab} branch={currentBranch} tabs={tabs} />
|
<FilesystemTab
|
||||||
|
canCollapse={canCollapse}
|
||||||
{effectiveTab === 'terminal' ? (
|
collapseNonce={collapseNonce}
|
||||||
<TerminalSlot />
|
cwd={currentCwd}
|
||||||
) : (
|
cwdName={cwdName}
|
||||||
<FilesystemTab
|
data={data}
|
||||||
canCollapse={canCollapse}
|
error={rootError}
|
||||||
collapseNonce={collapseNonce}
|
hasCwd={hasCwd}
|
||||||
cwd={currentCwd}
|
loading={rootLoading}
|
||||||
cwdName={cwdName}
|
onActivateFile={onActivateFile}
|
||||||
data={data}
|
onActivateFolder={onActivateFolder}
|
||||||
error={rootError}
|
onChangeFolder={chooseFolder}
|
||||||
hasCwd={hasCwd}
|
onCollapseAll={collapseAll}
|
||||||
loading={rootLoading}
|
onLoadChildren={loadChildren}
|
||||||
onActivateFile={onActivateFile}
|
onNodeOpenChange={setNodeOpen}
|
||||||
onActivateFolder={onActivateFolder}
|
onPreviewFile={previewFile}
|
||||||
onChangeFolder={chooseFolder}
|
onRefresh={() => void refreshRoot()}
|
||||||
onCollapseAll={collapseAll}
|
openState={openState}
|
||||||
onLoadChildren={loadChildren}
|
/>
|
||||||
onNodeOpenChange={setNodeOpen}
|
|
||||||
onPreviewFile={previewFile}
|
|
||||||
onRefresh={() => void refreshRoot()}
|
|
||||||
openState={openState}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</aside>
|
</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 {
|
interface FilesystemTabProps extends FileTreeBodyProps {
|
||||||
canCollapse: boolean
|
canCollapse: boolean
|
||||||
cwdName: string
|
cwdName: string
|
||||||
|
|||||||
@@ -2,14 +2,10 @@ import { atom } from 'nanostores'
|
|||||||
|
|
||||||
import { persistBoolean, storedBoolean } from '@/lib/storage'
|
import { persistBoolean, storedBoolean } from '@/lib/storage'
|
||||||
|
|
||||||
export type RightSidebarTabId = 'files' | 'git' | 'terminal' | 'web'
|
|
||||||
|
|
||||||
const TAKEOVER_KEY = 'hermes.desktop.terminalTakeover'
|
const TAKEOVER_KEY = 'hermes.desktop.terminalTakeover'
|
||||||
|
|
||||||
export const $rightSidebarTab = atom<RightSidebarTabId>('files')
|
|
||||||
export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false))
|
export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false))
|
||||||
|
|
||||||
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
|
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
|
||||||
|
|
||||||
export const setRightSidebarTab = (tab: RightSidebarTabId) => $rightSidebarTab.set(tab)
|
|
||||||
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)
|
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)
|
||||||
|
|||||||
65
apps/desktop/src/app/right-sidebar/terminal/buffer.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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,7 +1,5 @@
|
|||||||
import '@xterm/xterm/css/xterm.css'
|
import '@xterm/xterm/css/xterm.css'
|
||||||
|
|
||||||
import { useStore } from '@nanostores/react'
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import { Loader } from '@/components/ui/loader'
|
import { Loader } from '@/components/ui/loader'
|
||||||
@@ -9,7 +7,7 @@ import { Tip } from '@/components/ui/tooltip'
|
|||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
|
|
||||||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||||
import { $terminalTakeover, setRightSidebarTab, setTerminalTakeover } from '../store'
|
import { setTerminalTakeover } from '../store'
|
||||||
|
|
||||||
import { addSelectionShortcutLabel } from './selection'
|
import { addSelectionShortcutLabel } from './selection'
|
||||||
import { useTerminalSession } from './use-terminal-session'
|
import { useTerminalSession } from './use-terminal-session'
|
||||||
@@ -21,41 +19,32 @@ interface TerminalTabProps {
|
|||||||
|
|
||||||
export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const { addSelectionToChat, hostRef, selection, selectionStyle, shellName, status } = useTerminalSession({
|
const { addSelectionToChat, hostRef, selection, selectionStyle, shellName, status } = useTerminalSession({
|
||||||
cwd,
|
cwd,
|
||||||
onAddSelectionToChat
|
onAddSelectionToChat
|
||||||
})
|
})
|
||||||
|
|
||||||
const takeover = useStore($terminalTakeover)
|
const label = t.rightSidebar.terminalHide
|
||||||
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 (
|
return (
|
||||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
|
<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">
|
<div className="flex h-8 shrink-0 items-center gap-2 px-2.5">
|
||||||
<SidebarPanelLabel className="text-white!">{shellName}</SidebarPanelLabel>
|
<SidebarPanelLabel className="text-(--ui-text-secondary)!">{shellName}</SidebarPanelLabel>
|
||||||
<Tip label={label}>
|
<Tip label={label}>
|
||||||
<Button
|
<Button
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className="ml-auto size-6 rounded-md text-white!"
|
className="ml-auto size-6 rounded-md text-(--ui-text-secondary)!"
|
||||||
onClick={toggleTakeover}
|
onClick={() => setTerminalTakeover(false)}
|
||||||
size="icon"
|
size="icon"
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Codicon name={takeover ? 'screen-normal' : 'screen-full'} size="0.875rem" />
|
<Codicon name="close" size="0.875rem" />
|
||||||
</Button>
|
</Button>
|
||||||
</Tip>
|
</Tip>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative min-h-0 flex-1 bg-[#002b36] p-2">
|
<div className="relative min-h-0 flex-1 bg-(--ui-editor-surface-background) p-2">
|
||||||
{status === 'starting' && (
|
{status === 'starting' && (
|
||||||
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
|
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
|
||||||
<Loader
|
<Loader
|
||||||
@@ -80,16 +69,17 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
{t.rightSidebar.addToChat}
|
{t.rightSidebar.addToChat}
|
||||||
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel()}</span>
|
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Outer div paints the dark inset; inner div is the xterm host so the
|
{/* Outer div paints terminal inset; inner div is the xterm host so the
|
||||||
canvas sizes to the *content* area and p-2 shows as terminal padding.
|
canvas sizes to the content area and p-2 stays as terminal padding.
|
||||||
Forcing screen/viewport bg avoids xterm's default black peeking
|
Screen/viewport inherit the live skin surface so the terminal blends
|
||||||
through the unused pixels below the last full row. */}
|
with the app and follows light/dark; the xterm canvas itself is
|
||||||
|
painted the resolved surface color in use-terminal-session. */}
|
||||||
<div
|
<div
|
||||||
className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-[#002b36]! [&_.xterm-viewport]:bg-[#002b36]!"
|
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)!"
|
||||||
ref={hostRef}
|
ref={hostRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { useStore } from '@nanostores/react'
|
|||||||
import { atom } from 'nanostores'
|
import { atom } from 'nanostores'
|
||||||
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { TERMINAL_BG } from './selection'
|
|
||||||
|
|
||||||
import { TerminalTab } from './index'
|
import { TerminalTab } from './index'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,7 +105,9 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
|
|||||||
visibility: visible ? 'visible' : 'hidden',
|
visibility: visible ? 'visible' : 'hidden',
|
||||||
pointerEvents: visible ? 'auto' : 'none',
|
pointerEvents: visible ? 'auto' : 'none',
|
||||||
zIndex: 4,
|
zIndex: 4,
|
||||||
backgroundColor: TERMINAL_BG,
|
// 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)',
|
||||||
contain: 'layout size paint'
|
contain: 'layout size paint'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +1,107 @@
|
|||||||
import type { ITheme, Terminal } from '@xterm/xterm'
|
import type { ITheme, Terminal } from '@xterm/xterm'
|
||||||
import type { CSSProperties } from 'react'
|
import type { CSSProperties } from 'react'
|
||||||
|
|
||||||
// Solarized-derived palette, but with bright ANSI 8–15 promoted to real
|
import { formatCombo, modKey } from '@/lib/keybinds/combo'
|
||||||
// accent variants instead of Schoonover's UI grays. Hermes' TUI skins (gold,
|
import type { DesktopTerminalPalette } from '@/themes/types'
|
||||||
// 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'
|
|
||||||
|
|
||||||
const THEME: ITheme = {
|
// VS Code's default integrated-terminal palette (terminalColorRegistry.ts) — a
|
||||||
background: TERMINAL_BG,
|
// fixed table per theme type, not luminance-derived. Light/dark diverge on
|
||||||
foreground: '#839496',
|
// purpose so each stays legible (e.g. mustard yellow on white).
|
||||||
cursor: '#93a1a1',
|
const DARK_THEME: ITheme = {
|
||||||
cursorAccent: TERMINAL_BG,
|
background: '#1e1e1e',
|
||||||
selectionBackground: '#586e7555',
|
foreground: '#cccccc',
|
||||||
black: '#073642',
|
cursor: '#cccccc',
|
||||||
red: '#dc322f',
|
cursorAccent: '#1e1e1e',
|
||||||
green: '#859900',
|
selectionBackground: '#264f7866',
|
||||||
yellow: '#b58900',
|
black: '#000000',
|
||||||
blue: '#268bd2',
|
red: '#cd3131',
|
||||||
magenta: '#d33682',
|
green: '#0dbc79',
|
||||||
cyan: '#2aa198',
|
yellow: '#e5e510',
|
||||||
white: '#eee8d5',
|
blue: '#2472c8',
|
||||||
brightBlack: '#586e75',
|
magenta: '#bc3fbc',
|
||||||
brightRed: '#f25c54',
|
cyan: '#11a8cd',
|
||||||
brightGreen: '#b3d437',
|
white: '#e5e5e5',
|
||||||
brightYellow: '#f7c948',
|
brightBlack: '#666666',
|
||||||
brightBlue: '#5fb3ff',
|
brightRed: '#f14c4c',
|
||||||
brightMagenta: '#ff6ab4',
|
brightGreen: '#23d18b',
|
||||||
brightCyan: '#5cd9c8',
|
brightYellow: '#f5f543',
|
||||||
brightWhite: '#fdf6e3'
|
brightBlue: '#3b8eea',
|
||||||
|
brightMagenta: '#d670d6',
|
||||||
|
brightCyan: '#29b8db',
|
||||||
|
brightWhite: '#e5e5e5'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const terminalTheme = (): ITheme => THEME
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
|
// 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
|
||||||
|
|
||||||
export const addSelectionShortcutLabel = () => (isMacPlatform() ? '⌘L' : 'Ctrl+L')
|
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 addSelectionShortcutLabel = formatCombo('mod+l')
|
||||||
|
|
||||||
export function isAddSelectionShortcut(event: KeyboardEvent) {
|
export function isAddSelectionShortcut(event: KeyboardEvent) {
|
||||||
const mod = isMacPlatform() ? event.metaKey : event.ctrlKey
|
const mod = event[modKey]
|
||||||
|
|
||||||
return mod && !event.shiftKey && event.key.toLowerCase() === 'l'
|
return mod && !event.shiftKey && event.key.toLowerCase() === 'l'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,20 @@ import { Unicode11Addon } from '@xterm/addon-unicode11'
|
|||||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||||
import { WebglAddon } from '@xterm/addon-webgl'
|
import { WebglAddon } from '@xterm/addon-webgl'
|
||||||
import { Terminal } from '@xterm/xterm'
|
import { Terminal } from '@xterm/xterm'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { CSSProperties } from 'react'
|
import type { CSSProperties } from 'react'
|
||||||
|
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
|
import { useTheme } from '@/themes/context'
|
||||||
|
|
||||||
import { isAddSelectionShortcut, terminalSelectionAnchor, terminalSelectionLabel, terminalTheme } from './selection'
|
import { makeTerminalReader, setActiveTerminalReader } from './buffer'
|
||||||
|
import {
|
||||||
|
isAddSelectionShortcut,
|
||||||
|
resolveSurfaceColor,
|
||||||
|
terminalSelectionAnchor,
|
||||||
|
terminalSelectionLabel,
|
||||||
|
terminalTheme
|
||||||
|
} from './selection'
|
||||||
|
|
||||||
type TerminalStatus = 'closed' | 'open' | 'starting'
|
type TerminalStatus = 'closed' | 'open' | 'starting'
|
||||||
|
|
||||||
@@ -64,10 +72,29 @@ function stripEscapeSequences(data: string) {
|
|||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
function isStartupSpacer(data: string) {
|
// Keep only the ANSI escape sequences from a chunk, dropping printable text. Lets
|
||||||
const text = stripEscapeSequences(data).replace(/[\s\r\n]/g, '')
|
// 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 = ''
|
||||||
|
|
||||||
return text === '' || text === '%'
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripInitialPromptGap(data: string) {
|
function stripInitialPromptGap(data: string) {
|
||||||
@@ -95,6 +122,14 @@ interface UseTerminalSessionOptions {
|
|||||||
onAddSelectionToChat: (text: string, label?: string) => void
|
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 {
|
function transferHasDropCandidates(t: DataTransfer): boolean {
|
||||||
if (t.types?.includes(HERMES_PATHS_MIME)) {
|
if (t.types?.includes(HERMES_PATHS_MIME)) {
|
||||||
return true
|
return true
|
||||||
@@ -184,8 +219,21 @@ function quotePathForShell(path: string, shellName: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSessionOptions) {
|
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 hostRef = useRef<HTMLDivElement | null>(null)
|
||||||
const termRef = useRef<Terminal | null>(null)
|
const termRef = useRef<Terminal | null>(null)
|
||||||
|
const webglRef = useRef<WebglAddon | null>(null)
|
||||||
const sessionIdRef = useRef<string | null>(null)
|
const sessionIdRef = useRef<string | null>(null)
|
||||||
const shellNameRef = useRef('shell')
|
const shellNameRef = useRef('shell')
|
||||||
const selectionLabelRef = useRef('')
|
const selectionLabelRef = useRef('')
|
||||||
@@ -200,19 +248,26 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||||||
onAddSelectionToChatRef.current = onAddSelectionToChat
|
onAddSelectionToChatRef.current = onAddSelectionToChat
|
||||||
}, [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 addSelectionToChat = useCallback(() => {
|
||||||
const selectedText = selectionRef.current || termRef.current?.getSelection() || ''
|
const selectedText = readSelection() || selectionRef.current
|
||||||
|
|
||||||
const label =
|
|
||||||
selectionLabelRef.current ||
|
|
||||||
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
|
|
||||||
|
|
||||||
const trimmed = selectedText.trim()
|
const trimmed = selectedText.trim()
|
||||||
|
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const label =
|
||||||
|
selectionLabelRef.current ||
|
||||||
|
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
|
||||||
|
|
||||||
onAddSelectionToChatRef.current(trimmed, label)
|
onAddSelectionToChatRef.current(trimmed, label)
|
||||||
termRef.current?.clearSelection()
|
termRef.current?.clearSelection()
|
||||||
selectionRef.current = ''
|
selectionRef.current = ''
|
||||||
@@ -220,15 +275,14 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||||||
setSelection('')
|
setSelection('')
|
||||||
setSelectionStyle(null)
|
setSelectionStyle(null)
|
||||||
triggerHaptic('selection')
|
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(() => {
|
useEffect(() => {
|
||||||
if (!selection.trim()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (!isAddSelectionShortcut(event)) {
|
if (!isAddSelectionShortcut(event) || !readSelection().trim()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +294,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||||
|
|
||||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||||
}, [addSelectionToChat, selection])
|
}, [addSelectionToChat, readSelection])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const host = hostRef.current
|
const host = hostRef.current
|
||||||
@@ -264,9 +318,19 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||||||
fontFamily: "'SF Mono', 'Menlo', 'Cascadia Code', 'JetBrains Mono', monospace",
|
fontFamily: "'SF Mono', 'Menlo', 'Cascadia Code', 'JetBrains Mono', monospace",
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
lineHeight: 1.12,
|
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,
|
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,
|
scrollback: 1000,
|
||||||
theme: terminalTheme()
|
theme: withSurface(initialThemeRef.current)
|
||||||
})
|
})
|
||||||
|
|
||||||
const fit = new FitAddon()
|
const fit = new FitAddon()
|
||||||
@@ -276,18 +340,10 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||||||
term.loadAddon(new Unicode11Addon())
|
term.loadAddon(new Unicode11Addon())
|
||||||
term.loadAddon(new WebLinksAddon())
|
term.loadAddon(new WebLinksAddon())
|
||||||
term.unicode.activeVersion = '11'
|
term.unicode.activeVersion = '11'
|
||||||
term.open(host)
|
|
||||||
term.focus()
|
|
||||||
|
|
||||||
// WebGL renderer matches the dashboard ChatPage path; xterm's default DOM
|
// Let the GUI chat agent read this pane via the `read_terminal` tool: the
|
||||||
// renderer paints SGR via CSS classes that visibly mute against our skins.
|
// gateway's terminal.read.request handler serializes the buffer through this.
|
||||||
try {
|
setActiveTerminalReader(makeTerminalReader(term))
|
||||||
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) => {
|
const onDragOver = (e: DragEvent) => {
|
||||||
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
|
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
|
||||||
@@ -328,6 +384,75 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||||||
host.removeEventListener('drop', onDrop)
|
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 = () => {
|
const fitAndResize = () => {
|
||||||
if (disposed || !host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
|
if (disposed || !host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
|
||||||
return
|
return
|
||||||
@@ -344,6 +469,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||||||
if (id && (lastSentSize?.cols !== term.cols || lastSentSize?.rows !== term.rows)) {
|
if (id && (lastSentSize?.cols !== term.cols || lastSentSize?.rows !== term.rows)) {
|
||||||
lastSentSize = { cols: term.cols, rows: term.rows }
|
lastSentSize = { cols: term.cols, rows: term.rows }
|
||||||
void terminalApi.resize(id, { cols: term.cols, rows: term.rows })
|
void terminalApi.resize(id, { cols: term.cols, rows: term.rows })
|
||||||
|
scheduleGapCleanup()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,6 +506,12 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||||||
const id = sessionIdRef.current
|
const id = sessionIdRef.current
|
||||||
|
|
||||||
if (id) {
|
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)
|
void terminalApi.write(id, data)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -396,87 +528,88 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||||||
|
|
||||||
cleanup.push(() => selectionDisposable.dispose())
|
cleanup.push(() => selectionDisposable.dispose())
|
||||||
|
|
||||||
term.attachCustomKeyEventHandler(event => {
|
const startSession = () =>
|
||||||
if (event.type !== 'keydown') {
|
void terminalApi
|
||||||
return true
|
.start({ cols: term.cols, cwd, rows: term.rows })
|
||||||
}
|
.then(session => {
|
||||||
|
if (disposed) {
|
||||||
|
void terminalApi.dispose(session.id)
|
||||||
|
|
||||||
if (isAddSelectionShortcut(event) && term.hasSelection()) {
|
return
|
||||||
event.preventDefault()
|
}
|
||||||
addSelectionToChat()
|
|
||||||
|
|
||||||
return false
|
sessionIdRef.current = session.id
|
||||||
}
|
lastSentSize = { cols: term.cols, rows: term.rows }
|
||||||
|
shellNameRef.current = session.shell || 'shell'
|
||||||
|
setShellName(session.shell || 'shell')
|
||||||
|
|
||||||
return true
|
const initial = term.hasSelection() ? term.getSelection() : ''
|
||||||
})
|
selectionRef.current = initial
|
||||||
|
selectionLabelRef.current = initial ? terminalSelectionLabel(term, shellNameRef.current, initial) : ''
|
||||||
|
|
||||||
fitAndResize()
|
setStatus('open')
|
||||||
|
|
||||||
void terminalApi
|
cleanup.push(
|
||||||
.start({ cols: term.cols, cwd, rows: term.rows })
|
terminalApi.onData(session.id, armedWrite),
|
||||||
.then(session => {
|
terminalApi.onExit(session.id, ({ code, signal }) => {
|
||||||
if (disposed) {
|
setStatus('closed')
|
||||||
void terminalApi.dispose(session.id)
|
term.write(`\r\n[terminal exited${signal ? `: ${signal}` : code !== null ? `: ${code}` : ''}]\r\n`)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
return
|
window.requestAnimationFrame(() => {
|
||||||
}
|
fitAndResize()
|
||||||
|
term.clearSelection() // drop any selection painted over transient boot rows
|
||||||
sessionIdRef.current = session.id
|
term.focus()
|
||||||
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 => {
|
||||||
.catch(error => {
|
setStatus('closed')
|
||||||
setStatus('closed')
|
term.write(`Terminal failed to start: ${error instanceof Error ? error.message : String(error)}\r\n`)
|
||||||
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()
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true
|
disposed = true
|
||||||
cleanup.forEach(run => run())
|
cleanup.forEach(run => run())
|
||||||
|
setActiveTerminalReader(null)
|
||||||
|
|
||||||
const id = sessionIdRef.current
|
const id = sessionIdRef.current
|
||||||
sessionIdRef.current = null
|
sessionIdRef.current = null
|
||||||
@@ -487,12 +620,34 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
|||||||
|
|
||||||
term.dispose()
|
term.dispose()
|
||||||
termRef.current = null
|
termRef.current = null
|
||||||
|
webglRef.current = null
|
||||||
shellNameRef.current = 'shell'
|
shellNameRef.current = 'shell'
|
||||||
selectionRef.current = ''
|
selectionRef.current = ''
|
||||||
selectionLabelRef.current = ''
|
selectionLabelRef.current = ''
|
||||||
}
|
}
|
||||||
}, [addSelectionToChat, cwd])
|
}, [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 {
|
return {
|
||||||
addSelectionToChat,
|
addSelectionToChat,
|
||||||
hostRef,
|
hostRef,
|
||||||
|
|||||||
107
apps/desktop/src/app/session-switcher.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
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,6 +1,7 @@
|
|||||||
import type { QueryClient } from '@tanstack/react-query'
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
|
||||||
import {
|
import {
|
||||||
appendAssistantTextPart,
|
appendAssistantTextPart,
|
||||||
appendReasoningPart,
|
appendReasoningPart,
|
||||||
@@ -18,6 +19,7 @@ import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
|
|||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||||
import { setClarifyRequest } from '@/store/clarify'
|
import { setClarifyRequest } from '@/store/clarify'
|
||||||
|
import { $gateway } from '@/store/gateway'
|
||||||
import { notify } from '@/store/notifications'
|
import { notify } from '@/store/notifications'
|
||||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||||
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
||||||
@@ -906,6 +908,21 @@ export function useMessageStream({
|
|||||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
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') {
|
} else if (event.type === 'error') {
|
||||||
const errorMessage = payload?.message || 'Hermes reported an error'
|
const errorMessage = payload?.message || 'Hermes reported an error'
|
||||||
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
|
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { cleanup, render } from '@testing-library/react'
|
import { cleanup, render, waitFor } from '@testing-library/react'
|
||||||
import type { MutableRefObject } from 'react'
|
import type { MutableRefObject } from 'react'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { $sessions, setSessions } from '@/store/session'
|
import { $composerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||||
|
import { $connection, $sessions, setSessions } from '@/store/session'
|
||||||
import type { SessionInfo } from '@/types/hermes'
|
import type { SessionInfo } from '@/types/hermes'
|
||||||
|
|
||||||
import { usePromptActions } from './use-prompt-actions'
|
import { uploadComposerAttachment, usePromptActions } from './use-prompt-actions'
|
||||||
|
|
||||||
vi.mock('@/hermes', () => ({
|
vi.mock('@/hermes', () => ({
|
||||||
getProfiles: vi.fn(async () => ({ profiles: [] })),
|
getProfiles: vi.fn(async () => ({ profiles: [] })),
|
||||||
@@ -42,7 +43,10 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
|||||||
|
|
||||||
interface HarnessHandle {
|
interface HarnessHandle {
|
||||||
steerPrompt: (text: string) => Promise<boolean>
|
steerPrompt: (text: string) => Promise<boolean>
|
||||||
submitText: (text: string, options?: { attachments?: never[]; fromQueue?: boolean }) => Promise<boolean>
|
submitText: (
|
||||||
|
text: string,
|
||||||
|
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
|
||||||
|
) => Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
function Harness({
|
function Harness({
|
||||||
@@ -50,16 +54,20 @@ function Harness({
|
|||||||
onReady,
|
onReady,
|
||||||
onSeedState,
|
onSeedState,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
requestGateway
|
requestGateway,
|
||||||
|
storedSessionId
|
||||||
}: {
|
}: {
|
||||||
busyRef?: MutableRefObject<boolean>
|
busyRef?: MutableRefObject<boolean>
|
||||||
onReady: (handle: HarnessHandle) => void
|
onReady: (handle: HarnessHandle) => void
|
||||||
onSeedState?: (state: Record<string, unknown>) => void
|
onSeedState?: (state: Record<string, unknown>) => void
|
||||||
refreshSessions: () => Promise<void>
|
refreshSessions: () => Promise<void>
|
||||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||||
|
storedSessionId?: null | string
|
||||||
}) {
|
}) {
|
||||||
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
||||||
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
const selectedStoredSessionIdRef: MutableRefObject<string | null> = {
|
||||||
|
current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId
|
||||||
|
}
|
||||||
const localBusyRef = busyRef ?? { current: false }
|
const localBusyRef = busyRef ?? { current: false }
|
||||||
|
|
||||||
const actions = usePromptActions({
|
const actions = usePromptActions({
|
||||||
@@ -314,3 +322,433 @@ describe('usePromptActions steerPrompt', () => {
|
|||||||
expect(requestGateway).not.toHaveBeenCalled()
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
|
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
|
||||||
import { type MutableRefObject, useCallback } from 'react'
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
import { getProfiles, transcribeAudio } from '@/hermes'
|
import { getProfiles, transcribeAudio } from '@/hermes'
|
||||||
import { translateNow, type Translations, useI18n } from '@/i18n'
|
import { translateNow, type Translations, useI18n } from '@/i18n'
|
||||||
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||||
import {
|
import {
|
||||||
attachmentDisplayText,
|
optimisticAttachmentRef,
|
||||||
parseCommandDispatch,
|
parseCommandDispatch,
|
||||||
parseSlashCommand,
|
parseSlashCommand,
|
||||||
pathLabel,
|
pathLabel,
|
||||||
@@ -24,10 +25,11 @@ import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
|||||||
import { setSessionYolo } from '@/lib/yolo-session'
|
import { setSessionYolo } from '@/lib/yolo-session'
|
||||||
import {
|
import {
|
||||||
$composerAttachments,
|
$composerAttachments,
|
||||||
addComposerAttachment,
|
|
||||||
clearComposerAttachments,
|
clearComposerAttachments,
|
||||||
type ComposerAttachment,
|
type ComposerAttachment,
|
||||||
terminalContextBlocksFromDraft
|
setComposerAttachmentUploadState,
|
||||||
|
terminalContextBlocksFromDraft,
|
||||||
|
updateComposerAttachment
|
||||||
} from '@/store/composer'
|
} from '@/store/composer'
|
||||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||||
@@ -47,6 +49,7 @@ import {
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
ClientSessionState,
|
ClientSessionState,
|
||||||
|
FileAttachResponse,
|
||||||
ImageAttachResponse,
|
ImageAttachResponse,
|
||||||
SessionSteerResponse,
|
SessionSteerResponse,
|
||||||
SessionTitleResponse,
|
SessionTitleResponse,
|
||||||
@@ -103,6 +106,136 @@ async function readImageForRemoteAttach(
|
|||||||
return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null
|
return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read a non-image file as a data URL for upload via file.attach. Returns null
|
||||||
|
// when the desktop bridge can't read the file (e.g. it was moved/deleted).
|
||||||
|
async function readFileDataUrlForAttach(filePath: string): Promise<string | null> {
|
||||||
|
const reader = window.hermesDesktop?.readFileDataUrl
|
||||||
|
|
||||||
|
if (!reader) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrl = await reader(filePath)
|
||||||
|
|
||||||
|
return dataUrl || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// The readFileDataUrl IPC base64-loads the whole file into memory and is
|
||||||
|
// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.cjs, which
|
||||||
|
// rejects with a raw "file is too large (N bytes; limit M bytes)" string. In
|
||||||
|
// remote mode every attachment's bytes go through that read, so a big file
|
||||||
|
// surfaces that internal message verbatim in the failure toast. Translate it
|
||||||
|
// into a friendly "too large to upload to the remote gateway" line, parsing the
|
||||||
|
// limit out of the message so it tracks the real cap. Non-cap errors pass
|
||||||
|
// through unchanged.
|
||||||
|
function friendlyRemoteAttachError(err: unknown, label: string): Error {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|
||||||
|
if (!/too large/i.test(message)) {
|
||||||
|
return err instanceof Error ? err : new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitBytes = Number(message.match(/limit (\d+) bytes/)?.[1])
|
||||||
|
const cap = Number.isFinite(limitBytes) && limitBytes > 0 ? ` (max ${Math.floor(limitBytes / (1024 * 1024))} MB)` : ''
|
||||||
|
|
||||||
|
return new Error(`${label} is too large to upload to the remote gateway${cap}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
type GatewayRequest = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage one file/image attachment into the session workspace and return the
|
||||||
|
* attachment rewritten with the gateway-side ref. Images upload their bytes in
|
||||||
|
* remote mode (so vision works) and pass the path locally; non-image files
|
||||||
|
* upload bytes remotely and pass the path locally. Throws on failure so callers
|
||||||
|
* can surface an error. Shared by submit-time sync, the eager drop-time upload,
|
||||||
|
* and the message-edit composer drop — keep them in lockstep.
|
||||||
|
*/
|
||||||
|
export async function uploadComposerAttachment(
|
||||||
|
attachment: ComposerAttachment,
|
||||||
|
opts: { remote: boolean; requestGateway: GatewayRequest; sessionId: string }
|
||||||
|
): Promise<ComposerAttachment> {
|
||||||
|
const { remote, requestGateway, sessionId } = opts
|
||||||
|
const path = attachment.path ?? ''
|
||||||
|
const label = attachment.label || pathLabel(path)
|
||||||
|
|
||||||
|
if (attachment.kind === 'image') {
|
||||||
|
let result: ImageAttachResponse
|
||||||
|
|
||||||
|
if (remote) {
|
||||||
|
let payload: Awaited<ReturnType<typeof readImageForRemoteAttach>>
|
||||||
|
|
||||||
|
try {
|
||||||
|
payload = await readImageForRemoteAttach(path)
|
||||||
|
} catch (err) {
|
||||||
|
throw friendlyRemoteAttachError(err, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error(`Could not read ${label}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
|
||||||
|
session_id: sessionId,
|
||||||
|
content_base64: payload.contentBase64,
|
||||||
|
filename: payload.filename
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
result = await requestGateway<ImageAttachResponse>('image.attach', {
|
||||||
|
path,
|
||||||
|
session_id: sessionId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.attached) {
|
||||||
|
throw new Error(result.message || `Could not attach ${label}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachedPath = result.path || path
|
||||||
|
|
||||||
|
return {
|
||||||
|
...attachment,
|
||||||
|
attachedSessionId: sessionId,
|
||||||
|
label: attachedPath ? pathLabel(attachedPath) : attachment.label,
|
||||||
|
path: attachedPath,
|
||||||
|
uploadState: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-image file.
|
||||||
|
let dataUrl: string | null = null
|
||||||
|
|
||||||
|
if (remote) {
|
||||||
|
try {
|
||||||
|
dataUrl = await readFileDataUrlForAttach(path)
|
||||||
|
} catch (err) {
|
||||||
|
throw friendlyRemoteAttachError(err, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dataUrl) {
|
||||||
|
throw new Error(`Could not read ${label}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await requestGateway<FileAttachResponse>('file.attach', {
|
||||||
|
name: label,
|
||||||
|
path,
|
||||||
|
session_id: sessionId,
|
||||||
|
...(dataUrl ? { data_url: dataUrl } : {})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.attached || !result.ref_text) {
|
||||||
|
throw new Error(result.message || `Could not attach ${label}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...attachment,
|
||||||
|
attachedSessionId: sessionId,
|
||||||
|
refText: result.ref_text,
|
||||||
|
uploadState: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface PromptActionsOptions {
|
interface PromptActionsOptions {
|
||||||
activeSessionId: string | null
|
activeSessionId: string | null
|
||||||
activeSessionIdRef: MutableRefObject<string | null>
|
activeSessionIdRef: MutableRefObject<string | null>
|
||||||
@@ -212,101 +345,168 @@ export function usePromptActions({
|
|||||||
[selectedStoredSessionIdRef, updateSessionState]
|
[selectedStoredSessionIdRef, updateSessionState]
|
||||||
)
|
)
|
||||||
|
|
||||||
const syncImageAttachmentsForSubmit = useCallback(
|
// In-flight drop-time eager uploads, keyed by attachment id. Submit joins
|
||||||
|
// these before re-uploading so a drop-then-immediately-Enter can't fire
|
||||||
|
// file.attach twice and stage duplicate copies on the gateway.
|
||||||
|
const eagerUploadInFlight = useRef<Map<string, Promise<void>>>(new Map())
|
||||||
|
|
||||||
|
const syncAttachmentsForSubmit = useCallback(
|
||||||
async (
|
async (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
attachments: ComposerAttachment[],
|
attachments: ComposerAttachment[],
|
||||||
options: { updateComposerAttachments?: boolean } = {}
|
options: { updateComposerAttachments?: boolean } = {}
|
||||||
) => {
|
): Promise<ComposerAttachment[]> => {
|
||||||
const updateComposerAttachments = options.updateComposerAttachments ?? true
|
const updateComposerAttachments = options.updateComposerAttachments ?? true
|
||||||
const images = attachments.filter(attachment => attachment.kind === 'image' && attachment.path)
|
|
||||||
const remote = $connection.get()?.mode === 'remote'
|
const remote = $connection.get()?.mode === 'remote'
|
||||||
|
const synced: ComposerAttachment[] = []
|
||||||
|
|
||||||
|
for (const original of attachments) {
|
||||||
|
let attachment = original
|
||||||
|
|
||||||
|
// Join a drop-time eager upload still in flight for this attachment
|
||||||
|
// before deciding anything — otherwise submit and the eager task both
|
||||||
|
// call file.attach and stage duplicate files. After it settles, take the
|
||||||
|
// store's updated copy (its gateway ref, or its failure) over the stale
|
||||||
|
// pre-upload snapshot.
|
||||||
|
const inFlight = eagerUploadInFlight.current.get(attachment.id)
|
||||||
|
|
||||||
|
if (inFlight) {
|
||||||
|
await inFlight
|
||||||
|
attachment = $composerAttachments.get().find(item => item.id === attachment.id) ?? attachment
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already-synced or pathless refs (terminal, url, etc.) pass through.
|
||||||
|
// A drop-time eager upload may already have staged this one (matching
|
||||||
|
// attachedSessionId) — don't re-upload it.
|
||||||
|
if (!attachment.path || attachment.attachedSessionId === sessionId) {
|
||||||
|
synced.push(attachment)
|
||||||
|
|
||||||
for (const attachment of images) {
|
|
||||||
if (attachment.attachedSessionId === sessionId) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: ImageAttachResponse
|
if (attachment.kind === 'image' || attachment.kind === 'file') {
|
||||||
|
const nextAttachment = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })
|
||||||
|
|
||||||
if (remote) {
|
// Update-only: never resurrect a chip the user removed mid-upload.
|
||||||
// The gateway is on another machine — it can't read attachment.path
|
if (updateComposerAttachments) {
|
||||||
// (a path on THIS disk). Upload the bytes via image.attach_bytes.
|
updateComposerAttachment(nextAttachment)
|
||||||
const payload = attachment.path ? await readImageForRemoteAttach(attachment.path) : null
|
|
||||||
|
|
||||||
if (!payload) {
|
|
||||||
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
|
|
||||||
throw new Error(`Could not read ${label}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
|
synced.push(nextAttachment)
|
||||||
session_id: sessionId,
|
|
||||||
content_base64: payload.contentBase64,
|
continue
|
||||||
filename: payload.filename
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
result = await requestGateway<ImageAttachResponse>('image.attach', {
|
|
||||||
session_id: sessionId,
|
|
||||||
path: attachment.path
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.attached) {
|
synced.push(attachment)
|
||||||
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
|
|
||||||
throw new Error(result.message || `Could not attach ${label}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachedPath = result.path || attachment.path
|
|
||||||
|
|
||||||
if (updateComposerAttachments) {
|
|
||||||
addComposerAttachment({
|
|
||||||
...attachment,
|
|
||||||
id: attachment.id,
|
|
||||||
label: attachedPath ? pathLabel(attachedPath) : attachment.label,
|
|
||||||
path: attachedPath,
|
|
||||||
attachedSessionId: sessionId
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return synced
|
||||||
},
|
},
|
||||||
[requestGateway]
|
[requestGateway]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Stage a freshly dropped file as soon as it lands (when a session already
|
||||||
|
// exists), so the upload runs while the user is still typing rather than
|
||||||
|
// stalling the send. The card shows a spinner via `uploadState`; on success
|
||||||
|
// the chip carries its gateway-side ref so submit skips re-uploading.
|
||||||
|
//
|
||||||
|
// Images are intentionally NOT eager-uploaded: attachImagePath adds the chip
|
||||||
|
// and then fills in `previewUrl` (the base64 thumbnail) on a second tick, so
|
||||||
|
// an eager upload would race that write — clobbering the thumbnail and
|
||||||
|
// swapping `path` to a gateway path the local preview can't read. Images are
|
||||||
|
// small and still byte-upload at submit via image.attach_bytes.
|
||||||
|
const eagerlyUploadAttachment = useCallback(
|
||||||
|
async (sessionId: string, attachment: ComposerAttachment) => {
|
||||||
|
const remote = $connection.get()?.mode === 'remote'
|
||||||
|
|
||||||
|
setComposerAttachmentUploadState(attachment.id, 'uploading')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update-only: if the user removed the chip while this was uploading,
|
||||||
|
// don't resurrect it — just drop the staged result on the floor.
|
||||||
|
updateComposerAttachment(await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId }))
|
||||||
|
} catch (err) {
|
||||||
|
// Leave the chip in place so submit-time sync can retry (or the user can
|
||||||
|
// remove it) and flag the card; also toast so a hard failure (unreadable
|
||||||
|
// file, gateway perms) isn't swallowed while the user keeps typing.
|
||||||
|
setComposerAttachmentUploadState(attachment.id, 'error')
|
||||||
|
notifyError(err, copy.dropFiles)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[copy.dropFiles, requestGateway]
|
||||||
|
)
|
||||||
|
|
||||||
|
const composerAttachments = useStore($composerAttachments)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeSessionId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const attachment of composerAttachments) {
|
||||||
|
const needsUpload =
|
||||||
|
attachment.kind === 'file' &&
|
||||||
|
Boolean(attachment.path) &&
|
||||||
|
!attachment.attachedSessionId &&
|
||||||
|
!attachment.uploadState &&
|
||||||
|
!eagerUploadInFlight.current.has(attachment.id)
|
||||||
|
|
||||||
|
if (!needsUpload) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = eagerlyUploadAttachment(activeSessionId, attachment).finally(() =>
|
||||||
|
eagerUploadInFlight.current.delete(attachment.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
eagerUploadInFlight.current.set(attachment.id, task)
|
||||||
|
}
|
||||||
|
}, [activeSessionId, composerAttachments, eagerlyUploadAttachment])
|
||||||
|
|
||||||
const submitPromptText = useCallback(
|
const submitPromptText = useCallback(
|
||||||
async (rawText: string, options?: SubmitTextOptions) => {
|
async (rawText: string, options?: SubmitTextOptions) => {
|
||||||
const visibleText = rawText.trim()
|
const visibleText = rawText.trim()
|
||||||
const usingComposerAttachments = !options?.attachments
|
const usingComposerAttachments = !options?.attachments
|
||||||
const attachments = options?.attachments ?? $composerAttachments.get()
|
const attachments = options?.attachments ?? $composerAttachments.get()
|
||||||
|
|
||||||
const contextRefs = attachments
|
|
||||||
.map(a => a.refText)
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n')
|
|
||||||
|
|
||||||
const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n')
|
const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n')
|
||||||
const hasImage = attachments.some(a => a.kind === 'image')
|
const hasImage = attachments.some(a => a.kind === 'image')
|
||||||
const attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
|
|
||||||
|
|
||||||
const text =
|
// Refs are recomputed after sync (file.attach rewrites @file: refs to
|
||||||
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
|
// workspace-relative paths the remote gateway can resolve). Seed the
|
||||||
(hasImage ? 'What do you see in this image?' : '')
|
// optimistic message with the pre-sync refs, then rewrite once synced.
|
||||||
|
// Images use their base64 preview so the thumbnail renders inline without
|
||||||
|
// a (remote-mode 403-prone) /api/media fetch — see optimisticAttachmentRef.
|
||||||
|
let attachmentRefs = attachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r))
|
||||||
|
const buildContextText = (atts: ComposerAttachment[]): string => {
|
||||||
|
const contextRefs = atts
|
||||||
|
.map(a => a.refText)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
return (
|
||||||
|
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
|
||||||
|
(atts.some(a => a.kind === 'image') ? 'What do you see in this image?' : '')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Queue drains fire on the busy→false settle edge, where busyRef (synced
|
// Queue drains fire on the busy→false settle edge, where busyRef (synced
|
||||||
// from $busy by a separate effect) may still read true — honoring it would
|
// from $busy by a separate effect) may still read true — honoring it would
|
||||||
// bounce the drained send. The drain lock serializes them; the user path
|
// bounce the drained send. The drain lock serializes them; the user path
|
||||||
// keeps the guard so a stray Enter mid-turn can't double-submit.
|
// keeps the guard so a stray Enter mid-turn can't double-submit.
|
||||||
if (!text || (!options?.fromQueue && busyRef.current)) {
|
const hasSendable = Boolean(visibleText || terminalContextBlocks || attachments.length || hasImage)
|
||||||
|
if (!hasSendable || (!options?.fromQueue && busyRef.current)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
|
||||||
const userMessage: ChatMessage = {
|
const buildUserMessage = (): ChatMessage => ({
|
||||||
id: optimisticId,
|
id: optimisticId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
|
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
|
||||||
attachmentRefs
|
attachmentRefs
|
||||||
}
|
})
|
||||||
|
|
||||||
const releaseBusy = () => {
|
const releaseBusy = () => {
|
||||||
setMutableRef(busyRef, false)
|
setMutableRef(busyRef, false)
|
||||||
@@ -323,7 +523,7 @@ export function usePromptActions({
|
|||||||
...state,
|
...state,
|
||||||
messages: state.messages.some(m => m.id === optimisticId)
|
messages: state.messages.some(m => m.id === optimisticId)
|
||||||
? state.messages
|
? state.messages
|
||||||
: [...state.messages, userMessage],
|
: [...state.messages, buildUserMessage()],
|
||||||
busy: true,
|
busy: true,
|
||||||
awaitingResponse: true,
|
awaitingResponse: true,
|
||||||
pendingBranchGroup: null,
|
pendingBranchGroup: null,
|
||||||
@@ -336,6 +536,18 @@ export function usePromptActions({
|
|||||||
selectedStoredSessionIdRef.current
|
selectedStoredSessionIdRef.current
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// After sync rewrites refs, refresh the optimistic message in place so the
|
||||||
|
// transcript shows the resolved @file: ref rather than the local path.
|
||||||
|
const rewriteOptimistic = (sid: string) =>
|
||||||
|
updateSessionState(
|
||||||
|
sid,
|
||||||
|
state => ({
|
||||||
|
...state,
|
||||||
|
messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message))
|
||||||
|
}),
|
||||||
|
selectedStoredSessionIdRef.current
|
||||||
|
)
|
||||||
|
|
||||||
const dropOptimistic = (sid: null | string) => {
|
const dropOptimistic = (sid: null | string) => {
|
||||||
if (!sid) {
|
if (!sid) {
|
||||||
setMessages(current => current.filter(m => m.id !== optimisticId))
|
setMessages(current => current.filter(m => m.id !== optimisticId))
|
||||||
@@ -366,7 +578,7 @@ export function usePromptActions({
|
|||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
seedOptimistic(sessionId)
|
seedOptimistic(sessionId)
|
||||||
} else {
|
} else {
|
||||||
setMessages(current => [...current, userMessage])
|
setMessages(current => [...current, buildUserMessage()])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -392,10 +604,47 @@ export function usePromptActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await syncImageAttachmentsForSubmit(sessionId, attachments, {
|
const syncedAttachments = await syncAttachmentsForSubmit(sessionId, attachments, {
|
||||||
updateComposerAttachments: usingComposerAttachments
|
updateComposerAttachments: usingComposerAttachments
|
||||||
})
|
})
|
||||||
await requestGateway('prompt.submit', { session_id: sessionId, text })
|
// Rewrite the optimistic message + prompt text with the synced refs so
|
||||||
|
// the gateway receives @file: paths that resolve in its workspace.
|
||||||
|
// (Images keep their inline base64 preview — see optimisticAttachmentRef.)
|
||||||
|
attachmentRefs = syncedAttachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r))
|
||||||
|
rewriteOptimistic(sessionId)
|
||||||
|
const text = buildContextText(syncedAttachments)
|
||||||
|
|
||||||
|
// On sleep/wake the gateway's in-memory session may have been cleared
|
||||||
|
// while the desktop app still holds the old session ID. Detect this,
|
||||||
|
// resume the stored session to re-register it, and retry once.
|
||||||
|
let submitErr: unknown = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requestGateway('prompt.submit', { session_id: sessionId, text })
|
||||||
|
} catch (firstErr) {
|
||||||
|
const firstMsg = firstErr instanceof Error ? firstErr.message : String(firstErr)
|
||||||
|
|
||||||
|
if (/session not found/i.test(firstMsg) && selectedStoredSessionIdRef.current) {
|
||||||
|
// Re-register the session in the gateway and get a fresh live ID.
|
||||||
|
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||||
|
session_id: selectedStoredSessionIdRef.current
|
||||||
|
})
|
||||||
|
const recoveredId = resumed?.session_id
|
||||||
|
|
||||||
|
if (recoveredId) {
|
||||||
|
activeSessionIdRef.current = recoveredId
|
||||||
|
await requestGateway('prompt.submit', { session_id: recoveredId, text })
|
||||||
|
} else {
|
||||||
|
submitErr = firstErr
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
submitErr = firstErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submitErr !== null) {
|
||||||
|
throw submitErr
|
||||||
|
}
|
||||||
|
|
||||||
if (usingComposerAttachments) {
|
if (usingComposerAttachments) {
|
||||||
clearComposerAttachments()
|
clearComposerAttachments()
|
||||||
@@ -442,7 +691,7 @@ export function usePromptActions({
|
|||||||
createBackendSessionForSend,
|
createBackendSessionForSend,
|
||||||
requestGateway,
|
requestGateway,
|
||||||
selectedStoredSessionIdRef,
|
selectedStoredSessionIdRef,
|
||||||
syncImageAttachmentsForSubmit,
|
syncAttachmentsForSubmit,
|
||||||
updateSessionState
|
updateSessionState
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -84,6 +84,60 @@ describe('useRouteResume', () => {
|
|||||||
expect(resumeSession).not.toHaveBeenCalled()
|
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', () => {
|
it('resumes when pathname changes to a routed session', () => {
|
||||||
const resumeSession = vi.fn(async () => undefined)
|
const resumeSession = vi.fn(async () => undefined)
|
||||||
const startFreshSessionDraft = vi.fn()
|
const startFreshSessionDraft = vi.fn()
|
||||||
@@ -133,4 +187,72 @@ describe('useRouteResume', () => {
|
|||||||
expect(resumeSession).toHaveBeenCalledTimes(1)
|
expect(resumeSession).toHaveBeenCalledTimes(1)
|
||||||
expect(resumeSession).toHaveBeenCalledWith('session-2', true)
|
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,13 +56,19 @@ export function useRouteResume({
|
|||||||
startFreshSessionDraft
|
startFreshSessionDraft
|
||||||
}: RouteResumeOptions) {
|
}: RouteResumeOptions) {
|
||||||
const lastPathnameRef = useRef<string | null>(null)
|
const lastPathnameRef = useRef<string | null>(null)
|
||||||
|
const seenGatewayStateRef = useRef(false)
|
||||||
const wasGatewayOpenRef = useRef(false)
|
const wasGatewayOpenRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const gatewayOpen = gatewayState === 'open'
|
const gatewayOpen = gatewayState === 'open'
|
||||||
const pathnameChanged = lastPathnameRef.current !== locationPathname
|
const pathnameChanged = lastPathnameRef.current !== locationPathname
|
||||||
const gatewayBecameOpen = !wasGatewayOpenRef.current && gatewayOpen
|
// 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
|
||||||
lastPathnameRef.current = locationPathname
|
lastPathnameRef.current = locationPathname
|
||||||
|
seenGatewayStateRef.current = true
|
||||||
wasGatewayOpenRef.current = gatewayOpen
|
wasGatewayOpenRef.current = gatewayOpen
|
||||||
|
|
||||||
if (currentView !== 'chat' || !gatewayOpen) {
|
if (currentView !== 'chat' || !gatewayOpen) {
|
||||||
@@ -77,12 +83,33 @@ export function useRouteResume({
|
|||||||
Boolean(cachedRuntime) &&
|
Boolean(cachedRuntime) &&
|
||||||
cachedRuntime === activeSessionIdRef.current
|
cachedRuntime === activeSessionIdRef.current
|
||||||
|
|
||||||
// Resume only when the route meaningfully changed (or gateway just opened).
|
// Self-heal a desynced view: the route points at a session that isn't the
|
||||||
// This avoids a transient /:sid re-resume during "new chat" state clears
|
// loaded one. A create/stream race can leave selected/active null while
|
||||||
// before the pathname updates from /:sid -> /.
|
// the route stays on /:sid (symptom: brand-new chat shows "Thinking" then
|
||||||
const shouldResume = pathnameChanged || gatewayBecameOpen
|
// 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
|
||||||
|
|
||||||
if (!alreadyActive && shouldResume && !creatingSessionRef.current) {
|
// 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
|
||||||
|
// before the pathname updates from /:sid -> /.
|
||||||
|
const shouldResume = pathnameChanged || gatewayBecameOpen || stuckOnRoutedSession
|
||||||
|
|
||||||
|
// 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) {
|
||||||
void resumeSession(routedSessionId, true)
|
void resumeSession(routedSessionId, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
$sessions,
|
$sessions,
|
||||||
$yoloActive,
|
$yoloActive,
|
||||||
getRememberedWorkspaceCwd,
|
getRememberedWorkspaceCwd,
|
||||||
|
workspaceCwdForNewSession,
|
||||||
sessionPinId,
|
sessionPinId,
|
||||||
setActiveSessionId,
|
setActiveSessionId,
|
||||||
setAwaitingResponse,
|
setAwaitingResponse,
|
||||||
@@ -311,8 +312,9 @@ export function useSessionActions({
|
|||||||
})
|
})
|
||||||
setSessionStartedAt(null)
|
setSessionStartedAt(null)
|
||||||
setTurnStartedAt(null)
|
setTurnStartedAt(null)
|
||||||
// New chats inherit the current workspace.
|
// New chats start in the configured default project dir when set,
|
||||||
setCurrentCwd(getRememberedWorkspaceCwd())
|
// otherwise the sticky last-used workspace (PR #37586).
|
||||||
|
setCurrentCwd(workspaceCwdForNewSession())
|
||||||
setCurrentBranch('')
|
setCurrentBranch('')
|
||||||
clearComposerDraft()
|
clearComposerDraft()
|
||||||
clearComposerAttachments()
|
clearComposerAttachments()
|
||||||
@@ -333,7 +335,7 @@ export function useSessionActions({
|
|||||||
// Route the new chat to the chosen profile's backend (null = primary,
|
// Route the new chat to the chosen profile's backend (null = primary,
|
||||||
// so single-profile users are unaffected).
|
// so single-profile users are unaffected).
|
||||||
await ensureGatewayProfile($newChatProfile.get())
|
await ensureGatewayProfile($newChatProfile.get())
|
||||||
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
|
const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession()
|
||||||
// Pass the owning profile so a new chat under a non-launch profile (global
|
// 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.
|
// remote mode) builds its agent + persists against THAT profile's home/db.
|
||||||
const newChatProfile = $newChatProfile.get()
|
const newChatProfile = $newChatProfile.get()
|
||||||
|
|||||||
@@ -150,6 +150,29 @@ export function useSessionStateCache({
|
|||||||
|
|
||||||
pendingViewStateRef.current = { sessionId, state }
|
pendingViewStateRef.current = { sessionId, state }
|
||||||
|
|
||||||
|
// Terminal / attention transitions (turn finished, error, or the agent is
|
||||||
|
// now waiting on the user) MUST reach the view immediately. Electron
|
||||||
|
// throttles `requestAnimationFrame` to ~0 while the window is
|
||||||
|
// backgrounded, occluded, or unfocused, so an RAF-deferred flush can be
|
||||||
|
// stranded in `pendingViewStateRef` indefinitely — that's the "new chat
|
||||||
|
// stuck on Thinking until I refocus / F5" bug. Flush these synchronously
|
||||||
|
// (cancelling any in-flight RAF, since we're about to publish the latest
|
||||||
|
// state anyway). The plain busy heartbeat stays RAF-batched: that
|
||||||
|
// coalescing exists only to keep periodic `session.info` updates from
|
||||||
|
// churning `$messages` and jerking the scroll position while reading.
|
||||||
|
const isCriticalTransition = !state.busy || state.needsInput
|
||||||
|
|
||||||
|
if (isCriticalTransition) {
|
||||||
|
if (viewSyncRafRef.current !== null && typeof window !== 'undefined') {
|
||||||
|
window.cancelAnimationFrame(viewSyncRafRef.current)
|
||||||
|
viewSyncRafRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
flushPendingViewState()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (viewSyncRafRef.current !== null) {
|
if (viewSyncRafRef.current !== null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { Check, Palette } from '@/lib/icons'
|
import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
||||||
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
||||||
import { useTheme } from '@/themes/context'
|
import { useTheme } from '@/themes/context'
|
||||||
import { BUILTIN_THEMES } from '@/themes/presets'
|
import { installVscodeThemeFromMarketplace } from '@/themes/install'
|
||||||
|
import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes'
|
||||||
|
|
||||||
import { MODE_OPTIONS } from './constants'
|
import { MODE_OPTIONS } from './constants'
|
||||||
import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||||
|
|
||||||
function ThemePreview({ name }: { name: string }) {
|
function ThemePreview({ name }: { name: string }) {
|
||||||
const t = BUILTIN_THEMES[name]
|
const t = resolveTheme(name)
|
||||||
|
|
||||||
if (!t) {
|
if (!t) {
|
||||||
return null
|
return null
|
||||||
@@ -54,6 +56,81 @@ function ThemePreview({ name }: { name: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VscodeThemeInstaller() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { setTheme } = useTheme()
|
||||||
|
const a = t.settings.appearance
|
||||||
|
const [id, setId] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [status, setStatus] = useState<{ kind: 'error' | 'success'; text: string } | null>(null)
|
||||||
|
|
||||||
|
const install = async () => {
|
||||||
|
const trimmed = id.trim()
|
||||||
|
|
||||||
|
if (!trimmed || busy) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true)
|
||||||
|
setStatus(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const theme = await installVscodeThemeFromMarketplace(trimmed)
|
||||||
|
|
||||||
|
triggerHaptic('crisp')
|
||||||
|
setTheme(theme.name)
|
||||||
|
setStatus({ kind: 'success', text: a.installed(theme.label) })
|
||||||
|
setId('')
|
||||||
|
} catch (error) {
|
||||||
|
setStatus({ kind: 'error', text: error instanceof Error ? error.message : a.installError })
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="min-w-0 flex-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 font-mono text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
|
||||||
|
disabled={busy}
|
||||||
|
onChange={event => {
|
||||||
|
setId(event.target.value)
|
||||||
|
setStatus(null)
|
||||||
|
}}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
void install()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={a.installPlaceholder}
|
||||||
|
spellCheck={false}
|
||||||
|
value={id}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg border border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] font-medium transition hover:bg-(--chrome-action-hover) disabled:opacity-50"
|
||||||
|
disabled={busy || !id.trim()}
|
||||||
|
onClick={() => void install()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
|
||||||
|
{busy ? a.installing : a.installButton}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{status && (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'mt-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height)',
|
||||||
|
status.kind === 'error' ? 'text-(--ui-red)' : 'text-(--ui-text-tertiary)'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function AppearanceSettings() {
|
export function AppearanceSettings() {
|
||||||
const { t, isSavingLocale } = useI18n()
|
const { t, isSavingLocale } = useI18n()
|
||||||
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
|
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
|
||||||
@@ -112,40 +189,62 @@ export function AppearanceSettings() {
|
|||||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{availableThemes.map(theme => {
|
{availableThemes.map(theme => {
|
||||||
const active = themeName === theme.name
|
const active = themeName === theme.name
|
||||||
|
const removable = isUserTheme(theme.name)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div className="group relative" key={theme.name}>
|
||||||
className={cn(
|
<button
|
||||||
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
|
className={cn(
|
||||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
'w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
|
||||||
)}
|
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||||
key={theme.name}
|
|
||||||
onClick={() => {
|
|
||||||
triggerHaptic('crisp')
|
|
||||||
setTheme(theme.name)
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<ThemePreview name={theme.name} />
|
|
||||||
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
|
||||||
{theme.label}
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
|
||||||
{theme.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{active && (
|
|
||||||
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
|
|
||||||
<Check className="size-3.5" />
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
onClick={() => {
|
||||||
</button>
|
triggerHaptic('crisp')
|
||||||
|
setTheme(theme.name)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ThemePreview name={theme.name} />
|
||||||
|
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||||
|
{theme.label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||||
|
{theme.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{active && (
|
||||||
|
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{removable && (
|
||||||
|
<button
|
||||||
|
aria-label={a.removeTheme}
|
||||||
|
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
|
||||||
|
onClick={() => {
|
||||||
|
triggerHaptic('crisp')
|
||||||
|
removeUserTheme(theme.name)
|
||||||
|
|
||||||
|
// Re-normalize off the now-missing skin → default.
|
||||||
|
if (active) {
|
||||||
|
setTheme(theme.name)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={a.removeTheme}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<VscodeThemeInstaller />
|
||||||
{showProfileNote && (
|
{showProfileNote && (
|
||||||
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||||
{a.themeProfileNote(activeProfileName)}
|
{a.themeProfileNote(activeProfileName)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { sessionTitle } from '@/lib/chat-runtime'
|
|||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
|
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
|
||||||
import { notify, notifyError } from '@/store/notifications'
|
import { notify, notifyError } from '@/store/notifications'
|
||||||
import { setSessions } from '@/store/session'
|
import { applyConfiguredDefaultProjectDir, ensureDefaultWorkspaceCwd, setSessions } from '@/store/session'
|
||||||
import type { SessionInfo } from '@/types/hermes'
|
import type { SessionInfo } from '@/types/hermes'
|
||||||
|
|
||||||
import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
|
import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
|
||||||
@@ -196,6 +196,7 @@ function DefaultProjectDirSetting() {
|
|||||||
|
|
||||||
setDir(result.dir)
|
setDir(result.dir)
|
||||||
setFallback(result.defaultLabel)
|
setFallback(result.defaultLabel)
|
||||||
|
applyConfiguredDefaultProjectDir(result.dir)
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -221,7 +222,8 @@ function DefaultProjectDirSetting() {
|
|||||||
|
|
||||||
const result = await settings.setDefaultProjectDir(picked.dir)
|
const result = await settings.setDefaultProjectDir(picked.dir)
|
||||||
setDir(result.dir)
|
setDir(result.dir)
|
||||||
notify({ durationMs: 2_000, kind: 'success', message: s.defaultDirUpdated })
|
applyConfiguredDefaultProjectDir(result.dir)
|
||||||
|
notify({ durationMs: 4_000, kind: 'success', message: s.defaultDirUpdated })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, s.updateDirFailed)
|
notifyError(err, s.updateDirFailed)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -241,6 +243,8 @@ function DefaultProjectDirSetting() {
|
|||||||
try {
|
try {
|
||||||
await settings.setDefaultProjectDir(null)
|
await settings.setDefaultProjectDir(null)
|
||||||
setDir(null)
|
setDir(null)
|
||||||
|
applyConfiguredDefaultProjectDir(null)
|
||||||
|
await ensureDefaultWorkspaceCwd()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, s.clearDirFailed)
|
notifyError(err, s.clearDirFailed)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -268,7 +272,7 @@ function DefaultProjectDirSetting() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
description={dir || s.defaultsTo(fallback || '~/hermes-projects')}
|
description={dir || s.defaultsTo(fallback || '~')}
|
||||||
title={dir ? dir : s.notSet}
|
title={dir ? dir : s.notSet}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '@/store/layout'
|
} from '@/store/layout'
|
||||||
import { $paneWidthOverride } from '@/store/panes'
|
import { $paneWidthOverride } from '@/store/panes'
|
||||||
import { $connection } from '@/store/session'
|
import { $connection } from '@/store/session'
|
||||||
|
import { isSecondaryWindow } from '@/store/windows'
|
||||||
|
|
||||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
||||||
|
|
||||||
@@ -28,9 +29,19 @@ interface AppShellProps {
|
|||||||
children: ReactNode
|
children: ReactNode
|
||||||
leftStatusbarItems?: readonly StatusbarItem[]
|
leftStatusbarItems?: readonly StatusbarItem[]
|
||||||
leftTitlebarTools?: readonly TitlebarTool[]
|
leftTitlebarTools?: readonly TitlebarTool[]
|
||||||
|
// Fixed-position overlays that must share <main>'s stacking context so pane
|
||||||
|
// resize handles (z-20) paint above them. The persistent terminal lives here:
|
||||||
|
// hoisting it to the root `overlays` layer (sibling of <main>, z above z-3)
|
||||||
|
// would cover every pane's drag handle.
|
||||||
|
mainOverlays?: ReactNode
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
overlays?: ReactNode
|
overlays?: ReactNode
|
||||||
|
// Rails that sit at the window's left edge in the flipped layout but never
|
||||||
|
// force-collapse to hover-reveal overlays — so they cover the top-left traffic
|
||||||
|
// lights (and zero the titlebar inset) even below the collapse breakpoint.
|
||||||
|
previewPaneOpen?: boolean
|
||||||
statusbarItems?: readonly StatusbarItem[]
|
statusbarItems?: readonly StatusbarItem[]
|
||||||
|
terminalPaneOpen?: boolean
|
||||||
titlebarTools?: readonly TitlebarTool[]
|
titlebarTools?: readonly TitlebarTool[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,9 +64,12 @@ export function AppShell({
|
|||||||
children,
|
children,
|
||||||
leftStatusbarItems,
|
leftStatusbarItems,
|
||||||
leftTitlebarTools,
|
leftTitlebarTools,
|
||||||
|
mainOverlays,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
overlays,
|
overlays,
|
||||||
|
previewPaneOpen = false,
|
||||||
statusbarItems,
|
statusbarItems,
|
||||||
|
terminalPaneOpen = false,
|
||||||
titlebarTools
|
titlebarTools
|
||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
const sidebarOpen = useStore($sidebarOpen)
|
const sidebarOpen = useStore($sidebarOpen)
|
||||||
@@ -75,10 +89,17 @@ export function AppShell({
|
|||||||
|
|
||||||
// The inset clears the top-left titlebar buttons when nothing covers the
|
// The inset clears the top-left titlebar buttons when nothing covers the
|
||||||
// window's left edge. Default layout: the sessions sidebar sits there.
|
// window's left edge. Default layout: the sessions sidebar sits there.
|
||||||
// Flipped layout: the file browser does instead. Below the collapse
|
// Flipped layout: the file browser does instead. Both force-collapse to a
|
||||||
// breakpoint both rails are force-collapsed (hover-reveal overlay), so the
|
// hover-reveal overlay (0px track) below the collapse breakpoint, so the edge
|
||||||
// edge is uncovered regardless of their stored open state.
|
// is uncovered there regardless of their stored open state. A standalone
|
||||||
const leftEdgePaneOpen = !narrowViewport && (panesFlipped ? fileBrowserOpen : sidebarOpen)
|
// session window renders no sidebar at all, so its edge is always uncovered.
|
||||||
|
const collapsibleLeftPaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen
|
||||||
|
// The terminal + preview rails never force-collapse, so when they're the
|
||||||
|
// leftmost open pane (flipped layout) they cover the edge even when narrow.
|
||||||
|
const persistentLeftPaneOpen = panesFlipped && (terminalPaneOpen || previewPaneOpen)
|
||||||
|
|
||||||
|
const leftEdgePaneOpen =
|
||||||
|
!isSecondaryWindow() && ((!narrowViewport && collapsibleLeftPaneOpen) || persistentLeftPaneOpen)
|
||||||
|
|
||||||
const titlebarContentInset = leftEdgePaneOpen
|
const titlebarContentInset = leftEdgePaneOpen
|
||||||
? 0
|
? 0
|
||||||
@@ -157,6 +178,11 @@ export function AppShell({
|
|||||||
{children}
|
{children}
|
||||||
</PaneShell>
|
</PaneShell>
|
||||||
|
|
||||||
|
{/* Fixed overlays scoped to main's stacking context (terminal). Rendered
|
||||||
|
after PaneShell so it paints over pane content, but its z stays under
|
||||||
|
the panes' z-20 resize handles, keeping every pane resizable. */}
|
||||||
|
{mainOverlays}
|
||||||
|
|
||||||
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
|
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
|
|||||||
import { useCallback, useMemo } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
|
|
||||||
import type { CommandCenterSection } from '@/app/command-center'
|
import type { CommandCenterSection } from '@/app/command-center'
|
||||||
|
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||||
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
|
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
Hash,
|
Hash,
|
||||||
Loader2,
|
Loader2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Terminal,
|
||||||
Zap,
|
Zap,
|
||||||
ZapFilled
|
ZapFilled
|
||||||
} from '@/lib/icons'
|
} from '@/lib/icons'
|
||||||
@@ -56,6 +58,7 @@ import type { StatusbarItem, StatusbarSelectModifiers } from '../statusbar-contr
|
|||||||
|
|
||||||
interface StatusbarItemsOptions {
|
interface StatusbarItemsOptions {
|
||||||
agentsOpen: boolean
|
agentsOpen: boolean
|
||||||
|
chatOpen: boolean
|
||||||
commandCenterOpen: boolean
|
commandCenterOpen: boolean
|
||||||
extraLeftItems: readonly StatusbarItem[]
|
extraLeftItems: readonly StatusbarItem[]
|
||||||
extraRightItems: readonly StatusbarItem[]
|
extraRightItems: readonly StatusbarItem[]
|
||||||
@@ -73,6 +76,7 @@ interface StatusbarItemsOptions {
|
|||||||
|
|
||||||
export function useStatusbarItems({
|
export function useStatusbarItems({
|
||||||
agentsOpen,
|
agentsOpen,
|
||||||
|
chatOpen,
|
||||||
commandCenterOpen,
|
commandCenterOpen,
|
||||||
extraLeftItems,
|
extraLeftItems,
|
||||||
extraRightItems,
|
extraRightItems,
|
||||||
@@ -90,6 +94,7 @@ export function useStatusbarItems({
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const copy = t.shell.statusbar
|
const copy = t.shell.statusbar
|
||||||
const activeSessionId = useStore($activeSessionId)
|
const activeSessionId = useStore($activeSessionId)
|
||||||
|
const terminalTakeover = useStore($terminalTakeover)
|
||||||
const yoloActive = useStore($yoloActive)
|
const yoloActive = useStore($yoloActive)
|
||||||
const busy = useStore($busy)
|
const busy = useStore($busy)
|
||||||
const currentFastMode = useStore($currentFastMode)
|
const currentFastMode = useStore($currentFastMode)
|
||||||
@@ -442,11 +447,21 @@ export function useStatusbarItems({
|
|||||||
variant: 'action' as const
|
variant: 'action' as const
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`,
|
||||||
|
hidden: !chatOpen,
|
||||||
|
icon: <Terminal className="size-3.5" />,
|
||||||
|
id: 'terminal',
|
||||||
|
onSelect: () => setTerminalTakeover(!$terminalTakeover.get()),
|
||||||
|
title: terminalTakeover ? copy.hideTerminal : copy.showTerminal,
|
||||||
|
variant: 'action'
|
||||||
|
},
|
||||||
clientVersionItem,
|
clientVersionItem,
|
||||||
...(backendVersionItem ? [backendVersionItem] : [])
|
...(backendVersionItem ? [backendVersionItem] : [])
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
busy,
|
busy,
|
||||||
|
chatOpen,
|
||||||
contextBar,
|
contextBar,
|
||||||
contextUsage,
|
contextUsage,
|
||||||
copy,
|
copy,
|
||||||
@@ -457,6 +472,7 @@ export function useStatusbarItems({
|
|||||||
modelMenuContent,
|
modelMenuContent,
|
||||||
sessionStartedAt,
|
sessionStartedAt,
|
||||||
showYoloToggle,
|
showYoloToggle,
|
||||||
|
terminalTakeover,
|
||||||
toggleYolo,
|
toggleYolo,
|
||||||
turnStartedAt,
|
turnStartedAt,
|
||||||
clientVersionItem,
|
clientVersionItem,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
type KeybindActionMeta,
|
type KeybindActionMeta,
|
||||||
type KeybindReadonly
|
type KeybindReadonly
|
||||||
} from '@/lib/keybinds/actions'
|
} from '@/lib/keybinds/actions'
|
||||||
import { formatCombo } from '@/lib/keybinds/combo'
|
import { formatCombo, formatFakeCombo } from '@/lib/keybinds/combo'
|
||||||
import { arraysEqual } from '@/lib/storage'
|
import { arraysEqual } from '@/lib/storage'
|
||||||
import {
|
import {
|
||||||
$bindings,
|
$bindings,
|
||||||
@@ -210,7 +210,7 @@ function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
|
|||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
{shortcut.keys.map(key => (
|
{shortcut.keys.map(key => (
|
||||||
<span className="kbd-cap" key={key}>
|
<span className="kbd-cap" key={key}>
|
||||||
{formatCombo(key)}
|
{formatFakeCombo(key)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,6 +27,20 @@ export interface ImageDetachResponse {
|
|||||||
count?: number
|
count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FileAttachResponse {
|
||||||
|
attached?: boolean
|
||||||
|
message?: string
|
||||||
|
// Gateway-side absolute path the file was staged to.
|
||||||
|
path?: string
|
||||||
|
// Workspace-relative path used to build ref_text.
|
||||||
|
ref_path?: string
|
||||||
|
// Rewritten @file: ref that resolves on the gateway (workspace-relative).
|
||||||
|
ref_text?: string
|
||||||
|
// True when bytes/host file were copied into the session workspace.
|
||||||
|
uploaded?: boolean
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface SlashExecResponse {
|
export interface SlashExecResponse {
|
||||||
output?: string
|
output?: string
|
||||||
warning?: string
|
warning?: string
|
||||||
|
|||||||
@@ -494,11 +494,9 @@ export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: Markdo
|
|||||||
|
|
||||||
const MarkdownTextImpl = () => {
|
const MarkdownTextImpl = () => {
|
||||||
return (
|
return (
|
||||||
<SmoothStreamingText>
|
<DeferStreamingText>
|
||||||
<DeferStreamingText>
|
<MarkdownTextSurface />
|
||||||
<MarkdownTextSurface />
|
</DeferStreamingText>
|
||||||
</DeferStreamingText>
|
|
||||||
</SmoothStreamingText>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,27 @@ function assistantMultiReasoningMessage(texts: string[]): ThreadMessage {
|
|||||||
} as ThreadMessage
|
} as ThreadMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assistantSeparatedReasoningMessage(): ThreadMessage {
|
||||||
|
return {
|
||||||
|
id: 'assistant-reasoning-separated-1',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'reasoning', text: ' Complete first thought.', status: { type: 'complete' } },
|
||||||
|
{ type: 'text', text: 'Interim answer.' },
|
||||||
|
{ type: 'reasoning', text: ' Streaming second thought.', status: { type: 'running' } }
|
||||||
|
],
|
||||||
|
status: { type: 'running' },
|
||||||
|
createdAt,
|
||||||
|
metadata: {
|
||||||
|
unstable_state: null,
|
||||||
|
unstable_annotations: [],
|
||||||
|
unstable_data: [],
|
||||||
|
steps: [],
|
||||||
|
custom: {}
|
||||||
|
}
|
||||||
|
} as ThreadMessage
|
||||||
|
}
|
||||||
|
|
||||||
function assistantTodoMessage(
|
function assistantTodoMessage(
|
||||||
todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }>,
|
todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }>,
|
||||||
running = true
|
running = true
|
||||||
@@ -685,6 +706,18 @@ describe('assistant-ui streaming renderer', () => {
|
|||||||
expect(reasoningParts[1]?.textContent).toBe('Second thought.')
|
expect(reasoningParts[1]?.textContent).toBe('Second thought.')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not reopen an earlier completed thinking group when a later group is running', () => {
|
||||||
|
const { container } = render(<RunningMessageHarness message={assistantSeparatedReasoningMessage()} />)
|
||||||
|
|
||||||
|
const disclosures = container.querySelectorAll('[data-slot="aui_thinking-disclosure"]')
|
||||||
|
expect(disclosures.length).toBe(2)
|
||||||
|
|
||||||
|
expect(disclosures[0].querySelector('button')?.getAttribute('aria-expanded')).toBe('false')
|
||||||
|
expect(disclosures[1].querySelector('button')?.getAttribute('aria-expanded')).toBe('true')
|
||||||
|
expect(container.textContent).not.toContain('Complete first thought.')
|
||||||
|
expect(container.textContent).toContain('Interim answer.')
|
||||||
|
})
|
||||||
|
|
||||||
it('renders live todo rows during a running turn', () => {
|
it('renders live todo rows during a running turn', () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<TodoHarness
|
<TodoHarness
|
||||||
|
|||||||
@@ -37,7 +37,12 @@ import {
|
|||||||
} from '@/app/chat/composer/focus'
|
} from '@/app/chat/composer/focus'
|
||||||
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
|
import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions'
|
||||||
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
|
import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions'
|
||||||
import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from '@/app/chat/composer/inline-refs'
|
import {
|
||||||
|
dragHasAttachments,
|
||||||
|
droppedFileInlineRefs,
|
||||||
|
type InlineRefInput,
|
||||||
|
insertInlineRefsIntoEditor
|
||||||
|
} from '@/app/chat/composer/inline-refs'
|
||||||
import {
|
import {
|
||||||
composerPlainText,
|
composerPlainText,
|
||||||
placeCaretEnd,
|
placeCaretEnd,
|
||||||
@@ -47,7 +52,8 @@ import {
|
|||||||
} from '@/app/chat/composer/rich-editor'
|
} from '@/app/chat/composer/rich-editor'
|
||||||
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
|
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
|
||||||
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
|
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
|
||||||
import { extractDroppedFiles, HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
import { extractDroppedFiles, HERMES_PATHS_MIME, isImagePath, partitionDroppedFiles } from '@/app/chat/hooks/use-composer-actions'
|
||||||
|
import { uploadComposerAttachment } from '@/app/session/hooks/use-prompt-actions'
|
||||||
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||||
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||||
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
|
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
|
||||||
@@ -76,6 +82,7 @@ import { Loader } from '@/components/ui/loader'
|
|||||||
import type { HermesGateway } from '@/hermes'
|
import type { HermesGateway } from '@/hermes'
|
||||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
|
import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runtime'
|
||||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||||
import { LinkifiedText } from '@/lib/external-link'
|
import { LinkifiedText } from '@/lib/external-link'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
@@ -84,7 +91,9 @@ import { extractPreviewTargets } from '@/lib/preview-targets'
|
|||||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
||||||
|
import type { ComposerAttachment } from '@/store/composer'
|
||||||
import { notifyError } from '@/store/notifications'
|
import { notifyError } from '@/store/notifications'
|
||||||
|
import { $connection } from '@/store/session'
|
||||||
import { $voicePlayback } from '@/store/voice-playback'
|
import { $voicePlayback } from '@/store/voice-playback'
|
||||||
|
|
||||||
type ThreadLoadingState = 'response' | 'session'
|
type ThreadLoadingState = 'response' | 'session'
|
||||||
@@ -468,7 +477,9 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
|
|||||||
s =>
|
s =>
|
||||||
s.thread.isRunning &&
|
s.thread.isRunning &&
|
||||||
s.message.status?.type === 'running' &&
|
s.message.status?.type === 'running' &&
|
||||||
s.message.parts.slice(Math.max(0, startIndex)).some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
|
s.message.parts
|
||||||
|
.slice(Math.max(0, startIndex), endIndex + 1)
|
||||||
|
.some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
|
||||||
)
|
)
|
||||||
|
|
||||||
// A reasoning group with no actual text is pure noise — drop the whole
|
// A reasoning group with no actual text is pure noise — drop the whole
|
||||||
@@ -711,8 +722,14 @@ function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
|
|||||||
// edit composer render the same bubble surface (rounded glass card);
|
// edit composer render the same bubble surface (rounded glass card);
|
||||||
// they only differ in border weight, cursor, and padding-right (the
|
// they only differ in border weight, cursor, and padding-right (the
|
||||||
// read-only view reserves room for the restore icon).
|
// read-only view reserves room for the restore icon).
|
||||||
|
//
|
||||||
|
// no-drag: sticky bubbles park at --sticky-human-top (~4px), sliding under the
|
||||||
|
// titlebar's [-webkit-app-region:drag] strips (app-shell.tsx). Electron resolves
|
||||||
|
// drag regions at the compositor level — z-index and pointer-events don't help —
|
||||||
|
// so without the carve-out, clicking a stuck bubble drags the window instead of
|
||||||
|
// opening the edit composer.
|
||||||
const USER_BUBBLE_BASE_CLASS =
|
const USER_BUBBLE_BASE_CLASS =
|
||||||
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left'
|
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
|
||||||
|
|
||||||
const USER_ACTION_ICON_BUTTON_CLASS =
|
const USER_ACTION_ICON_BUTTON_CLASS =
|
||||||
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
|
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
|
||||||
@@ -962,6 +979,10 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||||||
const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top')
|
const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top')
|
||||||
const [focusRequestId, setFocusRequestId] = useState(0)
|
const [focusRequestId, setFocusRequestId] = useState(0)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
// True while OS-drop files are being staged/uploaded into the session. Blocks
|
||||||
|
// submit and shows a spinner so confirming the edit can't race the async
|
||||||
|
// upload and drop the gateway-side ref before it lands in the draft.
|
||||||
|
const [staging, setStaging] = useState(false)
|
||||||
const expanded = draft.includes('\n')
|
const expanded = draft.includes('\n')
|
||||||
const canSubmit = draft.trim().length > 0
|
const canSubmit = draft.trim().length > 0
|
||||||
const at = useAtCompletions({ cwd, gateway, sessionId })
|
const at = useAtCompletions({ cwd, gateway, sessionId })
|
||||||
@@ -1178,18 +1199,14 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||||||
[aui, closeTrigger, refreshTrigger, requestEditFocus, trigger]
|
[aui, closeTrigger, refreshTrigger, requestEditFocus, trigger]
|
||||||
)
|
)
|
||||||
|
|
||||||
const insertDroppedRefs = useCallback(
|
const insertRefStrings = useCallback(
|
||||||
(candidates: ReturnType<typeof extractDroppedFiles>) => {
|
(refs: InlineRefInput[]) => {
|
||||||
const editor = editorRef.current
|
const editor = editorRef.current
|
||||||
|
|
||||||
if (!editor) {
|
if (!editor || refs.length === 0) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const refs = candidates
|
|
||||||
.map(candidate => droppedFileInlineRef(candidate, cwd))
|
|
||||||
.filter((ref): ref is string => Boolean(ref))
|
|
||||||
|
|
||||||
const nextDraft = insertInlineRefsIntoEditor(editor, refs)
|
const nextDraft = insertInlineRefsIntoEditor(editor, refs)
|
||||||
|
|
||||||
if (nextDraft === null) {
|
if (nextDraft === null) {
|
||||||
@@ -1202,7 +1219,60 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
[aui, cwd, requestEditFocus]
|
[aui, requestEditFocus]
|
||||||
|
)
|
||||||
|
|
||||||
|
const insertDroppedRefs = useCallback(
|
||||||
|
(candidates: ReturnType<typeof extractDroppedFiles>) => insertRefStrings(droppedFileInlineRefs(candidates, cwd)),
|
||||||
|
[cwd, insertRefStrings]
|
||||||
|
)
|
||||||
|
|
||||||
|
// OS/Finder drops carry an absolute path on THIS machine — the gateway can't
|
||||||
|
// read it in remote mode, and an image needs its bytes uploaded for vision.
|
||||||
|
// Stage each through the same file.attach/image.attach_bytes pipeline the main
|
||||||
|
// composer uses, then insert the *gateway-side* ref the agent can resolve —
|
||||||
|
// never the raw local path (the MahmoudR remote-attach bug, which the main
|
||||||
|
// composer fixes but this edit composer used to reproduce).
|
||||||
|
const uploadOsDropRefs = useCallback(
|
||||||
|
async (osDrops: ReturnType<typeof extractDroppedFiles>): Promise<InlineRefInput[]> => {
|
||||||
|
if (!gateway || !sessionId) {
|
||||||
|
// No session to stage into — best-effort inline refs (matches old path).
|
||||||
|
return droppedFileInlineRefs(osDrops, cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
const remote = $connection.get()?.mode === 'remote'
|
||||||
|
const requestGateway = <T,>(method: string, params?: Record<string, unknown>) => gateway.request<T>(method, params)
|
||||||
|
const refs: InlineRefInput[] = []
|
||||||
|
|
||||||
|
for (const candidate of osDrops) {
|
||||||
|
const path = candidate.path || ''
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind: ComposerAttachment['kind'] =
|
||||||
|
candidate.file?.type.startsWith('image/') || isImagePath(candidate.file?.name || path) ? 'image' : 'file'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uploaded = await uploadComposerAttachment(
|
||||||
|
{ detail: path, id: attachmentId(kind, path), kind, label: pathLabel(path), path },
|
||||||
|
{ remote, requestGateway, sessionId }
|
||||||
|
)
|
||||||
|
|
||||||
|
const ref = attachmentDisplayText(uploaded)
|
||||||
|
|
||||||
|
if (ref) {
|
||||||
|
refs.push(ref)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err, t.desktop.dropFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return refs
|
||||||
|
},
|
||||||
|
[cwd, gateway, sessionId, t.desktop.dropFiles]
|
||||||
)
|
)
|
||||||
|
|
||||||
const resetDragState = useCallback(() => {
|
const resetDragState = useCallback(() => {
|
||||||
@@ -1256,9 +1326,25 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
resetDragState()
|
resetDragState()
|
||||||
|
|
||||||
if (insertDroppedRefs(candidates)) {
|
// In-app drags (project tree / gutter) are workspace-relative paths that
|
||||||
|
// resolve on the gateway as-is, so they stay inline refs. OS drops need to
|
||||||
|
// be staged + uploaded first, then their gateway-side ref is inserted.
|
||||||
|
const { inAppRefs, osDrops } = partitionDroppedFiles(candidates)
|
||||||
|
|
||||||
|
if (insertDroppedRefs(inAppRefs)) {
|
||||||
triggerHaptic('selection')
|
triggerHaptic('selection')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (osDrops.length) {
|
||||||
|
setStaging(true)
|
||||||
|
void uploadOsDropRefs(osDrops)
|
||||||
|
.then(refs => {
|
||||||
|
if (insertRefStrings(refs)) {
|
||||||
|
triggerHaptic('selection')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setStaging(false))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInput = (event: FormEvent<HTMLDivElement>) => {
|
const handleInput = (event: FormEvent<HTMLDivElement>) => {
|
||||||
@@ -1289,7 +1375,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||||||
const submitEdit = (editor: HTMLDivElement) => {
|
const submitEdit = (editor: HTMLDivElement) => {
|
||||||
const nextDraft = syncDraftFromEditor(editor)
|
const nextDraft = syncDraftFromEditor(editor)
|
||||||
|
|
||||||
if (submitting || !nextDraft.trim()) {
|
if (submitting || staging || !nextDraft.trim()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1446,10 +1532,19 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||||||
suppressContentEditableWarning
|
suppressContentEditableWarning
|
||||||
/>
|
/>
|
||||||
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
|
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
|
||||||
|
{staging && (
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute bottom-2 left-2 inline-flex items-center gap-1 rounded-full bg-background/80 px-1.5 py-0.5 text-[0.62rem] text-muted-foreground backdrop-blur-[1px]"
|
||||||
|
data-slot="aui_edit-staging"
|
||||||
|
>
|
||||||
|
<Loader2Icon className="size-3 animate-spin" />
|
||||||
|
{copy.attachingFile}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
aria-label={copy.sendEdited}
|
aria-label={copy.sendEdited}
|
||||||
className={cn('absolute right-2 bottom-2 size-5', USER_ACTION_ICON_BUTTON_CLASS)}
|
className={cn('absolute right-2 bottom-2 size-5', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||||
disabled={!canSubmit || submitting}
|
disabled={!canSubmit || submitting || staging}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const editor = editorRef.current
|
const editor = editorRef.current
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
|||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { ChevronDown, Loader2 } from '@/lib/icons'
|
import { ChevronDown, Loader2 } from '@/lib/icons'
|
||||||
|
import { formatCombo } from '@/lib/keybinds/combo'
|
||||||
import { $gateway } from '@/store/gateway'
|
import { $gateway } from '@/store/gateway'
|
||||||
import { notifyError } from '@/store/notifications'
|
import { notifyError } from '@/store/notifications'
|
||||||
import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
|
import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
|
||||||
@@ -50,8 +51,6 @@ export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
|
|||||||
return <ApprovalBar request={request} />
|
return <ApprovalBar request={request} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
|
|
||||||
|
|
||||||
const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const copy = t.assistant.approval
|
const copy = t.assistant.approval
|
||||||
@@ -127,7 +126,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
|
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
|
||||||
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
|
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{formatCombo('mod+enter')}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
<span aria-hidden className="w-px self-stretch bg-primary/20" />
|
<span aria-hidden className="w-px self-stretch bg-primary/20" />
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import { DisclosureRow } from '@/components/chat/disclosure-row'
|
|||||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
|
||||||
import { CopyButton } from '@/components/ui/copy-button'
|
import { CopyButton } from '@/components/ui/copy-button'
|
||||||
import { FadeText } from '@/components/ui/fade-text'
|
import { FadeText } from '@/components/ui/fade-text'
|
||||||
|
import { ToolIcon } from '@/components/ui/tool-icon'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
|
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
|
||||||
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
|
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
|
||||||
@@ -136,7 +136,7 @@ function ToolGlyph({ copy, icon, status }: { copy: ToolStatusCopy; icon?: string
|
|||||||
const node = status ? (
|
const node = status ? (
|
||||||
statusGlyph(status, copy)
|
statusGlyph(status, copy)
|
||||||
) : icon ? (
|
) : icon ? (
|
||||||
<Codicon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
|
<ToolIcon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
|
||||||
) : null
|
) : null
|
||||||
|
|
||||||
return node ? <span className={TOOL_HEADER_GLYPH_WRAP_CLASS}>{node}</span> : null
|
return node ? <span className={TOOL_HEADER_GLYPH_WRAP_CLASS}>{node}</span> : null
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { ExportedMessageRepository } from '@assistant-ui/core/internal'
|
||||||
|
// Clicking a user bubble must open the inline edit composer — through the
|
||||||
|
// app's incremental external-store runtime (which reimplements capability
|
||||||
|
// resolution, incl. `edit: onEdit !== undefined`) and the stock runtime.
|
||||||
|
//
|
||||||
|
// Note: this covers the React/runtime wiring only. The Electron-level failure
|
||||||
|
// mode (titlebar -webkit-app-region:drag swallowing clicks on *stuck* sticky
|
||||||
|
// bubbles) is not reproducible in jsdom — see USER_BUBBLE_BASE_CLASS's no-drag
|
||||||
|
// carve-out in thread.tsx.
|
||||||
|
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
|
||||||
|
|
||||||
|
import { Thread } from './thread'
|
||||||
|
|
||||||
|
const createdAt = new Date('2026-05-01T00:00:00.000Z')
|
||||||
|
|
||||||
|
class TestResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||||
|
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||||
|
window.setTimeout(() => callback(performance.now()), 0)
|
||||||
|
)
|
||||||
|
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
|
||||||
|
|
||||||
|
Element.prototype.scrollTo = function scrollTo() {}
|
||||||
|
|
||||||
|
function stubOffsetDimension(
|
||||||
|
prop: 'offsetHeight' | 'offsetWidth',
|
||||||
|
clientProp: 'clientHeight' | 'clientWidth',
|
||||||
|
fallback: number
|
||||||
|
) {
|
||||||
|
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
|
||||||
|
|
||||||
|
Object.defineProperty(HTMLElement.prototype, prop, {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
|
||||||
|
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
|
||||||
|
|
||||||
|
function userMessage(): ThreadMessage {
|
||||||
|
return {
|
||||||
|
id: 'user-1',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: 'edit me please' }],
|
||||||
|
attachments: [],
|
||||||
|
createdAt,
|
||||||
|
metadata: { custom: {} }
|
||||||
|
} as ThreadMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantMessage(): ThreadMessage {
|
||||||
|
return {
|
||||||
|
id: 'assistant-1',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'text', text: 'done' }],
|
||||||
|
status: { type: 'complete', reason: 'stop' },
|
||||||
|
createdAt,
|
||||||
|
metadata: {
|
||||||
|
unstable_state: null,
|
||||||
|
unstable_annotations: [],
|
||||||
|
unstable_data: [],
|
||||||
|
steps: [],
|
||||||
|
custom: {}
|
||||||
|
}
|
||||||
|
} as ThreadMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors chat/index.tsx: incremental runtime + messageRepository + onEdit.
|
||||||
|
function IncrementalHarness({ onEdit }: { onEdit: () => Promise<void> }) {
|
||||||
|
const repository = ExportedMessageRepository.fromArray([userMessage(), assistantMessage()])
|
||||||
|
|
||||||
|
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
|
||||||
|
messageRepository: repository,
|
||||||
|
isRunning: false,
|
||||||
|
setMessages: () => {},
|
||||||
|
onNew: async () => {},
|
||||||
|
onEdit,
|
||||||
|
onCancel: async () => {},
|
||||||
|
onReload: async () => {}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AssistantRuntimeProvider runtime={runtime}>
|
||||||
|
<Thread />
|
||||||
|
</AssistantRuntimeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control: stock external store runtime.
|
||||||
|
function StockHarness({ onEdit }: { onEdit: () => Promise<void> }) {
|
||||||
|
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||||
|
messages: [userMessage(), assistantMessage()],
|
||||||
|
isRunning: false,
|
||||||
|
onNew: async () => {},
|
||||||
|
onEdit
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AssistantRuntimeProvider runtime={runtime}>
|
||||||
|
<Thread />
|
||||||
|
</AssistantRuntimeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('click-to-edit user message', () => {
|
||||||
|
it('opens the edit composer with the incremental runtime', async () => {
|
||||||
|
const { container } = render(<IncrementalHarness onEdit={async () => {}} />)
|
||||||
|
|
||||||
|
const bubble = await screen.findByRole('button', { name: 'Edit message' })
|
||||||
|
|
||||||
|
fireEvent.click(bubble)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the edit composer with the stock runtime', async () => {
|
||||||
|
const { container } = render(<StockHarness onEdit={async () => {}} />)
|
||||||
|
|
||||||
|
const bubble = await screen.findByRole('button', { name: 'Edit message' })
|
||||||
|
|
||||||
|
fireEvent.click(bubble)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -30,6 +30,8 @@ export interface PaneProps {
|
|||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
defaultOpen?: boolean
|
defaultOpen?: boolean
|
||||||
|
/** Paints a persistent hairline on the resize edge (not just the hover sash) so the pane boundary is always visible. */
|
||||||
|
divider?: boolean
|
||||||
/** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */
|
/** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
/** Like disabled, but keeps hoverReveal alive — collapses the track without writing to the store (e.g. narrow window). */
|
/** Like disabled, but keeps hoverReveal alive — collapses the track without writing to the store (e.g. narrow window). */
|
||||||
@@ -94,19 +96,35 @@ const remPx = () =>
|
|||||||
? 16
|
? 16
|
||||||
: Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16
|
: Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16
|
||||||
|
|
||||||
// Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem") to pixels for drag clamping.
|
const viewportPx = () => (typeof window === 'undefined' ? 1280 : window.innerWidth)
|
||||||
|
|
||||||
|
// Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem" | "Nvw" | "N%") to
|
||||||
|
// pixels for drag clamping. Viewport units resolve against the current window width.
|
||||||
function widthToPx(value: WidthValue | undefined) {
|
function widthToPx(value: WidthValue | undefined) {
|
||||||
if (typeof value === 'number') {
|
if (typeof value === 'number') {
|
||||||
return Number.isFinite(value) ? value : undefined
|
return Number.isFinite(value) ? value : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem)?$/)
|
const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem|vw|%)?$/)
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
return Number.parseFloat(match[1]) * (match[2] === 'rem' ? remPx() : 1)
|
const n = Number.parseFloat(match[1])
|
||||||
|
|
||||||
|
switch (match[2]) {
|
||||||
|
case 'rem':
|
||||||
|
return n * remPx()
|
||||||
|
|
||||||
|
case 'vw':
|
||||||
|
|
||||||
|
case '%':
|
||||||
|
return (n * viewportPx()) / 100
|
||||||
|
|
||||||
|
default:
|
||||||
|
return n
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement {
|
function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement {
|
||||||
@@ -217,6 +235,7 @@ export function Pane({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
defaultOpen = true,
|
defaultOpen = true,
|
||||||
|
divider = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
hoverReveal = false,
|
hoverReveal = false,
|
||||||
id,
|
id,
|
||||||
@@ -409,6 +428,7 @@ export function Pane({
|
|||||||
role="separator"
|
role="separator"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
|
{divider && <span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-(--ui-stroke-secondary)" />}
|
||||||
<span className="absolute inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2 bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100" />
|
<span className="absolute inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2 bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
65
apps/desktop/src/components/ui/tool-icon.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
// Solid (filled) glyphs for in-thread tool rows. Codicons are an outline icon
|
||||||
|
// *font*, so an outline glyph has no separate fillable region — a filled look
|
||||||
|
// can't be derived from it (stroke-thickening just bolds the outline). To get
|
||||||
|
// the Cursor-style filled tool icons we render dedicated solid SVG paths,
|
||||||
|
// keyed by the same names used in `TOOL_META` (tool-fallback-model.ts).
|
||||||
|
//
|
||||||
|
// Paths are Phosphor Icons (MIT) "fill" weight, 256×256 viewBox. Inlining the
|
||||||
|
// path data mirrors the existing precedent in `directive-text.tsx`.
|
||||||
|
const TOOL_ICON_PATHS: Record<string, string> = {
|
||||||
|
diff: 'M118.18,213.08c-.11.14-.24.27-.36.4l-.16.18-.17.15a4.83,4.83,0,0,1-.42.37,3.92,3.92,0,0,1-.32.25l-.3.22-.38.23a2.91,2.91,0,0,1-.3.17l-.37.19-.34.15-.36.13a2.84,2.84,0,0,1-.38.13l-.36.1c-.14,0-.26.07-.4.09l-.42.07-.35.05a7,7,0,0,1-.79,0H64a8,8,0,0,1,0-16H92.69L55,162.34a23.85,23.85,0,0,1-7-17V95a32,32,0,1,1,16,0v50.38A8,8,0,0,0,66.34,151L104,188.69V160a8,8,0,0,1,16,0v48a7,7,0,0,1,0,.8c0,.11,0,.21,0,.32s0,.3-.07.46a2.83,2.83,0,0,1-.09.37c0,.13-.06.26-.1.39s-.08.23-.12.35l-.14.39-.15.31c-.06.13-.12.27-.19.4s-.11.18-.16.28l-.24.39-.21.28ZM208,161V110.63a23.85,23.85,0,0,0-7-17L163.31,56H192a8,8,0,0,0,0-16H143.82l-.6,0c-.14,0-.28,0-.41.06l-.37,0-.43.11-.33.08-.4.14-.34.13-.35.16-.36.18a3.14,3.14,0,0,0-.31.18c-.12.07-.25.14-.36.22a3.55,3.55,0,0,0-.31.23,3.81,3.81,0,0,0-.32.24c-.15.12-.28.24-.42.37l-.17.15-.16.18c-.12.13-.25.26-.36.4l-.26.35-.21.28-.24.39c-.05.1-.11.19-.16.28s-.13.27-.19.4l-.15.31-.14.39c0,.12-.09.23-.12.35s-.07.26-.1.39a2.83,2.83,0,0,0-.09.37c0,.16,0,.31-.07.46s0,.21-.05.32a7,7,0,0,0,0,.8V96a8,8,0,0,0,16,0V67.31L189.66,105a8,8,0,0,1,2.34,5.66V161a32,32,0,1,0,16,0Z',
|
||||||
|
edit: 'M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM192,108.68,147.31,64l24-24L216,84.68Z',
|
||||||
|
eye: 'M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,168a40,40,0,1,1,40-40A40,40,0,0,1,128,168Z',
|
||||||
|
file: 'M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM152,88V44l44,44Z',
|
||||||
|
'file-media':
|
||||||
|
'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM156,88a12,12,0,1,1-12,12A12,12,0,0,1,156,88Zm60,112H40V160.69l46.34-46.35a8,8,0,0,1,11.32,0h0L165,181.66a8,8,0,0,0,11.32-11.32l-17.66-17.65L173,138.34a8,8,0,0,1,11.31,0L216,170.07V200Z',
|
||||||
|
files:
|
||||||
|
'M213.66,66.34l-40-40A8,8,0,0,0,168,24H88A16,16,0,0,0,72,40V56H56A16,16,0,0,0,40,72V216a16,16,0,0,0,16,16H168a16,16,0,0,0,16-16V200h16a16,16,0,0,0,16-16V72A8,8,0,0,0,213.66,66.34ZM136,192H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm0-32H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm64,24H184V104a8,8,0,0,0-2.34-5.66l-40-40A8,8,0,0,0,136,56H88V40h76.69L200,75.31Z',
|
||||||
|
globe:
|
||||||
|
'M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm78.36,64H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM216,128a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM128,43a115.27,115.27,0,0,1,26,45H102A115.11,115.11,0,0,1,128,43ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48Zm50.35,61.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z',
|
||||||
|
question:
|
||||||
|
'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,168a12,12,0,1,1,12-12A12,12,0,0,1,128,192Zm8-48.72V144a8,8,0,0,1-16,0v-8a8,8,0,0,1,8-8c13.23,0,24-9,24-20s-10.77-20-24-20-24,9-24,20v4a8,8,0,0,1-16,0v-4c0-19.85,17.94-36,40-36s40,16.15,40,36C168,125.38,154.24,139.93,136,143.28Z',
|
||||||
|
search:
|
||||||
|
'M168,112a56,56,0,1,1-56-56A56,56,0,0,1,168,112Zm61.66,117.66a8,8,0,0,1-11.32,0l-50.06-50.07a88,88,0,1,1,11.32-11.31l50.06,50.06A8,8,0,0,1,229.66,229.66ZM112,184a72,72,0,1,0-72-72A72.08,72.08,0,0,0,112,184Z',
|
||||||
|
terminal:
|
||||||
|
'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm-91,94.25-40,32a8,8,0,1,1-10-12.5L107.19,128,75,102.25a8,8,0,1,1,10-12.5l40,32a8,8,0,0,1,0,12.5ZM176,168H136a8,8,0,0,1,0-16h40a8,8,0,0,1,0,16Z',
|
||||||
|
tools:
|
||||||
|
'M232,96a72,72,0,0,1-100.94,66L79,222.22c-.12.14-.26.29-.39.42a32,32,0,0,1-45.26-45.26c.14-.13.28-.27.43-.39L94,124.94a72.07,72.07,0,0,1,83.54-98.78,8,8,0,0,1,3.93,13.19L144,80l5.66,26.35L176,112l40.65-37.52a8,8,0,0,1,13.19,3.93A72.6,72.6,0,0,1,232,96Z',
|
||||||
|
watch:
|
||||||
|
'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm56,112H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48a8,8,0,0,1,0,16Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolIconProps {
|
||||||
|
className?: string
|
||||||
|
name: string
|
||||||
|
size?: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filled tool glyph. Falls back to the outline codicon font for any name not
|
||||||
|
* covered by the solid set so new tools still render an icon. */
|
||||||
|
export function ToolIcon({ className, name, size = '0.875rem' }: ToolIconProps) {
|
||||||
|
const path = TOOL_ICON_PATHS[name]
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
return <Codicon className={className} name={name} size={size} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const dimension: React.CSSProperties = { height: size, width: size }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn('shrink-0', className)}
|
||||||
|
fill="currentColor"
|
||||||
|
style={dimension}
|
||||||
|
viewBox="0 0 256 256"
|
||||||
|
>
|
||||||
|
<path d={path} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
apps/desktop/src/global.d.ts
vendored
@@ -18,6 +18,10 @@ declare global {
|
|||||||
// reaper spares it while its chat is active.
|
// reaper spares it while its chat is active.
|
||||||
touchBackend: (profile?: string | null) => Promise<{ ok: boolean }>
|
touchBackend: (profile?: string | null) => Promise<{ ok: boolean }>
|
||||||
getGatewayWsUrl: (profile?: null | string) => Promise<string>
|
getGatewayWsUrl: (profile?: null | string) => Promise<string>
|
||||||
|
// Open (or focus) a standalone OS window for a single chat session so
|
||||||
|
// the user can work with multiple chats side by side. Returns ok:false
|
||||||
|
// with an error code when the sessionId is empty/invalid.
|
||||||
|
openSessionWindow: (sessionId: string) => Promise<{ ok: boolean; error?: string }>
|
||||||
getBootProgress: () => Promise<DesktopBootProgress>
|
getBootProgress: () => Promise<DesktopBootProgress>
|
||||||
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
|
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
|
||||||
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||||
@@ -51,8 +55,9 @@ declare global {
|
|||||||
setPreviewShortcutActive?: (active: boolean) => void
|
setPreviewShortcutActive?: (active: boolean) => void
|
||||||
openExternal: (url: string) => Promise<void>
|
openExternal: (url: string) => Promise<void>
|
||||||
fetchLinkTitle: (url: string) => Promise<string>
|
fetchLinkTitle: (url: string) => Promise<string>
|
||||||
|
sanitizeWorkspaceCwd: (cwd?: null | string) => Promise<{ cwd: string; sanitized: boolean }>
|
||||||
settings: {
|
settings: {
|
||||||
getDefaultProjectDir: () => Promise<{ defaultLabel: string; dir: null | string }>
|
getDefaultProjectDir: () => Promise<{ defaultLabel: string; dir: null | string; resolvedCwd: string }>
|
||||||
pickDefaultProjectDir: () => Promise<{ canceled: boolean; dir: null | string }>
|
pickDefaultProjectDir: () => Promise<{ canceled: boolean; dir: null | string }>
|
||||||
setDefaultProjectDir: (dir: null | string) => Promise<{ dir: null | string }>
|
setDefaultProjectDir: (dir: null | string) => Promise<{ dir: null | string }>
|
||||||
}
|
}
|
||||||
@@ -92,10 +97,40 @@ declare global {
|
|||||||
summary: () => Promise<DesktopUninstallSummary>
|
summary: () => Promise<DesktopUninstallSummary>
|
||||||
run: (mode: DesktopUninstallMode) => Promise<DesktopUninstallResult>
|
run: (mode: DesktopUninstallMode) => Promise<DesktopUninstallResult>
|
||||||
}
|
}
|
||||||
|
themes: {
|
||||||
|
// Download a VS Code Marketplace extension and return the raw color
|
||||||
|
// theme files it contributes. The renderer converts + persists them.
|
||||||
|
fetchMarketplace: (id: string) => Promise<DesktopMarketplaceThemeResult>
|
||||||
|
// Search the Marketplace for color-theme extensions. An empty query
|
||||||
|
// returns the most-installed themes.
|
||||||
|
searchMarketplace: (query: string) => Promise<DesktopMarketplaceSearchItem[]>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DesktopMarketplaceSearchItem {
|
||||||
|
extensionId: string
|
||||||
|
displayName: string
|
||||||
|
publisher: string
|
||||||
|
description: string
|
||||||
|
installs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopMarketplaceThemeFile {
|
||||||
|
label: string
|
||||||
|
/** VS Code's `uiTheme` for this entry (vs-dark / vs / hc-black). */
|
||||||
|
uiTheme?: string
|
||||||
|
/** Raw theme JSON (JSONC) text, parsed + converted by the renderer. */
|
||||||
|
contents: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesktopMarketplaceThemeResult {
|
||||||
|
extensionId: string
|
||||||
|
displayName: string
|
||||||
|
themes: DesktopMarketplaceThemeFile[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface HermesTerminalSession {
|
export interface HermesTerminalSession {
|
||||||
cwd: string
|
cwd: string
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
|
import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
|
||||||
|
import { formatCombo } from '@/lib/keybinds/combo'
|
||||||
|
|
||||||
import type { Translations } from './types'
|
import type { Translations } from './types'
|
||||||
|
|
||||||
@@ -179,6 +180,15 @@ export const en: Translations = {
|
|||||||
'session.new': 'New session',
|
'session.new': 'New session',
|
||||||
'session.next': 'Next session',
|
'session.next': 'Next session',
|
||||||
'session.prev': 'Previous session',
|
'session.prev': 'Previous session',
|
||||||
|
'session.slot.1': 'Switch to recent session 1',
|
||||||
|
'session.slot.2': 'Switch to recent session 2',
|
||||||
|
'session.slot.3': 'Switch to recent session 3',
|
||||||
|
'session.slot.4': 'Switch to recent session 4',
|
||||||
|
'session.slot.5': 'Switch to recent session 5',
|
||||||
|
'session.slot.6': 'Switch to recent session 6',
|
||||||
|
'session.slot.7': 'Switch to recent session 7',
|
||||||
|
'session.slot.8': 'Switch to recent session 8',
|
||||||
|
'session.slot.9': 'Switch to recent session 9',
|
||||||
'session.focusSearch': 'Search sessions',
|
'session.focusSearch': 'Search sessions',
|
||||||
'session.togglePin': 'Pin / unpin current session',
|
'session.togglePin': 'Pin / unpin current session',
|
||||||
'composer.focus': 'Focus composer',
|
'composer.focus': 'Focus composer',
|
||||||
@@ -293,7 +303,17 @@ export const en: Translations = {
|
|||||||
technicalDesc: 'Include raw tool args/results and low-level details.',
|
technicalDesc: 'Include raw tool args/results and low-level details.',
|
||||||
themeTitle: 'Theme',
|
themeTitle: 'Theme',
|
||||||
themeDesc: 'Desktop palettes only. The selected mode is applied on top.',
|
themeDesc: 'Desktop palettes only. The selected mode is applied on top.',
|
||||||
themeProfileNote: profile => `Saved for the ${profile} profile — each profile keeps its own theme.`
|
themeProfileNote: profile => `Saved for the ${profile} profile — each profile keeps its own theme.`,
|
||||||
|
installTitle: 'Install from VS Code',
|
||||||
|
installDesc:
|
||||||
|
'Paste a Marketplace extension id (e.g. dracula-theme.theme-dracula) to convert its color theme into a desktop palette.',
|
||||||
|
installPlaceholder: 'publisher.extension',
|
||||||
|
installButton: 'Install',
|
||||||
|
installing: 'Installing…',
|
||||||
|
installError: 'Could not install that theme.',
|
||||||
|
installed: name => `Installed “${name}”.`,
|
||||||
|
removeTheme: 'Remove theme',
|
||||||
|
importedBadge: 'Imported'
|
||||||
},
|
},
|
||||||
fieldLabels: FIELD_LABELS,
|
fieldLabels: FIELD_LABELS,
|
||||||
fieldDescriptions: FIELD_DESCRIPTIONS,
|
fieldDescriptions: FIELD_DESCRIPTIONS,
|
||||||
@@ -499,7 +519,7 @@ export const en: Translations = {
|
|||||||
loading: 'Loading archived sessions…',
|
loading: 'Loading archived sessions…',
|
||||||
archivedTitle: 'Archived sessions',
|
archivedTitle: 'Archived sessions',
|
||||||
archivedIntro:
|
archivedIntro:
|
||||||
'Archived chats are hidden from the sidebar but keep all their messages. Ctrl/⌘-click a chat in the sidebar to archive it.',
|
`Archived chats are hidden from the sidebar but keep all their messages. ${formatCombo('mod')}-click a chat in the sidebar to archive it.`,
|
||||||
emptyArchivedTitle: 'Nothing archived',
|
emptyArchivedTitle: 'Nothing archived',
|
||||||
emptyArchivedDesc: 'Archive a chat to hide it here.',
|
emptyArchivedDesc: 'Archive a chat to hide it here.',
|
||||||
unarchive: 'Unarchive',
|
unarchive: 'Unarchive',
|
||||||
@@ -510,7 +530,7 @@ export const en: Translations = {
|
|||||||
defaultDirTitle: 'Default project directory',
|
defaultDirTitle: 'Default project directory',
|
||||||
defaultDirDesc:
|
defaultDirDesc:
|
||||||
'New sessions start in this folder unless you pick another. Leave it unset to use your home directory.',
|
'New sessions start in this folder unless you pick another. Leave it unset to use your home directory.',
|
||||||
defaultDirUpdated: 'Default project directory updated',
|
defaultDirUpdated: `Default project directory updated — start a new chat (${formatCombo('mod+n')}) for it to take effect`,
|
||||||
defaultsTo: label => `Defaults to ${label}.`,
|
defaultsTo: label => `Defaults to ${label}.`,
|
||||||
change: 'Change',
|
change: 'Change',
|
||||||
choose: 'Choose',
|
choose: 'Choose',
|
||||||
@@ -627,6 +647,17 @@ export const en: Translations = {
|
|||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
changeTheme: 'Change theme...',
|
changeTheme: 'Change theme...',
|
||||||
changeColorMode: 'Change color mode...',
|
changeColorMode: 'Change color mode...',
|
||||||
|
installTheme: {
|
||||||
|
title: 'Install theme...',
|
||||||
|
placeholder: 'Search the VS Code Marketplace...',
|
||||||
|
loading: 'Searching the Marketplace...',
|
||||||
|
error: 'Could not reach the Marketplace.',
|
||||||
|
empty: 'No matching themes.',
|
||||||
|
install: 'Install',
|
||||||
|
installing: 'Installing...',
|
||||||
|
installed: 'Installed',
|
||||||
|
installs: count => `${count} installs`
|
||||||
|
},
|
||||||
settingsFields: 'Settings fields',
|
settingsFields: 'Settings fields',
|
||||||
mcpServers: 'MCP servers',
|
mcpServers: 'MCP servers',
|
||||||
archivedChats: 'Archived chats',
|
archivedChats: 'Archived chats',
|
||||||
@@ -1075,12 +1106,14 @@ export const en: Translations = {
|
|||||||
export: 'Export',
|
export: 'Export',
|
||||||
rename: 'Rename',
|
rename: 'Rename',
|
||||||
archive: 'Archive',
|
archive: 'Archive',
|
||||||
|
newWindow: 'New window',
|
||||||
copyIdFailed: 'Could not copy session ID',
|
copyIdFailed: 'Could not copy session ID',
|
||||||
actionsFor: title => `Actions for ${title}`,
|
actionsFor: title => `Actions for ${title}`,
|
||||||
sessionActions: 'Session actions',
|
sessionActions: 'Session actions',
|
||||||
sessionRunning: 'Session running',
|
sessionRunning: 'Session running',
|
||||||
needsInput: 'Needs your input',
|
needsInput: 'Needs your input',
|
||||||
waitingForAnswer: 'Waiting for your answer',
|
waitingForAnswer: 'Waiting for your answer',
|
||||||
|
handoffOrigin: platform => `Handed off from ${platform}`,
|
||||||
renamed: 'Renamed',
|
renamed: 'Renamed',
|
||||||
renameFailed: 'Rename failed',
|
renameFailed: 'Rename failed',
|
||||||
renameTitle: 'Rename session',
|
renameTitle: 'Rename session',
|
||||||
@@ -1119,7 +1152,7 @@ export const en: Translations = {
|
|||||||
],
|
],
|
||||||
startVoice: 'Start voice conversation',
|
startVoice: 'Start voice conversation',
|
||||||
queueMessage: 'Queue message',
|
queueMessage: 'Queue message',
|
||||||
steer: 'Steer the current run (⌘⏎)',
|
steer: 'Steer the current run',
|
||||||
stop: 'Stop',
|
stop: 'Stop',
|
||||||
send: 'Send',
|
send: 'Send',
|
||||||
speaking: 'Speaking',
|
speaking: 'Speaking',
|
||||||
@@ -1460,6 +1493,8 @@ export const en: Translations = {
|
|||||||
branch: branch => `branch ${branch}`,
|
branch: branch => `branch ${branch}`,
|
||||||
closeCommandCenter: 'Close Command Center',
|
closeCommandCenter: 'Close Command Center',
|
||||||
openCommandCenter: 'Open Command Center',
|
openCommandCenter: 'Open Command Center',
|
||||||
|
showTerminal: 'Show terminal',
|
||||||
|
hideTerminal: 'Hide terminal',
|
||||||
gateway: 'Gateway',
|
gateway: 'Gateway',
|
||||||
gatewayReady: 'ready',
|
gatewayReady: 'ready',
|
||||||
gatewayNeedsSetup: 'needs setup',
|
gatewayNeedsSetup: 'needs setup',
|
||||||
@@ -1515,8 +1550,7 @@ export const en: Translations = {
|
|||||||
tryAgain: 'Try again',
|
tryAgain: 'Try again',
|
||||||
loadingTree: 'Loading file tree',
|
loadingTree: 'Loading file tree',
|
||||||
loadingFiles: 'Loading files',
|
loadingFiles: 'Loading files',
|
||||||
terminalFocus: 'Focus terminal view',
|
terminalHide: 'Hide terminal',
|
||||||
terminalSplit: 'Return to split view',
|
|
||||||
addToChat: 'Add to chat'
|
addToChat: 'Add to chat'
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1621,7 +1655,8 @@ export const en: Translations = {
|
|||||||
restoreCheckpoint: 'Restore checkpoint',
|
restoreCheckpoint: 'Restore checkpoint',
|
||||||
restoreNext: 'Restore next checkpoint',
|
restoreNext: 'Restore next checkpoint',
|
||||||
goForward: 'Go forward',
|
goForward: 'Go forward',
|
||||||
sendEdited: 'Send edited message'
|
sendEdited: 'Send edited message',
|
||||||
|
attachingFile: 'Attaching…'
|
||||||
},
|
},
|
||||||
approval: {
|
approval: {
|
||||||
gatewayDisconnected: 'Hermes gateway is not connected',
|
gatewayDisconnected: 'Hermes gateway is not connected',
|
||||||
@@ -1643,7 +1678,7 @@ export const en: Translations = {
|
|||||||
loadingQuestion: 'Loading question…',
|
loadingQuestion: 'Loading question…',
|
||||||
other: 'Other (type your answer)',
|
other: 'Other (type your answer)',
|
||||||
placeholder: 'Type your answer…',
|
placeholder: 'Type your answer…',
|
||||||
shortcut: '⌘/Ctrl + Enter to send',
|
shortcut: `${formatCombo('mod+enter')} to send`,
|
||||||
back: 'Back',
|
back: 'Back',
|
||||||
skip: 'Skip',
|
skip: 'Skip',
|
||||||
send: 'Send'
|
send: 'Send'
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineFieldCopy } from '@/app/settings/field-copy'
|
import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||||
|
import { formatCombo } from '@/lib/keybinds/combo'
|
||||||
|
|
||||||
import { defineLocale } from './define-locale'
|
import { defineLocale } from './define-locale'
|
||||||
|
|
||||||
@@ -216,7 +217,16 @@ export const ja = defineLocale({
|
|||||||
technicalDesc: '生のツール引数、結果、低レベルの詳細を含めます。',
|
technicalDesc: '生のツール引数、結果、低レベルの詳細を含めます。',
|
||||||
themeTitle: 'テーマ',
|
themeTitle: 'テーマ',
|
||||||
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。',
|
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。',
|
||||||
themeProfileNote: profile => `「${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`
|
themeProfileNote: profile => `「${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`,
|
||||||
|
installTitle: 'VS Code から導入',
|
||||||
|
installDesc: 'Marketplace の拡張機能 ID(例: dracula-theme.theme-dracula)を貼り付けると、その配色テーマをデスクトップ用パレットに変換します。',
|
||||||
|
installPlaceholder: 'publisher.extension',
|
||||||
|
installButton: 'インストール',
|
||||||
|
installing: 'インストール中…',
|
||||||
|
installError: 'そのテーマをインストールできませんでした。',
|
||||||
|
installed: name => `「${name}」をインストールしました。`,
|
||||||
|
removeTheme: 'テーマを削除',
|
||||||
|
importedBadge: 'インポート済み'
|
||||||
},
|
},
|
||||||
fieldLabels: defineFieldCopy({
|
fieldLabels: defineFieldCopy({
|
||||||
model: 'デフォルトモデル',
|
model: 'デフォルトモデル',
|
||||||
@@ -633,7 +643,7 @@ export const ja = defineLocale({
|
|||||||
loading: 'アーカイブ済みセッションを読み込み中…',
|
loading: 'アーカイブ済みセッションを読み込み中…',
|
||||||
archivedTitle: 'アーカイブ済みセッション',
|
archivedTitle: 'アーカイブ済みセッション',
|
||||||
archivedIntro:
|
archivedIntro:
|
||||||
'アーカイブ済みチャットはサイドバーでは非表示になりますが、すべてのメッセージは保持されます。サイドバーのチャットを Ctrl/⌘ クリックするとアーカイブできます。',
|
`アーカイブ済みチャットはサイドバーでは非表示になりますが、すべてのメッセージは保持されます。サイドバーのチャットを ${formatCombo('mod')} クリックするとアーカイブできます。`,
|
||||||
emptyArchivedTitle: 'アーカイブがありません',
|
emptyArchivedTitle: 'アーカイブがありません',
|
||||||
emptyArchivedDesc: 'チャットをアーカイブするとここに表示されます。',
|
emptyArchivedDesc: 'チャットをアーカイブするとここに表示されます。',
|
||||||
unarchive: 'アーカイブを解除',
|
unarchive: 'アーカイブを解除',
|
||||||
@@ -762,6 +772,17 @@ export const ja = defineLocale({
|
|||||||
settings: '設定',
|
settings: '設定',
|
||||||
changeTheme: 'テーマを変更...',
|
changeTheme: 'テーマを変更...',
|
||||||
changeColorMode: 'カラーモードを変更...',
|
changeColorMode: 'カラーモードを変更...',
|
||||||
|
installTheme: {
|
||||||
|
title: 'テーマをインストール...',
|
||||||
|
placeholder: 'VS Code Marketplace を検索...',
|
||||||
|
loading: 'Marketplace を検索中...',
|
||||||
|
error: 'Marketplace に接続できませんでした。',
|
||||||
|
empty: '一致するテーマがありません。',
|
||||||
|
install: 'インストール',
|
||||||
|
installing: 'インストール中...',
|
||||||
|
installed: 'インストール済み',
|
||||||
|
installs: count => `${count} 回インストール`
|
||||||
|
},
|
||||||
settingsFields: '設定フィールド',
|
settingsFields: '設定フィールド',
|
||||||
mcpServers: 'MCP サーバー',
|
mcpServers: 'MCP サーバー',
|
||||||
archivedChats: 'アーカイブ済みチャット',
|
archivedChats: 'アーカイブ済みチャット',
|
||||||
@@ -1218,12 +1239,14 @@ export const ja = defineLocale({
|
|||||||
export: 'エクスポート',
|
export: 'エクスポート',
|
||||||
rename: '名前を変更',
|
rename: '名前を変更',
|
||||||
archive: 'アーカイブ',
|
archive: 'アーカイブ',
|
||||||
|
newWindow: '新しいウィンドウ',
|
||||||
copyIdFailed: 'セッション ID をコピーできませんでした',
|
copyIdFailed: 'セッション ID をコピーできませんでした',
|
||||||
actionsFor: title => `${title} のアクション`,
|
actionsFor: title => `${title} のアクション`,
|
||||||
sessionActions: 'セッションアクション',
|
sessionActions: 'セッションアクション',
|
||||||
sessionRunning: 'セッション実行中',
|
sessionRunning: 'セッション実行中',
|
||||||
needsInput: '入力が必要です',
|
needsInput: '入力が必要です',
|
||||||
waitingForAnswer: '回答を待っています',
|
waitingForAnswer: '回答を待っています',
|
||||||
|
handoffOrigin: platform => `${platform} から引き継ぎ`,
|
||||||
renamed: '名前を変更しました',
|
renamed: '名前を変更しました',
|
||||||
renameFailed: '名前の変更に失敗しました',
|
renameFailed: '名前の変更に失敗しました',
|
||||||
renameTitle: 'セッションの名前を変更',
|
renameTitle: 'セッションの名前を変更',
|
||||||
@@ -1603,6 +1626,8 @@ export const ja = defineLocale({
|
|||||||
branch: branch => `ブランチ ${branch}`,
|
branch: branch => `ブランチ ${branch}`,
|
||||||
closeCommandCenter: 'コマンドセンターを閉じる',
|
closeCommandCenter: 'コマンドセンターを閉じる',
|
||||||
openCommandCenter: 'コマンドセンターを開く',
|
openCommandCenter: 'コマンドセンターを開く',
|
||||||
|
showTerminal: 'ターミナルを表示',
|
||||||
|
hideTerminal: 'ターミナルを非表示',
|
||||||
gateway: 'ゲートウェイ',
|
gateway: 'ゲートウェイ',
|
||||||
gatewayReady: '準備完了',
|
gatewayReady: '準備完了',
|
||||||
gatewayNeedsSetup: '設定が必要',
|
gatewayNeedsSetup: '設定が必要',
|
||||||
@@ -1658,8 +1683,7 @@ export const ja = defineLocale({
|
|||||||
tryAgain: '再試行',
|
tryAgain: '再試行',
|
||||||
loadingTree: 'ファイルツリーを読み込み中',
|
loadingTree: 'ファイルツリーを読み込み中',
|
||||||
loadingFiles: 'ファイルを読み込み中',
|
loadingFiles: 'ファイルを読み込み中',
|
||||||
terminalFocus: 'ターミナルビューにフォーカス',
|
terminalHide: 'ターミナルを非表示',
|
||||||
terminalSplit: '分割ビューに戻る',
|
|
||||||
addToChat: 'チャットに追加'
|
addToChat: 'チャットに追加'
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1765,7 +1789,8 @@ export const ja = defineLocale({
|
|||||||
restoreCheckpoint: 'チェックポイントを復元',
|
restoreCheckpoint: 'チェックポイントを復元',
|
||||||
restoreNext: '次のチェックポイントに戻す',
|
restoreNext: '次のチェックポイントに戻す',
|
||||||
goForward: '進む',
|
goForward: '進む',
|
||||||
sendEdited: '編集済みメッセージを送信'
|
sendEdited: '編集済みメッセージを送信',
|
||||||
|
attachingFile: '添付中…'
|
||||||
},
|
},
|
||||||
approval: {
|
approval: {
|
||||||
gatewayDisconnected: 'Hermes ゲートウェイが接続されていません',
|
gatewayDisconnected: 'Hermes ゲートウェイが接続されていません',
|
||||||
@@ -1787,7 +1812,7 @@ export const ja = defineLocale({
|
|||||||
loadingQuestion: '質問を読み込み中…',
|
loadingQuestion: '質問を読み込み中…',
|
||||||
other: 'その他(回答を入力)',
|
other: 'その他(回答を入力)',
|
||||||
placeholder: '回答を入力…',
|
placeholder: '回答を入力…',
|
||||||
shortcut: '⌘/Ctrl + Enter で送信',
|
shortcut: `${formatCombo('mod+enter')} で送信`,
|
||||||
back: '戻る',
|
back: '戻る',
|
||||||
skip: 'スキップ',
|
skip: 'スキップ',
|
||||||
send: '送信'
|
send: '送信'
|
||||||
|
|||||||
@@ -220,6 +220,15 @@ export interface Translations {
|
|||||||
themeTitle: string
|
themeTitle: string
|
||||||
themeDesc: string
|
themeDesc: string
|
||||||
themeProfileNote: (profile: string) => string
|
themeProfileNote: (profile: string) => string
|
||||||
|
installTitle: string
|
||||||
|
installDesc: string
|
||||||
|
installPlaceholder: string
|
||||||
|
installButton: string
|
||||||
|
installing: string
|
||||||
|
installError: string
|
||||||
|
installed: (name: string) => string
|
||||||
|
removeTheme: string
|
||||||
|
importedBadge: string
|
||||||
}
|
}
|
||||||
fieldLabels: Record<string, string>
|
fieldLabels: Record<string, string>
|
||||||
fieldDescriptions: Record<string, string>
|
fieldDescriptions: Record<string, string>
|
||||||
@@ -534,6 +543,17 @@ export interface Translations {
|
|||||||
settings: string
|
settings: string
|
||||||
changeTheme: string
|
changeTheme: string
|
||||||
changeColorMode: string
|
changeColorMode: string
|
||||||
|
installTheme: {
|
||||||
|
title: string
|
||||||
|
placeholder: string
|
||||||
|
loading: string
|
||||||
|
error: string
|
||||||
|
empty: string
|
||||||
|
install: string
|
||||||
|
installing: string
|
||||||
|
installed: string
|
||||||
|
installs: (count: string) => string
|
||||||
|
}
|
||||||
settingsFields: string
|
settingsFields: string
|
||||||
mcpServers: string
|
mcpServers: string
|
||||||
archivedChats: string
|
archivedChats: string
|
||||||
@@ -832,12 +852,14 @@ export interface Translations {
|
|||||||
export: string
|
export: string
|
||||||
rename: string
|
rename: string
|
||||||
archive: string
|
archive: string
|
||||||
|
newWindow: string
|
||||||
copyIdFailed: string
|
copyIdFailed: string
|
||||||
actionsFor: (title: string) => string
|
actionsFor: (title: string) => string
|
||||||
sessionActions: string
|
sessionActions: string
|
||||||
sessionRunning: string
|
sessionRunning: string
|
||||||
needsInput: string
|
needsInput: string
|
||||||
waitingForAnswer: string
|
waitingForAnswer: string
|
||||||
|
handoffOrigin: (platform: string) => string
|
||||||
renamed: string
|
renamed: string
|
||||||
renameFailed: string
|
renameFailed: string
|
||||||
renameTitle: string
|
renameTitle: string
|
||||||
@@ -1132,6 +1154,8 @@ export interface Translations {
|
|||||||
branch: (branch: string) => string
|
branch: (branch: string) => string
|
||||||
closeCommandCenter: string
|
closeCommandCenter: string
|
||||||
openCommandCenter: string
|
openCommandCenter: string
|
||||||
|
showTerminal: string
|
||||||
|
hideTerminal: string
|
||||||
gateway: string
|
gateway: string
|
||||||
gatewayReady: string
|
gatewayReady: string
|
||||||
gatewayNeedsSetup: string
|
gatewayNeedsSetup: string
|
||||||
@@ -1187,8 +1211,7 @@ export interface Translations {
|
|||||||
tryAgain: string
|
tryAgain: string
|
||||||
loadingTree: string
|
loadingTree: string
|
||||||
loadingFiles: string
|
loadingFiles: string
|
||||||
terminalFocus: string
|
terminalHide: string
|
||||||
terminalSplit: string
|
|
||||||
addToChat: string
|
addToChat: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1292,6 +1315,7 @@ export interface Translations {
|
|||||||
restoreNext: string
|
restoreNext: string
|
||||||
goForward: string
|
goForward: string
|
||||||
sendEdited: string
|
sendEdited: string
|
||||||
|
attachingFile: string
|
||||||
}
|
}
|
||||||
approval: {
|
approval: {
|
||||||
gatewayDisconnected: string
|
gatewayDisconnected: string
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineFieldCopy } from '@/app/settings/field-copy'
|
import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||||
|
import { formatCombo } from '@/lib/keybinds/combo'
|
||||||
|
|
||||||
import { defineLocale } from './define-locale'
|
import { defineLocale } from './define-locale'
|
||||||
|
|
||||||
@@ -210,7 +211,16 @@ export const zhHant = defineLocale({
|
|||||||
technicalDesc: '包含原始工具參數、結果與底層細節。',
|
technicalDesc: '包含原始工具參數、結果與底層細節。',
|
||||||
themeTitle: '主題',
|
themeTitle: '主題',
|
||||||
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。',
|
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。',
|
||||||
themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。`
|
themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。`,
|
||||||
|
installTitle: '從 VS Code 安裝',
|
||||||
|
installDesc: '貼上 Marketplace 擴充功能 ID(例如 dracula-theme.theme-dracula),將其配色主題轉換為桌面調色盤。',
|
||||||
|
installPlaceholder: 'publisher.extension',
|
||||||
|
installButton: '安裝',
|
||||||
|
installing: '安裝中…',
|
||||||
|
installError: '無法安裝該主題。',
|
||||||
|
installed: name => `已安裝「${name}」。`,
|
||||||
|
removeTheme: '移除主題',
|
||||||
|
importedBadge: '已匯入'
|
||||||
},
|
},
|
||||||
fieldLabels: defineFieldCopy({
|
fieldLabels: defineFieldCopy({
|
||||||
model: '預設模型',
|
model: '預設模型',
|
||||||
@@ -618,7 +628,7 @@ export const zhHant = defineLocale({
|
|||||||
loading: '正在載入已封存工作階段…',
|
loading: '正在載入已封存工作階段…',
|
||||||
archivedTitle: '已封存工作階段',
|
archivedTitle: '已封存工作階段',
|
||||||
archivedIntro:
|
archivedIntro:
|
||||||
'已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 Ctrl/⌘ 點擊聊天即可封存。',
|
`已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 ${formatCombo('mod')} 點擊聊天即可封存。`,
|
||||||
emptyArchivedTitle: '暫無封存',
|
emptyArchivedTitle: '暫無封存',
|
||||||
emptyArchivedDesc: '封存一個聊天後會顯示在這裡。',
|
emptyArchivedDesc: '封存一個聊天後會顯示在這裡。',
|
||||||
unarchive: '取消封存',
|
unarchive: '取消封存',
|
||||||
@@ -745,6 +755,17 @@ export const zhHant = defineLocale({
|
|||||||
settings: '設定',
|
settings: '設定',
|
||||||
changeTheme: '變更主題...',
|
changeTheme: '變更主題...',
|
||||||
changeColorMode: '變更色彩模式...',
|
changeColorMode: '變更色彩模式...',
|
||||||
|
installTheme: {
|
||||||
|
title: '安裝主題...',
|
||||||
|
placeholder: '搜尋 VS Code Marketplace...',
|
||||||
|
loading: '正在搜尋 Marketplace...',
|
||||||
|
error: '無法連接到 Marketplace。',
|
||||||
|
empty: '沒有符合的主題。',
|
||||||
|
install: '安裝',
|
||||||
|
installing: '安裝中...',
|
||||||
|
installed: '已安裝',
|
||||||
|
installs: count => `${count} 次安裝`
|
||||||
|
},
|
||||||
settingsFields: '設定欄位',
|
settingsFields: '設定欄位',
|
||||||
mcpServers: 'MCP 伺服器',
|
mcpServers: 'MCP 伺服器',
|
||||||
archivedChats: '已封存聊天',
|
archivedChats: '已封存聊天',
|
||||||
@@ -1184,12 +1205,14 @@ export const zhHant = defineLocale({
|
|||||||
export: '匯出',
|
export: '匯出',
|
||||||
rename: '重新命名',
|
rename: '重新命名',
|
||||||
archive: '封存',
|
archive: '封存',
|
||||||
|
newWindow: '新視窗',
|
||||||
copyIdFailed: '無法複製工作階段 ID',
|
copyIdFailed: '無法複製工作階段 ID',
|
||||||
actionsFor: title => `${title} 的動作`,
|
actionsFor: title => `${title} 的動作`,
|
||||||
sessionActions: '工作階段動作',
|
sessionActions: '工作階段動作',
|
||||||
sessionRunning: '工作階段執行中',
|
sessionRunning: '工作階段執行中',
|
||||||
needsInput: '需要您的輸入',
|
needsInput: '需要您的輸入',
|
||||||
waitingForAnswer: '等待您的回答',
|
waitingForAnswer: '等待您的回答',
|
||||||
|
handoffOrigin: platform => `從 ${platform} 轉接`,
|
||||||
renamed: '已重新命名',
|
renamed: '已重新命名',
|
||||||
renameFailed: '重新命名失敗',
|
renameFailed: '重新命名失敗',
|
||||||
renameTitle: '重新命名工作階段',
|
renameTitle: '重新命名工作階段',
|
||||||
@@ -1564,6 +1587,8 @@ export const zhHant = defineLocale({
|
|||||||
branch: branch => `分支 ${branch}`,
|
branch: branch => `分支 ${branch}`,
|
||||||
closeCommandCenter: '關閉命令中心',
|
closeCommandCenter: '關閉命令中心',
|
||||||
openCommandCenter: '開啟命令中心',
|
openCommandCenter: '開啟命令中心',
|
||||||
|
showTerminal: '顯示終端機',
|
||||||
|
hideTerminal: '隱藏終端機',
|
||||||
gateway: '閘道',
|
gateway: '閘道',
|
||||||
gatewayReady: '就緒',
|
gatewayReady: '就緒',
|
||||||
gatewayNeedsSetup: '需要設定',
|
gatewayNeedsSetup: '需要設定',
|
||||||
@@ -1619,8 +1644,7 @@ export const zhHant = defineLocale({
|
|||||||
tryAgain: '重試',
|
tryAgain: '重試',
|
||||||
loadingTree: '正在載入檔案樹',
|
loadingTree: '正在載入檔案樹',
|
||||||
loadingFiles: '正在載入檔案',
|
loadingFiles: '正在載入檔案',
|
||||||
terminalFocus: '聚焦終端機檢視',
|
terminalHide: '隱藏終端機',
|
||||||
terminalSplit: '返回分割檢視',
|
|
||||||
addToChat: '新增至聊天'
|
addToChat: '新增至聊天'
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1726,7 +1750,8 @@ export const zhHant = defineLocale({
|
|||||||
restoreCheckpoint: '還原檢查點',
|
restoreCheckpoint: '還原檢查點',
|
||||||
restoreNext: '還原至下一個檢查點',
|
restoreNext: '還原至下一個檢查點',
|
||||||
goForward: '前進',
|
goForward: '前進',
|
||||||
sendEdited: '傳送編輯後的訊息'
|
sendEdited: '傳送編輯後的訊息',
|
||||||
|
attachingFile: '正在附加…'
|
||||||
},
|
},
|
||||||
approval: {
|
approval: {
|
||||||
gatewayDisconnected: 'Hermes 閘道未連線',
|
gatewayDisconnected: 'Hermes 閘道未連線',
|
||||||
@@ -1748,7 +1773,7 @@ export const zhHant = defineLocale({
|
|||||||
loadingQuestion: '正在載入問題…',
|
loadingQuestion: '正在載入問題…',
|
||||||
other: '其他(輸入您的答案)',
|
other: '其他(輸入您的答案)',
|
||||||
placeholder: '輸入您的答案…',
|
placeholder: '輸入您的答案…',
|
||||||
shortcut: '⌘/Ctrl + Enter 傳送',
|
shortcut: `${formatCombo('mod+enter')} 傳送`,
|
||||||
back: '返回',
|
back: '返回',
|
||||||
skip: '略過',
|
skip: '略過',
|
||||||
send: '傳送'
|
send: '傳送'
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineFieldCopy } from '@/app/settings/field-copy'
|
import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||||
|
import { formatCombo } from '@/lib/keybinds/combo'
|
||||||
|
|
||||||
import type { Translations } from './types'
|
import type { Translations } from './types'
|
||||||
|
|
||||||
@@ -175,6 +176,15 @@ export const zh: Translations = {
|
|||||||
'session.new': '新建会话',
|
'session.new': '新建会话',
|
||||||
'session.next': '下一个会话',
|
'session.next': '下一个会话',
|
||||||
'session.prev': '上一个会话',
|
'session.prev': '上一个会话',
|
||||||
|
'session.slot.1': '切换到最近会话 1',
|
||||||
|
'session.slot.2': '切换到最近会话 2',
|
||||||
|
'session.slot.3': '切换到最近会话 3',
|
||||||
|
'session.slot.4': '切换到最近会话 4',
|
||||||
|
'session.slot.5': '切换到最近会话 5',
|
||||||
|
'session.slot.6': '切换到最近会话 6',
|
||||||
|
'session.slot.7': '切换到最近会话 7',
|
||||||
|
'session.slot.8': '切换到最近会话 8',
|
||||||
|
'session.slot.9': '切换到最近会话 9',
|
||||||
'session.focusSearch': '搜索会话',
|
'session.focusSearch': '搜索会话',
|
||||||
'session.togglePin': '固定/取消固定当前会话',
|
'session.togglePin': '固定/取消固定当前会话',
|
||||||
'composer.focus': '聚焦输入框',
|
'composer.focus': '聚焦输入框',
|
||||||
@@ -288,7 +298,16 @@ export const zh: Translations = {
|
|||||||
technicalDesc: '包含原始工具参数/结果及底层细节。',
|
technicalDesc: '包含原始工具参数/结果及底层细节。',
|
||||||
themeTitle: '主题',
|
themeTitle: '主题',
|
||||||
themeDesc: '仅桌面端调色板。所选模式叠加其上。',
|
themeDesc: '仅桌面端调色板。所选模式叠加其上。',
|
||||||
themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。`
|
themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。`,
|
||||||
|
installTitle: '从 VS Code 安装',
|
||||||
|
installDesc: '粘贴 Marketplace 扩展 ID(例如 dracula-theme.theme-dracula),将其配色主题转换为桌面调色板。',
|
||||||
|
installPlaceholder: 'publisher.extension',
|
||||||
|
installButton: '安装',
|
||||||
|
installing: '安装中…',
|
||||||
|
installError: '无法安装该主题。',
|
||||||
|
installed: name => `已安装「${name}」。`,
|
||||||
|
removeTheme: '移除主题',
|
||||||
|
importedBadge: '已导入'
|
||||||
},
|
},
|
||||||
fieldLabels: defineFieldCopy({
|
fieldLabels: defineFieldCopy({
|
||||||
model: '默认模型',
|
model: '默认模型',
|
||||||
@@ -694,7 +713,7 @@ export const zh: Translations = {
|
|||||||
sessions: {
|
sessions: {
|
||||||
loading: '正在加载已归档会话…',
|
loading: '正在加载已归档会话…',
|
||||||
archivedTitle: '已归档会话',
|
archivedTitle: '已归档会话',
|
||||||
archivedIntro: '已归档对话会从侧边栏隐藏,但会保留全部消息。在侧边栏 Ctrl/⌘ 点击对话即可归档。',
|
archivedIntro: `已归档对话会从侧边栏隐藏,但会保留全部消息。在侧边栏 ${formatCombo('mod')} 点击对话即可归档。`,
|
||||||
emptyArchivedTitle: '暂无归档',
|
emptyArchivedTitle: '暂无归档',
|
||||||
emptyArchivedDesc: '归档一个对话后会显示在这里。',
|
emptyArchivedDesc: '归档一个对话后会显示在这里。',
|
||||||
unarchive: '取消归档',
|
unarchive: '取消归档',
|
||||||
@@ -820,6 +839,17 @@ export const zh: Translations = {
|
|||||||
settings: '设置',
|
settings: '设置',
|
||||||
changeTheme: '更改主题...',
|
changeTheme: '更改主题...',
|
||||||
changeColorMode: '更改颜色模式...',
|
changeColorMode: '更改颜色模式...',
|
||||||
|
installTheme: {
|
||||||
|
title: '安装主题...',
|
||||||
|
placeholder: '搜索 VS Code Marketplace...',
|
||||||
|
loading: '正在搜索 Marketplace...',
|
||||||
|
error: '无法连接到 Marketplace。',
|
||||||
|
empty: '没有匹配的主题。',
|
||||||
|
install: '安装',
|
||||||
|
installing: '安装中...',
|
||||||
|
installed: '已安装',
|
||||||
|
installs: count => `${count} 次安装`
|
||||||
|
},
|
||||||
settingsFields: '设置字段',
|
settingsFields: '设置字段',
|
||||||
mcpServers: 'MCP 服务器',
|
mcpServers: 'MCP 服务器',
|
||||||
archivedChats: '已归档对话',
|
archivedChats: '已归档对话',
|
||||||
@@ -1262,12 +1292,14 @@ export const zh: Translations = {
|
|||||||
export: '导出',
|
export: '导出',
|
||||||
rename: '重命名',
|
rename: '重命名',
|
||||||
archive: '归档',
|
archive: '归档',
|
||||||
|
newWindow: '新窗口',
|
||||||
copyIdFailed: '无法复制会话 ID',
|
copyIdFailed: '无法复制会话 ID',
|
||||||
actionsFor: title => `${title} 的操作`,
|
actionsFor: title => `${title} 的操作`,
|
||||||
sessionActions: '会话操作',
|
sessionActions: '会话操作',
|
||||||
sessionRunning: '会话运行中',
|
sessionRunning: '会话运行中',
|
||||||
needsInput: '需要你输入',
|
needsInput: '需要你输入',
|
||||||
waitingForAnswer: '正在等待你的回答',
|
waitingForAnswer: '正在等待你的回答',
|
||||||
|
handoffOrigin: platform => `从 ${platform} 转接`,
|
||||||
renamed: '已重命名',
|
renamed: '已重命名',
|
||||||
renameFailed: '重命名失败',
|
renameFailed: '重命名失败',
|
||||||
renameTitle: '重命名会话',
|
renameTitle: '重命名会话',
|
||||||
@@ -1306,7 +1338,7 @@ export const zh: Translations = {
|
|||||||
],
|
],
|
||||||
startVoice: '开始语音对话',
|
startVoice: '开始语音对话',
|
||||||
queueMessage: '排队消息',
|
queueMessage: '排队消息',
|
||||||
steer: '引导当前运行 (⌘⏎)',
|
steer: '引导当前运行',
|
||||||
stop: '停止',
|
stop: '停止',
|
||||||
send: '发送',
|
send: '发送',
|
||||||
speaking: '讲话中',
|
speaking: '讲话中',
|
||||||
@@ -1641,6 +1673,8 @@ export const zh: Translations = {
|
|||||||
branch: branch => `分支 ${branch}`,
|
branch: branch => `分支 ${branch}`,
|
||||||
closeCommandCenter: '关闭命令中心',
|
closeCommandCenter: '关闭命令中心',
|
||||||
openCommandCenter: '打开命令中心',
|
openCommandCenter: '打开命令中心',
|
||||||
|
showTerminal: '显示终端',
|
||||||
|
hideTerminal: '隐藏终端',
|
||||||
gateway: '网关',
|
gateway: '网关',
|
||||||
gatewayReady: '就绪',
|
gatewayReady: '就绪',
|
||||||
gatewayNeedsSetup: '需要设置',
|
gatewayNeedsSetup: '需要设置',
|
||||||
@@ -1696,8 +1730,7 @@ export const zh: Translations = {
|
|||||||
tryAgain: '重试',
|
tryAgain: '重试',
|
||||||
loadingTree: '正在加载文件树',
|
loadingTree: '正在加载文件树',
|
||||||
loadingFiles: '正在加载文件',
|
loadingFiles: '正在加载文件',
|
||||||
terminalFocus: '聚焦终端视图',
|
terminalHide: '隐藏终端',
|
||||||
terminalSplit: '返回分栏视图',
|
|
||||||
addToChat: '添加到对话'
|
addToChat: '添加到对话'
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1801,7 +1834,8 @@ export const zh: Translations = {
|
|||||||
restoreCheckpoint: '恢复检查点',
|
restoreCheckpoint: '恢复检查点',
|
||||||
restoreNext: '恢复下一个检查点',
|
restoreNext: '恢复下一个检查点',
|
||||||
goForward: '前进',
|
goForward: '前进',
|
||||||
sendEdited: '发送编辑后的消息'
|
sendEdited: '发送编辑后的消息',
|
||||||
|
attachingFile: '正在附加…'
|
||||||
},
|
},
|
||||||
approval: {
|
approval: {
|
||||||
gatewayDisconnected: 'Hermes 网关未连接',
|
gatewayDisconnected: 'Hermes 网关未连接',
|
||||||
@@ -1823,7 +1857,7 @@ export const zh: Translations = {
|
|||||||
loadingQuestion: '正在加载问题…',
|
loadingQuestion: '正在加载问题…',
|
||||||
other: '其他 (输入你的答案)',
|
other: '其他 (输入你的答案)',
|
||||||
placeholder: '输入你的答案…',
|
placeholder: '输入你的答案…',
|
||||||
shortcut: '⌘/Ctrl + Enter 发送',
|
shortcut: `${formatCombo('mod+enter')} 发送`,
|
||||||
back: '返回',
|
back: '返回',
|
||||||
skip: '跳过',
|
skip: '跳过',
|
||||||
send: '发送'
|
send: '发送'
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ export type GatewayEventPayload = {
|
|||||||
// secret.request (skill credential capture)
|
// secret.request (skill credential capture)
|
||||||
env_var?: string
|
env_var?: string
|
||||||
prompt?: string
|
prompt?: string
|
||||||
|
// terminal.read.request (GUI agent reading the in-app terminal pane)
|
||||||
|
start?: number
|
||||||
|
count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function textPart(text: string): ChatMessagePart {
|
export function textPart(text: string): ChatMessagePart {
|
||||||
|
|||||||
@@ -1,6 +1,42 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { coerceThinkingText } from './chat-runtime'
|
import type { ComposerAttachment } from '@/store/composer'
|
||||||
|
|
||||||
|
import { coerceThinkingText, optimisticAttachmentRef } from './chat-runtime'
|
||||||
|
|
||||||
|
const DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANS'
|
||||||
|
|
||||||
|
function attachment(overrides: Partial<ComposerAttachment> & Pick<ComposerAttachment, 'kind'>): ComposerAttachment {
|
||||||
|
return { id: 'a', label: 'file.png', ...overrides }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('optimisticAttachmentRef', () => {
|
||||||
|
it('renders an image from its in-hand base64 preview (no @image: path ref)', () => {
|
||||||
|
const ref = optimisticAttachmentRef(attachment({ kind: 'image', detail: '/tmp/shot.png', previewUrl: DATA_URL }))
|
||||||
|
|
||||||
|
// The raw data URL flows through extractEmbeddedImages → inline thumbnail,
|
||||||
|
// dodging the remote /api/media 403 an @image:<localpath> ref would hit.
|
||||||
|
expect(ref).toBe(DATA_URL)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to an @image: path ref when no preview is available', () => {
|
||||||
|
expect(optimisticAttachmentRef(attachment({ kind: 'image', detail: '/tmp/shot.png' }))).toBe('@image:/tmp/shot.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores a non-data preview url and uses the path ref', () => {
|
||||||
|
const ref = optimisticAttachmentRef(
|
||||||
|
attachment({ kind: 'image', detail: '/tmp/shot.png', previewUrl: 'https://example.com/x.png' })
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(ref).toBe('@image:/tmp/shot.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes non-image attachments straight through to attachmentDisplayText', () => {
|
||||||
|
expect(optimisticAttachmentRef(attachment({ kind: 'file', refText: '@file:src/a.ts', previewUrl: DATA_URL }))).toBe(
|
||||||
|
'@file:src/a.ts'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('coerceThinkingText', () => {
|
describe('coerceThinkingText', () => {
|
||||||
it('strips streaming status prefixes from thinking deltas', () => {
|
it('strips streaming status prefixes from thinking deltas', () => {
|
||||||
|
|||||||
@@ -165,6 +165,29 @@ export function attachmentDisplayText(attachment: ComposerAttachment): string |
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display ref for the optimistic (in-flight) user bubble.
|
||||||
|
*
|
||||||
|
* Images prefer their in-hand base64 preview (a `data:` URL) over a file path.
|
||||||
|
* `DirectiveContent` runs `extractEmbeddedImages` first, so a raw `data:` URL
|
||||||
|
* renders as an inline thumbnail with zero network. An `@image:<localpath>` ref
|
||||||
|
* would instead route through `/api/media`, which in remote mode 403s ("Path
|
||||||
|
* outside media roots") on a local path the gateway can't read yet — flashing a
|
||||||
|
* fallback chip until submit uploads the bytes. The preview also survives the
|
||||||
|
* post-sync rewrite (bytes go to the agent via the attached-image pipeline, not
|
||||||
|
* this display ref), so the thumbnail stays stable instead of remounting.
|
||||||
|
*
|
||||||
|
* Everything else (files, folders, terminals, post-sync `@file:` refs) falls
|
||||||
|
* through to `attachmentDisplayText`.
|
||||||
|
*/
|
||||||
|
export function optimisticAttachmentRef(attachment: ComposerAttachment): string | null {
|
||||||
|
if (attachment.kind === 'image' && attachment.previewUrl?.startsWith('data:')) {
|
||||||
|
return attachment.previewUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachmentDisplayText(attachment)
|
||||||
|
}
|
||||||
|
|
||||||
export function personalityNamesFromConfig(config: unknown): string[] {
|
export function personalityNamesFromConfig(config: unknown): string[] {
|
||||||
const root = config && typeof config === 'object' ? (config as Record<string, unknown>) : {}
|
const root = config && typeof config === 'object' ? (config as Record<string, unknown>) : {}
|
||||||
const agent = root.agent && typeof root.agent === 'object' ? (root.agent as Record<string, unknown>) : {}
|
const agent = root.agent && typeof root.agent === 'object' ? (root.agent as Record<string, unknown>) : {}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
// like navigate / theme); labels come from i18n (`t.keybinds.actions[id]`). To
|
// like navigate / theme); labels come from i18n (`t.keybinds.actions[id]`). To
|
||||||
// add a hotkey, add a row here and a handler there — nothing else.
|
// add a hotkey, add a row here and a handler there — nothing else.
|
||||||
|
|
||||||
|
import type { Combo, FakeCombo } from "./combo";
|
||||||
|
|
||||||
|
|
||||||
export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view'
|
export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view'
|
||||||
|
|
||||||
// The self-referential opener — bound + dispatched like any action, but shown in
|
// The self-referential opener — bound + dispatched like any action, but shown in
|
||||||
@@ -13,13 +16,7 @@ export const KEYBIND_PANEL_ACTION = 'keybinds.openPanel'
|
|||||||
|
|
||||||
// `composer` is read-only; the rest are rebindable. `view` is the catch-all for
|
// `composer` is read-only; the rest are rebindable. `view` is the catch-all for
|
||||||
// layout, appearance, and the panel-opener.
|
// layout, appearance, and the panel-opener.
|
||||||
export const KEYBIND_CATEGORIES: readonly KeybindCategory[] = [
|
export const KEYBIND_CATEGORIES: readonly KeybindCategory[] = ['composer', 'profiles', 'session', 'navigation', 'view']
|
||||||
'composer',
|
|
||||||
'profiles',
|
|
||||||
'session',
|
|
||||||
'navigation',
|
|
||||||
'view'
|
|
||||||
]
|
|
||||||
|
|
||||||
export interface KeybindActionMeta {
|
export interface KeybindActionMeta {
|
||||||
id: string
|
id: string
|
||||||
@@ -33,14 +30,29 @@ export interface KeybindActionMeta {
|
|||||||
// `profile.default`) — ⌘` is macOS-reserved (window cycling) and ⌘0 is reset-zoom.
|
// `profile.default`) — ⌘` is macOS-reserved (window cycling) and ⌘0 is reset-zoom.
|
||||||
export const PROFILE_SLOT_COUNT = 18
|
export const PROFILE_SLOT_COUNT = 18
|
||||||
|
|
||||||
function comboForSlot(slot: number): string {
|
const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => {
|
||||||
return slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}`
|
const slot = i+1
|
||||||
}
|
const combo = (slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}`) as Combo
|
||||||
|
|
||||||
const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => ({
|
return ({
|
||||||
id: `profile.switch.${i + 1}`,
|
id: `profile.switch.${i + 1}`,
|
||||||
category: 'profiles' as const,
|
category: 'profiles' as const,
|
||||||
defaults: [comboForSlot(i + 1)]
|
defaults: [combo]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ⌘` on macOS / Ctrl+` elsewhere (the `~` key), plus the Shift/tilde variant.
|
||||||
|
// `mod` keeps one binding cross-platform; on macOS this shadows the system
|
||||||
|
// window-cycler, which is fine for a single-window app.
|
||||||
|
const TERMINAL_TOGGLE_DEFAULTS = ['mod+`', 'mod+shift+`']
|
||||||
|
|
||||||
|
// Positional jumps — ^1…^9, mirroring profiles' ⌘1…⌘9.
|
||||||
|
export const SESSION_SLOT_COUNT = 9
|
||||||
|
|
||||||
|
const SESSION_SLOT_ACTIONS: KeybindActionMeta[] = Array.from({ length: SESSION_SLOT_COUNT }, (_, i) => ({
|
||||||
|
id: `session.slot.${i + 1}`,
|
||||||
|
category: 'session' as const,
|
||||||
|
defaults: [`ctrl+${i + 1}`]
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
|
export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
|
||||||
@@ -58,8 +70,11 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
|
|||||||
|
|
||||||
// ── Session ──────────────────────────────────────────────────────────────
|
// ── Session ──────────────────────────────────────────────────────────────
|
||||||
{ id: 'session.new', category: 'session', defaults: ['mod+n', 'shift+n'] },
|
{ id: 'session.new', category: 'session', defaults: ['mod+n', 'shift+n'] },
|
||||||
{ id: 'session.next', category: 'session', defaults: [] },
|
// ⌃Tab / ⌃⇧Tab — the universal tab-cycle chord. Literally Control, not Cmd
|
||||||
{ id: 'session.prev', category: 'session', defaults: [] },
|
// (macOS reserves Cmd+Tab for app switching); see `ctrl` in combo.ts.
|
||||||
|
{ id: 'session.next', category: 'session', defaults: ['ctrl+tab'] },
|
||||||
|
{ id: 'session.prev', category: 'session', defaults: ['ctrl+shift+tab'] },
|
||||||
|
...SESSION_SLOT_ACTIONS,
|
||||||
{ id: 'session.focusSearch', category: 'session', defaults: ['mod+shift+f'] },
|
{ id: 'session.focusSearch', category: 'session', defaults: ['mod+shift+f'] },
|
||||||
{ id: 'session.togglePin', category: 'session', defaults: [] },
|
{ id: 'session.togglePin', category: 'session', defaults: [] },
|
||||||
|
|
||||||
@@ -78,7 +93,7 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
|
|||||||
{ id: 'view.toggleSidebar', category: 'view', defaults: ['mod+b'] },
|
{ id: 'view.toggleSidebar', category: 'view', defaults: ['mod+b'] },
|
||||||
{ id: 'view.toggleRightSidebar', category: 'view', defaults: ['mod+j'] },
|
{ id: 'view.toggleRightSidebar', category: 'view', defaults: ['mod+j'] },
|
||||||
{ id: 'view.showFiles', category: 'view', defaults: [] },
|
{ id: 'view.showFiles', category: 'view', defaults: [] },
|
||||||
{ id: 'view.showTerminal', category: 'view', defaults: [] },
|
{ id: 'view.showTerminal', category: 'view', defaults: TERMINAL_TOGGLE_DEFAULTS },
|
||||||
// ⌘\ — the backslash reads like a mirror line flipping the layout.
|
// ⌘\ — the backslash reads like a mirror line flipping the layout.
|
||||||
{ id: 'view.flipPanes', category: 'view', defaults: ['mod+\\'] },
|
{ id: 'view.flipPanes', category: 'view', defaults: ['mod+\\'] },
|
||||||
{ id: 'appearance.toggleMode', category: 'view', defaults: ['shift+x'] },
|
{ id: 'appearance.toggleMode', category: 'view', defaults: ['shift+x'] },
|
||||||
@@ -93,10 +108,12 @@ export function keybindAction(id: string): KeybindActionMeta | undefined {
|
|||||||
return ACTION_BY_ID.get(id)
|
return ACTION_BY_ID.get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type KeybindBindings = Record<string, string[]>
|
export type KeybindBindings = Record<string, Combo[]>
|
||||||
|
|
||||||
export function defaultBindings(): KeybindBindings {
|
export function defaultBindings(): KeybindBindings {
|
||||||
return Object.fromEntries(KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults]]))
|
return Object.fromEntries<string, Combo[]>(
|
||||||
|
KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults] as Combo[]])
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fixed, non-rebindable shortcuts surfaced read-only in the panel so the map is
|
// Fixed, non-rebindable shortcuts surfaced read-only in the panel so the map is
|
||||||
@@ -106,7 +123,7 @@ export function defaultBindings(): KeybindBindings {
|
|||||||
export interface KeybindReadonly {
|
export interface KeybindReadonly {
|
||||||
id: string
|
id: string
|
||||||
category: KeybindCategory
|
category: KeybindCategory
|
||||||
keys: readonly string[]
|
keys: readonly FakeCombo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
|
export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
|
||||||
|
|||||||
86
apps/desktop/src/lib/keybinds/combo.test.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// `IS_MAC` is resolved once at module load from `navigator`, so each platform
|
||||||
|
// case overrides the platform and re-imports the module fresh.
|
||||||
|
async function loadCombo(platform: string) {
|
||||||
|
Object.defineProperty(window.navigator, 'platform', { value: platform, configurable: true })
|
||||||
|
vi.resetModules()
|
||||||
|
|
||||||
|
return import('./combo')
|
||||||
|
}
|
||||||
|
|
||||||
|
function keydown(init: KeyboardEventInit): KeyboardEvent {
|
||||||
|
return new KeyboardEvent('keydown', init)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('comboFromEvent — ctrl as a distinct modifier on macOS', () => {
|
||||||
|
it('reports Control+Tab as "ctrl+tab" on macOS (not Cmd)', async () => {
|
||||||
|
const { comboFromEvent } = await loadCombo('MacIntel')
|
||||||
|
|
||||||
|
expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true }))).toBe('ctrl+tab')
|
||||||
|
expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true, shiftKey: true }))).toBe('ctrl+shift+tab')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps Cmd as "mod" and distinct from Control on macOS', async () => {
|
||||||
|
const { comboFromEvent } = await loadCombo('MacIntel')
|
||||||
|
|
||||||
|
expect(comboFromEvent(keydown({ code: 'KeyK', metaKey: true }))).toBe('mod+k')
|
||||||
|
expect(comboFromEvent(keydown({ code: 'KeyK', ctrlKey: true }))).toBe('ctrl+k')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats Control as the "mod" accelerator off macOS', async () => {
|
||||||
|
const { comboFromEvent } = await loadCombo('Win32')
|
||||||
|
|
||||||
|
expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true }))).toBe('mod+tab')
|
||||||
|
expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true, shiftKey: true }))).toBe('mod+shift+tab')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('canonicalizeCombo', () => {
|
||||||
|
it('leaves "ctrl+…" untouched on macOS', async () => {
|
||||||
|
const { canonicalizeCombo } = await loadCombo('MacIntel')
|
||||||
|
|
||||||
|
expect(canonicalizeCombo('ctrl+tab')).toBe('ctrl+tab')
|
||||||
|
expect(canonicalizeCombo('ctrl+shift+tab')).toBe('ctrl+shift+tab')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('folds "ctrl+…" to "mod+…" off macOS so a real Control press resolves', async () => {
|
||||||
|
const { canonicalizeCombo } = await loadCombo('Win32')
|
||||||
|
|
||||||
|
expect(canonicalizeCombo('ctrl+tab')).toBe('mod+tab')
|
||||||
|
expect(canonicalizeCombo('ctrl+shift+tab')).toBe('mod+shift+tab')
|
||||||
|
// Non-ctrl combos are unchanged.
|
||||||
|
expect(canonicalizeCombo('mod+k')).toBe('mod+k')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatCombo — honest Control labels', () => {
|
||||||
|
it('renders the Control glyph on macOS', async () => {
|
||||||
|
const { formatCombo } = await loadCombo('MacIntel')
|
||||||
|
|
||||||
|
expect(formatCombo('ctrl+tab')).toBe('⌃⇥')
|
||||||
|
expect(formatCombo('ctrl+shift+tab')).toBe('⌃⇧⇥')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders "Ctrl+…" off macOS (base key keeps its glyph)', async () => {
|
||||||
|
const { formatCombo } = await loadCombo('Win32')
|
||||||
|
|
||||||
|
expect(formatCombo('ctrl+tab')).toBe('Ctrl+⇥')
|
||||||
|
expect(formatCombo('ctrl+shift+tab')).toBe('Ctrl+Shift+⇥')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('comboAllowedInInput', () => {
|
||||||
|
it('lets ctrl combos fire while typing (e.g. ⌃Tab from the composer)', async () => {
|
||||||
|
const { comboAllowedInInput } = await loadCombo('MacIntel')
|
||||||
|
|
||||||
|
expect(comboAllowedInInput('ctrl+tab')).toBe(true)
|
||||||
|
expect(comboAllowedInInput('ctrl+shift+tab')).toBe(true)
|
||||||
|
expect(comboAllowedInInput('mod+k')).toBe(true)
|
||||||
|
expect(comboAllowedInInput('shift+x')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||