Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44ef0150ab | ||
|
|
f019a9c491 | ||
|
|
46ea0a184d | ||
|
|
49f1b9e4b4 | ||
|
|
c77c470d27 | ||
|
|
e114b31eda | ||
|
|
fd1ec8033d | ||
|
|
28f1590b7a | ||
|
|
ada04573a9 | ||
|
|
a23728dfcc | ||
|
|
9b43ab8de5 | ||
|
|
188e52db91 | ||
|
|
5005b79bc3 | ||
|
|
d0ea4caf7f | ||
|
|
6a2909fe5a | ||
|
|
9272e4019a | ||
|
|
feb50eee70 | ||
|
|
e0a999aa8a | ||
|
|
55a76ec669 | ||
|
|
d9f7e7ac81 | ||
|
|
e618cbee44 | ||
|
|
2f0ee66467 | ||
|
|
cbc1d901ba | ||
|
|
84eb5f1f89 | ||
|
|
e5472da584 | ||
|
|
3ab783a7bb | ||
|
|
06aa140fa1 | ||
|
|
dd28f2ac9c | ||
|
|
9bdf01852a | ||
|
|
a92cbcac45 | ||
|
|
e67ab2e042 | ||
|
|
b6da66c5be | ||
|
|
dfba3f3e51 | ||
|
|
b28dd3417d | ||
|
|
918aef267b | ||
|
|
205ed71ba0 | ||
|
|
d6b0c23f87 | ||
|
|
7d0246ab57 | ||
|
|
ae5b2de2fa | ||
|
|
1e047677a5 | ||
|
|
6ed9a2de8f | ||
|
|
54343bcade | ||
|
|
b6945ce772 | ||
|
|
591c329f15 | ||
|
|
afec339e96 | ||
|
|
d704df2d6e | ||
|
|
39933f758b | ||
|
|
21e172b94a | ||
|
|
46e513ef51 | ||
|
|
1daecfa4b0 | ||
|
|
4a626ed187 | ||
|
|
4df280d511 | ||
|
|
a51a7b9b92 | ||
|
|
115671ae6b | ||
|
|
01eaba7061 | ||
|
|
7982560845 | ||
|
|
4b06c98fe4 | ||
|
|
ab2472e692 | ||
|
|
7466182179 | ||
|
|
ea4fe15631 | ||
|
|
bb1c8b6f1a | ||
|
|
082025abcd | ||
|
|
30a7a94120 | ||
|
|
123b945731 | ||
|
|
cbc82511ea | ||
|
|
a13db76eaa | ||
|
|
33807e2b14 | ||
|
|
a429a2a0bf | ||
|
|
d963ad56c1 | ||
|
|
3be9fb7317 | ||
|
|
63e824831c | ||
|
|
dd5e97bd7f | ||
|
|
c47b9d126f | ||
|
|
ac76bbe21f | ||
|
|
31c40c72c0 | ||
|
|
79bfddd37c | ||
|
|
c2050183a5 | ||
|
|
b34ee80741 | ||
|
|
bb0619dbce |
@@ -1,5 +1,5 @@
|
||||
watch_file pyproject.toml uv.lock
|
||||
watch_file ui-tui/package-lock.json ui-tui/package.json
|
||||
watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
|
||||
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix
|
||||
|
||||
use flake
|
||||
|
||||
@@ -4,10 +4,10 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'ui-tui/package-lock.json'
|
||||
- 'package-lock.json'
|
||||
- 'package.json'
|
||||
- 'ui-tui/package.json'
|
||||
- 'apps/dashboard/package-lock.json'
|
||||
- 'apps/dashboard/package.json'
|
||||
- 'apps/desktop/package.json'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
@@ -27,9 +27,9 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
# ── Auto-fix on main ───────────────────────────────────────────────
|
||||
# Fires when a push to main touches package.json or package-lock.json
|
||||
# in ui-tui/ or apps/dashboard/. Runs fix-lockfiles and pushes the hash
|
||||
# update commit directly to main so Nix builds never stay broken.
|
||||
# Fires when a push to main touches package.json or package-lock.json.
|
||||
# Runs fix-lockfiles and pushes the hash update commit directly to main
|
||||
# so Nix builds never stay broken.
|
||||
#
|
||||
# Safety invariants:
|
||||
# 1. The fix commit only touches nix/*.nix files, which are NOT in
|
||||
@@ -109,8 +109,8 @@ jobs:
|
||||
# our computed hashes are stale. Abort and let the next triggered
|
||||
# run recompute from the correct package-lock state.
|
||||
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
|
||||
'ui-tui/package-lock.json' 'ui-tui/package.json' \
|
||||
'apps/dashboard/package-lock.json' 'apps/dashboard/package.json' || true)"
|
||||
'package-lock.json' 'package.json' \
|
||||
'ui-tui/package.json' 'apps/desktop/package.json' || true)"
|
||||
if [ -n "$pkg_changed" ]; then
|
||||
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
|
||||
exit 0
|
||||
|
||||
@@ -37,23 +37,16 @@ jobs:
|
||||
|
||||
- name: Check flake
|
||||
id: flake
|
||||
if: runner.os == 'Linux'
|
||||
continue-on-error: true
|
||||
run: nix flake check --print-build-logs
|
||||
|
||||
- name: Build package
|
||||
id: build
|
||||
if: runner.os == 'Linux'
|
||||
continue-on-error: true
|
||||
run: nix build --print-build-logs
|
||||
|
||||
# When the real Nix build fails, run a targeted diagnostic to see if
|
||||
# When the flake check fails, run a targeted diagnostic to see if
|
||||
# the failure is specifically a stale npm lockfile hash in one of the
|
||||
# known npm subpackages (tui / web). This avoids surfacing a generic
|
||||
# "build failed" message when the fix is a single known command.
|
||||
- name: Diagnose npm lockfile hashes
|
||||
id: hash_check
|
||||
if: (steps.flake.outcome == 'failure' || steps.build.outcome == 'failure') && runner.os == 'Linux'
|
||||
if: steps.flake.outcome == 'failure' && runner.os == 'Linux'
|
||||
continue-on-error: true
|
||||
env:
|
||||
LINK_SHA: ${{ steps.sha.outputs.full }}
|
||||
@@ -88,30 +81,25 @@ jobs:
|
||||
- Or [run the Nix Lockfile Fix workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/nix-lockfile-fix.yml) manually (pass PR `#${{ github.event.pull_request.number }}`)
|
||||
- Or locally: `nix run .#fix-lockfiles` and commit the diff
|
||||
|
||||
# Clear the sticky comment when either the build passed outright (no
|
||||
# Clear the sticky comment when either the flake check passed outright (no
|
||||
# hash check needed) or the hash check explicitly returned stale=false
|
||||
# (build failed for a non-hash reason).
|
||||
# (check failed for a non-hash reason).
|
||||
- name: Clear sticky PR comment (resolved)
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
runner.os == 'Linux' &&
|
||||
(steps.hash_check.outputs.stale == 'false' ||
|
||||
(steps.flake.outcome == 'success' && steps.build.outcome == 'success'))
|
||||
steps.flake.outcome == 'success')
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
delete: true
|
||||
|
||||
- name: Final fail if build or flake failed
|
||||
if: steps.flake.outcome == 'failure' || steps.build.outcome == 'failure'
|
||||
- name: Final fail if flake check failed
|
||||
if: steps.flake.outcome == 'failure'
|
||||
run: |
|
||||
if [ "${{ steps.hash_check.outputs.stale }}" == "true" ]; then
|
||||
echo "::error::Nix build failed due to stale npm lockfile hash. Run: nix run .#fix-lockfiles"
|
||||
else
|
||||
echo "::error::Nix build/flake check failed. See logs above."
|
||||
echo "::error::Nix flake check failed. See logs above."
|
||||
fi
|
||||
exit 1
|
||||
|
||||
- name: Evaluate flake (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: nix flake show --json > /dev/null
|
||||
|
||||
@@ -28,7 +28,6 @@ on:
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'ui-tui/package.json'
|
||||
- 'ui-tui/package-lock.json'
|
||||
- 'website/package.json'
|
||||
- 'website/package-lock.json'
|
||||
- '.github/workflows/osv-scanner.yml'
|
||||
@@ -39,7 +38,6 @@ on:
|
||||
- 'pyproject.toml'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'ui-tui/package-lock.json'
|
||||
- 'website/package-lock.json'
|
||||
schedule:
|
||||
# Weekly scan against main — catches CVEs published after merge for
|
||||
@@ -62,6 +60,6 @@ jobs:
|
||||
# the three sources of truth and skip vendored / test / worktree dirs.
|
||||
scan-args: |-
|
||||
--lockfile=uv.lock
|
||||
--lockfile=ui-tui/package-lock.json
|
||||
--lockfile=package-lock.json
|
||||
--lockfile=website/package-lock.json
|
||||
fail-on-vuln: false
|
||||
|
||||
+19
-4
@@ -113,8 +113,8 @@ WORKDIR /opt/hermes
|
||||
# ui-tui/package.json. Copying the tree up front lets npm resolve the
|
||||
# workspace to real content instead of stopping at a bare package.json.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY web/package.json web/package-lock.json web/
|
||||
COPY ui-tui/package.json ui-tui/package-lock.json ui-tui/
|
||||
COPY web/package.json web/
|
||||
COPY ui-tui/package.json ui-tui/
|
||||
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
|
||||
|
||||
# `npm_config_install_links=false` forces npm to install `file:` deps as
|
||||
@@ -131,8 +131,6 @@ ENV npm_config_install_links=false
|
||||
|
||||
RUN npm install --prefer-offline --no-audit && \
|
||||
npx playwright install --with-deps chromium --only-shell && \
|
||||
(cd web && npm install --prefer-offline --no-audit) && \
|
||||
(cd ui-tui && npm install --prefer-offline --no-audit) && \
|
||||
npm cache clean --force
|
||||
|
||||
# ---------- Layer-cached Python dependency install ----------
|
||||
@@ -245,6 +243,23 @@ COPY --chmod=0755 docker/cont-init.d/02-reconcile-profiles /etc/cont-init.d/02-r
|
||||
|
||||
# ---------- Runtime ----------
|
||||
ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
|
||||
# Point the TUI launcher at the prebuilt bundle baked at build time (Layer 8:
|
||||
# `ui-tui && npm run build`). This makes _make_tui_argv take the prebuilt-bundle
|
||||
# fast path (`node --expose-gc /opt/hermes/ui-tui/dist/entry.js`) and skip the
|
||||
# _tui_need_npm_install / runtime `npm install` branch entirely — exactly the
|
||||
# nix/packaged-release path the launcher was designed for.
|
||||
#
|
||||
# Why this is required (not just an optimization): the root package-lock.json
|
||||
# describes the WHOLE monorepo workspace set (root + web + ui-tui + apps/*),
|
||||
# but the image only installs root/web/ui-tui (apps/* — the desktop app — is
|
||||
# never `npm install`ed here). So the actualized node_modules permanently
|
||||
# disagrees with the canonical lock, _tui_need_npm_install() returns True on
|
||||
# every launch, and the runtime `npm install` it triggers (a) can never
|
||||
# converge against the partial monorepo and (b) races itself across concurrent
|
||||
# embedded-chat (/api/pty) connections → ENOTEMPTY → the chat tab dies with a
|
||||
# 502 / "[session ended]". Pointing at the prebuilt bundle sidesteps the whole
|
||||
# check. (A separate launcher hardening is tracked independently.)
|
||||
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
|
||||
ENV HERMES_HOME=/opt/data
|
||||
|
||||
# `docker exec` privilege-drop shim. When operators run
|
||||
|
||||
@@ -1621,6 +1621,47 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
)
|
||||
|
||||
|
||||
def _refresh_nous_recommended_model(
|
||||
*, vision: bool, stale_model: Optional[str]
|
||||
) -> Optional[str]:
|
||||
"""Re-fetch the Nous Portal's recommended model after a stale-model 404.
|
||||
|
||||
Long-lived processes (gateway, watchers) cache the Portal's
|
||||
``recommended-models`` payload for 10 minutes and, in practice, can pin a
|
||||
model for the whole process lifetime. When that model is later dropped from
|
||||
the Nous → OpenRouter catalog, every auxiliary call 404s with
|
||||
"model does not exist". This forces a fresh Portal fetch and returns a
|
||||
model name to retry with:
|
||||
|
||||
* the Portal's current recommendation for the task, if it differs from
|
||||
the model that just failed; otherwise
|
||||
* ``_NOUS_MODEL`` (google/gemini-3-flash-preview), the known-good default,
|
||||
if it too differs from the failed model.
|
||||
|
||||
Returns ``None`` when no usable alternative is available (e.g. the Portal
|
||||
still recommends the exact model that just 404'd and the default also
|
||||
matches it) — callers should then let the original error propagate.
|
||||
"""
|
||||
stale = (stale_model or "").strip().lower()
|
||||
fresh: Optional[str] = None
|
||||
try:
|
||||
from hermes_cli.models import get_nous_recommended_aux_model
|
||||
|
||||
fresh = get_nous_recommended_aux_model(vision=vision, force_refresh=True)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Nous recommended-model refresh failed (%s); using default %s",
|
||||
exc, _NOUS_MODEL,
|
||||
)
|
||||
if fresh and fresh.strip().lower() != stale:
|
||||
return fresh
|
||||
# Portal recommendation unchanged or unavailable — fall back to the
|
||||
# hardcoded known-good default, but only if it's actually different.
|
||||
if _NOUS_MODEL.strip().lower() != stale:
|
||||
return _NOUS_MODEL
|
||||
return None
|
||||
|
||||
|
||||
def _read_main_model() -> str:
|
||||
"""Read the user's configured main model from config.yaml.
|
||||
|
||||
@@ -2451,6 +2492,46 @@ def _is_unsupported_temperature_error(exc: Exception) -> bool:
|
||||
return _is_unsupported_parameter_error(exc, "temperature")
|
||||
|
||||
|
||||
def _is_model_not_found_error(exc: Exception) -> bool:
|
||||
"""Detect "the requested model doesn't exist" errors (404 / invalid model).
|
||||
|
||||
This fires when a resolved model name is no longer served by the endpoint
|
||||
— most commonly when a long-lived process pinned a Portal-recommended model
|
||||
that has since been dropped from the Nous → OpenRouter catalog. The Nous
|
||||
proxy returns 404 with a body like::
|
||||
|
||||
Model 'gpt-5.4-mini' not found. The requested model does not exist
|
||||
in our configuration or OpenRouter catalog.
|
||||
|
||||
Distinct from :func:`_is_payment_error` (which also matches some 404s for
|
||||
free-tier/credit language) — this one keys on "does not exist / not found /
|
||||
not a valid model" phrasing, and explicitly excludes the billing keywords
|
||||
that the payment path already owns so the two predicates don't overlap.
|
||||
"""
|
||||
status = getattr(exc, "status_code", None)
|
||||
err_lower = str(exc).lower()
|
||||
# Billing/quota 404s belong to _is_payment_error — don't claim them here.
|
||||
if any(kw in err_lower for kw in (
|
||||
"credits", "insufficient funds", "billing", "out of funds",
|
||||
"balance_depleted", "no usable credits", "free tier", "free-tier",
|
||||
"not available on the free tier",
|
||||
)):
|
||||
return False
|
||||
if status not in {404, 400, None}:
|
||||
return False
|
||||
return any(kw in err_lower for kw in (
|
||||
"model does not exist",
|
||||
"does not exist in our configuration",
|
||||
"openrouter catalog",
|
||||
"is not a valid model",
|
||||
"no such model",
|
||||
"model not found",
|
||||
"the model `", # OpenAI-style: "The model `X` does not exist"
|
||||
"model_not_found",
|
||||
"unknown model",
|
||||
))
|
||||
|
||||
|
||||
def _evict_cached_clients(provider: str) -> None:
|
||||
"""Drop cached auxiliary clients for a provider so fresh creds are used."""
|
||||
normalized = _normalize_aux_provider(provider)
|
||||
@@ -5027,6 +5108,32 @@ def call_llm(
|
||||
raise
|
||||
first_err = retry_err
|
||||
|
||||
# ── Stale-model self-heal (Nous Portal recommendation drift) ───
|
||||
# A long-lived process can pin a Portal-recommended model that has
|
||||
# since been dropped from the Nous → OpenRouter catalog, so every
|
||||
# auxiliary call 404s with "model does not exist". Force a fresh
|
||||
# Portal fetch and retry once with the current recommendation (or the
|
||||
# known-good default). Only applies to Nous-routed calls.
|
||||
_heal_is_nous = (
|
||||
resolved_provider == "nous"
|
||||
or base_url_host_matches(_base_info, "inference-api.nousresearch.com")
|
||||
)
|
||||
if _is_model_not_found_error(first_err) and _heal_is_nous:
|
||||
healed_model = _refresh_nous_recommended_model(
|
||||
vision=(task == "vision"), stale_model=kwargs.get("model"))
|
||||
if healed_model and healed_model != kwargs.get("model"):
|
||||
logger.warning(
|
||||
"Auxiliary %s: model %r no longer in Nous catalog; "
|
||||
"retrying with refreshed recommendation %r",
|
||||
task or "call", kwargs.get("model"), healed_model,
|
||||
)
|
||||
kwargs["model"] = healed_model
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
client.chat.completions.create(**kwargs), task)
|
||||
except Exception as retry_err:
|
||||
first_err = retry_err
|
||||
|
||||
# ── Nous auth refresh parity with main agent ──────────────────
|
||||
client_is_nous = (
|
||||
resolved_provider == "nous"
|
||||
@@ -5464,6 +5571,31 @@ async def async_call_llm(
|
||||
raise
|
||||
first_err = retry_err
|
||||
|
||||
# ── Stale-model self-heal (Nous Portal recommendation drift) ───
|
||||
# See the sync call_llm() path for the rationale: a long-lived process
|
||||
# can pin a Portal-recommended model that has since been dropped from
|
||||
# the Nous → OpenRouter catalog, 404'ing every auxiliary call. Force a
|
||||
# fresh Portal fetch and retry once with the current recommendation.
|
||||
_heal_is_nous = (
|
||||
resolved_provider == "nous"
|
||||
or base_url_host_matches(_client_base, "inference-api.nousresearch.com")
|
||||
)
|
||||
if _is_model_not_found_error(first_err) and _heal_is_nous:
|
||||
healed_model = _refresh_nous_recommended_model(
|
||||
vision=(task == "vision"), stale_model=kwargs.get("model"))
|
||||
if healed_model and healed_model != kwargs.get("model"):
|
||||
logger.warning(
|
||||
"Auxiliary %s (async): model %r no longer in Nous catalog; "
|
||||
"retrying with refreshed recommendation %r",
|
||||
task or "call", kwargs.get("model"), healed_model,
|
||||
)
|
||||
kwargs["model"] = healed_model
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await client.chat.completions.create(**kwargs), task)
|
||||
except Exception as retry_err:
|
||||
first_err = retry_err
|
||||
|
||||
# ── Nous auth refresh parity with main agent ──────────────────
|
||||
client_is_nous = (
|
||||
resolved_provider == "nous"
|
||||
|
||||
@@ -1891,6 +1891,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
# via `hermes auth openai-codex`.
|
||||
if isinstance(tokens, dict) and tokens.get("access_token"):
|
||||
active_sources.add("device_code")
|
||||
custom_label = str(state.get("label") or "").strip()
|
||||
changed |= _upsert_entry(
|
||||
entries,
|
||||
provider,
|
||||
@@ -1902,7 +1903,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
"refresh_token": tokens.get("refresh_token"),
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
"last_refresh": state.get("last_refresh"),
|
||||
"label": label_from_token(tokens.get("access_token", ""), "device_code"),
|
||||
"label": custom_label or label_from_token(tokens.get("access_token", ""), "device_code"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+34
-5
@@ -6,16 +6,42 @@ gateway/cron startup). The local-CLI backend deliberately leaves it unset and
|
||||
relies on the launch dir. Reading it in one place keeps the system prompt, the
|
||||
tool surfaces, and context-file discovery agreeing on where the agent lives.
|
||||
|
||||
The #29531 per-session extension point is this function: a future PR adds a
|
||||
contextvar arm inside `resolve_agent_cwd` and `.set()`s it at the
|
||||
`set_session_vars` seam — by design, not a reopening hazard.
|
||||
Multi-session gateways can pin a logical cwd via the `_SESSION_CWD`
|
||||
contextvar; CLI/cron fall through to `TERMINAL_CWD`/launch cwd.
|
||||
"""
|
||||
|
||||
import os
|
||||
from contextvars import ContextVar, Token
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_UNSET: Any = object()
|
||||
|
||||
_SESSION_CWD: ContextVar = ContextVar("HERMES_SESSION_CWD", default=_UNSET)
|
||||
|
||||
|
||||
def set_session_cwd(cwd: str | None) -> Token:
|
||||
"""Pin the logical cwd for the current context."""
|
||||
return _SESSION_CWD.set((cwd or "").strip())
|
||||
|
||||
|
||||
def clear_session_cwd() -> None:
|
||||
_SESSION_CWD.set("")
|
||||
|
||||
|
||||
def _session_cwd_override() -> str:
|
||||
value = _SESSION_CWD.get()
|
||||
if value is _UNSET:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def resolve_agent_cwd() -> Path:
|
||||
override = _session_cwd_override()
|
||||
if override:
|
||||
p = Path(override).expanduser()
|
||||
if p.is_dir():
|
||||
return p
|
||||
raw = os.environ.get("TERMINAL_CWD", "").strip()
|
||||
if raw:
|
||||
p = Path(raw).expanduser()
|
||||
@@ -27,7 +53,10 @@ def resolve_agent_cwd() -> Path:
|
||||
def resolve_context_cwd() -> Path | None:
|
||||
# None means "no configured cwd": build_context_files_prompt then falls back
|
||||
# to the launch dir (os.getcwd()) — correct for the local CLI. The gateway
|
||||
# avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py).
|
||||
# No getcwd arm here: that fallback is owned by the caller, not this resolver.
|
||||
# avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py)
|
||||
# or, per session, the _SESSION_CWD contextvar above.
|
||||
override = _session_cwd_override()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
raw = os.environ.get("TERMINAL_CWD", "").strip()
|
||||
return Path(raw).expanduser() if raw else None
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hermes Setup</title>
|
||||
<title>Hermes</title>
|
||||
</head>
|
||||
<body class="h-full antialiased">
|
||||
<div id="root" class="h-full"></div>
|
||||
|
||||
@@ -208,7 +208,7 @@ pub async fn launch_hermes_desktop(
|
||||
/// Walks the well-known electron-builder unpacked-app paths under
|
||||
/// `install_root`. Mirrors the resolver in `cmd_gui` (apps/desktop/release/
|
||||
/// <os>-unpacked/<exe>).
|
||||
fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option<PathBuf> {
|
||||
pub(crate) fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option<PathBuf> {
|
||||
let release_dir = install_root.join("apps").join("desktop").join("release");
|
||||
let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") {
|
||||
&[
|
||||
@@ -232,6 +232,35 @@ fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option<PathBuf>
|
||||
None
|
||||
}
|
||||
|
||||
/// True when a prior install completed (bootstrap-complete marker present) AND a
|
||||
/// launchable desktop app exists on disk. Used by the installer's launcher fast
|
||||
/// path so a bare re-open just opens Hermes instead of re-running setup.
|
||||
pub(crate) fn hermes_is_installed(install_root: &std::path::Path) -> bool {
|
||||
install_root.join(".hermes-bootstrap-complete").exists()
|
||||
&& resolve_hermes_desktop_exe(install_root).is_some()
|
||||
}
|
||||
|
||||
/// Spawn the already-built desktop app, detached. Returns Err if no built app
|
||||
/// exists or the spawn fails, so the caller can fall back to showing the
|
||||
/// installer UI.
|
||||
pub(crate) fn spawn_installed_desktop(install_root: &std::path::Path) -> std::io::Result<()> {
|
||||
let exe = resolve_hermes_desktop_exe(install_root).ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::NotFound, "no built Hermes desktop app")
|
||||
})?;
|
||||
let mut cmd = std::process::Command::new(&exe);
|
||||
cmd.current_dir(exe.parent().unwrap_or(install_root));
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// DETACHED_PROCESS = 0x00000008 — keep the desktop alive after the
|
||||
// installer exits, mirroring launch_hermes_desktop. Kept correct here
|
||||
// even though the only caller is macOS-gated today, so future reuse on
|
||||
// Windows doesn't reintroduce the relaunch race.
|
||||
cmd.creation_flags(0x0000_0008);
|
||||
}
|
||||
cmd.spawn().map(|_child| ())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bootstrap implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -50,6 +50,20 @@ impl AppMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when the args request a forced installer UI (repair/reinstall)
|
||||
/// via `--reinstall` or `--repair`, which overrides the macOS launcher
|
||||
/// fast-path so a broken install can be repaired. Arg-iterator generic so it's
|
||||
/// unit-testable, mirroring `AppMode::from_args`. Independent of mode selection:
|
||||
/// these flags never flip Install<->Update.
|
||||
pub fn force_setup_from_args<I, S>(args: I) -> bool
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
args.into_iter()
|
||||
.any(|a| a.as_ref() == "--reinstall" || a.as_ref() == "--repair")
|
||||
}
|
||||
|
||||
/// Process-wide install state, shared across Tauri commands.
|
||||
///
|
||||
/// The bootstrap is a one-shot, single-tenant process — we only need one
|
||||
@@ -85,7 +99,11 @@ pub fn run() {
|
||||
let _guard = paths::init_logging();
|
||||
|
||||
let mode = AppMode::from_args(std::env::args().skip(1));
|
||||
tracing::info!(?mode, "Hermes Setup starting");
|
||||
// Escape hatch: `--reinstall`/`--repair` forces the installer UI even when
|
||||
// Hermes is already installed, so users can re-run setup to repair a broken
|
||||
// install instead of the launcher fast path silently relaunching the app.
|
||||
let force_setup = force_setup_from_args(std::env::args().skip(1));
|
||||
tracing::info!(?mode, force_setup, "Hermes installer starting");
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
@@ -93,6 +111,60 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(Arc::new(AppState::new(mode)))
|
||||
.setup(move |app| {
|
||||
use tauri::Manager;
|
||||
// Launcher fast path (macOS only): a bare ("Install") launch when
|
||||
// Hermes is already installed should NOT show the installer or
|
||||
// rebuild — it should just open the app, so the /Applications
|
||||
// "Hermes" doubles as a normal launcher (first run installs, every
|
||||
// later run launches instantly). The window is kept hidden until
|
||||
// here via `"visible": false` so this path never flashes a window.
|
||||
//
|
||||
// Gated to macOS deliberately: on Windows/Linux the installer keeps
|
||||
// its existing behavior (Windows users relaunch via the Start
|
||||
// Menu/Desktop "Hermes" shortcuts that install.ps1 creates, and a
|
||||
// reliable detached relaunch there needs the DETACHED_PROCESS +
|
||||
// startup-grace handling used by launch_hermes_desktop — out of
|
||||
// scope here). So this is a pure no-op on non-macOS.
|
||||
//
|
||||
// `--reinstall`/`--repair` opts out so a broken install can be
|
||||
// repaired by re-running setup instead of launching the bad app.
|
||||
if cfg!(target_os = "macos") && mode == AppMode::Install && !force_setup {
|
||||
let install_root = paths::hermes_home().join("hermes-agent");
|
||||
if bootstrap::hermes_is_installed(&install_root) {
|
||||
match bootstrap::spawn_installed_desktop(&install_root) {
|
||||
Ok(()) => {
|
||||
// Brief grace so the spawned app is registered
|
||||
// before we exit (mirrors launch_hermes_desktop).
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
tracing::info!(
|
||||
"hermes already installed — relaunched desktop; exiting installer"
|
||||
);
|
||||
app.handle().exit(0);
|
||||
return Ok(());
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
?err,
|
||||
"relaunch of installed desktop failed; showing installer UI"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// First run / repair install, or Update mode: reveal the UI.
|
||||
match app.get_webview_window("main") {
|
||||
Some(win) => {
|
||||
if let Err(err) = win.show() {
|
||||
tracing::error!(?err, "failed to show main installer window");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::error!("main installer window not found; installer UI will not appear");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Mode (install vs update)
|
||||
get_mode,
|
||||
@@ -115,7 +187,7 @@ pub fn run() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AppMode;
|
||||
use super::{force_setup_from_args, AppMode};
|
||||
|
||||
#[test]
|
||||
fn bare_args_are_install() {
|
||||
@@ -131,4 +203,30 @@ mod tests {
|
||||
AppMode::Update
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reinstall_and_repair_flags_force_setup() {
|
||||
assert!(force_setup_from_args(["--reinstall"]));
|
||||
assert!(force_setup_from_args(["--repair"]));
|
||||
assert!(force_setup_from_args(["--foo", "--repair", "--bar"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_or_unrelated_args_do_not_force_setup() {
|
||||
assert!(!force_setup_from_args(Vec::<String>::new()));
|
||||
assert!(!force_setup_from_args(["--foo", "bar"]));
|
||||
// --update must not be mistaken for a force-setup flag.
|
||||
assert!(!force_setup_from_args(["--update"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_setup_flags_do_not_affect_mode_selection() {
|
||||
// The repair flags must never flip Install<->Update.
|
||||
assert_eq!(AppMode::from_args(["--reinstall"]), AppMode::Install);
|
||||
assert_eq!(AppMode::from_args(["--repair"]), AppMode::Install);
|
||||
assert_eq!(
|
||||
AppMode::from_args(["--update", "--reinstall"]),
|
||||
AppMode::Update
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Hermes Setup",
|
||||
"productName": "Hermes",
|
||||
"version": "0.0.1",
|
||||
"identifier": "com.nousresearch.hermes.setup",
|
||||
"build": {
|
||||
@@ -13,7 +13,7 @@
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Hermes Setup",
|
||||
"title": "Hermes",
|
||||
"width": 880,
|
||||
"height": 620,
|
||||
"minWidth": 720,
|
||||
@@ -22,7 +22,8 @@
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"center": true
|
||||
"center": true,
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -33,7 +34,7 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "DeveloperTool",
|
||||
"shortDescription": "Hermes Setup",
|
||||
"shortDescription": "Hermes",
|
||||
"longDescription": "Installs Hermes Agent on your machine. Drives scripts/install.ps1 (Windows) and scripts/install.sh (macOS/Linux).",
|
||||
"publisher": "Nous Research",
|
||||
"copyright": "Copyright © 2026 Nous Research",
|
||||
|
||||
+16
-3
@@ -111,15 +111,28 @@ npm run test:desktop:all
|
||||
|
||||
Boot logs land in `HERMES_HOME/logs/desktop.log` (includes backend output and recent Python tracebacks) — check it first if the app reports a boot failure.
|
||||
|
||||
**macOS / Linux:**
|
||||
|
||||
```bash
|
||||
# Force a clean first-launch setup
|
||||
rm "$HOME/.hermes/hermes-agent/.hermes-bootstrap-complete" # macOS/Linux
|
||||
rm "$HOME/.hermes/hermes-agent/.hermes-bootstrap-complete"
|
||||
# Rebuild a broken Python venv
|
||||
rm -rf "$HOME/.hermes/hermes-agent/venv" # macOS/Linux
|
||||
# Reset a stuck macOS microphone prompt
|
||||
rm -rf "$HOME/.hermes/hermes-agent/venv"
|
||||
# Reset a stuck macOS microphone prompt (macOS only)
|
||||
tccutil reset Microphone com.nousresearch.hermes
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
|
||||
```powershell
|
||||
# Force a clean first-launch setup
|
||||
Remove-Item "$env:LOCALAPPDATA\hermes\hermes-agent\.hermes-bootstrap-complete"
|
||||
# Rebuild a broken Python venv
|
||||
Remove-Item -Recurse -Force "$env:LOCALAPPDATA\hermes\hermes-agent\venv"
|
||||
```
|
||||
|
||||
> The default Hermes home on Windows is `%LOCALAPPDATA%\hermes`. Set the `HERMES_HOME` env var if you've relocated it.
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
|
||||
@@ -32,8 +32,58 @@ function bundledRuntimeImportCheck(platform = process.platform) {
|
||||
return platform === 'win32' ? 'import fastapi, uvicorn, winpty' : 'import fastapi, uvicorn, ptyprocess'
|
||||
}
|
||||
|
||||
const GPU_OVERRIDE_ON = new Set(['1', 'true', 'yes', 'on'])
|
||||
const GPU_OVERRIDE_OFF = new Set(['0', 'false', 'no', 'off'])
|
||||
|
||||
/**
|
||||
* Decide whether the app is being shown over a remote/forwarded display, where
|
||||
* Chromium's GPU compositor produces an unstable, flickering surface (it can't
|
||||
* present accelerated layers cleanly over the wire). Native local Windows/macOS
|
||||
* sessions composite locally and never hit this, so we only fall back to
|
||||
* software rendering when a remote display is detected.
|
||||
*
|
||||
* Returns a short reason string when GPU acceleration should be disabled, or
|
||||
* null to keep it enabled. `HERMES_DESKTOP_DISABLE_GPU` overrides detection
|
||||
* both ways (1/true/yes/on → always disable, 0/false/no/off → never disable).
|
||||
*
|
||||
* Pure + dependency-free so it can be unit-tested and called before app ready.
|
||||
*/
|
||||
function detectRemoteDisplay(options = {}) {
|
||||
const env = options.env ?? process.env
|
||||
const platform = options.platform ?? process.platform
|
||||
|
||||
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '').trim().toLowerCase()
|
||||
if (GPU_OVERRIDE_ON.has(override)) return 'override (HERMES_DESKTOP_DISABLE_GPU)'
|
||||
if (GPU_OVERRIDE_OFF.has(override)) return null
|
||||
|
||||
// Launched from an SSH session → the display is X11-forwarded or otherwise
|
||||
// remote. Covers the common `ssh user@box` + GUI-forwarding case.
|
||||
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return 'ssh-session'
|
||||
|
||||
if (platform === 'linux') {
|
||||
// X11 forwarding sets DISPLAY to "<host>:N" (e.g. "localhost:10.0"); a
|
||||
// local X server is ":0"/":1" with no host part before the colon.
|
||||
// NB: WSLg deliberately isn't treated as remote — it reports
|
||||
// GPU-accelerated vGPU surfaces locally and doesn't show the flicker.
|
||||
const display = String(env.DISPLAY || '')
|
||||
if (display.includes(':') && display.split(':')[0]) {
|
||||
return `x11-forwarding (DISPLAY=${display})`
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
// RDP sessions report SESSIONNAME like "RDP-Tcp#7"; the local console is
|
||||
// "Console".
|
||||
const sessionName = String(env.SESSIONNAME || '')
|
||||
if (/^rdp-/i.test(sessionName)) return `rdp (SESSIONNAME=${sessionName})`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
bundledRuntimeImportCheck,
|
||||
detectRemoteDisplay,
|
||||
isWindowsBinaryPathInWsl,
|
||||
isWslEnvironment
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@ const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
|
||||
const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const {
|
||||
bundledRuntimeImportCheck,
|
||||
detectRemoteDisplay,
|
||||
isWindowsBinaryPathInWsl,
|
||||
isWslEnvironment
|
||||
} = require('./bootstrap-platform.cjs')
|
||||
|
||||
test('isWslEnvironment detects WSL2 env vars on linux', () => {
|
||||
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
|
||||
@@ -28,6 +33,53 @@ test('bundledRuntimeImportCheck selects platform-specific import checks', () =>
|
||||
assert.equal(bundledRuntimeImportCheck('linux'), 'import fastapi, uvicorn, ptyprocess')
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay keeps GPU on for local sessions', () => {
|
||||
// Plain local X11, Wayland, native Windows, native macOS — no remote signal.
|
||||
assert.equal(detectRemoteDisplay({ env: { DISPLAY: ':0' }, platform: 'linux' }), null)
|
||||
assert.equal(detectRemoteDisplay({ env: { WAYLAND_DISPLAY: 'wayland-0' }, platform: 'linux' }), null)
|
||||
assert.equal(detectRemoteDisplay({ env: { SESSIONNAME: 'Console' }, platform: 'win32' }), null)
|
||||
assert.equal(detectRemoteDisplay({ env: {}, platform: 'darwin' }), null)
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay does not treat WSLg as remote', () => {
|
||||
// WSLg renders locally via vGPU and doesn't show the flicker, so a WSL
|
||||
// session with a local DISPLAY keeps hardware acceleration on.
|
||||
assert.equal(detectRemoteDisplay({ env: { WSL_DISTRO_NAME: 'Ubuntu', DISPLAY: ':0' }, platform: 'linux' }), null)
|
||||
assert.equal(detectRemoteDisplay({ env: { WSL_INTEROP: '/run/WSL/1_interop', DISPLAY: ':0' }, platform: 'linux' }), null)
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay flags SSH sessions on any platform', () => {
|
||||
assert.equal(detectRemoteDisplay({ env: { SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' }, platform: 'linux' }), 'ssh-session')
|
||||
assert.equal(detectRemoteDisplay({ env: { SSH_CLIENT: '1.2.3.4 5 22' }, platform: 'darwin' }), 'ssh-session')
|
||||
assert.equal(detectRemoteDisplay({ env: { SSH_TTY: '/dev/pts/0' }, platform: 'win32' }), 'ssh-session')
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay flags forwarded X11 displays but not local ones', () => {
|
||||
assert.match(String(detectRemoteDisplay({ env: { DISPLAY: 'localhost:10.0' }, platform: 'linux' })), /x11-forwarding/)
|
||||
assert.match(String(detectRemoteDisplay({ env: { DISPLAY: '192.168.1.5:0' }, platform: 'linux' })), /x11-forwarding/)
|
||||
assert.equal(detectRemoteDisplay({ env: { DISPLAY: ':1' }, platform: 'linux' }), null)
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay flags RDP sessions', () => {
|
||||
assert.match(String(detectRemoteDisplay({ env: { SESSIONNAME: 'RDP-Tcp#7' }, platform: 'win32' })), /^rdp/)
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay honors the HERMES_DESKTOP_DISABLE_GPU override both ways', () => {
|
||||
// Force-on even on a local display.
|
||||
assert.match(
|
||||
String(detectRemoteDisplay({ env: { HERMES_DESKTOP_DISABLE_GPU: '1', DISPLAY: ':0' }, platform: 'linux' })),
|
||||
/override/
|
||||
)
|
||||
// Force-off even over SSH (escape hatch when a remote display has working accel).
|
||||
assert.equal(
|
||||
detectRemoteDisplay({
|
||||
env: { HERMES_DESKTOP_DISABLE_GPU: 'false', SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' },
|
||||
platform: 'linux'
|
||||
}),
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('packaged electron entrypoints do not require unpackaged npm modules', () => {
|
||||
const electronDir = __dirname
|
||||
const entrypoints = ['main.cjs', 'preload.cjs', 'bootstrap-platform.cjs']
|
||||
|
||||
@@ -8,5 +8,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
+463
-11
@@ -23,7 +23,7 @@ const net = require('node:net')
|
||||
const path = require('node:path')
|
||||
const { fileURLToPath, pathToFileURL } = require('node:url')
|
||||
const { execFileSync, spawn } = require('node:child_process')
|
||||
const { isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
const {
|
||||
@@ -73,6 +73,26 @@ const IS_MAC = process.platform === 'darwin'
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
const IS_WSL = isWslEnvironment()
|
||||
const APP_ROOT = app.getAppPath()
|
||||
|
||||
// Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU
|
||||
// compositor flicker — accelerated layers can't be presented cleanly over the
|
||||
// wire, so the window flashes during scroll/streaming/animation. Local
|
||||
// Windows/macOS (and WSLg, which renders locally via vGPU) composite on the
|
||||
// GPU and never see it. Fall back to software rendering when a remote display
|
||||
// is detected; it's rock-steady over the wire and the CPU cost is negligible
|
||||
// next to the connection's latency. Must run before app `ready` — these
|
||||
// switches only apply pre-launch. Override with HERMES_DESKTOP_DISABLE_GPU
|
||||
// (1/true → always disable, 0/false → keep GPU on).
|
||||
const REMOTE_DISPLAY_REASON = detectRemoteDisplay()
|
||||
if (REMOTE_DISPLAY_REASON) {
|
||||
app.disableHardwareAcceleration()
|
||||
// Belt-and-suspenders for X11/VNC, where the Viz compositor can still glitch
|
||||
// with only --disable-gpu: force compositing onto the CPU too.
|
||||
app.commandLine.appendSwitch('disable-gpu-compositing')
|
||||
console.log(
|
||||
`[hermes] remote display detected (${REMOTE_DISPLAY_REASON}); disabling GPU hardware acceleration to prevent flicker`
|
||||
)
|
||||
}
|
||||
const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..')
|
||||
|
||||
// Build-time install stamp -- the git ref this .exe was built against.
|
||||
@@ -429,6 +449,13 @@ function registerMediaProtocol() {
|
||||
let mainWindow = null
|
||||
let hermesProcess = null
|
||||
let connectionPromise = null
|
||||
// Auto-reload budget for renderer crashes. A deterministic startup crash would
|
||||
// otherwise loop forever (reload → crash → reload), pinning CPU and spamming
|
||||
// logs. Allow a few reloads per rolling window, then stop and leave the dead
|
||||
// window so the user can read the error / quit.
|
||||
const RENDERER_RELOAD_WINDOW_MS = 60_000
|
||||
const RENDERER_RELOAD_MAX = 3
|
||||
let rendererReloadTimes = []
|
||||
// Latched bootstrap failure: when the first-launch install fails, we hold
|
||||
// onto the error so subsequent startHermes() calls (e.g. the renderer's
|
||||
// ensureGatewayOpen retrying after the WS won't open) return the same error
|
||||
@@ -438,6 +465,10 @@ let bootstrapFailure = null
|
||||
// Active first-launch install, so the renderer's Cancel button (and app quit)
|
||||
// can abort the in-flight install.sh/ps1 instead of leaving it running.
|
||||
let bootstrapAbortController = null
|
||||
// Set by the renderer's "Repair install" IPC. While true, resolution skips the
|
||||
// existing-install adopt branch (3b) so repair re-drives the installer instead
|
||||
// of re-adopting the install we're repairing. Cleared once a bootstrap runs.
|
||||
let forceBootstrapRepair = false
|
||||
let connectionConfigCache = null
|
||||
const hermesLog = []
|
||||
const previewWatchers = new Map()
|
||||
@@ -528,6 +559,39 @@ function openExternalUrl(rawUrl) {
|
||||
return false
|
||||
}
|
||||
|
||||
// `file://` URLs come from the artifacts panel (the renderer can't open
|
||||
// them itself because Chromium blocks file:// navigation from the app
|
||||
// origin). Hand them to `shell.openPath`, which dispatches to the OS
|
||||
// file association. If the OS can't open it (`error` is a non-empty
|
||||
// string), fall back to revealing the file in the system file manager.
|
||||
if (parsed.protocol === 'file:') {
|
||||
let localPath
|
||||
try {
|
||||
localPath = fileURLToPath(parsed.toString())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
void shell
|
||||
.openPath(localPath)
|
||||
.then(error => {
|
||||
if (!error) {
|
||||
return
|
||||
}
|
||||
|
||||
rememberLog(`[file] openPath failed: ${error}; revealing in folder instead`)
|
||||
|
||||
try {
|
||||
shell.showItemInFolder(localPath)
|
||||
} catch (revealError) {
|
||||
rememberLog(`[file] showItemInFolder failed: ${revealError.message}`)
|
||||
}
|
||||
})
|
||||
.catch(error => rememberLog(`[file] openPath rejected: ${error.message}`))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (!['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {
|
||||
return false
|
||||
}
|
||||
@@ -1466,8 +1530,12 @@ function readJson(filePath) {
|
||||
// Marker schema (version 1):
|
||||
// {
|
||||
// schemaVersion: 1,
|
||||
// pinnedCommit: "<40-char SHA>", // what install.ps1 was driven against
|
||||
// pinnedCommit: "<40-char SHA>" | null, // what install.ps1 was driven against;
|
||||
// // may be null for adopted installs
|
||||
// pinnedBranch: "<branch name>" | null,
|
||||
// adopted: <bool>, // true when we adopted a pre-existing
|
||||
// // install rather than bootstrapping it;
|
||||
// // treated as authoritative even sans commit
|
||||
// completedAt: "<ISO 8601>",
|
||||
// desktopVersion: "<app.getVersion()>" // for forensics
|
||||
// }
|
||||
@@ -1475,11 +1543,25 @@ function readBootstrapMarker() {
|
||||
return readJson(BOOTSTRAP_COMPLETE_MARKER)
|
||||
}
|
||||
|
||||
// Marker-independent: is the canonical install at ACTIVE_HERMES_ROOT actually
|
||||
// runnable right now? A complete CLI install (`install.sh --include-desktop`)
|
||||
// or a DMG launch over a prior CLI install satisfies this WITHOUT the desktop
|
||||
// ever having written the bootstrap marker -- so we must be able to recognise
|
||||
// "already installed" off the filesystem alone, not just the marker.
|
||||
function isActiveRuntimeUsable() {
|
||||
return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT))
|
||||
}
|
||||
|
||||
function isBootstrapComplete() {
|
||||
const marker = readBootstrapMarker()
|
||||
if (!marker || typeof marker !== 'object') return false
|
||||
if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) return false
|
||||
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) return false
|
||||
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) {
|
||||
// Adopted markers (an existing install we detected and took ownership of,
|
||||
// possibly without a resolvable commit) are still authoritative -- they
|
||||
// attest a runnable install we deliberately decided to forward to.
|
||||
if (marker.adopted !== true) return false
|
||||
}
|
||||
// We DELIBERATELY do NOT verify that the checkout is currently at the
|
||||
// pinned commit -- users update via the in-app update path or `hermes
|
||||
// update`, which moves HEAD legitimately. The marker just attests "we
|
||||
@@ -1487,7 +1569,22 @@ function isBootstrapComplete() {
|
||||
// a runnable venv: an interrupted or split-home install can leave the marker
|
||||
// + checkout without a venv, and trusting that spawns a dead backend
|
||||
// ("gateway offline") instead of re-running bootstrap to repair it.
|
||||
return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT))
|
||||
return isActiveRuntimeUsable()
|
||||
}
|
||||
|
||||
// HEAD commit of ACTIVE_HERMES_ROOT so an adopted marker carries the same
|
||||
// provenance a freshly-bootstrapped one would. null when git is unavailable or
|
||||
// the root isn't a checkout -- the marker stays valid via its `adopted` flag.
|
||||
function readActiveHeadCommit() {
|
||||
try {
|
||||
const sha = execFileSync(resolveGitBinary(), ['-C', ACTIVE_HERMES_ROOT, 'rev-parse', 'HEAD'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
}).trim()
|
||||
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeBootstrapMarker(payload) {
|
||||
@@ -1496,6 +1593,7 @@ function writeBootstrapMarker(payload) {
|
||||
schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION,
|
||||
pinnedCommit: payload.pinnedCommit || null,
|
||||
pinnedBranch: payload.pinnedBranch || null,
|
||||
adopted: Boolean(payload.adopted),
|
||||
completedAt: new Date().toISOString(),
|
||||
desktopVersion: app.getVersion()
|
||||
}
|
||||
@@ -1519,10 +1617,18 @@ function resolveRendererIndex() {
|
||||
}
|
||||
|
||||
function resolveHermesCwd() {
|
||||
// In a packaged build, `process.cwd()` resolves to the install root (e.g.
|
||||
// `…/win-unpacked` on Windows or `/Applications/Hermes.app/Contents/...`
|
||||
// on macOS). Sessions spawned there leave files inside the app bundle
|
||||
// and bewilder users when "where did my files go?" is the install dir.
|
||||
// The user-configurable default project directory wins over everything,
|
||||
// followed by env hints (only honored when packaged if they point at a
|
||||
// real directory), then the home dir.
|
||||
const candidates = [
|
||||
readDefaultProjectDir(),
|
||||
process.env.HERMES_DESKTOP_CWD,
|
||||
process.env.INIT_CWD,
|
||||
process.cwd(),
|
||||
IS_PACKAGED ? null : process.cwd(),
|
||||
!IS_PACKAGED ? SOURCE_REPO_ROOT : null,
|
||||
app.getPath('home')
|
||||
]
|
||||
@@ -1536,6 +1642,48 @@ function resolveHermesCwd() {
|
||||
return app.getPath('home')
|
||||
}
|
||||
|
||||
// Persisted "Default project directory" — surfaced as a setting in the
|
||||
// renderer (see app/settings/sessions-settings.tsx). Stored as JSON in
|
||||
// userData so it survives self-updates without bleeding into the new
|
||||
// install. `null` means "no preference, fall back to the usual chain".
|
||||
const DEFAULT_PROJECT_DIR_CONFIG_FILENAME = 'project-dir.json'
|
||||
|
||||
function defaultProjectDirConfigPath() {
|
||||
return path.join(app.getPath('userData'), DEFAULT_PROJECT_DIR_CONFIG_FILENAME)
|
||||
}
|
||||
|
||||
function readDefaultProjectDir() {
|
||||
try {
|
||||
const raw = fs.readFileSync(defaultProjectDirConfigPath(), 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
if (parsed && typeof parsed.dir === 'string' && parsed.dir.trim()) {
|
||||
const resolved = path.resolve(parsed.dir)
|
||||
|
||||
if (directoryExists(resolved)) {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Missing / unreadable / malformed → fall through to the rest of the
|
||||
// candidate chain.
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function writeDefaultProjectDir(dir) {
|
||||
const target = defaultProjectDirConfigPath()
|
||||
const payload = dir ? JSON.stringify({ dir: path.resolve(dir) }, null, 2) : JSON.stringify({}, null, 2)
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||
fs.writeFileSync(target, payload, 'utf8')
|
||||
} catch (error) {
|
||||
rememberLog(`[settings] write default project dir failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function createPythonBackend(root, label, dashboardArgs, options = {}) {
|
||||
const python = findPythonForRoot(root)
|
||||
if (!python) return null
|
||||
@@ -1603,6 +1751,24 @@ function resolveHermesBackend(dashboardArgs) {
|
||||
return createActiveBackend(dashboardArgs)
|
||||
}
|
||||
|
||||
// 3b. Existing-but-unmarked install at ACTIVE_HERMES_ROOT. The marker is
|
||||
// written only by OUR bootstrap, so a runtime from `install.sh
|
||||
// --include-desktop` (or a DMG launch over a prior CLI install) is
|
||||
// runnable yet markerless -- without this we'd fall to step 6 and re-run
|
||||
// the WHOLE install on top of a working one. ACTIVE_HERMES_ROOT is our
|
||||
// canonical location (unlike a random `hermes` on PATH), so adopt it:
|
||||
// stamp the marker once and forward straight to the app. Repair skips
|
||||
// this so a broken-but-present venv still gets rebuilt.
|
||||
if (!forceBootstrapRepair && isActiveRuntimeUsable()) {
|
||||
rememberLog(`[bootstrap] adopting existing install at ${ACTIVE_HERMES_ROOT}; skipping first-launch setup`)
|
||||
try {
|
||||
writeBootstrapMarker({ pinnedCommit: readActiveHeadCommit(), pinnedBranch: null, adopted: true })
|
||||
} catch (err) {
|
||||
rememberLog(`[bootstrap] could not stamp adopted marker: ${err.message}`)
|
||||
}
|
||||
return createActiveBackend(dashboardArgs)
|
||||
}
|
||||
|
||||
// 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from
|
||||
// a previous tool-only setup, or pip-installed system-wide. Use it but
|
||||
// do NOT write a bootstrap marker; the user did this themselves and we
|
||||
@@ -1793,6 +1959,9 @@ async function ensureRuntime(backend) {
|
||||
}
|
||||
|
||||
rememberLog('[bootstrap] bootstrap complete; marker written. Re-resolving backend.')
|
||||
// A repair (if any) has now re-run, so clear the gate -- the re-resolution
|
||||
// below SHOULD land on the fresh marker fast-path rather than skip it.
|
||||
forceBootstrapRepair = false
|
||||
// Re-resolve now that the install exists. The new resolution lands in
|
||||
// step 3 (bootstrap-complete marker) and we recurse to wire venvPython.
|
||||
return ensureRuntime(resolveHermesBackend(backend.args))
|
||||
@@ -2582,9 +2751,31 @@ function buildApplicationMenu() {
|
||||
{ role: 'forceReload' },
|
||||
{ role: 'toggleDevTools' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
{ role: 'zoomIn' },
|
||||
{ role: 'zoomOut' },
|
||||
{
|
||||
label: 'Actual Size',
|
||||
accelerator: 'CommandOrControl+0',
|
||||
click: () => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.setZoomLevel(0) }
|
||||
},
|
||||
{
|
||||
label: 'Zoom In',
|
||||
accelerator: 'CommandOrControl+Plus',
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
const next = Math.min(mainWindow.webContents.getZoomLevel() + 0.1, 9)
|
||||
mainWindow.webContents.setZoomLevel(next)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Zoom Out',
|
||||
accelerator: 'CommandOrControl+-',
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
const next = Math.max(mainWindow.webContents.getZoomLevel() - 0.1, -9)
|
||||
mainWindow.webContents.setZoomLevel(next)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' }
|
||||
]
|
||||
@@ -2643,6 +2834,32 @@ function installPreviewShortcut(window) {
|
||||
})
|
||||
}
|
||||
|
||||
function installZoomShortcuts(window) {
|
||||
// Override Ctrl/Cmd + +/-/0 with half the default zoom step (0.1 vs 0.2).
|
||||
// The menu items handle this on macOS (where the menu is always present),
|
||||
// but on Linux/Windows the menu is null and Chromium's default handler
|
||||
// would use the full 0.2 step, so we intercept here for consistency.
|
||||
const ZOOM_STEP = 0.1
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
const mod = IS_MAC ? input.meta : input.control
|
||||
if (!mod || input.alt || input.shift) return
|
||||
|
||||
const key = input.key
|
||||
if (key === '0') {
|
||||
event.preventDefault()
|
||||
window.webContents.setZoomLevel(0)
|
||||
} else if (key === '=' || key === '+') {
|
||||
event.preventDefault()
|
||||
const next = Math.min(window.webContents.getZoomLevel() + ZOOM_STEP, 9)
|
||||
window.webContents.setZoomLevel(next)
|
||||
} else if (key === '-') {
|
||||
event.preventDefault()
|
||||
const next = Math.max(window.webContents.getZoomLevel() - ZOOM_STEP, -9)
|
||||
window.webContents.setZoomLevel(next)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function installContextMenu(window) {
|
||||
window.webContents.on('context-menu', (_event, params) => {
|
||||
const template = []
|
||||
@@ -2695,6 +2912,28 @@ function installContextMenu(window) {
|
||||
)
|
||||
}
|
||||
|
||||
// Spell-check suggestions for the misspelled word under the caret.
|
||||
// Chromium surfaces them on `params.dictionarySuggestions`; we offer the
|
||||
// top 5 plus a "Add to dictionary" affordance.
|
||||
const suggestions = Array.isArray(params.dictionarySuggestions) ? params.dictionarySuggestions : []
|
||||
|
||||
if (isEditable && params.misspelledWord && suggestions.length > 0) {
|
||||
if (template.length) template.push({ type: 'separator' })
|
||||
|
||||
for (const suggestion of suggestions.slice(0, 5)) {
|
||||
template.push({
|
||||
label: suggestion,
|
||||
click: () => window.webContents.replaceMisspelling(suggestion)
|
||||
})
|
||||
}
|
||||
|
||||
template.push({ type: 'separator' })
|
||||
template.push({
|
||||
label: 'Add to dictionary',
|
||||
click: () => window.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord)
|
||||
})
|
||||
}
|
||||
|
||||
if (hasSelection || isEditable) {
|
||||
if (template.length) template.push({ type: 'separator' })
|
||||
if (isEditable) {
|
||||
@@ -3207,6 +3446,7 @@ function createWindow() {
|
||||
|
||||
installPreviewShortcut(mainWindow)
|
||||
installDevToolsShortcut(mainWindow)
|
||||
installZoomShortcuts(mainWindow)
|
||||
installContextMenu(mainWindow)
|
||||
mainWindow.webContents.setWindowOpenHandler(details => {
|
||||
openExternalUrl(details.url)
|
||||
@@ -3222,6 +3462,51 @@ function createWindow() {
|
||||
openExternalUrl(url)
|
||||
})
|
||||
|
||||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||
rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`)
|
||||
|
||||
if (details?.reason === 'crashed' || details?.reason === 'oom') {
|
||||
const now = Date.now()
|
||||
rendererReloadTimes = rendererReloadTimes.filter(t => now - t < RENDERER_RELOAD_WINDOW_MS)
|
||||
|
||||
if (rendererReloadTimes.length >= RENDERER_RELOAD_MAX) {
|
||||
rememberLog(
|
||||
`[renderer] suppressing reload: ${rendererReloadTimes.length} crashes within ${RENDERER_RELOAD_WINDOW_MS}ms (likely a crash loop)`
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
rendererReloadTimes.push(now)
|
||||
setImmediate(() => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
try {
|
||||
mainWindow.webContents.reload()
|
||||
} catch (err) {
|
||||
rememberLog(`[renderer] reload after crash failed: ${err?.message || err}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.webContents.on('unresponsive', () => rememberLog('[renderer] webContents became unresponsive'))
|
||||
|
||||
// Electron always passes the event first. The canonical (Electron 36+) shape
|
||||
// is (event, messageDetails); the deprecated positional shape is
|
||||
// (event, level, message, line, sourceId). Handle both. `level` is numeric
|
||||
// (0..3), where 3 === error.
|
||||
mainWindow.webContents.on('console-message', (_event, detailsOrLevel, message, line, sourceId) => {
|
||||
const details = detailsOrLevel && typeof detailsOrLevel === 'object' ? detailsOrLevel : null
|
||||
const level = details ? details.level : detailsOrLevel
|
||||
|
||||
if (level !== 3) return
|
||||
|
||||
const text = details ? details.message : message
|
||||
const src = details ? details.sourceUrl : sourceId
|
||||
const lineNo = details ? details.lineNumber : line
|
||||
rememberLog(`[renderer console] ${text} (${src}:${lineNo})`)
|
||||
})
|
||||
|
||||
if (DEV_SERVER) {
|
||||
mainWindow.loadURL(DEV_SERVER)
|
||||
} else {
|
||||
@@ -3242,6 +3527,7 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||
// full backend flow (including a fresh runBootstrap pass).
|
||||
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
|
||||
bootstrapFailure = null
|
||||
forceBootstrapRepair = false
|
||||
connectionPromise = null
|
||||
bootstrapState = {
|
||||
active: false,
|
||||
@@ -3269,6 +3555,9 @@ ipcMain.handle('hermes:bootstrap:repair', async () => {
|
||||
rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`)
|
||||
}
|
||||
bootstrapFailure = null
|
||||
// Force the next resolution past both the marker fast-path and the adopt
|
||||
// branch so the installer actually re-runs (the whole point of repair).
|
||||
forceBootstrapRepair = true
|
||||
resetHermesConnection()
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -3372,13 +3661,21 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => {
|
||||
const properties = ['openFile']
|
||||
if (options?.directories) properties.push('openDirectory')
|
||||
const properties = options?.directories ? ['openDirectory'] : ['openFile']
|
||||
if (options?.multiple !== false) properties.push('multiSelections')
|
||||
|
||||
let resolvedDefaultPath
|
||||
if (options?.defaultPath) {
|
||||
try {
|
||||
resolvedDefaultPath = path.resolve(String(options.defaultPath))
|
||||
} catch {
|
||||
resolvedDefaultPath = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
title: options?.title || 'Add context',
|
||||
defaultPath: options?.defaultPath ? path.resolve(String(options.defaultPath)) : undefined,
|
||||
defaultPath: resolvedDefaultPath,
|
||||
properties,
|
||||
filters: Array.isArray(options?.filters) ? options.filters : undefined
|
||||
})
|
||||
@@ -3437,6 +3734,45 @@ ipcMain.handle('hermes:openExternal', (_event, url) => {
|
||||
}
|
||||
})
|
||||
|
||||
// User-configurable default project directory. The renderer reads this on
|
||||
// settings mount and seeds the value into the picker; writing back persists
|
||||
// it via writeDefaultProjectDir so resolveHermesCwd picks it up on the next
|
||||
// session spawn (no app restart needed).
|
||||
ipcMain.handle('hermes:setting:defaultProjectDir:get', async () => ({
|
||||
dir: readDefaultProjectDir(),
|
||||
defaultLabel: path.join(app.getPath('home'), 'hermes-projects')
|
||||
}))
|
||||
|
||||
ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => {
|
||||
const next = typeof dir === 'string' && dir.trim() ? dir.trim() : null
|
||||
|
||||
if (next) {
|
||||
try {
|
||||
fs.mkdirSync(next, { recursive: true })
|
||||
} catch (error) {
|
||||
throw new Error(`Could not create directory: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
writeDefaultProjectDir(next)
|
||||
|
||||
return { dir: next }
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:setting:defaultProjectDir:pick', async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: 'Choose default project directory',
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
defaultPath: readDefaultProjectDir() || app.getPath('home')
|
||||
})
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { canceled: true, dir: null }
|
||||
}
|
||||
|
||||
return { canceled: false, dir: result.filePaths[0] }
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url))
|
||||
|
||||
ipcMain.handle('hermes:logs:reveal', async () => {
|
||||
@@ -3737,7 +4073,99 @@ ipcMain.handle('hermes:version', async () => ({
|
||||
hermesRoot: resolveUpdateRoot()
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// macOS first-launch placement: move into /Applications and pin to the Dock
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The DMG and CLI-built apps launch from wherever the user left them (a DMG
|
||||
// mount, ~/Downloads, ~/.hermes/...) -- which means Gatekeeper translocation,
|
||||
// no Dock tile, and "which icon do I click?" confusion. On first packaged
|
||||
// launch we relocate into /Applications (Electron relaunches from there) and,
|
||||
// once we're that canonical copy, pin to the Dock. Both macOS-only,
|
||||
// packaged-only, best-effort, run at most once.
|
||||
|
||||
// Move the bundle into /Applications and relaunch. Returns true when a relaunch
|
||||
// is underway (caller must stop init). No-op in dev, off macOS, or already in
|
||||
// /Applications. `existsAndRunning` -> another copy owns the slot; don't fight
|
||||
// it. `exists` -> stale copy; replace it so there's exactly one current app.
|
||||
function maybeRelocateToApplications() {
|
||||
if (!IS_MAC || !IS_PACKAGED || process.env.HERMES_DESKTOP_NO_AUTO_MOVE === '1') return false
|
||||
try {
|
||||
if (app.isInApplicationsFolder()) return false
|
||||
const moved = app.moveToApplicationsFolder({ conflictHandler: type => type !== 'existsAndRunning' })
|
||||
if (moved) rememberLog('[install] relocated into /Applications; relaunching')
|
||||
return moved
|
||||
} catch (err) {
|
||||
rememberLog(`[install] move to /Applications skipped: ${err.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const DOCK_PINNED_MARKER = 'dock-pinned.json'
|
||||
|
||||
// Pin the /Applications copy to the Dock once. macOS has no Electron API for
|
||||
// this, so we append to com.apple.dock's persistent-apps and restart the Dock.
|
||||
// Guarded by a userData marker + membership check so we never duplicate the tile.
|
||||
function maybePinToDock() {
|
||||
if (!IS_MAC || !IS_PACKAGED || process.env.HERMES_DESKTOP_NO_DOCK_PIN === '1') return
|
||||
const marker = path.join(app.getPath('userData'), DOCK_PINNED_MARKER)
|
||||
if (fileExists(marker)) return
|
||||
|
||||
let bundle
|
||||
try {
|
||||
if (!app.isInApplicationsFolder()) return // don't pin a soon-to-be-stale path
|
||||
bundle = runningAppBundle()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!bundle) return
|
||||
|
||||
// The Dock stores tiles as file-reference URLs (type 15), e.g.
|
||||
// file:///Applications/Hermes.app/ -- NOT a raw POSIX path. A type-0/raw-path
|
||||
// tile is silently dropped when the Dock rewrites persistent-apps on restart.
|
||||
const url = pathToFileURL(bundle.endsWith('/') ? bundle : `${bundle}/`).href
|
||||
|
||||
const done = (note = {}) => {
|
||||
try {
|
||||
fs.writeFileSync(marker, JSON.stringify({ bundle, pinnedAt: new Date().toISOString(), ...note }) + '\n')
|
||||
} catch {
|
||||
// best-effort; we re-check next launch (membership guard dedupes)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const apps = execFileSync('defaults', ['read', 'com.apple.dock', 'persistent-apps'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
if (apps.includes(url)) return done({ alreadyPresent: true })
|
||||
} catch {
|
||||
// persistent-apps may not exist yet; -array-add creates it
|
||||
}
|
||||
|
||||
const tile =
|
||||
'<dict><key>tile-data</key><dict><key>file-data</key><dict>' +
|
||||
`<key>_CFURLString</key><string>${url}</string><key>_CFURLStringType</key><integer>15</integer>` +
|
||||
'</dict></dict></dict>'
|
||||
try {
|
||||
execFileSync('defaults', ['write', 'com.apple.dock', 'persistent-apps', '-array-add', tile], { stdio: 'ignore' })
|
||||
// Flush the write through cfprefsd before restarting the Dock, otherwise the
|
||||
// Dock reloads stale prefs and our tile is lost in the race.
|
||||
execFileSync('defaults', ['read', 'com.apple.dock', 'persistent-apps'], { stdio: 'ignore' })
|
||||
execFileSync('killall', ['Dock'], { stdio: 'ignore' })
|
||||
done()
|
||||
rememberLog(`[install] pinned to Dock: ${url}`)
|
||||
} catch (err) {
|
||||
rememberLog(`[install] Dock pin skipped: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// macOS: relocate into /Applications before anything else so setup + state
|
||||
// land in the final location; on success this relaunches, so bail here.
|
||||
if (maybeRelocateToApplications()) return
|
||||
maybePinToDock()
|
||||
|
||||
if (IS_MAC) {
|
||||
Menu.setApplicationMenu(buildApplicationMenu())
|
||||
} else {
|
||||
@@ -3746,6 +4174,7 @@ app.whenReady().then(() => {
|
||||
installMediaPermissions()
|
||||
registerMediaProtocol()
|
||||
ensureWslWindowsFonts()
|
||||
configureSpellChecker()
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
@@ -3753,6 +4182,29 @@ app.whenReady().then(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// Seed Chromium's spellchecker with the system locale (falling back to en-US).
|
||||
// On macOS Electron uses the native spellchecker which ignores this list, but
|
||||
// on Windows/Linux Chromium downloads Hunspell dictionaries on demand and
|
||||
// won't enable any without an explicit language.
|
||||
function configureSpellChecker() {
|
||||
try {
|
||||
const defaultSession = session.defaultSession
|
||||
|
||||
if (!defaultSession || typeof defaultSession.setSpellCheckerLanguages !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
const available = defaultSession.availableSpellCheckerLanguages || []
|
||||
const locale = (app.getLocale && app.getLocale()) || 'en-US'
|
||||
const candidates = [locale, locale.split('-')[0], 'en-US', 'en']
|
||||
const chosen = candidates.find(lang => available.includes(lang)) || 'en-US'
|
||||
|
||||
defaultSession.setSpellCheckerLanguages([chosen])
|
||||
} catch (error) {
|
||||
rememberLog(`Spellchecker setup failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
app.on('before-quit', () => {
|
||||
// Quitting mid-install should stop the installer, not orphan it.
|
||||
if (bootstrapAbortController) {
|
||||
|
||||
@@ -31,6 +31,11 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
|
||||
settings: {
|
||||
getDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:get'),
|
||||
setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir),
|
||||
pickDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:pick')
|
||||
},
|
||||
revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'),
|
||||
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
|
||||
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<link rel="icon" type="image/png" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="shortcut icon" href="/apple-touch-icon.png" />
|
||||
<title>Hermes</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
-18363
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hermes/shared": "file:../shared",
|
||||
"@icons-pack/react-simple-icons": "^13.13.0",
|
||||
"@nanostores/react": "^1.1.0",
|
||||
"@nous-research/ui": "^0.13.0",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// Reproduce + diagnose the "scroll wheel resets position while reading" bug.
|
||||
//
|
||||
// The complaint (Windows, mouse wheel): scrolling UP through a chat to re-read
|
||||
// older content randomly yanks the view to a different position, so you have to
|
||||
// fight the scrollbar. Mac users on trackpads don't see it.
|
||||
//
|
||||
// Hypothesis: the thread scroller has the browser default `overflow-anchor:
|
||||
// auto`, and the thread renders items in natural document flow (padding
|
||||
// spacers, NOT transforms). When an item above the viewport is measured by
|
||||
// @tanstack/react-virtual (its real height differs a lot from the 220px
|
||||
// estimate) — or when Shiki/images/fonts reflow it — TWO mechanisms both
|
||||
// adjust scrollTop for the same delta: TanStack's measurement compensation AND
|
||||
// the browser's native scroll anchoring. The double-correction lurches the
|
||||
// view. A mouse wheel's coarse, discrete notches mount/measure several
|
||||
// under-estimated turns per tick, so the over-correction is large and visible;
|
||||
// a trackpad's ~1-3px/frame keeps it sub-perceptual.
|
||||
//
|
||||
// This script drives synthetic mouse-wheel-UP scrolling on a long thread and
|
||||
// measures how much a tracked on-screen turn jumps, first with
|
||||
// `overflow-anchor: auto` (reproduce) then `overflow-anchor: none` (the fix).
|
||||
// If the fix run shows dramatically fewer/smaller jumps, the hypothesis holds.
|
||||
//
|
||||
// Prereq: a running desktop app with remote debugging on 9222, on a thread
|
||||
// with enough history to scroll (the longer / more code+tool blocks, the
|
||||
// better the repro). Then: node apps/desktop/scripts/diag-scroll-reset.mjs
|
||||
|
||||
const NOTCHES = 14 // wheel-up ticks per sweep
|
||||
const NOTCH_PX = 120 // Windows wheel notch ≈ 120px
|
||||
const NOTCH_GAP_MS = 130 // let each smooth-scroll animation settle
|
||||
const REVERSE_JUMP_PX = 6 // tracked turn moving UP while scrolling up = wrong way
|
||||
const LURCH_PX = 60 // single-frame on-screen jump that reads as a "reset"
|
||||
|
||||
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http'))
|
||||
if (!tgt) {
|
||||
console.error('No page target on :9222. Is the desktop app running with --remote-debugging-port=9222?')
|
||||
process.exit(1)
|
||||
}
|
||||
const ws = new WebSocket(tgt.webSocketDebuggerUrl)
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
ws.addEventListener('message', ev => {
|
||||
const m = JSON.parse(ev.data)
|
||||
if (m.id != null && pending.has(m.id)) {
|
||||
pending.get(m.id)(m)
|
||||
pending.delete(m.id)
|
||||
}
|
||||
})
|
||||
await new Promise(r => ws.addEventListener('open', r))
|
||||
const send = (m, p = {}) =>
|
||||
new Promise(r => {
|
||||
const i = ++id
|
||||
pending.set(i, r)
|
||||
ws.send(JSON.stringify({ id: i, method: m, params: p }))
|
||||
})
|
||||
const evalP = async expr => {
|
||||
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true })
|
||||
if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.text)
|
||||
return r.result.result.value
|
||||
}
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
// Install per-sweep instrumentation. `mode` is the overflow-anchor value to
|
||||
// force inline so we A/B the exact same thread regardless of any CSS fix.
|
||||
// Starts from ~45% down the thread so there's room to scroll up into
|
||||
// not-yet-measured turns, tags the turn nearest viewport-center as the anchor,
|
||||
// then records (per rAF) scrollTop + that turn's on-screen top, plus every
|
||||
// scrollTop *setter* write (TanStack compensation) and ResizeObserver hit.
|
||||
async function arm(mode) {
|
||||
await evalP(`(() => {
|
||||
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!v) throw new Error('thread viewport not found')
|
||||
|
||||
// Force the overflow-anchor behavior under test (inline beats CSS).
|
||||
v.style.overflowAnchor = ${JSON.stringify(mode)}
|
||||
|
||||
// Park ~45% down so a wheel-up sweep climbs into estimated-but-unmeasured
|
||||
// turns above the fold (where the measurement correction fires).
|
||||
v.scrollTop = Math.round(v.scrollHeight * 0.45)
|
||||
|
||||
// Tag the turn closest to viewport center; we track its on-screen top.
|
||||
const vr = v.getBoundingClientRect()
|
||||
const center = vr.top + v.clientHeight / 2
|
||||
let best = null, bestD = Infinity
|
||||
for (const el of v.querySelectorAll('[data-index]')) {
|
||||
const r = el.getBoundingClientRect()
|
||||
const d = Math.abs((r.top + r.height / 2) - center)
|
||||
if (d < bestD) { bestD = d; best = el }
|
||||
}
|
||||
document.querySelectorAll('[data-se-anchor]').forEach(e => e.removeAttribute('data-se-anchor'))
|
||||
if (best) best.setAttribute('data-se-anchor', '1')
|
||||
const anchorIndex = best ? best.getAttribute('data-index') : null
|
||||
|
||||
const samples = []
|
||||
const writes = []
|
||||
const ros = []
|
||||
const t0 = performance.now()
|
||||
|
||||
// Intercept scrollTop writes → these are JS (TanStack) corrections.
|
||||
// Native browser scroll anchoring does NOT go through this setter, so a
|
||||
// scrollTop change with no write in the same frame is a native adjust.
|
||||
const desc = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop')
|
||||
Object.defineProperty(v, 'scrollTop', {
|
||||
configurable: true,
|
||||
get() { return desc.get.call(this) },
|
||||
set(val) {
|
||||
writes.push({ t: performance.now() - t0, val, sh: this.scrollHeight })
|
||||
desc.set.call(this, val)
|
||||
}
|
||||
})
|
||||
window.__restoreScrollTop = () => Object.defineProperty(v, 'scrollTop', desc)
|
||||
|
||||
const ro = new ResizeObserver(entries => {
|
||||
for (const e of entries) {
|
||||
ros.push({ t: performance.now() - t0, slot: e.target.getAttribute?.('data-slot') || e.target.tagName, h: Math.round(e.contentRect.height) })
|
||||
}
|
||||
})
|
||||
ro.observe(v)
|
||||
if (v.firstElementChild) ro.observe(v.firstElementChild)
|
||||
|
||||
let running = true
|
||||
const tick = () => {
|
||||
if (!running) return
|
||||
const a = v.querySelector('[data-se-anchor]')
|
||||
const ar = a ? a.getBoundingClientRect() : null
|
||||
samples.push({
|
||||
t: performance.now() - t0,
|
||||
st: Math.round(v.scrollTop * 100) / 100,
|
||||
sh: v.scrollHeight,
|
||||
ch: v.clientHeight,
|
||||
atop: ar ? Math.round(ar.top * 100) / 100 : null,
|
||||
aconn: !!a
|
||||
})
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
|
||||
window.__se = { samples, writes, ros, anchorIndex, dpr: window.devicePixelRatio, stop() { running = false; ro.disconnect(); window.__restoreScrollTop?.() } }
|
||||
return true
|
||||
})()`)
|
||||
}
|
||||
|
||||
async function wheelUpSweep() {
|
||||
const { x, y } = await evalP(`(() => {
|
||||
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
const r = v.getBoundingClientRect()
|
||||
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }
|
||||
})()`)
|
||||
|
||||
for (let i = 0; i < NOTCHES; i++) {
|
||||
await send('Input.dispatchMouseEvent', { type: 'mouseWheel', x, y, deltaX: 0, deltaY: -NOTCH_PX })
|
||||
await sleep(NOTCH_GAP_MS)
|
||||
}
|
||||
await sleep(400)
|
||||
}
|
||||
|
||||
async function collect() {
|
||||
const data = JSON.parse(await evalP(`(() => { window.__se.stop(); return JSON.stringify(window.__se) })()`))
|
||||
return data
|
||||
}
|
||||
|
||||
function analyze(label, data) {
|
||||
const { samples, writes, ros, anchorIndex, dpr } = data
|
||||
let reverseJumps = 0
|
||||
let reverseSum = 0
|
||||
let lurches = 0
|
||||
let maxJump = 0
|
||||
let nativeMoves = 0
|
||||
let prev = null
|
||||
for (const s of samples) {
|
||||
if (prev && prev.aconn && s.aconn && prev.atop != null && s.atop != null) {
|
||||
const dTop = s.atop - prev.atop // wheel-up should move content DOWN → dTop >= 0
|
||||
const dSt = s.st - prev.st
|
||||
// Native (browser-anchoring) move: scrollTop changed with no setter write in this frame window.
|
||||
const wroteThisFrame = writes.some(w => w.t > prev.t && w.t <= s.t)
|
||||
if (Math.abs(dSt) > 0.5 && !wroteThisFrame) nativeMoves++
|
||||
if (dTop < -REVERSE_JUMP_PX) {
|
||||
reverseJumps++
|
||||
reverseSum += -dTop
|
||||
}
|
||||
if (Math.abs(dTop) > LURCH_PX) lurches++
|
||||
if (Math.abs(dTop) > maxJump) maxJump = Math.abs(dTop)
|
||||
}
|
||||
prev = s
|
||||
}
|
||||
console.log(`\n── ${label} ──`)
|
||||
console.log(` devicePixelRatio: ${dpr}${Number.isInteger(dpr) ? '' : ' (fractional — Windows scaling, worsens rounding jitter)'}`)
|
||||
console.log(` tracked turn index: ${anchorIndex}`)
|
||||
console.log(` rAF frames: ${samples.length}`)
|
||||
console.log(` scrollTop writes: ${writes.length} (TanStack measurement corrections)`)
|
||||
console.log(` ResizeObserver hits: ${ros.length}`)
|
||||
console.log(` native scroll moves: ${nativeMoves} (scrollTop moved with NO JS write = browser anchoring)`)
|
||||
console.log(` reverse jumps: ${reverseJumps} (tracked turn yanked UP while scrolling up; total ${reverseSum.toFixed(0)}px)`)
|
||||
console.log(` big lurches (>${LURCH_PX}px): ${lurches}`)
|
||||
console.log(` max single-frame jump: ${maxJump.toFixed(0)}px`)
|
||||
return { reverseJumps, reverseSum, lurches, maxJump, nativeMoves }
|
||||
}
|
||||
|
||||
console.log(`Wheel-up repro: ${NOTCHES} notches × ${NOTCH_PX}px, anchored mid-thread.\n`)
|
||||
|
||||
await arm('auto')
|
||||
await sleep(150)
|
||||
await wheelUpSweep()
|
||||
const a = analyze('overflow-anchor: auto (current / repro)', await collect())
|
||||
|
||||
await sleep(300)
|
||||
|
||||
await arm('none')
|
||||
await sleep(150)
|
||||
await wheelUpSweep()
|
||||
const b = analyze('overflow-anchor: none (proposed fix)', await collect())
|
||||
|
||||
// Clean up our tag.
|
||||
await evalP(`document.querySelectorAll('[data-se-anchor]').forEach(e => e.removeAttribute('data-se-anchor'))`)
|
||||
|
||||
console.log('\n══ verdict ══')
|
||||
const drop = (x, y) => (x === 0 ? (y === 0 ? '0' : 'n/a') : `${Math.round((1 - y / x) * 100)}% fewer`)
|
||||
console.log(` reverse jumps: auto=${a.reverseJumps} none=${b.reverseJumps} (${drop(a.reverseJumps, b.reverseJumps)})`)
|
||||
console.log(` big lurches: auto=${a.lurches} none=${b.lurches} (${drop(a.lurches, b.lurches)})`)
|
||||
console.log(` max jump: auto=${a.maxJump.toFixed(0)}px none=${b.maxJump.toFixed(0)}px`)
|
||||
console.log(` native moves: auto=${a.nativeMoves} none=${b.nativeMoves} (browser anchoring should ~vanish at none)`)
|
||||
if (a.reverseJumps + a.lurches > 0 && b.reverseJumps + b.lurches < a.reverseJumps + a.lurches) {
|
||||
console.log('\n → Jumps drop sharply with overflow-anchor:none → root cause confirmed.')
|
||||
} else if (a.reverseJumps + a.lurches === 0) {
|
||||
console.log('\n → No jumps captured this run. Use a longer thread (many code/tool blocks),')
|
||||
console.log(' raise NOTCHES, and ensure you start scrolled up from the bottom.')
|
||||
}
|
||||
|
||||
ws.close()
|
||||
@@ -1,14 +1,20 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
|
||||
@@ -17,6 +23,24 @@ import { cn } from '@/lib/utils'
|
||||
import { GHOST_ICON_BTN } from './controls'
|
||||
import type { ChatBarState } from './types'
|
||||
|
||||
const PROMPT_SNIPPETS: readonly PromptSnippet[] = [
|
||||
{
|
||||
description: 'Audit the current change for regressions, dropped edge cases, and missing tests.',
|
||||
label: 'Code review',
|
||||
text: 'Please review this for bugs, regressions, and missing tests.'
|
||||
},
|
||||
{
|
||||
description: 'Outline an approach before touching code so the diff stays focused.',
|
||||
label: 'Implementation plan',
|
||||
text: 'Please make a concise implementation plan before changing code.'
|
||||
},
|
||||
{
|
||||
description: 'Walk through how the selected code works and link to the key files.',
|
||||
label: 'Explain this',
|
||||
text: 'Please explain how this works and point me to the key files.'
|
||||
}
|
||||
]
|
||||
|
||||
export function ContextMenu({
|
||||
state,
|
||||
onInsertText,
|
||||
@@ -25,81 +49,114 @@ export function ContextMenu({
|
||||
onPickFiles,
|
||||
onPickFolders,
|
||||
onPickImages
|
||||
}: {
|
||||
state: ChatBarState
|
||||
onInsertText: (text: string) => void
|
||||
onOpenUrlDialog: () => void
|
||||
onPasteClipboardImage?: () => void
|
||||
onPickFiles?: () => void
|
||||
onPickFolders?: () => void
|
||||
onPickImages?: () => void
|
||||
}) {
|
||||
}: ContextMenuProps) {
|
||||
// Prompt snippets used to be a Radix submenu. That submenu didn't open
|
||||
// reliably when the parent menu was positioned at the bottom of the
|
||||
// window (composer "+" anchor), so we promoted it to a real Dialog —
|
||||
// easier to grow with search / descriptions, and no positioning math.
|
||||
const [snippetsOpen, setSnippetsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={state.tools.label}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
|
||||
)}
|
||||
disabled={!state.tools.enabled}
|
||||
size="icon"
|
||||
title={state.tools.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="1rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
|
||||
<DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85">
|
||||
Attach
|
||||
</DropdownMenuLabel>
|
||||
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
|
||||
Files…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
|
||||
Folder…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
|
||||
Images…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
|
||||
Paste image
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
|
||||
URL…
|
||||
</ContextMenuItem>
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={state.tools.label}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
|
||||
)}
|
||||
disabled={!state.tools.enabled}
|
||||
size="icon"
|
||||
title={state.tools.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="1rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
|
||||
<DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85">
|
||||
Attach
|
||||
</DropdownMenuLabel>
|
||||
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
|
||||
Files…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
|
||||
Folder…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
|
||||
Images…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
|
||||
Paste image
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
|
||||
URL…
|
||||
</ContextMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<MessageSquareText />
|
||||
<span>Prompt snippets</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-72">
|
||||
{[
|
||||
{ label: 'Code review', text: 'Please review this for bugs, regressions, and missing tests.' },
|
||||
{ label: 'Implementation plan', text: 'Please make a concise implementation plan before changing code.' },
|
||||
{ label: 'Explain this', text: 'Please explain how this works and point me to the key files.' }
|
||||
].map(snippet => (
|
||||
<ContextMenuItem icon={MessageSquareText} key={snippet.label} onSelect={() => onInsertText(snippet.text)}>
|
||||
{snippet.label}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<ContextMenuItem icon={MessageSquareText} onSelect={() => setSnippetsOpen(true)}>
|
||||
Prompt snippets…
|
||||
</ContextMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
|
||||
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference files
|
||||
inline.
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
|
||||
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference files
|
||||
inline.
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<PromptSnippetsDialog
|
||||
onInsertText={onInsertText}
|
||||
onOpenChange={setSnippetsOpen}
|
||||
open={snippetsOpen}
|
||||
snippets={PROMPT_SNIPPETS}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptSnippetsDialog({
|
||||
onInsertText,
|
||||
onOpenChange,
|
||||
open,
|
||||
snippets
|
||||
}: PromptSnippetsDialogProps) {
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md gap-3">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Prompt snippets</DialogTitle>
|
||||
<DialogDescription>Pick a starter prompt to drop into the composer.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ul className="grid gap-1">
|
||||
{snippets.map(snippet => (
|
||||
<li key={snippet.label}>
|
||||
<button
|
||||
className="group/snippet flex w-full cursor-pointer items-start gap-2.5 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) focus-visible:border-(--ui-stroke-tertiary) focus-visible:bg-(--ui-control-hover-background) focus-visible:outline-none"
|
||||
onClick={() => {
|
||||
onInsertText(snippet.text)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquareText className="mt-0.5 size-3.5 shrink-0 text-(--ui-text-tertiary) group-hover/snippet:text-foreground" />
|
||||
<span className="grid min-w-0 gap-0.5">
|
||||
<span className="text-sm font-medium text-foreground">{snippet.label}</span>
|
||||
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{snippet.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -108,12 +165,7 @@ export function ContextMenuItem({
|
||||
disabled,
|
||||
icon: Icon,
|
||||
onSelect
|
||||
}: {
|
||||
children: string
|
||||
disabled?: boolean
|
||||
icon: IconComponent
|
||||
onSelect?: () => void
|
||||
}) {
|
||||
}: ContextMenuItemProps) {
|
||||
return (
|
||||
<DropdownMenuItem disabled={disabled} onSelect={onSelect}>
|
||||
<Icon />
|
||||
@@ -121,3 +173,33 @@ export function ContextMenuItem({
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
interface ContextMenuItemProps {
|
||||
children: string
|
||||
disabled?: boolean
|
||||
icon: IconComponent
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
onInsertText: (text: string) => void
|
||||
onOpenUrlDialog: () => void
|
||||
onPasteClipboardImage?: () => void
|
||||
onPickFiles?: () => void
|
||||
onPickFolders?: () => void
|
||||
onPickImages?: () => void
|
||||
state: ChatBarState
|
||||
}
|
||||
|
||||
interface PromptSnippet {
|
||||
description: string
|
||||
label: string
|
||||
text: string
|
||||
}
|
||||
|
||||
interface PromptSnippetsDialogProps {
|
||||
onInsertText: (text: string) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
open: boolean
|
||||
snippets: readonly PromptSnippet[]
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
enqueueQueuedPrompt,
|
||||
type QueuedPromptEntry,
|
||||
removeQueuedPrompt,
|
||||
shouldAutoDrainOnSettle,
|
||||
updateQueuedPrompt
|
||||
} from '@/store/composer-queue'
|
||||
import { $messages } from '@/store/session'
|
||||
@@ -124,6 +125,12 @@ export function ChatBar({
|
||||
const draftRef = useRef(draft)
|
||||
const previousBusyRef = useRef(busy)
|
||||
const drainingQueueRef = useRef(false)
|
||||
// Set when the user explicitly interrupts the running turn via the Stop
|
||||
// button (busy + empty composer). It suppresses the next busy→false
|
||||
// auto-drain so an explicit Stop actually halts instead of immediately
|
||||
// firing the head of the queue. The queue is preserved; the user resumes
|
||||
// it deliberately via Cmd/Ctrl+K, Enter, or the per-row "send now" arrow.
|
||||
const userInterruptedRef = useRef(false)
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const [urlOpen, setUrlOpen] = useState(false)
|
||||
@@ -414,6 +421,14 @@ export function ChatBar({
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
// Set synchronously in keydown when the open trigger popover consumes a
|
||||
// navigation/control key (Arrow/Enter/Tab/Escape). The subsequent keyup must
|
||||
// NOT run refreshTrigger for that keypress: it never edits text, and for
|
||||
// Escape the keydown has already set trigger=null, so a keyup refresh would
|
||||
// re-detect the still-present `/` and instantly reopen the menu. A ref is
|
||||
// used instead of reading `trigger` in keyup because by keyup time React has
|
||||
// re-rendered and the handler closure sees the post-keydown state.
|
||||
const triggerKeyConsumedRef = useRef(false)
|
||||
|
||||
const refreshTrigger = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
@@ -442,7 +457,14 @@ export function ChatBar({
|
||||
const detected = detectTrigger(before ?? composerPlainText(editor))
|
||||
|
||||
setTrigger(detected)
|
||||
setTriggerActive(0)
|
||||
|
||||
// Only reset the highlight when the trigger actually changed (opened, or
|
||||
// the query/kind differs). Re-detecting the *same* trigger — e.g. on a
|
||||
// caret move (mouseup) or a stray refresh — must preserve the user's
|
||||
// current selection instead of snapping back to the first item.
|
||||
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
|
||||
setTriggerActive(0)
|
||||
}
|
||||
}, [trigger])
|
||||
|
||||
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
@@ -558,6 +580,7 @@ export function ChatBar({
|
||||
if (trigger && triggerItems.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx + 1) % triggerItems.length)
|
||||
|
||||
return
|
||||
@@ -565,6 +588,7 @@ export function ChatBar({
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
|
||||
|
||||
return
|
||||
@@ -572,6 +596,7 @@ export function ChatBar({
|
||||
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
const item = triggerItems[triggerActive]
|
||||
|
||||
if (item) {
|
||||
@@ -583,6 +608,7 @@ export function ChatBar({
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
closeTrigger()
|
||||
|
||||
return
|
||||
@@ -603,6 +629,18 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
const handleEditorKeyUp = () => {
|
||||
// If this keyup belongs to a key the open trigger popover already consumed
|
||||
// in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never
|
||||
// edit text, and for Escape the keydown already closed the menu — a refresh
|
||||
// here would re-detect the still-present `/` and instantly reopen it. We
|
||||
// read a ref set during keydown rather than `trigger`, because by keyup
|
||||
// time React has re-rendered and `trigger` may already be null.
|
||||
if (triggerKeyConsumedRef.current) {
|
||||
triggerKeyConsumedRef.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
@@ -844,26 +882,42 @@ export function ChatBar({
|
||||
[queueEdit, runDrain]
|
||||
)
|
||||
|
||||
const interruptAndSendNextQueued = useCallback(async () => {
|
||||
if (queuedPrompts.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
await Promise.resolve(onCancel())
|
||||
|
||||
return drainNextQueued()
|
||||
}, [drainNextQueued, onCancel, queuedPrompts.length])
|
||||
|
||||
// Auto-drain on busy → false (turn settled).
|
||||
// Auto-drain on busy → false (turn settled). An explicit user interrupt
|
||||
// (Stop button) sets userInterruptedRef so we skip exactly one auto-drain:
|
||||
// the user asked to halt, so we must not immediately re-send the queue.
|
||||
// The queued turns stay intact and the user resumes them on demand.
|
||||
useEffect(() => {
|
||||
const wasBusy = previousBusyRef.current
|
||||
previousBusyRef.current = busy
|
||||
|
||||
if (busy || !wasBusy || queuedPrompts.length === 0) {
|
||||
// Clear the interrupt latch when a new turn starts (false → true). This
|
||||
// guards the sub-frame race where a Stop click lands after busy already
|
||||
// flipped false (button not yet unmounted): the stale latch can no longer
|
||||
// survive into the next turn and wrongly suppress its natural auto-drain.
|
||||
if (busy && !wasBusy) {
|
||||
userInterruptedRef.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void drainNextQueued()
|
||||
const interrupted = userInterruptedRef.current
|
||||
|
||||
// Consume the interrupt latch on any settle so a later natural completion
|
||||
// is not wrongly suppressed.
|
||||
if (!busy && wasBusy && interrupted) {
|
||||
userInterruptedRef.current = false
|
||||
}
|
||||
|
||||
if (
|
||||
shouldAutoDrainOnSettle({
|
||||
isBusy: busy,
|
||||
queueLength: queuedPrompts.length,
|
||||
userInterrupted: interrupted,
|
||||
wasBusy
|
||||
})
|
||||
) {
|
||||
void drainNextQueued()
|
||||
}
|
||||
}, [busy, drainNextQueued, queuedPrompts.length])
|
||||
|
||||
// Clean up queue edit when its target disappears (session swap or external delete).
|
||||
@@ -886,9 +940,13 @@ export function ChatBar({
|
||||
} else if (busy) {
|
||||
if (hasComposerPayload) {
|
||||
queueCurrentDraft()
|
||||
} else if (queuedPrompts.length > 0) {
|
||||
void interruptAndSendNextQueued()
|
||||
} else {
|
||||
// Stop button: an explicit interrupt must actually halt the running
|
||||
// turn. Mark the interrupt so the busy→false auto-drain effect skips
|
||||
// re-sending the queue — otherwise a queued follow-up would fire the
|
||||
// instant we cancel and Stop would appear to "never work". Queued
|
||||
// turns are preserved; the user sends them on demand.
|
||||
userInterruptedRef.current = true
|
||||
triggerHaptic('cancel')
|
||||
void Promise.resolve(onCancel())
|
||||
}
|
||||
@@ -1024,6 +1082,8 @@ export function ChatBar({
|
||||
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
|
||||
<div
|
||||
aria-label="Message"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
className={cn(
|
||||
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
|
||||
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
|
||||
@@ -1045,6 +1105,7 @@ export function ChatBar({
|
||||
onPaste={handlePaste}
|
||||
ref={editorRef}
|
||||
role="textbox"
|
||||
spellCheck="true"
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
{/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { act, fireEvent, render } from '@testing-library/react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useLiveCompletionAdapter } from './hooks/use-live-completion-adapter'
|
||||
import { detectTrigger, type TriggerState } from './text-utils'
|
||||
|
||||
// Faithful mirror of index.tsx's trigger wiring, driven through REAL DOM
|
||||
// keydown+keyup events on a contentEditable. Exercises the parts a direct
|
||||
// reducer-call repro misses: the keyup -> refreshTrigger path, the
|
||||
// keydown-set "consumed" ref that guards it, and per-press keydown+keyup
|
||||
// ordering (critical for Escape, whose keydown nulls `trigger` before keyup).
|
||||
function Harness({
|
||||
onState
|
||||
}: {
|
||||
onState: (s: { active: number; items: readonly Unstable_TriggerItem[]; open: boolean }) => void
|
||||
}) {
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const triggerKeyConsumedRef = useRef(false)
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
|
||||
const { adapter } = useLiveCompletionAdapter({
|
||||
enabled: true,
|
||||
debounceMs: 0,
|
||||
fetcher: async (query: string) => ({
|
||||
query,
|
||||
items: Array.from({ length: 5 }, (_, i) => ({ text: `/cmd${i}`, display: `/cmd${i}`, meta: '' }))
|
||||
}),
|
||||
toItem: (entry, index) => ({ id: `${entry.text}|${index}`, type: 'slash', label: entry.text.slice(1) })
|
||||
})
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null = trigger?.kind === '/' ? adapter : null
|
||||
|
||||
const refreshTrigger = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor) {return}
|
||||
const raw = editor.textContent ?? ''
|
||||
|
||||
if (!raw.includes('@') && !raw.includes('/')) {
|
||||
if (trigger) {
|
||||
setTrigger(null)
|
||||
setTriggerActive(0)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const detected = detectTrigger(raw)
|
||||
setTrigger(detected)
|
||||
|
||||
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
|
||||
setTriggerActive(0)
|
||||
}
|
||||
}, [trigger])
|
||||
|
||||
useEffect(() => {
|
||||
if (!trigger || !triggerAdapter?.search) {
|
||||
setTriggerItems([])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setTriggerItems(triggerAdapter.search(trigger.query))
|
||||
}, [trigger, triggerAdapter])
|
||||
|
||||
useEffect(() => {
|
||||
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
|
||||
}, [triggerItems.length])
|
||||
|
||||
onState({ active: triggerActive, items: triggerItems, open: trigger !== null })
|
||||
|
||||
const closeTrigger = () => {
|
||||
setTrigger(null)
|
||||
setTriggerItems([])
|
||||
setTriggerActive(0)
|
||||
}
|
||||
|
||||
// Exact copies of index.tsx handlers, including the keydown-set "consumed"
|
||||
// ref that the keyup consults.
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (trigger && triggerItems.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx + 1) % triggerItems.length)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
closeTrigger()
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = () => {
|
||||
if (triggerKeyConsumedRef.current) {
|
||||
triggerKeyConsumedRef.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// index.tsx defers via setTimeout(refreshTrigger, 0); call synchronously
|
||||
// here so the test deterministically observes the keyup-driven refresh.
|
||||
refreshTrigger()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
contentEditable
|
||||
data-testid="editor"
|
||||
onInput={() => refreshTrigger()}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
ref={editorRef}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
})
|
||||
}
|
||||
|
||||
describe('slash menu navigation — real DOM keydown+keyup', () => {
|
||||
it('cycles through ALL items and Esc closes (and stays closed)', async () => {
|
||||
vi.useRealTimers()
|
||||
let latest = { active: 0, items: [] as readonly Unstable_TriggerItem[], open: false }
|
||||
const { getByTestId } = render(<Harness onState={s => (latest = s)} />)
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
// Simulate typing '/'.
|
||||
await act(async () => {
|
||||
editor.textContent = '/'
|
||||
fireEvent.input(editor)
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(latest.open).toBe(true)
|
||||
expect(latest.items.length).toBe(5)
|
||||
|
||||
// ArrowDown 6x with REAL keydown+keyup pairs. Bug = stuck [0,1,0,1,...].
|
||||
const seen: number[] = [latest.active]
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(editor, { key: 'ArrowDown' })
|
||||
fireEvent.keyUp(editor, { key: 'ArrowDown' })
|
||||
await Promise.resolve()
|
||||
})
|
||||
seen.push(latest.active)
|
||||
}
|
||||
|
||||
expect(seen).toEqual([0, 1, 2, 3, 4, 0, 1])
|
||||
|
||||
// Escape: keydown closes; keyup must NOT reopen (the '/' is still in text).
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(editor, { key: 'Escape' })
|
||||
fireEvent.keyUp(editor, { key: 'Escape' })
|
||||
await Promise.resolve()
|
||||
})
|
||||
await flush()
|
||||
expect(latest.open).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { detectTrigger } from './text-utils'
|
||||
|
||||
describe('detectTrigger', () => {
|
||||
it('detects a bare slash trigger with an empty query', () => {
|
||||
expect(detectTrigger('/')).toEqual({ kind: '/', query: '', tokenLength: 1 })
|
||||
})
|
||||
|
||||
it('detects a slash command query', () => {
|
||||
expect(detectTrigger('/skill')).toEqual({ kind: '/', query: 'skill', tokenLength: 6 })
|
||||
})
|
||||
|
||||
it('detects a bare at-mention trigger with an empty query', () => {
|
||||
expect(detectTrigger('@')).toEqual({ kind: '@', query: '', tokenLength: 1 })
|
||||
})
|
||||
|
||||
it('detects an at-mention query', () => {
|
||||
expect(detectTrigger('@file')).toEqual({ kind: '@', query: 'file', tokenLength: 5 })
|
||||
})
|
||||
|
||||
it('returns null for plain text', () => {
|
||||
expect(detectTrigger('hello there')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -97,6 +97,17 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
key={tab.id}
|
||||
// Middle-click closes the tab, matching browser/IDE muscle
|
||||
// memory. `onMouseDown` swallows the middle-button press so
|
||||
// Chromium doesn't switch into autoscroll mode.
|
||||
onAuxClick={event => {
|
||||
if (event.button !== 1) return
|
||||
event.preventDefault()
|
||||
closeRightRailTab(tab.id)
|
||||
}}
|
||||
onMouseDown={event => {
|
||||
if (event.button === 1) event.preventDefault()
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
|
||||
|
||||
@@ -67,6 +67,12 @@ import { VirtualSessionList } from './virtual-session-list'
|
||||
|
||||
const VIRTUALIZE_THRESHOLD = 25
|
||||
|
||||
// Render the modifier key the user actually presses on this platform. The
|
||||
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
|
||||
// else) in desktop-controller.tsx, but the hint should match muscle memory.
|
||||
const NEW_SESSION_KBD: readonly string[] =
|
||||
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') ? ['⌘', 'N'] : ['Ctrl', 'N']
|
||||
|
||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{
|
||||
id: 'new-session',
|
||||
@@ -438,7 +444,7 @@ export function ChatSidebar({
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
|
||||
{item.id === 'new-session' && (
|
||||
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={['⇧', 'N']} />
|
||||
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={[...NEW_SESSION_KBD]} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -540,23 +546,28 @@ export function ChatSidebar({
|
||||
forceEmptyState={showSessionSkeletons}
|
||||
groups={agentsGrouped ? agentGroups : undefined}
|
||||
headerAction={
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
|
||||
className={cn(
|
||||
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setSidebarRecentsOpen(true)
|
||||
setSidebarAgentsGrouped(!agentsGrouped)
|
||||
}}
|
||||
size="icon-xs"
|
||||
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
||||
</Button>
|
||||
// Grouping operates on unpinned recents; if everything is
|
||||
// pinned the toggle does nothing visible, so hide it to avoid
|
||||
// a phantom click target.
|
||||
agentSessions.length > 0 ? (
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
|
||||
className={cn(
|
||||
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setSidebarRecentsOpen(true)
|
||||
setSidebarAgentsGrouped(!agentsGrouped)
|
||||
}}
|
||||
size="icon-xs"
|
||||
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
label="Sessions"
|
||||
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
|
||||
@@ -633,7 +644,7 @@ function SidebarPinnedEmptyState() {
|
||||
<span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
|
||||
<Codicon name="pin" size="0.75rem" />
|
||||
</span>
|
||||
<span>Shift click to pin a chat</span>
|
||||
<span>Shift-click a chat to pin · drag to reorder</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -428,14 +428,6 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
||||
return (
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
filters={
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
|
||||
<Codicon name="add" />
|
||||
New cron
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
onSearchChange={setQuery}
|
||||
searchPlaceholder="Search cron jobs..."
|
||||
searchTrailingAction={
|
||||
@@ -457,6 +449,10 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
||||
{!jobs ? (
|
||||
<PageLoader label="Loading cron jobs..." />
|
||||
) : visibleJobs.length === 0 ? (
|
||||
// Empty state owns the primary "create" CTA — we used to also have
|
||||
// one in the filters bar but it was redundant. Only show the button
|
||||
// when there are zero jobs total; the search-empty case ("No
|
||||
// matches") just asks the user to broaden their query.
|
||||
<EmptyState
|
||||
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
|
||||
description={
|
||||
@@ -469,6 +465,19 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
{/* Inline header replaces the old top-bar "New cron" button. We
|
||||
still need a single, always-visible affordance to add a job
|
||||
when the list is non-empty (rows themselves only expose
|
||||
edit/pause/trigger/delete). */}
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
|
||||
{enabledCount}/{totalCount} active
|
||||
</span>
|
||||
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
|
||||
<Codicon name="add" />
|
||||
New cron
|
||||
</Button>
|
||||
</div>
|
||||
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobRow
|
||||
@@ -484,8 +493,6 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden">{totalCount === 0 ? 'No scheduled jobs' : `${enabledCount}/${totalCount} active`}</div>
|
||||
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef } from 'react'
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
$gatewayState,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
$workingSessionIds,
|
||||
mergeWorkingSessions,
|
||||
sessionPinId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
@@ -59,10 +61,11 @@ import { ChatSidebar } from './chat/sidebar'
|
||||
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
|
||||
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
|
||||
import { ModelPickerOverlay } from './model-picker-overlay'
|
||||
import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
||||
import { RightSidebarPane } from './right-sidebar'
|
||||
import { $terminalTakeover } from './right-sidebar/store'
|
||||
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
||||
import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from './routes'
|
||||
import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
|
||||
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
|
||||
import { useCwdActions } from './session/hooks/use-cwd-actions'
|
||||
import { useHermesConfig } from './session/hooks/use-hermes-config'
|
||||
@@ -77,6 +80,7 @@ import { AppShell } from './shell/app-shell'
|
||||
import { useOverlayRouting } from './shell/hooks/use-overlay-routing'
|
||||
import { useStatusSnapshot } from './shell/hooks/use-status-snapshot'
|
||||
import { useStatusbarItems } from './shell/hooks/use-statusbar-items'
|
||||
import { ModelMenuPanel } from './shell/model-menu-panel'
|
||||
import type { StatusbarItem } from './shell/statusbar-controls'
|
||||
import type { TitlebarTool } from './shell/titlebar-controls'
|
||||
import { useGroupRegistry } from './shell/use-group-registry'
|
||||
@@ -204,7 +208,12 @@ export function DesktopController() {
|
||||
const result = await listSessions(limit, 1)
|
||||
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
setSessions(result.sessions)
|
||||
// Don't hard-replace: a session whose first turn is still in flight has
|
||||
// message_count 0 in the DB, so min_messages=1 omits it. Since every
|
||||
// message.complete refreshes the list, a plain replace would drop the
|
||||
// other still-running new chats the moment one of them finishes. Keep
|
||||
// any working session the server hasn't surfaced yet.
|
||||
setSessions(prev => mergeWorkingSessions(prev, result.sessions, $workingSessionIds.get()))
|
||||
setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
|
||||
}
|
||||
} finally {
|
||||
@@ -274,6 +283,22 @@ export function DesktopController() {
|
||||
requestGateway
|
||||
})
|
||||
|
||||
const openProviderSettings = useCallback(() => {
|
||||
navigate(`${SETTINGS_ROUTE}?tab=keys`)
|
||||
}, [navigate])
|
||||
|
||||
const modelMenuContent = useMemo(
|
||||
() =>
|
||||
gatewayState === 'open' ? (
|
||||
<ModelMenuPanel
|
||||
gateway={gatewayRef.current || undefined}
|
||||
onSelectModel={selectModel}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
) : null,
|
||||
[gatewayRef, gatewayState, requestGateway, selectModel]
|
||||
)
|
||||
|
||||
useContextSuggestions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
@@ -372,14 +397,22 @@ export function DesktopController() {
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement
|
||||
|
||||
if (editing || event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey) {
|
||||
if (event.defaultPrevented || event.repeat || event.altKey || event.code !== 'KeyN') {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.shiftKey && event.code === 'KeyN') {
|
||||
event.preventDefault()
|
||||
startFreshSessionDraft()
|
||||
// Two accelerators for "new session":
|
||||
// - Cmd/Ctrl+N (browser-like, works while typing in any input)
|
||||
// - Shift+N (single-key, only when no input is focused)
|
||||
const accelerator = event.metaKey || event.ctrlKey
|
||||
const singleKey = !accelerator && !editing && event.shiftKey
|
||||
|
||||
if (!accelerator && !singleKey) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
startFreshSessionDraft()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
@@ -489,6 +522,7 @@ export function DesktopController() {
|
||||
gatewayLogLines,
|
||||
gatewayState,
|
||||
inferenceStatus,
|
||||
modelMenuContent,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
statusSnapshot,
|
||||
@@ -523,6 +557,7 @@ export function DesktopController() {
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
||||
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
|
||||
<UpdatesOverlay />
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
|
||||
@@ -21,6 +21,8 @@ import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
import { PlatformAvatar } from './platform-icon'
|
||||
|
||||
interface MessagingViewProps extends React.ComponentProps<'section'> {
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
}
|
||||
@@ -39,29 +41,6 @@ const STATE_LABELS: Record<string, string> = {
|
||||
startup_failed: 'Startup failed'
|
||||
}
|
||||
|
||||
const PLATFORM_TINTS: Record<string, string> = {
|
||||
telegram: 'bg-sky-500/15 text-sky-600 dark:text-sky-300',
|
||||
discord: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-300',
|
||||
slack: 'bg-violet-500/15 text-violet-600 dark:text-violet-300',
|
||||
mattermost: 'bg-blue-500/15 text-blue-600 dark:text-blue-300',
|
||||
matrix: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300',
|
||||
signal: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-300',
|
||||
whatsapp: 'bg-green-500/15 text-green-600 dark:text-green-300',
|
||||
bluebubbles: 'bg-blue-500/15 text-blue-600 dark:text-blue-300',
|
||||
homeassistant: 'bg-teal-500/15 text-teal-600 dark:text-teal-300',
|
||||
email: 'bg-amber-500/15 text-amber-600 dark:text-amber-300',
|
||||
sms: 'bg-rose-500/15 text-rose-600 dark:text-rose-300',
|
||||
dingtalk: 'bg-blue-500/15 text-blue-600 dark:text-blue-300',
|
||||
feishu: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-300',
|
||||
wecom: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300',
|
||||
wecom_callback: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300',
|
||||
weixin: 'bg-green-500/15 text-green-600 dark:text-green-300',
|
||||
qqbot: 'bg-amber-500/15 text-amber-600 dark:text-amber-300',
|
||||
yuanbao: 'bg-orange-500/15 text-orange-600 dark:text-orange-300',
|
||||
api_server: 'bg-slate-500/15 text-slate-600 dark:text-slate-300',
|
||||
webhook: 'bg-zinc-500/15 text-zinc-600 dark:text-zinc-300'
|
||||
}
|
||||
|
||||
const PILL_TONE: Record<StatusTone, string> = {
|
||||
good: 'bg-primary/10 text-primary',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
@@ -442,19 +421,6 @@ function PlatformRow({
|
||||
)
|
||||
}
|
||||
|
||||
function PlatformAvatar({ platformId, platformName }: { platformId: string; platformName: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex size-6 shrink-0 items-center justify-center rounded-md text-[length:var(--conversation-caption-font-size)] font-medium',
|
||||
PLATFORM_TINTS[platformId] || 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)'
|
||||
)}
|
||||
>
|
||||
{platformName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function PlatformDetail({
|
||||
edits,
|
||||
onClear,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
|
||||
import {
|
||||
SiApple,
|
||||
SiBilibili,
|
||||
SiDiscord,
|
||||
SiGmail,
|
||||
SiHomeassistant,
|
||||
SiMatrix,
|
||||
SiMattermost,
|
||||
SiQq,
|
||||
SiSignal,
|
||||
SiTelegram,
|
||||
SiWechat,
|
||||
SiWhatsapp
|
||||
} from '@icons-pack/react-simple-icons'
|
||||
|
||||
import { Globe, Link as LinkIcon, MessageSquareText } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// We render simpleicons.org brand glyphs for platforms whose owners publish a
|
||||
// usable mark (telegram, discord, matrix, ...). A few brands — Slack, Dingtalk,
|
||||
// Feishu, WeCom — have been removed from Simple Icons at the brand owner's
|
||||
// request, so we fall back to a colored letter monogram for those.
|
||||
//
|
||||
// `iconColor` is the brand's hex from simpleicons.org so we can paint each
|
||||
// glyph in its native color on top of a soft tint. The fallback monogram uses
|
||||
// the same hex to keep visual consistency.
|
||||
type IconKind = 'brand' | 'generic'
|
||||
|
||||
interface PlatformIconSpec {
|
||||
Icon: ComponentType<SVGProps<SVGSVGElement>>
|
||||
color: string
|
||||
kind: IconKind
|
||||
}
|
||||
|
||||
const PLATFORM_ICONS: Record<string, PlatformIconSpec> = {
|
||||
telegram: { Icon: SiTelegram, color: '#26A5E4', kind: 'brand' },
|
||||
discord: { Icon: SiDiscord, color: '#5865F2', kind: 'brand' },
|
||||
// Slack removed from Simple Icons by Salesforce request — letter monogram.
|
||||
mattermost: { Icon: SiMattermost, color: '#0058CC', kind: 'brand' },
|
||||
matrix: { Icon: SiMatrix, color: '#000000', kind: 'brand' },
|
||||
signal: { Icon: SiSignal, color: '#3A76F0', kind: 'brand' },
|
||||
whatsapp: { Icon: SiWhatsapp, color: '#25D366', kind: 'brand' },
|
||||
bluebubbles: { Icon: SiApple, color: '#0BD318', kind: 'brand' },
|
||||
homeassistant: { Icon: SiHomeassistant, color: '#18BCF2', kind: 'brand' },
|
||||
email: { Icon: SiGmail, color: '#EA4335', kind: 'brand' },
|
||||
sms: { Icon: MessageSquareText, color: '#F43F5E', kind: 'generic' },
|
||||
webhook: { Icon: LinkIcon, color: '#71717A', kind: 'generic' },
|
||||
api_server: { Icon: Globe, color: '#64748B', kind: 'generic' },
|
||||
weixin: { Icon: SiWechat, color: '#07C160', kind: 'brand' },
|
||||
qqbot: { Icon: SiQq, color: '#EB1923', kind: 'brand' },
|
||||
yuanbao: { Icon: SiBilibili, color: '#FB7299', kind: 'brand' }
|
||||
}
|
||||
|
||||
interface PlatformAvatarProps {
|
||||
platformId: string
|
||||
platformName: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PlatformAvatar({ className, platformId, platformName }: PlatformAvatarProps) {
|
||||
const spec = PLATFORM_ICONS[platformId]
|
||||
|
||||
const baseClass = cn(
|
||||
'inline-grid size-6 shrink-0 place-items-center rounded-md text-[length:var(--conversation-caption-font-size)] font-medium',
|
||||
className
|
||||
)
|
||||
|
||||
if (!spec) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}
|
||||
>
|
||||
{platformName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const { Icon, color } = spec
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={baseClass}
|
||||
style={{
|
||||
// 16% tint of the brand color so the glyph reads against any surface
|
||||
// without the avatar dominating the row.
|
||||
backgroundColor: `color-mix(in srgb, ${color} 16%, transparent)`,
|
||||
color
|
||||
}}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { ModelVisibilityDialog } from '@/components/model-visibility-dialog'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { $modelVisibilityOpen, setModelVisibilityOpen } from '@/store/model-visibility'
|
||||
import { $activeSessionId, $gatewayState } from '@/store/session'
|
||||
|
||||
interface ModelVisibilityOverlayProps {
|
||||
gateway?: HermesGateway
|
||||
onOpenProviders: () => void
|
||||
}
|
||||
|
||||
export function ModelVisibilityOverlay({ gateway, onOpenProviders }: ModelVisibilityOverlayProps) {
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const gatewayOpen = useStore($gatewayState) === 'open'
|
||||
const open = useStore($modelVisibilityOpen)
|
||||
|
||||
if (!gatewayOpen) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ModelVisibilityDialog
|
||||
gw={gateway}
|
||||
onOpenChange={setModelVisibilityOpen}
|
||||
onOpenProviders={onOpenProviders}
|
||||
open={open}
|
||||
sessionId={activeSessionId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -28,10 +28,16 @@ export function PageSearchShell({
|
||||
{...props}
|
||||
className={cn('flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)', className)}
|
||||
>
|
||||
<div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5">
|
||||
{/*
|
||||
This header sits in the titlebar row, so it overlaps the OS window-drag
|
||||
region painted by the shell. Without `-webkit-app-region: no-drag` on
|
||||
the search row, mousedown on the input gets intercepted as a window-
|
||||
drag start and the input never receives focus (visible as "I can't
|
||||
click the search box" on the messaging/cron/etc pages).
|
||||
*/}
|
||||
<div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5 [-webkit-app-region:no-drag]">
|
||||
{/* Reserve the top-right titlebar tools + native window-controls
|
||||
footprint so the full-width search input never slides under them
|
||||
(this header sits in the titlebar row at the window top). */}
|
||||
footprint so the full-width search input never slides under them. */}
|
||||
<div
|
||||
style={{
|
||||
paddingRight:
|
||||
|
||||
@@ -11,6 +11,8 @@ const ROW_HEIGHT = 22
|
||||
const INDENT = 10
|
||||
|
||||
interface ProjectTreeProps {
|
||||
collapseNonce: number
|
||||
cwd: string
|
||||
data: TreeNode[]
|
||||
onActivateFile: (path: string) => void
|
||||
onActivateFolder: (path: string) => void
|
||||
@@ -21,6 +23,8 @@ interface ProjectTreeProps {
|
||||
}
|
||||
|
||||
export function ProjectTree({
|
||||
collapseNonce,
|
||||
cwd,
|
||||
data,
|
||||
onActivateFile,
|
||||
onActivateFolder,
|
||||
@@ -63,7 +67,7 @@ export function ProjectTree({
|
||||
|
||||
onNodeOpenChange(id, node.isOpen)
|
||||
|
||||
if (node.isOpen && node.data.children === undefined) {
|
||||
if (node.isOpen && node.data?.isDirectory && node.data.children === undefined) {
|
||||
void onLoadChildren(id)
|
||||
}
|
||||
},
|
||||
@@ -72,7 +76,7 @@ export function ProjectTree({
|
||||
|
||||
const handleActivate = useCallback(
|
||||
(node: NodeApi<TreeNode>) => {
|
||||
if (!node.data.isDirectory) {
|
||||
if (node.data && !node.data.isDirectory) {
|
||||
onPreviewFile?.(node.data.id)
|
||||
}
|
||||
},
|
||||
@@ -83,7 +87,7 @@ export function ProjectTree({
|
||||
<div className="min-h-0 flex-1 overflow-hidden" ref={containerRef}>
|
||||
{size.height > 0 && size.width > 0 ? (
|
||||
<Tree<TreeNode>
|
||||
childrenAccessor={node => (node.isDirectory ? (node.children ?? []) : null)}
|
||||
childrenAccessor={node => (node?.isDirectory ? (node.children ?? []) : null)}
|
||||
data={data}
|
||||
disableDrag
|
||||
disableDrop
|
||||
@@ -91,6 +95,7 @@ export function ProjectTree({
|
||||
height={size.height}
|
||||
indent={INDENT}
|
||||
initialOpenState={openState}
|
||||
key={`${cwd}:${collapseNonce}`}
|
||||
onActivate={handleActivate}
|
||||
onToggle={handleToggle}
|
||||
openByDefault={false}
|
||||
@@ -135,6 +140,10 @@ function ProjectTreeRow({
|
||||
onAttachFolder: (path: string) => void
|
||||
onPreviewFile?: (path: string) => void
|
||||
}) {
|
||||
if (!node.data) {
|
||||
return <div style={style} />
|
||||
}
|
||||
|
||||
const isFolder = node.data.isDirectory
|
||||
const isPlaceholder = node.data.id.endsWith('::__loading__')
|
||||
|
||||
|
||||
@@ -47,16 +47,20 @@ function placeholderChild(parentId: string): TreeNode {
|
||||
}
|
||||
|
||||
export interface UseProjectTreeResult {
|
||||
/** Bumped by collapseAll so callers can remount the tree fully collapsed. */
|
||||
collapseNonce: number
|
||||
data: TreeNode[]
|
||||
openState: Record<string, boolean>
|
||||
rootError: string | null
|
||||
rootLoading: boolean
|
||||
collapseAll: () => void
|
||||
loadChildren: (id: string) => Promise<void>
|
||||
refreshRoot: () => Promise<void>
|
||||
setNodeOpen: (id: string, open: boolean) => void
|
||||
}
|
||||
|
||||
interface ProjectTreeState {
|
||||
collapseNonce: number
|
||||
cwd: string
|
||||
data: TreeNode[]
|
||||
loaded: boolean
|
||||
@@ -67,6 +71,7 @@ interface ProjectTreeState {
|
||||
}
|
||||
|
||||
const initialState: ProjectTreeState = {
|
||||
collapseNonce: 0,
|
||||
cwd: '',
|
||||
data: [],
|
||||
loaded: false,
|
||||
@@ -112,6 +117,7 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
|
||||
}
|
||||
|
||||
$projectTree.set({
|
||||
collapseNonce: current.collapseNonce,
|
||||
cwd,
|
||||
data: [],
|
||||
loaded: false,
|
||||
@@ -174,6 +180,19 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
[cwd]
|
||||
)
|
||||
|
||||
// Clears the recorded open state and bumps the nonce; the tree is keyed on
|
||||
// the nonce so it remounts with everything collapsed (loaded children stay
|
||||
// cached in `data`, just hidden).
|
||||
const collapseAll = useCallback(() => {
|
||||
setProjectTree(current => {
|
||||
if (current.cwd !== cwd) {
|
||||
return current
|
||||
}
|
||||
|
||||
return { ...current, collapseNonce: current.collapseNonce + 1, openState: {} }
|
||||
})
|
||||
}, [cwd])
|
||||
|
||||
const loadChildren = useCallback(
|
||||
async (id: string) => {
|
||||
if (!cwd || inflight.has(id)) {
|
||||
@@ -222,6 +241,8 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
collapseAll,
|
||||
collapseNonce: state.cwd === cwd ? state.collapseNonce : 0,
|
||||
data: state.cwd === cwd ? state.data : [],
|
||||
loadChildren,
|
||||
openState: state.cwd === cwd ? state.openState : {},
|
||||
@@ -231,10 +252,12 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
setNodeOpen
|
||||
}),
|
||||
[
|
||||
collapseAll,
|
||||
cwd,
|
||||
loadChildren,
|
||||
refreshRoot,
|
||||
setNodeOpen,
|
||||
state.collapseNonce,
|
||||
state.cwd,
|
||||
state.data,
|
||||
state.openState,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { ErrorBoundary } from '@/components/error-boundary'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
@@ -52,7 +53,10 @@ export function RightSidebarPane({
|
||||
.pop() ?? currentCwd)
|
||||
: 'No folder selected'
|
||||
|
||||
const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd)
|
||||
const { collapseAll, collapseNonce, data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } =
|
||||
useProjectTree(currentCwd)
|
||||
|
||||
const canCollapse = Object.values(openState).some(Boolean)
|
||||
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
|
||||
|
||||
const chooseFolder = async () => {
|
||||
@@ -97,6 +101,8 @@ export function RightSidebarPane({
|
||||
<TerminalSlot />
|
||||
) : (
|
||||
<FilesystemTab
|
||||
canCollapse={canCollapse}
|
||||
collapseNonce={collapseNonce}
|
||||
cwd={currentCwd}
|
||||
cwdName={cwdName}
|
||||
data={data}
|
||||
@@ -106,6 +112,7 @@ export function RightSidebarPane({
|
||||
onActivateFile={onActivateFile}
|
||||
onActivateFolder={onActivateFolder}
|
||||
onChangeFolder={chooseFolder}
|
||||
onCollapseAll={collapseAll}
|
||||
onLoadChildren={loadChildren}
|
||||
onNodeOpenChange={setNodeOpen}
|
||||
onPreviewFile={previewFile}
|
||||
@@ -160,13 +167,22 @@ function RightSidebarChrome({
|
||||
}
|
||||
|
||||
interface FilesystemTabProps extends FileTreeBodyProps {
|
||||
canCollapse: boolean
|
||||
cwdName: string
|
||||
hasCwd: boolean
|
||||
onChangeFolder: () => Promise<void> | void
|
||||
onCollapseAll: () => void
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
const HEADER_ACTION_CLASS =
|
||||
'size-6 shrink-0 rounded-md text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-2 focus-visible:ring-sidebar-ring'
|
||||
|
||||
const HEADER_ACTION_REVEAL_CLASS = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
|
||||
|
||||
function FilesystemTab({
|
||||
canCollapse,
|
||||
collapseNonce,
|
||||
cwd,
|
||||
cwdName,
|
||||
data,
|
||||
@@ -176,6 +192,7 @@ function FilesystemTab({
|
||||
onActivateFile,
|
||||
onActivateFolder,
|
||||
onChangeFolder,
|
||||
onCollapseAll,
|
||||
onLoadChildren,
|
||||
onNodeOpenChange,
|
||||
onPreviewFile,
|
||||
@@ -188,14 +205,35 @@ function FilesystemTab({
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => void onChangeFolder()}
|
||||
title={hasCwd ? cwd : 'No folder selected'}
|
||||
title={hasCwd ? `${cwd} — click to change folder` : 'Open a folder'}
|
||||
type="button"
|
||||
>
|
||||
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
|
||||
</button>
|
||||
<Button
|
||||
aria-label="Open folder"
|
||||
className={HEADER_ACTION_CLASS}
|
||||
onClick={() => void onChangeFolder()}
|
||||
size="icon"
|
||||
title={hasCwd ? 'Open a different folder' : 'Open a folder'}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="folder-opened" size="0.8125rem" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Collapse all folders"
|
||||
className={HEADER_ACTION_REVEAL_CLASS}
|
||||
disabled={!hasCwd || !canCollapse}
|
||||
onClick={onCollapseAll}
|
||||
size="icon"
|
||||
title="Collapse all folders"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="collapse-all" size="0.8125rem" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Refresh tree"
|
||||
className="pointer-events-none size-6 shrink-0 rounded-md text-sidebar-foreground/70 opacity-0 transition-opacity hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-sidebar-ring group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100"
|
||||
className={HEADER_ACTION_REVEAL_CLASS}
|
||||
disabled={!hasCwd || loading}
|
||||
onClick={onRefresh}
|
||||
size="icon"
|
||||
@@ -206,6 +244,7 @@ function FilesystemTab({
|
||||
</Button>
|
||||
</RightSidebarSectionHeader>
|
||||
<FileTreeBody
|
||||
collapseNonce={collapseNonce}
|
||||
cwd={cwd}
|
||||
data={data}
|
||||
error={error}
|
||||
@@ -226,6 +265,7 @@ export function RightSidebarSectionHeader({ children }: { children: ReactNode })
|
||||
}
|
||||
|
||||
interface FileTreeBodyProps {
|
||||
collapseNonce: number
|
||||
cwd: string
|
||||
data: ReturnType<typeof useProjectTree>['data']
|
||||
error: string | null
|
||||
@@ -239,6 +279,7 @@ interface FileTreeBodyProps {
|
||||
}
|
||||
|
||||
function FileTreeBody({
|
||||
collapseNonce,
|
||||
cwd,
|
||||
data,
|
||||
error,
|
||||
@@ -267,15 +308,34 @@ function FileTreeBody({
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectTree
|
||||
data={data}
|
||||
onActivateFile={onActivateFile}
|
||||
onActivateFolder={onActivateFolder}
|
||||
onLoadChildren={onLoadChildren}
|
||||
onNodeOpenChange={onNodeOpenChange}
|
||||
onPreviewFile={onPreviewFile}
|
||||
openState={openState}
|
||||
/>
|
||||
<ErrorBoundary
|
||||
fallback={({ reset }) => (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<EmptyState body="The file tree hit an error rendering this folder." title="Tree error" />
|
||||
<button
|
||||
className="text-[0.68rem] font-medium text-muted-foreground transition hover:text-foreground"
|
||||
onClick={reset}
|
||||
type="button"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
key={cwd}
|
||||
label="file-tree"
|
||||
>
|
||||
<ProjectTree
|
||||
collapseNonce={collapseNonce}
|
||||
cwd={cwd}
|
||||
data={data}
|
||||
onActivateFile={onActivateFile}
|
||||
onActivateFolder={onActivateFolder}
|
||||
onLoadChildren={onLoadChildren}
|
||||
onNodeOpenChange={onNodeOpenChange}
|
||||
onPreviewFile={onPreviewFile}
|
||||
openState={openState}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -50,16 +50,23 @@ export function useCwdActions({
|
||||
}
|
||||
|
||||
if (!activeSessionId) {
|
||||
setCurrentCwd(trimmed)
|
||||
|
||||
try {
|
||||
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', {
|
||||
key: 'project',
|
||||
cwd: trimmed
|
||||
})
|
||||
|
||||
setCurrentCwd(info.cwd || trimmed)
|
||||
// Adopt the backend's normalized cwd so the persisted workspace and
|
||||
// branch stay consistent with what the agent will use.
|
||||
if (info.cwd) {
|
||||
setCurrentCwd(info.cwd)
|
||||
}
|
||||
|
||||
setCurrentBranch(info.branch || '')
|
||||
} catch (err) {
|
||||
notifyError(err, 'Working directory change failed')
|
||||
} catch {
|
||||
setCurrentBranch('')
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useCallback } from 'react'
|
||||
|
||||
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentModel, setCurrentProvider } from '@/store/session'
|
||||
import { $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
interface ModelSelection {
|
||||
@@ -48,38 +48,53 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Returns whether the switch succeeded so callers can await it before
|
||||
// applying follow-up changes (e.g. editing a model's reasoning/fast must land
|
||||
// on the right active model — bail rather than write to the previous one).
|
||||
const selectModel = useCallback(
|
||||
(selection: ModelSelection) => {
|
||||
async (selection: ModelSelection): Promise<boolean> => {
|
||||
const includeGlobal = selection.persistGlobal || !activeSessionId
|
||||
// Snapshot for rollback: the switch is applied optimistically, so a
|
||||
// failure must restore the prior model/provider (store + query cache)
|
||||
// rather than leave the UI showing a model the backend never selected.
|
||||
const prevModel = $currentModel.get()
|
||||
const prevProvider = $currentProvider.get()
|
||||
|
||||
setCurrentModel(selection.model)
|
||||
setCurrentProvider(selection.provider)
|
||||
updateModelOptionsCache(selection.provider, selection.model, selection.persistGlobal || !activeSessionId)
|
||||
updateModelOptionsCache(selection.provider, selection.model, includeGlobal)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (activeSessionId) {
|
||||
await requestGateway('slash.exec', {
|
||||
session_id: activeSessionId,
|
||||
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
|
||||
})
|
||||
try {
|
||||
if (activeSessionId) {
|
||||
await requestGateway('slash.exec', {
|
||||
session_id: activeSessionId,
|
||||
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
|
||||
})
|
||||
|
||||
if (selection.persistGlobal) {
|
||||
void refreshCurrentModel()
|
||||
}
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
|
||||
})
|
||||
|
||||
return
|
||||
if (selection.persistGlobal) {
|
||||
void refreshCurrentModel()
|
||||
}
|
||||
|
||||
await setGlobalModel(selection.provider, selection.model)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Model switch failed')
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
})()
|
||||
|
||||
await setGlobalModel(selection.provider, selection.model)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
setCurrentModel(prevModel)
|
||||
setCurrentProvider(prevProvider)
|
||||
updateModelOptionsCache(prevProvider, prevModel, includeGlobal)
|
||||
notifyError(err, 'Model switch failed')
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
[activeSessionId, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
|
||||
)
|
||||
|
||||
@@ -65,7 +65,7 @@ interface PromptActionsOptions {
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
busyRef: MutableRefObject<boolean>
|
||||
branchCurrentSession: () => Promise<boolean>
|
||||
createBackendSessionForSend: () => Promise<string | null>
|
||||
createBackendSessionForSend: (preview?: string | null) => Promise<string | null>
|
||||
handleSkinCommand: (arg: string) => string
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
selectedStoredSessionIdRef: MutableRefObject<string | null>
|
||||
@@ -296,7 +296,7 @@ export function usePromptActions({
|
||||
|
||||
if (!sessionId) {
|
||||
try {
|
||||
sessionId = await createBackendSessionForSend()
|
||||
sessionId = await createBackendSessionForSend(visibleText)
|
||||
} catch (err) {
|
||||
dropOptimistic(null)
|
||||
releaseBusy()
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
$currentCwd,
|
||||
$messages,
|
||||
$sessions,
|
||||
getRememberedWorkspaceCwd,
|
||||
setActiveSessionId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
setMessages,
|
||||
setSelectedStoredSessionId,
|
||||
setSessions,
|
||||
setSessionsTotal,
|
||||
setSessionStartedAt,
|
||||
setTurnStartedAt
|
||||
} from '@/store/session'
|
||||
@@ -291,7 +293,8 @@ export function useSessionActions({
|
||||
})
|
||||
setSessionStartedAt(null)
|
||||
setTurnStartedAt(null)
|
||||
setCurrentCwd('')
|
||||
// New chats inherit the current workspace.
|
||||
setCurrentCwd(getRememberedWorkspaceCwd())
|
||||
setCurrentBranch('')
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
@@ -300,7 +303,7 @@ export function useSessionActions({
|
||||
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
|
||||
)
|
||||
|
||||
const createBackendSessionForSend = useCallback(async (): Promise<string | null> => {
|
||||
const createBackendSessionForSend = useCallback(async (preview: string | null = null): Promise<string | null> => {
|
||||
const startingActiveSessionId = activeSessionIdRef.current
|
||||
const startingStoredSessionId = selectedStoredSessionIdRef.current
|
||||
const startingRouteToken = getRouteToken()
|
||||
@@ -308,7 +311,7 @@ export function useSessionActions({
|
||||
creatingSessionRef.current = true
|
||||
|
||||
try {
|
||||
const cwd = $currentCwd.get().trim()
|
||||
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
|
||||
const stored = created.stored_session_id ?? null
|
||||
|
||||
@@ -327,7 +330,11 @@ export function useSessionActions({
|
||||
ensureSessionState(created.session_id, stored)
|
||||
|
||||
if (stored) {
|
||||
upsertOptimisticSession(created, stored)
|
||||
// Seed the sidebar preview with the user's first message so the row
|
||||
// reads meaningfully while the turn is in flight, instead of flashing
|
||||
// "Untitled session" until the turn persists and auto-title runs. The
|
||||
// server later returns its own preview/title and supersedes this.
|
||||
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
|
||||
navigate(sessionRoute(stored), { replace: true })
|
||||
}
|
||||
|
||||
@@ -687,6 +694,9 @@ export function useSessionActions({
|
||||
const previousPinned = $pinnedSessionIds.get()
|
||||
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
|
||||
// doesn't keep claiming the removed row is still on the server.
|
||||
setSessionsTotal(prev => Math.max(0, prev - 1))
|
||||
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
|
||||
|
||||
// Tear down before awaiting so the route effect can't resume the
|
||||
@@ -709,6 +719,7 @@ export function useSessionActions({
|
||||
} catch (err) {
|
||||
if (removed) {
|
||||
setSessions(prev => [removed, ...prev])
|
||||
setSessionsTotal(prev => prev + 1)
|
||||
}
|
||||
|
||||
$pinnedSessionIds.set(previousPinned)
|
||||
@@ -761,6 +772,10 @@ export function useSessionActions({
|
||||
|
||||
// Soft-hide: drop from the sidebar immediately, keep the data.
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
// Archived sessions are hidden by the listSessions(min_messages=1) query
|
||||
// on the next refresh, so they count as "removed" for the load-more
|
||||
// footer math.
|
||||
setSessionsTotal(prev => Math.max(0, prev - 1))
|
||||
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
|
||||
|
||||
if (wasSelected) {
|
||||
@@ -773,6 +788,7 @@ export function useSessionActions({
|
||||
} catch (err) {
|
||||
if (archived) {
|
||||
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
|
||||
setSessionsTotal(prev => prev + 1)
|
||||
}
|
||||
|
||||
$pinnedSessionIds.set(previousPinned)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $busy, $messages, setSessionWorking } from '@/store/session'
|
||||
import { $busy, $messages, noteSessionActivity, setSessionWorking } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
@@ -95,6 +95,19 @@ export function useSessionStateCache({
|
||||
|
||||
const syncSessionStateToView = useCallback(
|
||||
(sessionId: string, state: ClientSessionState) => {
|
||||
// Only the currently-viewed session may stage into the shared `$messages`
|
||||
// view. A background session (e.g. one still busy and emitting stream /
|
||||
// error updates after the user toggled away) must update its own cache
|
||||
// entry but never the view — otherwise its messages clobber the
|
||||
// foreground transcript and appear to "bleed" into every other session.
|
||||
// The flush below also re-checks the active id, but staging here is what
|
||||
// prevents a background write from overwriting an already-pending
|
||||
// foreground write within the same animation frame (only one RAF is
|
||||
// scheduled, so the last `pendingViewStateRef` writer would otherwise win).
|
||||
if (sessionId !== activeSessionIdRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingViewStateRef.current = { sessionId, state }
|
||||
|
||||
if (viewSyncRafRef.current !== null) {
|
||||
@@ -140,6 +153,13 @@ export function useSessionStateCache({
|
||||
}
|
||||
|
||||
setSessionWorking(next.storedSessionId, next.busy)
|
||||
// Every state update is effectively a "still alive" heartbeat for
|
||||
// streaming events. The session-store watchdog uses this to keep the
|
||||
// working flag alive during long-running turns and to clear it once
|
||||
// the stream goes silent.
|
||||
if (next.busy) {
|
||||
noteSessionActivity(next.storedSessionId)
|
||||
}
|
||||
syncSessionStateToView(sessionId, next)
|
||||
|
||||
return next
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
$updateChecking,
|
||||
$updateStatus,
|
||||
checkUpdates,
|
||||
openUpdatesWindow
|
||||
openUpdatesWindow,
|
||||
refreshDesktopVersion
|
||||
} from '@/store/updates'
|
||||
|
||||
import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||
@@ -46,6 +47,14 @@ export function AboutSettings() {
|
||||
const checking = useStore($updateChecking)
|
||||
const [justChecked, setJustChecked] = useState(false)
|
||||
|
||||
// The version atom is loaded once at app boot, which makes About show a
|
||||
// stale number after a self-update (the running binary is current, the
|
||||
// displayed string is not). Re-read on mount so opening About always
|
||||
// reflects the running build.
|
||||
useEffect(() => {
|
||||
void refreshDesktopVersion()
|
||||
}, [])
|
||||
|
||||
const behind = status?.behind ?? 0
|
||||
const supported = status?.supported !== false
|
||||
const applying = apply.applying || apply.stage === 'restart'
|
||||
|
||||
@@ -22,8 +22,6 @@ import {
|
||||
import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import type { EnvPatch, EnvRowProps, ProviderGroup, SearchProps } from './types'
|
||||
|
||||
const SHOW_ADVANCED_STORAGE_KEY = 'desktop.settings.keys.show_advanced'
|
||||
|
||||
interface EnvActionsProps {
|
||||
varKey: string
|
||||
info: EnvVarInfo
|
||||
@@ -186,8 +184,11 @@ function EnvProviderGroup({
|
||||
group: ProviderGroup
|
||||
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const setCount = group.entries.filter(([, info]) => info.is_set).length
|
||||
// Default-expand providers that already have at least one key set; the
|
||||
// user is much more likely to be coming back to edit those than to start
|
||||
// configuring a fresh provider from scratch.
|
||||
const [expanded, setExpanded] = useState(setCount > 0)
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl bg-background/60">
|
||||
@@ -222,27 +223,17 @@ export function KeysSettings({ query }: SearchProps) {
|
||||
const [revealed, setRevealed] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
|
||||
const [showAdvanced, setShowAdvanced] = useState<boolean>(() => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(SHOW_ADVANCED_STORAGE_KEY)
|
||||
|
||||
if (stored === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return stored === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// We used to hide ~80% of rows behind a global "Show advanced" toggle, but
|
||||
// everything in this view is configuration-level — "advanced" was a poor
|
||||
// distinction. The full list is rendered now and provider groups
|
||||
// default-collapsed-unless-set keep the surface manageable.
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(SHOW_ADVANCED_STORAGE_KEY, showAdvanced ? 'true' : 'false')
|
||||
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
|
||||
} catch {
|
||||
// Ignore persistence failures and keep in-memory preference.
|
||||
// Ignore — old key cleanup is best-effort.
|
||||
}
|
||||
}, [showAdvanced])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -262,28 +253,21 @@ export function KeysSettings({ query }: SearchProps) {
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
const filterEnv = useCallback(
|
||||
(info: EnvVarInfo, key: string, q: string, cat: string, extra?: string) => {
|
||||
if (asText(info.category) !== cat) {
|
||||
return false
|
||||
}
|
||||
const filterEnv = useCallback((info: EnvVarInfo, key: string, q: string, cat: string, extra?: string) => {
|
||||
if (asText(info.category) !== cat) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!showAdvanced && Boolean(info.advanced)) {
|
||||
return false
|
||||
}
|
||||
if (!q) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!q) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
key.toLowerCase().includes(q) ||
|
||||
includesQuery(info.description, q) ||
|
||||
Boolean(extra && extra.toLowerCase().includes(q))
|
||||
)
|
||||
},
|
||||
[showAdvanced]
|
||||
)
|
||||
return (
|
||||
key.toLowerCase().includes(q) ||
|
||||
includesQuery(info.description, q) ||
|
||||
Boolean(extra && extra.toLowerCase().includes(q))
|
||||
)
|
||||
}, [])
|
||||
|
||||
const providerGroups = useMemo<ProviderGroup[]>(() => {
|
||||
if (!vars) {
|
||||
@@ -415,12 +399,6 @@ export function KeysSettings({ query }: SearchProps) {
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="mb-4 flex justify-end">
|
||||
<Button onClick={() => setShowAdvanced(s => !s)} size="sm" variant="outline">
|
||||
{showAdvanced ? 'Hide advanced' : 'Show advanced'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<SectionHeading
|
||||
icon={Zap}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, ArchiveOff, Loader2, Trash2 } from '@/lib/icons'
|
||||
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { setSessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
@@ -105,6 +105,8 @@ export function SessionsSettings({ query }: SearchProps) {
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<DefaultProjectDirSetting />
|
||||
|
||||
<SectionHeading
|
||||
icon={Archive}
|
||||
meta={sessions.length ? String(sessions.length) : undefined}
|
||||
@@ -166,3 +168,104 @@ export function SessionsSettings({ query }: SearchProps) {
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
// Lets the user pin the default cwd for new sessions. Without this, packaged
|
||||
// builds on Windows used to spawn sessions in the install dir (`win-unpacked`
|
||||
// / Program Files), which buried any files Hermes wrote there.
|
||||
function DefaultProjectDirSetting() {
|
||||
const [dir, setDir] = useState<null | string>(null)
|
||||
const [fallback, setFallback] = useState<string>('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// The bridge is only present when running inside Electron. In a Vitest
|
||||
// / Storybook / non-Electron context `window.hermesDesktop` is
|
||||
// undefined, so guard the WHOLE call chain rather than chaining
|
||||
// `?.settings.getDefaultProjectDir().then(...)` (the latter would
|
||||
// short-circuit to `undefined.then(...)` and throw at runtime).
|
||||
const settings = window.hermesDesktop?.settings
|
||||
|
||||
if (!settings) {
|
||||
return
|
||||
}
|
||||
|
||||
let alive = true
|
||||
|
||||
void settings.getDefaultProjectDir().then(result => {
|
||||
if (!alive) return
|
||||
setDir(result.dir)
|
||||
setFallback(result.defaultLabel)
|
||||
})
|
||||
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const choose = useCallback(async () => {
|
||||
const settings = window.hermesDesktop?.settings
|
||||
|
||||
if (!settings) return
|
||||
|
||||
setBusy(true)
|
||||
|
||||
try {
|
||||
const picked = await settings.pickDefaultProjectDir()
|
||||
|
||||
if (picked.canceled || !picked.dir) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await settings.setDefaultProjectDir(picked.dir)
|
||||
setDir(result.dir)
|
||||
notify({ durationMs: 2_000, kind: 'success', message: 'Default project directory updated' })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not update default directory')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clear = useCallback(async () => {
|
||||
const settings = window.hermesDesktop?.settings
|
||||
|
||||
if (!settings) return
|
||||
|
||||
setBusy(true)
|
||||
|
||||
try {
|
||||
await settings.setDefaultProjectDir(null)
|
||||
setDir(null)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not clear default directory')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<SectionHeading icon={FolderOpen} title="Default project directory" />
|
||||
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
New sessions start in this folder unless you pick another. Leave it unset to use your home directory.
|
||||
</p>
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button disabled={busy} onClick={() => void choose()} size="sm" type="button" variant="outline">
|
||||
<FolderOpen className="size-3.5" />
|
||||
<span>{dir ? 'Change' : 'Choose'}</span>
|
||||
</Button>
|
||||
{dir && (
|
||||
<Button disabled={busy} onClick={() => void clear()} size="sm" type="button" variant="ghost">
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={dir || `Defaults to ${fallback || '~/hermes-projects'}.`}
|
||||
title={dir ? dir : 'Not set'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { CommandCenterSection } from '@/app/command-center'
|
||||
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
|
||||
import { Activity, AlertCircle, Clock, Command, Cpu, Hash, Loader2, Sparkles } from '@/lib/icons'
|
||||
import { Activity, AlertCircle, ChevronDown, Clock, Command, Hash, Loader2, Sparkles } from '@/lib/icons'
|
||||
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
||||
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -11,8 +13,10 @@ import { $desktopActionTasks } from '@/store/activity'
|
||||
import { $previewServerRestartStatus } from '@/store/preview'
|
||||
import {
|
||||
$busy,
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort,
|
||||
$currentUsage,
|
||||
$sessionStartedAt,
|
||||
$turnStartedAt,
|
||||
@@ -34,6 +38,7 @@ interface StatusbarItemsOptions {
|
||||
gatewayLogLines: readonly string[]
|
||||
gatewayState: string
|
||||
inferenceStatus: RuntimeReadinessResult | null
|
||||
modelMenuContent?: ReactNode
|
||||
openAgents: () => void
|
||||
openCommandCenterSection: (section: CommandCenterSection) => void
|
||||
statusSnapshot: StatusResponse | null
|
||||
@@ -48,14 +53,17 @@ export function useStatusbarItems({
|
||||
gatewayLogLines,
|
||||
gatewayState,
|
||||
inferenceStatus,
|
||||
modelMenuContent,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
statusSnapshot,
|
||||
toggleCommandCenter
|
||||
}: StatusbarItemsOptions) {
|
||||
const busy = useStore($busy)
|
||||
const currentFastMode = useStore($currentFastMode)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const currentReasoningEffort = useStore($currentReasoningEffort)
|
||||
const currentUsage = useStore($currentUsage)
|
||||
const desktopActionTasks = useStore($desktopActionTasks)
|
||||
const previewServerRestartStatus = useStore($previewServerRestartStatus)
|
||||
@@ -269,17 +277,51 @@ export function useStatusbarItems({
|
||||
variant: 'text'
|
||||
},
|
||||
{
|
||||
detail: currentProvider || '',
|
||||
icon: <Cpu className="size-3" />,
|
||||
id: 'model-summary',
|
||||
label: currentModel || 'No model selected',
|
||||
onSelect: () => setModelPickerOpen(true),
|
||||
title: currentProvider ? `Switch model · ${currentProvider}: ${currentModel || ''}` : 'Open model picker',
|
||||
variant: 'action'
|
||||
label: (
|
||||
<span className="inline-flex min-w-0 items-center gap-0.5">
|
||||
<span className="truncate">
|
||||
{formatModelStatusLabel(currentModel, {
|
||||
fastMode: currentFastMode,
|
||||
reasoningEffort: currentReasoningEffort
|
||||
})}
|
||||
</span>
|
||||
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
|
||||
</span>
|
||||
),
|
||||
...(modelMenuContent
|
||||
? {
|
||||
menuAlign: 'end' as const,
|
||||
menuClassName: 'w-64',
|
||||
menuContent: modelMenuContent,
|
||||
title: currentProvider
|
||||
? `Model · ${currentProvider}: ${currentModel || 'none'}`
|
||||
: 'Switch model',
|
||||
variant: 'menu' as const
|
||||
}
|
||||
: {
|
||||
onSelect: () => setModelPickerOpen(true),
|
||||
title: currentProvider
|
||||
? `${currentProvider} · ${currentModel || 'no model'}`
|
||||
: 'Open model picker',
|
||||
variant: 'action' as const
|
||||
})
|
||||
},
|
||||
versionItem
|
||||
],
|
||||
[busy, contextBar, contextUsage, currentModel, currentProvider, sessionStartedAt, turnStartedAt, versionItem]
|
||||
[
|
||||
busy,
|
||||
contextBar,
|
||||
contextUsage,
|
||||
currentFastMode,
|
||||
currentModel,
|
||||
currentProvider,
|
||||
currentReasoningEffort,
|
||||
modelMenuContent,
|
||||
sessionStartedAt,
|
||||
turnStartedAt,
|
||||
versionItem
|
||||
]
|
||||
)
|
||||
|
||||
const leftStatusbarItems = useMemo(
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
dropdownMenuRow,
|
||||
dropdownMenuSectionLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSubContent
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentReasoningEffort,
|
||||
setCurrentFastMode,
|
||||
setCurrentReasoningEffort
|
||||
} from '@/store/session'
|
||||
|
||||
// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned
|
||||
// by the Thinking toggle, not the radio.
|
||||
const EFFORT_OPTIONS = [
|
||||
{ value: 'minimal', label: 'Minimal' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'xhigh', label: 'Max' }
|
||||
] as const
|
||||
|
||||
/** How "fast" is achieved for a given model — two different mechanisms:
|
||||
* - `param`: the Anthropic/OpenAI `speed=fast` request parameter.
|
||||
* - `variant`: a separate `…-fast` sibling model selected via the model field.
|
||||
*/
|
||||
export type FastControl =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'param'; on: boolean }
|
||||
| { kind: 'variant'; baseId: string; fastId: string; on: boolean }
|
||||
|
||||
/** Resolve the fast mechanism for a model: prefer the speed=fast parameter
|
||||
* when the backend supports it, else fall back to a `…-fast` sibling model. */
|
||||
export function resolveFastControl(
|
||||
model: string,
|
||||
providerModels: readonly string[],
|
||||
paramSupported: boolean,
|
||||
currentFastMode: boolean
|
||||
): FastControl {
|
||||
if (paramSupported) {
|
||||
return { kind: 'param', on: currentFastMode }
|
||||
}
|
||||
|
||||
if (/-fast$/i.test(model)) {
|
||||
const baseId = model.replace(/-fast$/i, '')
|
||||
|
||||
// Only a toggle if there's a base to switch back to; otherwise it's a
|
||||
// standalone fast model with no "off" state.
|
||||
return providerModels.includes(baseId)
|
||||
? { kind: 'variant', baseId, fastId: model, on: true }
|
||||
: { kind: 'none' }
|
||||
}
|
||||
|
||||
const fastId = `${model}-fast`
|
||||
|
||||
if (providerModels.includes(fastId)) {
|
||||
return { kind: 'variant', baseId: model, fastId, on: false }
|
||||
}
|
||||
|
||||
// Fast isn't natively offered here, but if the session still has the speed
|
||||
// param on (carried over from a previous model), expose the toggle so it can
|
||||
// be turned off rather than stranded.
|
||||
if (currentFastMode) {
|
||||
return { kind: 'param', on: true }
|
||||
}
|
||||
|
||||
return { kind: 'none' }
|
||||
}
|
||||
|
||||
interface ModelEditSubmenuProps {
|
||||
/** How fast mode is offered for this model (param toggle vs. variant swap). */
|
||||
fastControl: FastControl
|
||||
/** Whether this row's model is the active one. */
|
||||
isActive: boolean
|
||||
/** Switch to this model (resolves false on failure). Awaited before applying
|
||||
* edits when not active so a failed switch doesn't write to the old model. */
|
||||
onActivate: () => Promise<boolean> | void
|
||||
/** Switch to a specific model id (used to swap base ⇄ -fast variant). */
|
||||
onSelectModel: (model: string) => Promise<boolean> | void
|
||||
/** Whether this model supports reasoning effort. */
|
||||
reasoning: boolean
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
export function ModelEditSubmenu({
|
||||
fastControl,
|
||||
isActive,
|
||||
onActivate,
|
||||
onSelectModel,
|
||||
reasoning,
|
||||
requestGateway
|
||||
}: ModelEditSubmenuProps) {
|
||||
// Reactive session state comes straight from the stores rather than being
|
||||
// drilled through the panel, so editing it re-renders only this submenu.
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const currentReasoningEffort = useStore($currentReasoningEffort)
|
||||
|
||||
const effort = normalizeEffort(currentReasoningEffort)
|
||||
const thinkingOn = isThinkingEnabled(currentReasoningEffort)
|
||||
|
||||
// Reasoning/fast are session-scoped (they apply to the active model), so
|
||||
// editing a non-active model first switches to it. Returns false if the
|
||||
// switch failed, so callers skip applying to the wrong (previous) model.
|
||||
const ensureActive = async (): Promise<boolean> => {
|
||||
if (isActive) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (await onActivate()) !== false
|
||||
}
|
||||
|
||||
const patchReasoning = async (next: string, rollback: string) => {
|
||||
setCurrentReasoningEffort(next)
|
||||
|
||||
try {
|
||||
if (!(await ensureActive())) {
|
||||
setCurrentReasoningEffort(rollback)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await requestGateway('config.set', {
|
||||
key: 'reasoning',
|
||||
session_id: activeSessionId ?? '',
|
||||
value: next
|
||||
})
|
||||
} catch (err) {
|
||||
setCurrentReasoningEffort(rollback)
|
||||
notifyError(err, 'Model option update failed')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFast = (enabled: boolean) => {
|
||||
if (fastControl.kind === 'variant') {
|
||||
// Fast is a separate model id — swap to it (or back to the base).
|
||||
void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (fastControl.kind === 'param') {
|
||||
setCurrentFastMode(enabled)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (!(await ensureActive())) {
|
||||
setCurrentFastMode(!enabled)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await requestGateway('config.set', {
|
||||
key: 'fast',
|
||||
session_id: activeSessionId ?? '',
|
||||
value: enabled ? 'fast' : 'normal'
|
||||
})
|
||||
} catch (err) {
|
||||
setCurrentFastMode(!enabled)
|
||||
notifyError(err, 'Fast mode update failed')
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
const hasFast = fastControl.kind !== 'none'
|
||||
const fastOn = fastControl.kind === 'none' ? false : fastControl.on
|
||||
|
||||
return (
|
||||
<DropdownMenuSubContent className="w-52 p-0" sideOffset={4}>
|
||||
{!hasFast && !reasoning ? (
|
||||
<div className="px-2.5 py-3 text-xs text-(--ui-text-tertiary)">No options for this model</div>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Options</DropdownMenuLabel>
|
||||
{reasoning ? (
|
||||
<DropdownMenuItem
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer')}
|
||||
onSelect={event => event.preventDefault()}
|
||||
>
|
||||
Thinking
|
||||
<Switch
|
||||
checked={thinkingOn}
|
||||
className="ml-auto cursor-pointer"
|
||||
onCheckedChange={checked => void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{hasFast ? (
|
||||
<DropdownMenuItem
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer')}
|
||||
onSelect={event => event.preventDefault()}
|
||||
>
|
||||
Fast
|
||||
<Switch checked={fastOn} className="ml-auto cursor-pointer" onCheckedChange={toggleFast} />
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{reasoning ? (
|
||||
<>
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Effort</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={value => void patchReasoning(value, currentReasoningEffort)}
|
||||
value={effort}
|
||||
>
|
||||
{EFFORT_OPTIONS.map(option => (
|
||||
<DropdownMenuRadioItem
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer')}
|
||||
key={option.value}
|
||||
onSelect={event => event.preventDefault()}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
)
|
||||
}
|
||||
|
||||
function isThinkingEnabled(effort: string): boolean {
|
||||
// Empty = Hermes default (medium) = on; only an explicit "none" is off.
|
||||
return (effort || 'medium').trim().toLowerCase() !== 'none'
|
||||
}
|
||||
|
||||
function normalizeEffort(effort: string): string {
|
||||
const value = (effort || 'medium').trim().toLowerCase()
|
||||
|
||||
// Thinking off → no effort selected in the radio group.
|
||||
if (value === 'none') {
|
||||
return ''
|
||||
}
|
||||
|
||||
return EFFORT_OPTIONS.some(option => option.value === value) ? value : 'medium'
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
dropdownMenuRow,
|
||||
DropdownMenuSearch,
|
||||
dropdownMenuSectionLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { getGlobalModelOptions } from '@/hermes'
|
||||
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$visibleModels,
|
||||
collapseModelFamilies,
|
||||
DEFAULT_VISIBLE_PER_PROVIDER,
|
||||
type ModelFamily,
|
||||
modelVisibilityKey,
|
||||
setModelVisibilityOpen
|
||||
} from '@/store/model-visibility'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort
|
||||
} from '@/store/session'
|
||||
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
|
||||
|
||||
interface ModelMenuPanelProps {
|
||||
gateway?: HermesGateway
|
||||
onSelectModel: (selection: { model: string; persistGlobal: boolean; provider: string }) => Promise<boolean> | void
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
interface ProviderGroup {
|
||||
families: ModelFamily[]
|
||||
provider: ModelOptionProvider
|
||||
}
|
||||
|
||||
export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
// Reactive session state is read from the stores here (not drilled in), so
|
||||
// toggling effort/fast/model re-renders this panel in place without forcing
|
||||
// the parent to rebuild the menu content (which would close the dropdown).
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const currentFastMode = useStore($currentFastMode)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const currentReasoningEffort = useStore($currentReasoningEffort)
|
||||
const visibleModels = useStore($visibleModels)
|
||||
|
||||
const modelOptions = useQuery({
|
||||
queryKey: ['model-options', activeSessionId || 'global'],
|
||||
queryFn: (): Promise<ModelOptionsResponse> => {
|
||||
if (gateway && activeSessionId) {
|
||||
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
|
||||
}
|
||||
|
||||
return getGlobalModelOptions()
|
||||
}
|
||||
})
|
||||
|
||||
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
|
||||
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
|
||||
const loading = modelOptions.isPending && !modelOptions.data
|
||||
|
||||
const error = modelOptions.error
|
||||
? modelOptions.error instanceof Error
|
||||
? modelOptions.error.message
|
||||
: String(modelOptions.error)
|
||||
: null
|
||||
|
||||
const providers = modelOptions.data?.providers
|
||||
|
||||
const switchTo = (model: string, provider: string) =>
|
||||
onSelectModel({ model, persistGlobal: !activeSessionId, provider })
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, visibleModels),
|
||||
[providers, search, optionsModel, optionsProvider, visibleModels]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSearch
|
||||
aria-label="Search models"
|
||||
onValueChange={setSearch}
|
||||
placeholder="Search models"
|
||||
value={search}
|
||||
/>
|
||||
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
|
||||
{loading ? (
|
||||
<DropdownMenuGroup className="py-1">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<DropdownMenuItem
|
||||
className={dropdownMenuRow}
|
||||
disabled
|
||||
key={index}
|
||||
onSelect={event => event.preventDefault()}
|
||||
>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
) : error ? (
|
||||
<DropdownMenuItem className={dropdownMenuRow} disabled>
|
||||
{error}
|
||||
</DropdownMenuItem>
|
||||
) : groups.length === 0 ? (
|
||||
<DropdownMenuItem className={dropdownMenuRow} disabled>
|
||||
No models found
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<div className="max-h-80 overflow-y-auto py-0.5">
|
||||
{groups.map(group => (
|
||||
<DropdownMenuGroup className="py-0.5" key={group.provider.slug}>
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{group.provider.name}</DropdownMenuLabel>
|
||||
{group.families.map(family => {
|
||||
// The active id may be the base or its -fast sibling; either
|
||||
// way this one family row represents both.
|
||||
const activeId =
|
||||
group.provider.slug === optionsProvider &&
|
||||
(optionsModel === family.id || optionsModel === family.fastId)
|
||||
? optionsModel
|
||||
: null
|
||||
|
||||
const isCurrent = activeId !== null
|
||||
const name = modelDisplayParts(family.id).name
|
||||
// Capabilities are looked up against the active/base id; the
|
||||
// -fast variant carries the same param support as its base.
|
||||
const caps = group.provider.capabilities?.[family.id]
|
||||
|
||||
// Single source of truth for the active row's fast state — keeps
|
||||
// the row label in lock-step with the submenu's Fast toggle and
|
||||
// handles the standalone `-fast` id case.
|
||||
const fastControl = resolveFastControl(
|
||||
activeId ?? family.id,
|
||||
group.provider.models ?? [],
|
||||
caps?.fast ?? false,
|
||||
currentFastMode
|
||||
)
|
||||
|
||||
// Grayed text: active row shows live state (Fast + effort);
|
||||
// others show a fast-capability hint.
|
||||
const meta = isCurrent
|
||||
? [fastControl.kind !== 'none' && fastControl.on ? 'Fast' : null, reasoningEffortLabel(currentReasoningEffort) || 'Med']
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: caps?.fast || family.fastId
|
||||
? 'Fast'
|
||||
: ''
|
||||
|
||||
// Every row is a hover-Edit submenu trigger. Activating it
|
||||
// (pointer or keyboard) switches to the family's base model;
|
||||
// the Fast toggle inside swaps to the -fast sibling (or flips
|
||||
// the speed param). The sub-trigger has no `onSelect`, so wire
|
||||
// both click and Enter/Space for keyboard parity.
|
||||
const activate = () => {
|
||||
if (!isCurrent) {
|
||||
void switchTo(family.id, group.provider.slug)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSub key={`${group.provider.slug}:${family.id}`}>
|
||||
<DropdownMenuSubTrigger
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer')}
|
||||
hideChevron
|
||||
onClick={activate}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
activate()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{name}
|
||||
{meta ? <span className="text-(--ui-text-tertiary)"> {meta}</span> : null}
|
||||
</span>
|
||||
{isCurrent ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
|
||||
</DropdownMenuSubTrigger>
|
||||
<ModelEditSubmenu
|
||||
fastControl={fastControl}
|
||||
isActive={isCurrent}
|
||||
onActivate={() => switchTo(family.id, group.provider.slug)}
|
||||
onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)}
|
||||
reasoning={caps?.reasoning ?? true}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
|
||||
<DropdownMenuItem
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer text-(--ui-text-tertiary)')}
|
||||
onSelect={() => setModelVisibilityOpen(true)}
|
||||
>
|
||||
Edit Models…
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsed we show the user's chosen models (or the curated default); typing
|
||||
// spans every available model so anything is reachable past the cut.
|
||||
const PER_PROVIDER_SEARCH = 12
|
||||
|
||||
function groupModels(
|
||||
providers: ModelOptionProvider[],
|
||||
search: string,
|
||||
current: { model: string; provider: string },
|
||||
visible: Set<string> | null
|
||||
): ProviderGroup[] {
|
||||
const q = search.trim().toLowerCase()
|
||||
const groups: ProviderGroup[] = []
|
||||
|
||||
for (const provider of providers) {
|
||||
const allFamilies = collapseModelFamilies(provider.models ?? [])
|
||||
|
||||
if (allFamilies.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const matches = (family: ModelFamily) =>
|
||||
`${family.id} ${family.fastId ?? ''} ${provider.name} ${provider.slug} ${displayModelName(family.id)}`
|
||||
.toLowerCase()
|
||||
.includes(q)
|
||||
|
||||
// Which model ids to show (the active one is always added on top of this).
|
||||
let shown: Set<string>
|
||||
|
||||
if (q) {
|
||||
// Search spans every family, regardless of visibility.
|
||||
shown = new Set(allFamilies.filter(matches).map(family => family.id))
|
||||
} else if (visible) {
|
||||
// User has customized which models show — honor their selection exactly.
|
||||
shown = new Set(
|
||||
allFamilies.filter(family => visible.has(modelVisibilityKey(provider.slug, family.id))).map(family => family.id)
|
||||
)
|
||||
} else {
|
||||
// Default: curated top-N families per provider.
|
||||
shown = new Set(allFamilies.slice(0, DEFAULT_VISIBLE_PER_PROVIDER).map(family => family.id))
|
||||
}
|
||||
|
||||
// Always include the active model — but keep every row in the provider's
|
||||
// stable curated order (filter `allFamilies`, never reorder), so selecting
|
||||
// a model can't shuffle the list.
|
||||
const activeId =
|
||||
provider.slug === current.provider && current.model
|
||||
? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id
|
||||
: undefined
|
||||
|
||||
let families = allFamilies.filter(family => shown.has(family.id) || family.id === activeId)
|
||||
|
||||
if (q) {
|
||||
families = families.slice(0, PER_PROVIDER_SEARCH)
|
||||
}
|
||||
|
||||
if (families.length > 0) {
|
||||
groups.push({ families, provider })
|
||||
}
|
||||
}
|
||||
|
||||
// Stable, logical group order: alphabetical by provider name. (The backend
|
||||
// floats the current provider first, which would reshuffle on every switch.)
|
||||
groups.sort((a, b) => a.provider.name.localeCompare(b.provider.name))
|
||||
|
||||
return groups
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export interface StatusbarItem {
|
||||
disabled?: boolean
|
||||
hidden?: boolean
|
||||
href?: string
|
||||
menuAlign?: 'center' | 'end' | 'start'
|
||||
menuClassName?: string
|
||||
menuContent?: ReactNode
|
||||
menuItems?: readonly StatusbarMenuItem[]
|
||||
@@ -54,14 +55,18 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-auto">
|
||||
{/* `overflow-x-clip` (not `overflow-x-auto`) so a wide status item — for
|
||||
example "Connecting…" on a fresh/untitled session — can't paint a
|
||||
horizontal scrollbar across the bottom of the window. Items already
|
||||
`truncate` their labels, so clipping is the right behavior. */}
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
|
||||
{leftItems
|
||||
.filter(item => !item.hidden)
|
||||
.map(item => (
|
||||
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-auto">
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
|
||||
{items
|
||||
.filter(item => !item.hidden)
|
||||
.map(item => (
|
||||
@@ -100,7 +105,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
align={item.menuAlign ?? 'start'}
|
||||
className={cn('w-56', item.menuContent && 'p-0', item.menuClassName)}
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
|
||||
@@ -13,7 +13,7 @@ export const TITLEBAR_FALLBACK_WINDOW_BUTTON_X = 24
|
||||
export const TITLEBAR_EDGE_INSET = 14
|
||||
|
||||
export const titlebarButtonClass =
|
||||
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] rounded-md text-muted-foreground/85 transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground'
|
||||
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] cursor-pointer rounded-md text-muted-foreground/85 transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground'
|
||||
|
||||
export const titlebarHeaderBaseClass =
|
||||
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]'
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { FC } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { ansiColorClass, hasAnsiCodes, parseAnsi } from '@/lib/ansi'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AnsiTextProps {
|
||||
text: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Renders text with embedded ANSI SGR codes as colored / bold spans. Falls
|
||||
* back to a plain string node when no codes are present so the parser cost
|
||||
* is paid only when there's something to colorize. */
|
||||
export const AnsiText: FC<AnsiTextProps> = ({ className, text }) => {
|
||||
const segments = useMemo(() => (hasAnsiCodes(text) ? parseAnsi(text) : null), [text])
|
||||
|
||||
if (!segments) {
|
||||
return <span className={className}>{text}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
{segments.map((segment, index) => (
|
||||
<span
|
||||
className={cn(segment.bold && 'font-semibold', segment.fg && ansiColorClass(segment.fg))}
|
||||
key={`ansi-${index}`}
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -48,7 +48,8 @@ import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/co
|
||||
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
|
||||
import { extractDroppedFiles, HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||
import { DirectiveContent, DirectiveText } from '@/components/assistant-ui/directive-text'
|
||||
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
|
||||
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
|
||||
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
|
||||
import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer'
|
||||
@@ -73,6 +74,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
|
||||
@@ -636,7 +638,7 @@ function messageAttachmentRefs(value: unknown): string[] {
|
||||
function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="group/user-message sticky top-0 z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-2"
|
||||
className="group/user-message sticky z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-2"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
@@ -684,6 +686,32 @@ const UserMessage: FC<{
|
||||
return messageAttachmentRefs(custom.attachmentRefs)
|
||||
})
|
||||
|
||||
// Sticky human bubbles clamp to ~2 lines with a soft fade so a long prompt
|
||||
// doesn't dominate the viewport while the response streams underneath; the
|
||||
// clamp lifts on hover / focus (see styles.css). We measure the *unclamped*
|
||||
// inner wrapper so the ResizeObserver only fires on real content / width
|
||||
// changes, not on every frame while the outer max-height animates open.
|
||||
const clampInnerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [bodyClamped, setBodyClamped] = useState(false)
|
||||
|
||||
const measureClamp = useCallback(() => {
|
||||
const inner = clampInnerRef.current
|
||||
const outer = inner?.parentElement
|
||||
|
||||
if (!inner || !outer) {
|
||||
return
|
||||
}
|
||||
|
||||
const styles = getComputedStyle(inner)
|
||||
const lineHeight = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20
|
||||
const fullHeight = inner.scrollHeight
|
||||
|
||||
outer.style.setProperty('--human-msg-full', `${fullHeight}px`)
|
||||
setBodyClamped(fullHeight > lineHeight * 2 + 1)
|
||||
}, [])
|
||||
|
||||
useResizeObserver(measureClamp, clampInnerRef)
|
||||
|
||||
const hasBody = messageText.trim().length > 0
|
||||
const isLatestUser = messageId === latestUserId
|
||||
const showStop = isLatestUser && threadRunning && Boolean(onCancel)
|
||||
@@ -703,9 +731,14 @@ const UserMessage: FC<{
|
||||
</span>
|
||||
)}
|
||||
{hasBody && (
|
||||
<span className="wrap-anywhere block whitespace-pre-line">
|
||||
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
|
||||
</span>
|
||||
// Render the user's text through a minimal markdown pipeline:
|
||||
// backtick `code` and ``` fenced ``` blocks, with directive chips
|
||||
// (`@file:` etc.) still resolved inside the plain-text spans.
|
||||
<div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
|
||||
<div ref={clampInnerRef}>
|
||||
<UserMessageText className="wrap-anywhere" text={messageText} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
@@ -840,6 +873,10 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
// See index.tsx: set in keydown when the open popover consumes a nav/control
|
||||
// key so the matching keyup skips refreshTrigger (timing-immune vs reading
|
||||
// `trigger`, which keyup sees as already-null after Escape).
|
||||
const triggerKeyConsumedRef = useRef(false)
|
||||
const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top')
|
||||
const [focusRequestId, setFocusRequestId] = useState(0)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
@@ -964,8 +1001,15 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
}
|
||||
|
||||
setTrigger(detected)
|
||||
setTriggerActive(0)
|
||||
}, [])
|
||||
|
||||
// Only reset the highlight when the trigger actually changed (opened, or
|
||||
// the query/kind differs). Re-detecting the *same* trigger — e.g. on a
|
||||
// caret move (mouseup) or a stray refresh — must preserve the user's
|
||||
// current selection instead of snapping back to the first item.
|
||||
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
|
||||
setTriggerActive(0)
|
||||
}
|
||||
}, [trigger])
|
||||
|
||||
const closeTrigger = useCallback(() => {
|
||||
setTrigger(null)
|
||||
@@ -1198,6 +1242,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
if (trigger && triggerItems.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx + 1) % triggerItems.length)
|
||||
|
||||
return
|
||||
@@ -1205,6 +1250,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
|
||||
|
||||
return
|
||||
@@ -1212,6 +1258,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
const item = triggerItems[triggerActive]
|
||||
|
||||
if (item) {
|
||||
@@ -1223,6 +1270,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
closeTrigger()
|
||||
|
||||
return
|
||||
@@ -1242,6 +1290,22 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = () => {
|
||||
// If this keyup belongs to a key the open trigger popover already consumed
|
||||
// in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never
|
||||
// edit text, and for Escape the keydown already closed the menu — a refresh
|
||||
// here would re-detect the still-present `/` and instantly reopen it. We
|
||||
// read a ref set during keydown rather than `trigger`, because by keyup
|
||||
// time React has re-rendered and `trigger` may already be null.
|
||||
if (triggerKeyConsumedRef.current) {
|
||||
triggerKeyConsumedRef.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Root className="contents" data-slot="aui_edit-composer-root">
|
||||
<StickyHumanMessageContainer>
|
||||
@@ -1292,7 +1356,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
onFocus={() => markActiveComposer('edit')}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={() => window.setTimeout(refreshTrigger, 0)}
|
||||
onKeyUp={handleKeyUp}
|
||||
onMouseUp={refreshTrigger}
|
||||
onPaste={handlePaste}
|
||||
ref={editorRef}
|
||||
|
||||
@@ -35,7 +35,18 @@ export interface ToolView {
|
||||
previewTarget?: string
|
||||
rawArgs: string
|
||||
rawResult: string
|
||||
/** Set for tools whose output naturally contains ANSI escape codes
|
||||
* (terminal/execute_code) so the renderer knows to run them through
|
||||
* the ANSI parser instead of printing them as literals. */
|
||||
rendersAnsi?: boolean
|
||||
searchHits?: SearchResultRow[]
|
||||
/** When the backend reports stderr as a separate stream (terminal /
|
||||
* execute_code), the renderer shows it as its own labeled, neutrally
|
||||
* tinted block under stdout — distinct from an error tone. */
|
||||
stderr?: string
|
||||
/** When set, the renderer uses stdout+stderr as separate sections and
|
||||
* ignores the merged `detail`. */
|
||||
stdout?: string
|
||||
status: ToolStatus
|
||||
subtitle: string
|
||||
title: string
|
||||
@@ -1002,6 +1013,10 @@ function toolDetailText(
|
||||
}
|
||||
|
||||
if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
|
||||
// Streams are split out into ToolView.stdout / ToolView.stderr by
|
||||
// buildToolView so the renderer can label them separately. The merged
|
||||
// fallback here is only used when the backend doesn't expose either
|
||||
// stream individually.
|
||||
const output = firstStringField(resultRecord, ['output', 'stdout', 'stderr'])
|
||||
|
||||
const lines = Array.isArray(resultRecord.lines)
|
||||
@@ -1209,6 +1224,18 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
|
||||
|
||||
const resultCount = status === 'error' ? null : toolResultCount(part, argsRecord, resultRecord)
|
||||
|
||||
// For shell/code tools we surface stdout and stderr as separate labeled
|
||||
// streams in the renderer. Many CLIs use stderr for informational
|
||||
// messages (npm progress, git hints), so we deliberately don't paint
|
||||
// stderr destructively even though it's tagged.
|
||||
const rendersAnsi = part.toolName === 'terminal' || part.toolName === 'execute_code'
|
||||
const stdout = rendersAnsi ? firstStringField(resultRecord, ['stdout']) : ''
|
||||
const stderrRaw = rendersAnsi ? firstStringField(resultRecord, ['stderr']) : ''
|
||||
// Only attach stderr when the backend actually returned it as its own
|
||||
// field — otherwise the merged `detail` already covers it and double-
|
||||
// rendering would duplicate output.
|
||||
const hasSplitStreams = rendersAnsi && (Boolean(stdout) || Boolean(stderrRaw))
|
||||
|
||||
return {
|
||||
countLabel: resultCount ? formatCountLabel(resultCount) : undefined,
|
||||
detail,
|
||||
@@ -1220,7 +1247,10 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
|
||||
previewTarget: toolPreviewTarget(part.toolName, argsRecord, resultRecord),
|
||||
rawArgs: prettyJson(part.args),
|
||||
rawResult: prettyJson(part.result),
|
||||
rendersAnsi: rendersAnsi || undefined,
|
||||
searchHits: searchHits?.length ? searchHits : undefined,
|
||||
stderr: hasSplitStreams ? stderrRaw || undefined : undefined,
|
||||
stdout: hasSplitStreams ? stdout || undefined : undefined,
|
||||
status,
|
||||
subtitle,
|
||||
title,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useStore } from '@nanostores/react'
|
||||
import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/shallow'
|
||||
|
||||
import { AnsiText } from '@/components/assistant-ui/ansi-text'
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { CompactMarkdown } from '@/components/chat/compact-markdown'
|
||||
@@ -344,11 +345,41 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
) : view.stdout || view.stderr ? (
|
||||
// Stdout + stderr split: render both as labeled blocks. stderr
|
||||
// is intentionally NOT painted destructive — many CLIs log
|
||||
// informational output there.
|
||||
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
|
||||
{view.detailLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{view.detailLabel}</p>}
|
||||
{view.stdout && (
|
||||
<div className="space-y-0.5">
|
||||
{view.stderr && <p className={TOOL_SECTION_LABEL_CLASS}>stdout</p>}
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
|
||||
{view.rendersAnsi ? <AnsiText text={view.stdout} /> : view.stdout}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{view.stderr && (
|
||||
<div className={cn('space-y-0.5', view.stdout && 'mt-1.5')}>
|
||||
<p className={TOOL_SECTION_LABEL_CLASS}>stderr</p>
|
||||
<pre
|
||||
className={cn(
|
||||
TOOL_SECTION_PRE_CLASS,
|
||||
'whitespace-pre-wrap wrap-anywhere text-(--ui-text-tertiary)'
|
||||
)}
|
||||
>
|
||||
{view.rendersAnsi ? <AnsiText text={view.stderr} /> : view.stderr}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
|
||||
{view.detailLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{view.detailLabel}</p>}
|
||||
{renderDetailAsCode ? (
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>{view.detail}</pre>
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
|
||||
{view.rendersAnsi ? <AnsiText text={view.detail} /> : view.detail}
|
||||
</pre>
|
||||
) : (
|
||||
<CompactMarkdown className={cn(TOOL_SECTION_SURFACE_CLASS, 'wrap-anywhere')} text={view.detail} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { FC } from 'react'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
|
||||
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// User messages should render the bare-minimum of markdown: backtick `code`
|
||||
// spans and ``` fenced blocks. We deliberately don't pull in the full
|
||||
// assistant Markdown pipeline (Streamdown + KaTeX + syntax highlighter)
|
||||
// because user input rarely contains structured docs and the heavy pipeline
|
||||
// adds a lot of runtime cost per bubble.
|
||||
//
|
||||
// Directive chips (`@file:`, `@image:`, ...) still resolve via DirectiveContent
|
||||
// inside the plain-text segments.
|
||||
|
||||
interface FenceSegment {
|
||||
kind: 'fence'
|
||||
code: string
|
||||
lang: string | null
|
||||
}
|
||||
|
||||
interface InlineSegment {
|
||||
kind: 'inline'
|
||||
text: string
|
||||
}
|
||||
|
||||
interface InlineCodeSegment {
|
||||
kind: 'inline-code'
|
||||
code: string
|
||||
}
|
||||
|
||||
interface InlineTextSegment {
|
||||
kind: 'inline-text'
|
||||
text: string
|
||||
}
|
||||
|
||||
type TopSegment = FenceSegment | InlineSegment
|
||||
type InlineNode = InlineCodeSegment | InlineTextSegment
|
||||
|
||||
const FENCE_RE = /```([^\n`]*)\n([\s\S]*?)```/g
|
||||
|
||||
// Greedy backtick run length so ``code with `backticks` inside`` works.
|
||||
const INLINE_CODE_RE = /(`+)([^`\n][\s\S]*?)\1/g
|
||||
|
||||
function splitFences(text: string): TopSegment[] {
|
||||
const segments: TopSegment[] = []
|
||||
let cursor = 0
|
||||
|
||||
for (const match of text.matchAll(FENCE_RE)) {
|
||||
const start = match.index ?? 0
|
||||
|
||||
if (start > cursor) {
|
||||
segments.push({ kind: 'inline', text: text.slice(cursor, start) })
|
||||
}
|
||||
|
||||
segments.push({
|
||||
kind: 'fence',
|
||||
lang: (match[1] || '').trim() || null,
|
||||
code: match[2] ?? ''
|
||||
})
|
||||
cursor = start + match[0].length
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
segments.push({ kind: 'inline', text: text.slice(cursor) })
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
function splitInlineCode(text: string): InlineNode[] {
|
||||
const nodes: InlineNode[] = []
|
||||
let cursor = 0
|
||||
|
||||
for (const match of text.matchAll(INLINE_CODE_RE)) {
|
||||
const start = match.index ?? 0
|
||||
|
||||
if (start > cursor) {
|
||||
nodes.push({ kind: 'inline-text', text: text.slice(cursor, start) })
|
||||
}
|
||||
|
||||
nodes.push({ kind: 'inline-code', code: match[2] })
|
||||
cursor = start + match[0].length
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
nodes.push({ kind: 'inline-text', text: text.slice(cursor) })
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
interface UserMessageTextProps {
|
||||
text: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const UserMessageText: FC<UserMessageTextProps> = ({ className, text }) => {
|
||||
const top = useMemo(() => splitFences(text), [text])
|
||||
|
||||
return (
|
||||
<span className={cn('block', className)} data-slot="aui_user-message-text">
|
||||
{top.map((segment, segmentIndex) => {
|
||||
if (segment.kind === 'fence') {
|
||||
return (
|
||||
<pre
|
||||
className="my-1.5 max-w-full overflow-x-auto rounded-md border border-border/45 bg-[color-mix(in_srgb,currentColor_5%,transparent)] px-2.5 py-2 font-mono text-[0.86em] leading-snug"
|
||||
data-slot="aui_user-fence"
|
||||
key={`fence-${segmentIndex}`}
|
||||
>
|
||||
<code className="block whitespace-pre">{segment.code}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={`inline-${segmentIndex}`}>
|
||||
<InlineSegmentView text={segment.text} />
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const InlineSegmentView: FC<{ text: string }> = ({ text }) => {
|
||||
const nodes = useMemo(() => splitInlineCode(text), [text])
|
||||
|
||||
return (
|
||||
<span className="wrap-anywhere block whitespace-pre-line">
|
||||
{nodes.map((node, nodeIndex) =>
|
||||
node.kind === 'inline-code' ? (
|
||||
<code
|
||||
className="mx-px rounded bg-[color-mix(in_srgb,currentColor_8%,transparent)] px-1 py-px font-mono text-[0.92em]"
|
||||
data-slot="aui_user-inline-code"
|
||||
key={`code-${nodeIndex}`}
|
||||
>
|
||||
{node.code}
|
||||
</code>
|
||||
) : (
|
||||
// Pass plain-text bits through DirectiveContent so @file:/@url: chips
|
||||
// still render. DirectiveContent already preserves whitespace.
|
||||
<Fragment key={`text-${nodeIndex}`}>
|
||||
<DirectiveContent text={node.text} />
|
||||
</Fragment>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -107,8 +107,9 @@ const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
|
||||
anthropic: { order: 1, title: 'Anthropic Claude' },
|
||||
'openai-codex': { order: 2, title: 'OpenAI Codex / ChatGPT' },
|
||||
'minimax-oauth': { order: 3, title: 'MiniMax' },
|
||||
'claude-code': { order: 4, title: 'Claude Code' },
|
||||
'qwen-oauth': { order: 5, title: 'Qwen Code' }
|
||||
'xai-oauth': { order: 4, title: 'xAI Grok' },
|
||||
'claude-code': { order: 5, title: 'Claude Code' },
|
||||
'qwen-oauth': { order: 6, title: 'Qwen Code' }
|
||||
}
|
||||
|
||||
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
|
||||
@@ -116,6 +117,7 @@ const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/
|
||||
const FLOW_SUBTITLES: Record<OAuthProvider['flow'], string> = {
|
||||
pkce: 'Opens your browser to sign in, then continues here',
|
||||
device_code: 'Opens a verification page in your browser — Hermes connects automatically',
|
||||
loopback: 'Opens your browser to sign in — Hermes connects automatically',
|
||||
external: 'Sign in once in your terminal, then come back to chat'
|
||||
}
|
||||
|
||||
@@ -565,6 +567,24 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
|
||||
)
|
||||
}
|
||||
|
||||
if (flow.status === 'awaiting_browser') {
|
||||
return (
|
||||
<Step title={`Sign in with ${title}`}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We opened {title} in your browser. Authorize Hermes there and you'll be connected
|
||||
automatically — nothing to copy or paste.
|
||||
</p>
|
||||
<FlowFooter left={<DocsLink href={flow.start.auth_url}>Re-open sign-in page</DocsLink>}>
|
||||
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Waiting for you to authorize...
|
||||
</span>
|
||||
<CancelBtn size="sm" />
|
||||
</FlowFooter>
|
||||
</Step>
|
||||
)
|
||||
}
|
||||
|
||||
if (flow.status === 'external_pending') {
|
||||
return (
|
||||
<Step title={`Sign in with ${title}`}>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle, RefreshCw } from '@/lib/icons'
|
||||
|
||||
export interface ErrorBoundaryFallbackProps {
|
||||
error: Error
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
fallback?: (props: ErrorBoundaryFallbackProps) => ReactNode
|
||||
label?: string
|
||||
onError?: (error: Error, info: ErrorInfo) => void
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
state: ErrorBoundaryState = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
const tag = this.props.label ? `[error-boundary:${this.props.label}]` : '[error-boundary]'
|
||||
console.error(tag, error, info.componentStack)
|
||||
this.props.onError?.(error, info)
|
||||
}
|
||||
|
||||
reset = () => {
|
||||
this.setState({ error: null })
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error } = this.state
|
||||
|
||||
if (!error) {
|
||||
return this.props.children
|
||||
}
|
||||
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback({ error, reset: this.reset })
|
||||
}
|
||||
|
||||
return <RootErrorFallback error={error} reset={this.reset} />
|
||||
}
|
||||
}
|
||||
|
||||
function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1500] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
|
||||
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
|
||||
<div className="flex items-start gap-3 border-b border-(--ui-stroke-tertiary) px-5 py-4">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10 text-destructive">
|
||||
<AlertTriangle className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Something broke in the interface</h2>
|
||||
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
|
||||
The view hit an unexpected error. Your chats and settings are safe - try again, or reload the window.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 p-5">
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 font-mono text-[0.7rem] leading-4 text-destructive">
|
||||
{error.message || String(error)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={reset}>
|
||||
<RefreshCw className="size-4" />
|
||||
Try again
|
||||
</Button>
|
||||
<Button onClick={() => window.location.reload()} variant="outline">
|
||||
Reload window
|
||||
</Button>
|
||||
<Button onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)} variant="ghost">
|
||||
Open logs
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { getGlobalModelOptions } from '@/hermes'
|
||||
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
|
||||
import {
|
||||
$visibleModels,
|
||||
collapseModelFamilies,
|
||||
effectiveVisibleKeys,
|
||||
modelVisibilityKey,
|
||||
setVisibleModels
|
||||
} from '@/store/model-visibility'
|
||||
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
interface ModelVisibilityDialogProps {
|
||||
gw?: HermesGateway
|
||||
onOpenChange: (open: boolean) => void
|
||||
onOpenProviders: () => void
|
||||
open: boolean
|
||||
sessionId?: string | null
|
||||
}
|
||||
|
||||
export function ModelVisibilityDialog({ gw, onOpenChange, onOpenProviders, open, sessionId }: ModelVisibilityDialogProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const stored = useStore($visibleModels)
|
||||
|
||||
const modelOptions = useQuery({
|
||||
queryKey: ['model-options', sessionId || 'global'],
|
||||
queryFn: (): Promise<ModelOptionsResponse> => {
|
||||
if (gw && sessionId) {
|
||||
return gw.request<ModelOptionsResponse>('model.options', { session_id: sessionId })
|
||||
}
|
||||
|
||||
return getGlobalModelOptions()
|
||||
},
|
||||
enabled: open
|
||||
})
|
||||
|
||||
const providers = useMemo(
|
||||
() => (modelOptions.data?.providers ?? []).filter(provider => (provider.models ?? []).length > 0),
|
||||
[modelOptions.data]
|
||||
)
|
||||
|
||||
const visible = effectiveVisibleKeys(stored, providers)
|
||||
|
||||
const toggle = (provider: ModelOptionProvider, model: string) => {
|
||||
const next = new Set(effectiveVisibleKeys($visibleModels.get(), providers))
|
||||
const key = modelVisibilityKey(provider.slug, model)
|
||||
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
next.add(key)
|
||||
}
|
||||
|
||||
setVisibleModels(next)
|
||||
}
|
||||
|
||||
const q = search.trim().toLowerCase()
|
||||
|
||||
const matches = (provider: ModelOptionProvider, model: string) =>
|
||||
!q || `${model} ${provider.name} ${provider.slug} ${displayModelName(model)}`.toLowerCase().includes(q)
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-xs gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="px-3 pb-1 pt-3">
|
||||
<DialogTitle className="text-[0.8125rem]">Models</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-3 py-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
className="h-5 w-full bg-transparent text-xs text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
|
||||
onChange={event => setSearch(event.target.value)}
|
||||
placeholder="Search models"
|
||||
type="text"
|
||||
value={search}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[55vh] overflow-y-auto pb-1">
|
||||
{providers.length === 0 ? (
|
||||
<div className="px-3 py-5 text-center text-xs text-muted-foreground">
|
||||
{modelOptions.isPending ? 'Loading…' : 'No authenticated providers.'}
|
||||
</div>
|
||||
) : (
|
||||
providers.map(provider => {
|
||||
const models = collapseModelFamilies(provider.models ?? []).filter(family =>
|
||||
matches(provider, family.id)
|
||||
)
|
||||
|
||||
if (models.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-0.5" key={provider.slug}>
|
||||
<div className="px-3 pb-0.5 pt-1 text-[0.625rem] font-medium uppercase tracking-wide text-(--ui-text-tertiary)">
|
||||
{provider.name}
|
||||
</div>
|
||||
{models.map(family => {
|
||||
const { name, tag } = modelDisplayParts(family.id)
|
||||
const key = modelVisibilityKey(provider.slug, family.id)
|
||||
|
||||
return (
|
||||
<label
|
||||
className="flex cursor-pointer items-center gap-2 px-3 py-1 text-xs hover:bg-accent/50"
|
||||
key={key}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{name}
|
||||
{tag ? <span className="text-(--ui-text-tertiary)"> {tag}</span> : null}
|
||||
</span>
|
||||
<Switch
|
||||
checked={visible.has(key)}
|
||||
className="cursor-pointer"
|
||||
onCheckedChange={() => toggle(provider, family.id)}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<button
|
||||
className="text-xs text-(--ui-text-tertiary) transition-colors hover:text-foreground"
|
||||
onClick={() => {
|
||||
onOpenChange(false)
|
||||
onOpenProviders()
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Add provider…
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-default disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -46,7 +46,10 @@ function DialogContent({
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-md duration-200 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]:zoom-in-95',
|
||||
// Cap height at 85vh and let long content scroll inside the dialog
|
||||
// instead of overflowing off-screen (long cron titles, tool detail
|
||||
// dumps, etc.). Individual dialogs can still override via className.
|
||||
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid max-h-[85vh] w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-md duration-200 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]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
data-slot="dialog-content"
|
||||
|
||||
@@ -4,6 +4,17 @@ import * as React from 'react'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Shared class tokens for edge-to-edge menus (use with `p-0` content): rows go
|
||||
// full-width, square, and compact so the highlight spans the whole surface.
|
||||
// Reuse these instead of re-deriving per menu so every searchable/compact menu
|
||||
// reads identically.
|
||||
export const dropdownMenuRow = 'gap-2 rounded-none px-2.5 py-1 text-xs'
|
||||
export const dropdownMenuSectionLabel = 'px-2.5 pt-1 pb-0.5 text-[0.625rem] font-medium uppercase tracking-wide'
|
||||
|
||||
// Keys that must reach Radix's menu handler (navigation/close). Everything else
|
||||
// is a filter keystroke and is stopped so the menu's typeahead doesn't hijack it.
|
||||
const DROPDOWN_NAV_KEYS = new Set(['ArrowDown', 'ArrowUp', 'Enter', 'Escape', 'Tab'])
|
||||
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
@@ -16,18 +27,65 @@ function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownM
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Borderless filter input for a searchable dropdown. Autofocuses, keeps the
|
||||
* menu's typeahead from eating keystrokes, and still lets arrow/enter/escape
|
||||
* drive the list. Drop it in as the first child of a `DropdownMenuContent`.
|
||||
*/
|
||||
function DropdownMenuSearch({
|
||||
className,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onValueChange,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<'input'>, 'type'> & {
|
||||
onValueChange?: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="px-2.5 py-1.5" data-slot="dropdown-menu-search">
|
||||
<input
|
||||
autoFocus
|
||||
className={cn(
|
||||
'h-4 w-full bg-transparent text-xs leading-none text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none',
|
||||
className
|
||||
)}
|
||||
onChange={event => {
|
||||
onChange?.(event)
|
||||
onValueChange?.(event.target.value)
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (!DROPDOWN_NAV_KEYS.has(event.key)) {
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
onKeyDown?.(event)
|
||||
}}
|
||||
type="text"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
collisionPadding = 8,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
// `dt-portal-scrollbar` reproduces the thin themed scrollbar from
|
||||
// `.scrollbar-dt` for portaled overlays (Radix renders this under
|
||||
// document.body, outside #root's scope). See styles.css.
|
||||
className={cn(
|
||||
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 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]:zoom-in-95',
|
||||
'dt-portal-scrollbar z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 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]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
// Keep the menu inside the viewport: Radix flips/shifts away from edges
|
||||
// (avoidCollisions defaults on); the padding stops it kissing the edge.
|
||||
collisionPadding={collisionPadding}
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
@@ -73,18 +131,16 @@ function DropdownMenuCheckboxItem({
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
checked={checked}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Codicon name="check" size="1rem" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
<DropdownMenuPrimitive.ItemIndicator className="ml-auto flex items-center pl-2 text-foreground">
|
||||
<Codicon name="check" size="0.75rem" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
@@ -101,18 +157,16 @@ function DropdownMenuRadioItem({
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Codicon name="primitive-dot" size="0.5rem" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
<DropdownMenuPrimitive.ItemIndicator className="ml-auto flex items-center pl-2 text-foreground">
|
||||
<Codicon name="check" size="0.75rem" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
@@ -161,10 +215,13 @@ function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuP
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
hideChevron = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
/** Suppress the trailing caret — for triggers that own their right-side affordance. */
|
||||
hideChevron?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
@@ -177,24 +234,40 @@ function DropdownMenuSubTrigger({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Codicon className="ml-auto text-(--ui-text-tertiary)" name="chevron-right" size="1rem" />
|
||||
{!hideChevron && <Codicon className="ml-auto text-(--ui-text-tertiary)" name="chevron-right" size="1rem" />}
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
collisionPadding = 8,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
className={cn(
|
||||
'z-50 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 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]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
{...props}
|
||||
/>
|
||||
// Portal the submenu out of the parent Content so it escapes that Content's
|
||||
// `overflow` clip. Without this, a submenu opening from a scrollable menu
|
||||
// gets visually cut off at the parent's edges. Radix Popper still anchors
|
||||
// it to the SubTrigger and handles collision/flip, so portaling is safe.
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
// `dt-portal-scrollbar` reproduces the themed scrollbar for portaled
|
||||
// overlays (rendered under document.body). Use a fixed `max-h-80`
|
||||
// rather than the Radix available-height variable: that variable is
|
||||
// only published on Content, NOT SubContent — using it here collapses
|
||||
// the submenu to 0px height.
|
||||
className={cn(
|
||||
'dt-portal-scrollbar z-50 max-h-80 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 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]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
// Flip to the other side / shift vertically when near a viewport edge
|
||||
// (e.g. the status bar menu opening from the bottom-right corner) so
|
||||
// the submenu never gets clipped.
|
||||
collisionPadding={collisionPadding}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -208,6 +281,7 @@ export {
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSearch,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
|
||||
Vendored
+5
@@ -27,6 +27,11 @@ declare global {
|
||||
setPreviewShortcutActive?: (active: boolean) => void
|
||||
openExternal: (url: string) => Promise<void>
|
||||
fetchLinkTitle: (url: string) => Promise<string>
|
||||
settings: {
|
||||
getDefaultProjectDir: () => Promise<{ defaultLabel: string; dir: null | string }>
|
||||
pickDefaultProjectDir: () => Promise<{ canceled: boolean; dir: null | string }>
|
||||
setDefaultProjectDir: (dir: null | string) => Promise<{ dir: null | string }>
|
||||
}
|
||||
revealLogs: () => Promise<{ ok: boolean; path: string; error?: string }>
|
||||
getRecentLogs: () => Promise<{ path: string; lines: string[] }>
|
||||
readDir: (path: string) => Promise<HermesReadDirResult>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ansiColorClass, hasAnsiCodes, parseAnsi } from './ansi'
|
||||
|
||||
const ESC = '\x1b'
|
||||
|
||||
describe('parseAnsi', () => {
|
||||
it('returns a single default segment for plain text', () => {
|
||||
expect(parseAnsi('hello world')).toEqual([{ bold: false, fg: null, text: 'hello world' }])
|
||||
})
|
||||
|
||||
it('returns nothing for an empty string', () => {
|
||||
expect(parseAnsi('')).toEqual([])
|
||||
})
|
||||
|
||||
it('parses a basic foreground color sequence and resets', () => {
|
||||
const input = `${ESC}[31merror${ESC}[0m ok`
|
||||
|
||||
expect(parseAnsi(input)).toEqual([
|
||||
{ bold: false, fg: 'red', text: 'error' },
|
||||
{ bold: false, fg: null, text: ' ok' }
|
||||
])
|
||||
})
|
||||
|
||||
it('treats bold (1) and bold-off (22) as toggles without affecting fg', () => {
|
||||
const input = `${ESC}[1mloud${ESC}[22m quiet`
|
||||
|
||||
expect(parseAnsi(input)).toEqual([
|
||||
{ bold: true, fg: null, text: 'loud' },
|
||||
{ bold: false, fg: null, text: ' quiet' }
|
||||
])
|
||||
})
|
||||
|
||||
it('treats default-fg (39) as a foreground-only reset (keeps bold)', () => {
|
||||
const input = `${ESC}[1;31mboth${ESC}[39mbold-only`
|
||||
|
||||
expect(parseAnsi(input)).toEqual([
|
||||
{ bold: true, fg: 'red', text: 'both' },
|
||||
{ bold: true, fg: null, text: 'bold-only' }
|
||||
])
|
||||
})
|
||||
|
||||
it('handles bright colors via the 90-97 range', () => {
|
||||
expect(parseAnsi(`${ESC}[92mgreen`)).toEqual([{ bold: false, fg: 'bright-green', text: 'green' }])
|
||||
})
|
||||
|
||||
it('coalesces adjacent runs with the same style', () => {
|
||||
const input = `${ESC}[31ma${ESC}[31mb${ESC}[31mc`
|
||||
|
||||
expect(parseAnsi(input)).toEqual([{ bold: false, fg: 'red', text: 'abc' }])
|
||||
})
|
||||
|
||||
it('skips 256-color (38;5) trailing args without painting fg or leaking the params as text', () => {
|
||||
// 256-color and truecolor aren't rendered (FG_BY_CODE doesn't cover them),
|
||||
// but the parser must consume the trailing `;5;<n>` / `;2;r;g;b` args so
|
||||
// they never bleed into the visible segment text.
|
||||
const segments = parseAnsi(`${ESC}[38;5;208morange${ESC}[0m`)
|
||||
|
||||
expect(segments).toHaveLength(1)
|
||||
expect(segments[0].fg).toBe(null)
|
||||
expect(segments[0].text).toBe('orange')
|
||||
})
|
||||
|
||||
it('skips truecolor (38;2;r;g;b) trailing args', () => {
|
||||
const segments = parseAnsi(`${ESC}[38;2;10;20;30mrgb${ESC}[0m`)
|
||||
|
||||
expect(segments).toHaveLength(1)
|
||||
expect(segments[0].fg).toBe(null)
|
||||
expect(segments[0].text).toBe('rgb')
|
||||
})
|
||||
|
||||
it('drops non-SGR CSI sequences (cursor motion, erase) without consuming surrounding text', () => {
|
||||
const input = `before${ESC}[2Jmiddle${ESC}[10;5Hafter`
|
||||
|
||||
expect(parseAnsi(input)).toEqual([{ bold: false, fg: null, text: 'beforemiddleafter' }])
|
||||
})
|
||||
|
||||
it('treats an empty SGR parameter (ESC[m) as a full reset', () => {
|
||||
const input = `${ESC}[1;31mfoo${ESC}[mbar`
|
||||
|
||||
expect(parseAnsi(input)).toEqual([
|
||||
{ bold: true, fg: 'red', text: 'foo' },
|
||||
{ bold: false, fg: null, text: 'bar' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAnsiCodes', () => {
|
||||
it('returns false for plain text', () => {
|
||||
expect(hasAnsiCodes('hello world')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when any CSI introducer is present', () => {
|
||||
expect(hasAnsiCodes(`${ESC}[31mred`)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ansiColorClass', () => {
|
||||
it('returns a non-empty Tailwind class string for every supported color', () => {
|
||||
const colors = [
|
||||
'black',
|
||||
'red',
|
||||
'green',
|
||||
'yellow',
|
||||
'blue',
|
||||
'magenta',
|
||||
'cyan',
|
||||
'white',
|
||||
'bright-black',
|
||||
'bright-red',
|
||||
'bright-green',
|
||||
'bright-yellow',
|
||||
'bright-blue',
|
||||
'bright-magenta',
|
||||
'bright-cyan',
|
||||
'bright-white'
|
||||
] as const
|
||||
|
||||
for (const color of colors) {
|
||||
expect(ansiColorClass(color)).toMatch(/\S/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
// Minimal ANSI SGR parser for rendering terminal output inside chat tool
|
||||
// cards. Only handles the SGR codes that show up in practice (color, bold,
|
||||
// reset); cursor motions and other CSI sequences are dropped silently.
|
||||
//
|
||||
// Returns a flat array of styled segments so callers can render them as
|
||||
// React spans without each consumer having to re-implement the parser.
|
||||
|
||||
export interface AnsiSegment {
|
||||
bold: boolean
|
||||
/** Tailwind text-color class or null for the default foreground. */
|
||||
fg: AnsiColor | null
|
||||
text: string
|
||||
}
|
||||
|
||||
export type AnsiColor =
|
||||
| 'black'
|
||||
| 'red'
|
||||
| 'green'
|
||||
| 'yellow'
|
||||
| 'blue'
|
||||
| 'magenta'
|
||||
| 'cyan'
|
||||
| 'white'
|
||||
| 'bright-black'
|
||||
| 'bright-red'
|
||||
| 'bright-green'
|
||||
| 'bright-yellow'
|
||||
| 'bright-blue'
|
||||
| 'bright-magenta'
|
||||
| 'bright-cyan'
|
||||
| 'bright-white'
|
||||
|
||||
const FG_BY_CODE: Record<number, AnsiColor> = {
|
||||
30: 'black',
|
||||
31: 'red',
|
||||
32: 'green',
|
||||
33: 'yellow',
|
||||
34: 'blue',
|
||||
35: 'magenta',
|
||||
36: 'cyan',
|
||||
37: 'white',
|
||||
90: 'bright-black',
|
||||
91: 'bright-red',
|
||||
92: 'bright-green',
|
||||
93: 'bright-yellow',
|
||||
94: 'bright-blue',
|
||||
95: 'bright-magenta',
|
||||
96: 'bright-cyan',
|
||||
97: 'bright-white'
|
||||
}
|
||||
|
||||
// CSI = ESC '[' params 'final'. We only care about SGR (final == 'm'); other
|
||||
// final bytes are matched and consumed so they don't leak into the rendered
|
||||
// text. Range covers the common CSI command set (A-Z / a-z / @).
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CSI_RE = /\x1b\[([\d;]*)([\x40-\x7e])/g
|
||||
// Other escape sequences (single-char OSC/SS3/etc.) — strip silently.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const OTHER_ESCAPE_RE = /\x1b[@-Z\\-_]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g
|
||||
|
||||
export function parseAnsi(input: string): AnsiSegment[] {
|
||||
if (!input) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Strip non-CSI escapes upfront — none of them carry text we want to keep
|
||||
// and CSI_RE wouldn't match them.
|
||||
const cleaned = input.replace(OTHER_ESCAPE_RE, '')
|
||||
|
||||
const segments: AnsiSegment[] = []
|
||||
let cursor = 0
|
||||
let bold = false
|
||||
let fg: AnsiColor | null = null
|
||||
|
||||
const pushText = (text: string) => {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
const last = segments.at(-1)
|
||||
|
||||
if (last && last.bold === bold && last.fg === fg) {
|
||||
last.text += text
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
segments.push({ bold, fg, text })
|
||||
}
|
||||
|
||||
CSI_RE.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = CSI_RE.exec(cleaned)) !== null) {
|
||||
const start = match.index
|
||||
|
||||
if (start > cursor) {
|
||||
pushText(cleaned.slice(cursor, start))
|
||||
}
|
||||
|
||||
if (match[2] === 'm') {
|
||||
const codes = match[1]
|
||||
.split(';')
|
||||
.map(part => (part === '' ? 0 : Number(part)))
|
||||
.filter(value => Number.isFinite(value))
|
||||
|
||||
for (let i = 0; i < codes.length; i += 1) {
|
||||
const code = codes[i]
|
||||
|
||||
if (code === 0) {
|
||||
bold = false
|
||||
fg = null
|
||||
} else if (code === 1) {
|
||||
bold = true
|
||||
} else if (code === 22) {
|
||||
bold = false
|
||||
} else if (code === 39) {
|
||||
fg = null
|
||||
} else if (code in FG_BY_CODE) {
|
||||
fg = FG_BY_CODE[code]
|
||||
} else if (code === 38) {
|
||||
// 256-color / truecolor — skip the trailing args we don't render.
|
||||
if (codes[i + 1] === 5) {
|
||||
i += 2
|
||||
} else if (codes[i + 1] === 2) {
|
||||
i += 4
|
||||
}
|
||||
}
|
||||
// Background colors (40-47, 100-107) and effects we don't render are
|
||||
// intentionally ignored — the segment keeps the prior bold/fg state.
|
||||
}
|
||||
}
|
||||
|
||||
cursor = CSI_RE.lastIndex
|
||||
}
|
||||
|
||||
if (cursor < cleaned.length) {
|
||||
pushText(cleaned.slice(cursor))
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
const TAILWIND_BY_COLOR: Record<AnsiColor, string> = {
|
||||
// Tuned for legibility against the muted bg-(--ui-bg-tertiary) surface used
|
||||
// in tool cards. We don't paint pure ANSI colors (#000, #fff) because they
|
||||
// disappear into the surface.
|
||||
'black': 'text-zinc-700 dark:text-zinc-300',
|
||||
'red': 'text-red-700 dark:text-red-300',
|
||||
'green': 'text-emerald-700 dark:text-emerald-300',
|
||||
'yellow': 'text-amber-700 dark:text-amber-300',
|
||||
'blue': 'text-blue-700 dark:text-blue-300',
|
||||
'magenta': 'text-fuchsia-700 dark:text-fuchsia-300',
|
||||
'cyan': 'text-cyan-700 dark:text-cyan-300',
|
||||
'white': 'text-zinc-600 dark:text-zinc-200',
|
||||
'bright-black': 'text-zinc-500 dark:text-zinc-400',
|
||||
'bright-red': 'text-rose-600 dark:text-rose-300',
|
||||
'bright-green': 'text-emerald-600 dark:text-emerald-200',
|
||||
'bright-yellow': 'text-amber-600 dark:text-amber-200',
|
||||
'bright-blue': 'text-sky-600 dark:text-sky-300',
|
||||
'bright-magenta': 'text-pink-600 dark:text-pink-300',
|
||||
'bright-cyan': 'text-teal-600 dark:text-teal-200',
|
||||
'bright-white': 'text-zinc-500 dark:text-zinc-100'
|
||||
}
|
||||
|
||||
export function ansiColorClass(color: AnsiColor): string {
|
||||
return TAILWIND_BY_COLOR[color]
|
||||
}
|
||||
|
||||
/** Returns true if the input contains at least one CSI sequence. Cheap check
|
||||
* so callers can skip the parser for plain-ASCII output. */
|
||||
export function hasAnsiCodes(input: string): boolean {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return /\x1b\[/.test(input)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label'
|
||||
|
||||
describe('model-status-label', () => {
|
||||
it('formats display names consistently', () => {
|
||||
expect(displayModelName('anthropic/claude-opus-4.8-fast')).toBe('Opus 4.8')
|
||||
expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5')
|
||||
})
|
||||
|
||||
it('maps reasoning effort to compact labels', () => {
|
||||
expect(reasoningEffortLabel('high')).toBe('High')
|
||||
expect(reasoningEffortLabel('xhigh')).toBe('Max')
|
||||
expect(reasoningEffortLabel('')).toBe('')
|
||||
})
|
||||
|
||||
it('appends fast + effort session state to the status label', () => {
|
||||
expect(formatModelStatusLabel('openai/gpt-5.5', { fastMode: true, reasoningEffort: 'high' })).toBe(
|
||||
'GPT-5.5 · Fast High'
|
||||
)
|
||||
})
|
||||
|
||||
it('always surfaces the effort (default medium) so the level is visible', () => {
|
||||
expect(formatModelStatusLabel('openai/gpt-5.5', { reasoningEffort: 'medium' })).toBe('GPT-5.5 · Med')
|
||||
expect(formatModelStatusLabel('openai/gpt-5.5')).toBe('GPT-5.5 · Med')
|
||||
})
|
||||
|
||||
it('returns just the placeholder name when there is no model', () => {
|
||||
expect(formatModelStatusLabel('')).toBe('No model')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
const REASONING_LABELS: Record<string, string> = {
|
||||
none: 'Off',
|
||||
minimal: 'Min',
|
||||
low: 'Low',
|
||||
medium: 'Med',
|
||||
high: 'High',
|
||||
xhigh: 'Max'
|
||||
}
|
||||
|
||||
export function reasoningEffortLabel(effort: string): string {
|
||||
const key = effort.trim().toLowerCase()
|
||||
|
||||
if (!key) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return REASONING_LABELS[key] ?? effort
|
||||
}
|
||||
|
||||
/** Strip provider prefix and normalize for display. */
|
||||
export function modelBaseId(model: string): string {
|
||||
const trimmed = model.trim()
|
||||
const slash = trimmed.lastIndexOf('/')
|
||||
|
||||
return slash >= 0 ? trimmed.slice(slash + 1) : trimmed
|
||||
}
|
||||
|
||||
// Trailing model-id variants that should render as a grayed tag beside the
|
||||
// name (e.g. "Opus 4.8" + "Fast") rather than collapsing two distinct ids to
|
||||
// the same display name.
|
||||
const VARIANT_TAGS: ReadonlyArray<readonly [RegExp, string]> = [
|
||||
[/-fast$/i, 'Fast'],
|
||||
[/-thinking$/i, 'Thinking'],
|
||||
[/-preview$/i, 'Preview'],
|
||||
[/-latest$/i, 'Latest']
|
||||
]
|
||||
|
||||
const titleCase = (text: string): string => text.replace(/\b\w/g, char => char.toUpperCase()).trim()
|
||||
|
||||
function prettifyBase(base: string): string {
|
||||
if (/^claude-/i.test(base)) {
|
||||
return titleCase(base.replace(/^claude-/i, '').replace(/-/g, ' '))
|
||||
}
|
||||
|
||||
if (/^gpt-/i.test(base)) {
|
||||
return base.replace(/^gpt-/i, 'GPT-')
|
||||
}
|
||||
|
||||
if (/^gemini-/i.test(base)) {
|
||||
return base.replace(/^gemini-/i, 'Gemini ').replace(/-/g, ' ')
|
||||
}
|
||||
|
||||
return titleCase(base.replace(/-/g, ' '))
|
||||
}
|
||||
|
||||
/** Split a model id into a clean display name plus an optional grayed variant
|
||||
* tag, so distinct ids (e.g. `…-4.8` vs `…-4.8-fast`) don't collapse. */
|
||||
export function modelDisplayParts(model: string): { name: string; tag: string } {
|
||||
let base = modelBaseId(model)
|
||||
let tag = ''
|
||||
|
||||
for (const [pattern, label] of VARIANT_TAGS) {
|
||||
if (pattern.test(base)) {
|
||||
tag = label
|
||||
base = base.replace(pattern, '')
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return { name: prettifyBase(base) || model.trim() || 'No model', tag }
|
||||
}
|
||||
|
||||
/** Friendly one-line model name for menus and the status bar. */
|
||||
export function displayModelName(model: string): string {
|
||||
return modelDisplayParts(model).name
|
||||
}
|
||||
|
||||
/** Status bar trigger label — model name plus the live session state (effort/fast). */
|
||||
export function formatModelStatusLabel(
|
||||
model: string,
|
||||
options?: { fastMode?: boolean; reasoningEffort?: string }
|
||||
): string {
|
||||
const name = displayModelName(model)
|
||||
|
||||
if (!model.trim()) {
|
||||
return name
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
// Fast is shown when the speed=fast param is on (options.fastMode) OR the
|
||||
// active model is a `…-fast` variant (fast via a separate model id).
|
||||
if (options?.fastMode || /-fast$/i.test(modelBaseId(model))) {
|
||||
parts.push('Fast')
|
||||
}
|
||||
|
||||
// Always surface the effort (empty = Hermes default of medium) so the
|
||||
// current reasoning level is visible at a glance, not just when non-default.
|
||||
parts.push(reasoningEffortLabel(options?.reasoningEffort ?? '') || 'Med')
|
||||
|
||||
return `${name} · ${parts.join(' ')}`
|
||||
}
|
||||
@@ -20,7 +20,11 @@ const PRIORITY_KEYS = [
|
||||
] as const
|
||||
|
||||
const ERROR_KEYS = ['error', 'errors', 'failure', 'exception'] as const
|
||||
const ERROR_MSG_KEYS = ['message', 'reason', 'detail', 'stderr'] as const
|
||||
// 'stderr' deliberately excluded: many CLIs emit informational lines on
|
||||
// stderr (npm progress, git's hint:, gcc's `In file included from`) that
|
||||
// aren't errors. Treating those as error signal flipped tool cards into
|
||||
// destructive styling for healthy commands.
|
||||
const ERROR_MSG_KEYS = ['message', 'reason', 'detail'] as const
|
||||
const NON_ERROR_TEXT = new Set(['', '0', 'false', 'none', 'null', 'nil', 'ok', 'success', 'n/a', 'na'])
|
||||
|
||||
type Json = Record<string, unknown>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createRoot } from 'react-dom/client'
|
||||
import { HashRouter } from 'react-router-dom'
|
||||
|
||||
import App from './app'
|
||||
import { ErrorBoundary } from './components/error-boundary'
|
||||
import { HapticsProvider } from './components/haptics-provider'
|
||||
import { installClipboardShim } from './lib/clipboard'
|
||||
import { ThemeProvider } from './themes/context'
|
||||
@@ -32,14 +33,16 @@ const queryClient = new QueryClient({
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<HapticsProvider>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</HapticsProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
<ErrorBoundary label="root">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<HapticsProvider>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</HapticsProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
enqueueQueuedPrompt,
|
||||
getQueuedPrompts,
|
||||
removeQueuedPrompt,
|
||||
shouldAutoDrainOnSettle,
|
||||
updateQueuedPrompt,
|
||||
updateQueuedPromptText
|
||||
} from './composer-queue'
|
||||
@@ -100,3 +101,37 @@ describe('composer queue store', () => {
|
||||
expect(parsed[SESSION_KEY]?.[0]?.text).toBe('persist me')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldAutoDrainOnSettle', () => {
|
||||
const base = { isBusy: false, queueLength: 1, userInterrupted: false, wasBusy: true }
|
||||
|
||||
it('drains the next queued prompt when a turn completes naturally', () => {
|
||||
expect(shouldAutoDrainOnSettle(base)).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT drain when the user explicitly interrupted (Stop button)', () => {
|
||||
// Regression: previously the Stop button "never worked" because cancelling
|
||||
// a turn flipped busy → false and the queue immediately re-fired its head.
|
||||
expect(shouldAutoDrainOnSettle({ ...base, userInterrupted: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not drain when the queue is empty', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0 })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not drain when interrupted even if the queue is also empty', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, queueLength: 0, userInterrupted: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores steady busy state (no true → false transition)', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, isBusy: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores busy entry (false → true, not a settle)', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, isBusy: true, wasBusy: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores steady idle state (was not busy)', () => {
|
||||
expect(shouldAutoDrainOnSettle({ ...base, wasBusy: false })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -188,3 +188,39 @@ export const clearQueuedPrompts = (key: string | null | undefined) => {
|
||||
|
||||
writeSession(sid, [])
|
||||
}
|
||||
|
||||
/** Inputs to {@link shouldAutoDrainOnSettle}, captured at a `busy` transition. */
|
||||
export interface AutoDrainSettleInput {
|
||||
wasBusy: boolean
|
||||
isBusy: boolean
|
||||
queueLength: number
|
||||
userInterrupted: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the composer should auto-drain the next queued prompt when a
|
||||
* turn settles (busy transitions true → false).
|
||||
*
|
||||
* The queue auto-advances when a turn *completes naturally*, but must NOT
|
||||
* advance when the user *explicitly interrupted* the turn via the Stop button.
|
||||
* Conflating the two made the Stop button appear to "never work": cancelling a
|
||||
* turn flipped busy → false, the queue immediately re-fired its head, and the
|
||||
* agent kept running. An explicit interrupt means stop — the queued turns are
|
||||
* preserved and the user resumes them deliberately (Cmd/Ctrl+K, Enter, or the
|
||||
* per-row "send now" arrow).
|
||||
*/
|
||||
export const shouldAutoDrainOnSettle = (params: AutoDrainSettleInput): boolean => {
|
||||
const { isBusy, queueLength, userInterrupted, wasBusy } = params
|
||||
|
||||
// Only react to a true → false transition; ignore steady state and entry.
|
||||
if (isBusy || !wasBusy) {
|
||||
return false
|
||||
}
|
||||
|
||||
// An explicit Stop suppresses exactly one auto-drain.
|
||||
if (userInterrupted) {
|
||||
return false
|
||||
}
|
||||
|
||||
return queueLength > 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
import type { ModelOptionProvider } from '@/types/hermes'
|
||||
|
||||
const STORAGE_KEY = 'hermes.desktop.visible-models'
|
||||
|
||||
/** Models shown per provider in the status-bar dropdown before the user has
|
||||
* customized the list. Backend `models` are already relevance-ordered. */
|
||||
export const DEFAULT_VISIBLE_PER_PROVIDER = 5
|
||||
|
||||
/** Stable key for a provider/model pair (`::` avoids colliding with model ids
|
||||
* that contain a single colon, e.g. `model:tag`). */
|
||||
export const modelVisibilityKey = (provider: string, model: string): string => `${provider}::${model}`
|
||||
|
||||
/** A model and its optional `…-fast` sibling, collapsed into one logical row.
|
||||
* `id` is the canonical (base) model; `fastId` is the fast variant if present. */
|
||||
export interface ModelFamily {
|
||||
fastId: string | null
|
||||
id: string
|
||||
}
|
||||
|
||||
/** Collapse a provider's model list so a base model and its `…-fast` variant
|
||||
* become a single family (one row, one toggle). Order is preserved by the
|
||||
* base model's position. A `…-fast` model with no base stands on its own. */
|
||||
export function collapseModelFamilies(models: readonly string[]): ModelFamily[] {
|
||||
const present = new Set(models)
|
||||
const families: ModelFamily[] = []
|
||||
const consumed = new Set<string>()
|
||||
|
||||
for (const model of models) {
|
||||
if (consumed.has(model)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (/-fast$/i.test(model) && present.has(model.replace(/-fast$/i, ''))) {
|
||||
// Represented by its base entry — the base attaches it as `fastId`.
|
||||
continue
|
||||
}
|
||||
|
||||
const fastId = `${model}-fast`
|
||||
const hasFast = present.has(fastId)
|
||||
families.push({ fastId: hasFast ? fastId : null, id: model })
|
||||
consumed.add(model)
|
||||
|
||||
if (hasFast) {
|
||||
consumed.add(fastId)
|
||||
}
|
||||
}
|
||||
|
||||
return families
|
||||
}
|
||||
|
||||
function loadVisible(): Set<string> | null {
|
||||
const raw = storedString(STORAGE_KEY)
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
return Array.isArray(parsed) ? new Set(parsed.filter((x): x is string => typeof x === 'string')) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Explicit set of visible `provider::model` keys, or null when the user
|
||||
* hasn't customized — in which case the curated default applies. */
|
||||
export const $visibleModels = atom<Set<string> | null>(loadVisible())
|
||||
|
||||
export const $modelVisibilityOpen = atom(false)
|
||||
|
||||
export function setVisibleModels(keys: Set<string>): void {
|
||||
$visibleModels.set(new Set(keys))
|
||||
persistString(STORAGE_KEY, JSON.stringify([...keys]))
|
||||
}
|
||||
|
||||
export function setModelVisibilityOpen(open: boolean): void {
|
||||
$modelVisibilityOpen.set(open)
|
||||
}
|
||||
|
||||
/** The default-visible key set: the curated top-N per provider. Used both as
|
||||
* the dropdown fallback and to seed the Edit Models dialog. */
|
||||
export function defaultVisibleKeys(providers: readonly ModelOptionProvider[]): Set<string> {
|
||||
const keys = new Set<string>()
|
||||
|
||||
for (const provider of providers) {
|
||||
const families = collapseModelFamilies(provider.models ?? [])
|
||||
|
||||
for (const family of families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)) {
|
||||
keys.add(modelVisibilityKey(provider.slug, family.id))
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
/** Resolve which keys are currently visible: the user's explicit set when
|
||||
* configured, otherwise the curated default for the given providers. */
|
||||
export function effectiveVisibleKeys(
|
||||
stored: Set<string> | null,
|
||||
providers: readonly ModelOptionProvider[]
|
||||
): Set<string> {
|
||||
return stored ?? defaultVisibleKeys(providers)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import type { ModelOptionProvider, OAuthProvider, OAuthStartResponse } from '@/t
|
||||
|
||||
type PkceStart = Extract<OAuthStartResponse, { flow: 'pkce' }>
|
||||
type DeviceStart = Extract<OAuthStartResponse, { flow: 'device_code' }>
|
||||
type LoopbackStart = Extract<OAuthStartResponse, { flow: 'loopback' }>
|
||||
|
||||
export type OnboardingMode = 'apikey' | 'oauth'
|
||||
|
||||
@@ -26,6 +27,10 @@ export type OnboardingFlow =
|
||||
| { provider: OAuthProvider; status: 'starting' }
|
||||
| { code: string; provider: OAuthProvider; start: PkceStart; status: 'awaiting_user' }
|
||||
| { copied: boolean; provider: OAuthProvider; start: DeviceStart; status: 'polling' }
|
||||
// Loopback PKCE (xAI Grok): browser opens, the local backend's 127.0.0.1
|
||||
// listener catches the redirect, and we poll until the worker finishes.
|
||||
// No code to paste and no user_code to show — just a waiting state.
|
||||
| { provider: OAuthProvider; start: LoopbackStart; status: 'awaiting_browser' }
|
||||
| { provider: OAuthProvider; start: OAuthStartResponse; status: 'submitting' }
|
||||
| { copied: boolean; provider: OAuthProvider; status: 'external_pending' }
|
||||
| { provider: OAuthProvider; status: 'success' }
|
||||
@@ -406,6 +411,26 @@ export async function refreshOnboarding(ctx: OnboardingContext) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Open a sign-in URL via the desktop bridge, falling back to window.open
|
||||
// when the bridge isn't present (e.g. the web dashboard / dev preview) so
|
||||
// the flow never silently stalls in a waiting state. Mirrors the pattern in
|
||||
// apps/desktop/src/app/artifacts/index.tsx.
|
||||
async function openSignInUrl(url: string) {
|
||||
if (window.hermesDesktop?.openExternal) {
|
||||
try {
|
||||
await window.hermesDesktop.openExternal(url)
|
||||
|
||||
return
|
||||
} catch {
|
||||
// Bridge present but failed (no OS handler, user denied, etc.). Fall
|
||||
// through to window.open so the sign-in URL still opens and the flow
|
||||
// doesn't strand a pending OAuth session in a waiting state.
|
||||
}
|
||||
}
|
||||
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
export async function startProviderOAuth(provider: OAuthProvider, ctx: OnboardingContext) {
|
||||
clearPoll()
|
||||
|
||||
@@ -419,7 +444,8 @@ export async function startProviderOAuth(provider: OAuthProvider, ctx: Onboardin
|
||||
|
||||
try {
|
||||
const start = await startOAuthLogin(provider.id)
|
||||
await window.hermesDesktop?.openExternal(start.flow === 'pkce' ? start.auth_url : start.verification_url)
|
||||
const browserUrl = start.flow === 'device_code' ? start.verification_url : start.auth_url
|
||||
await openSignInUrl(browserUrl)
|
||||
|
||||
if (start.flow === 'pkce') {
|
||||
setFlow({ status: 'awaiting_user', provider, start, code: '' })
|
||||
@@ -427,14 +453,26 @@ export async function startProviderOAuth(provider: OAuthProvider, ctx: Onboardin
|
||||
return
|
||||
}
|
||||
|
||||
if (start.flow === 'loopback') {
|
||||
// No code to paste: the redirect lands on the backend's loopback
|
||||
// listener. Just wait and poll the session until the worker finishes.
|
||||
setFlow({ status: 'awaiting_browser', provider, start })
|
||||
pollTimer = window.setInterval(() => void pollSession(provider, start, ctx), POLL_MS)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setFlow({ status: 'polling', provider, start, copied: false })
|
||||
pollTimer = window.setInterval(() => void pollDevice(provider, start, ctx), POLL_MS)
|
||||
pollTimer = window.setInterval(() => void pollSession(provider, start, ctx), POLL_MS)
|
||||
} catch (error) {
|
||||
setFlow({ status: 'error', provider, message: `Could not start sign-in: ${errMessage(error)}` })
|
||||
}
|
||||
}
|
||||
|
||||
async function pollDevice(provider: OAuthProvider, start: DeviceStart, ctx: OnboardingContext) {
|
||||
// Poll a session-backed flow (device_code or loopback) until it resolves.
|
||||
// Both shapes only need the session_id to poll; the start is threaded
|
||||
// through to the error flow so the user can retry from the same context.
|
||||
async function pollSession(provider: OAuthProvider, start: DeviceStart | LoopbackStart, ctx: OnboardingContext) {
|
||||
try {
|
||||
const { error_message, status } = await pollOAuthSession(provider.id, start.session_id)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { sessionPinId } from './session'
|
||||
import { mergeWorkingSessions, sessionPinId } from './session'
|
||||
|
||||
const session = (over: Partial<SessionInfo>): SessionInfo => ({
|
||||
archived: false,
|
||||
@@ -34,3 +34,46 @@ describe('sessionPinId', () => {
|
||||
expect(sessionPinId(session({ id: 'tip', _lineage_root_id: 'root' }))).toBe('root')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeWorkingSessions', () => {
|
||||
it('returns the server page untouched when nothing is working', () => {
|
||||
const previous = [session({ id: 'a' }), session({ id: 'b' })]
|
||||
const incoming = [session({ id: 'a' })]
|
||||
|
||||
expect(mergeWorkingSessions(previous, incoming, [])).toBe(incoming)
|
||||
})
|
||||
|
||||
it('keeps a still-working session the server omitted', () => {
|
||||
// Repro of the disappearing-sessions bug: A finished and is returned by the
|
||||
// server, but B and C are mid-first-response (message_count 0 in the DB) so
|
||||
// listSessions(min_messages=1) skips them. They must survive the refresh.
|
||||
const previous = [session({ id: 'c' }), session({ id: 'b' }), session({ id: 'a' })]
|
||||
const incoming = [session({ id: 'a', message_count: 2 })]
|
||||
|
||||
const merged = mergeWorkingSessions(previous, incoming, ['b', 'c'])
|
||||
|
||||
expect(merged.map(s => s.id)).toEqual(['c', 'b', 'a'])
|
||||
// The finished session comes from the fresh server payload, not the stale
|
||||
// optimistic copy.
|
||||
expect(merged.find(s => s.id === 'a')?.message_count).toBe(2)
|
||||
})
|
||||
|
||||
it('does not duplicate a working session the server already returned', () => {
|
||||
const previous = [session({ id: 'b' }), session({ id: 'a' })]
|
||||
const incoming = [session({ id: 'b', message_count: 4 }), session({ id: 'a' })]
|
||||
|
||||
const merged = mergeWorkingSessions(previous, incoming, ['b'])
|
||||
|
||||
expect(merged.map(s => s.id)).toEqual(['b', 'a'])
|
||||
expect(merged.find(s => s.id === 'b')?.message_count).toBe(4)
|
||||
})
|
||||
|
||||
it('never resurrects a non-working session the server dropped', () => {
|
||||
// A deleted/archived session is removed from `previous` optimistically and
|
||||
// is not in the working set, so it must stay gone after a refresh.
|
||||
const previous = [session({ id: 'b' }), session({ id: 'gone' })]
|
||||
const incoming = [session({ id: 'b' })]
|
||||
|
||||
expect(mergeWorkingSessions(previous, incoming, ['b']).map(s => s.id)).toEqual(['b'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,10 +3,15 @@ import { atom } from 'nanostores'
|
||||
import type { ContextSuggestion } from '@/app/types'
|
||||
import type { HermesConnection } from '@/global'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
import type { SessionInfo, UsageStats } from '@/types/hermes'
|
||||
|
||||
type Updater<T> = T | ((current: T) => T)
|
||||
|
||||
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
|
||||
|
||||
export const getRememberedWorkspaceCwd = (): string => storedString(WORKSPACE_CWD_KEY)?.trim() || ''
|
||||
|
||||
interface AppAtom<T> {
|
||||
get: () => T
|
||||
set: (value: T) => void
|
||||
@@ -22,6 +27,33 @@ function updateAtom<T>(store: AppAtom<T>, next: Updater<T>) {
|
||||
export const sessionPinId = (session: Pick<SessionInfo, '_lineage_root_id' | 'id'>): string =>
|
||||
session._lineage_root_id ?? session.id
|
||||
|
||||
/** Merge a fresh server session page into the in-memory list, keeping any
|
||||
* still-"working" session the server omitted.
|
||||
*
|
||||
* A brand-new session's first user message isn't flushed to the SessionDB
|
||||
* until its turn is persisted, so `listSessions(min_messages=1)` skips
|
||||
* sessions that are mid-first-response. Because every `message.complete`
|
||||
* triggers a full refresh, a hard replace makes concurrent new chats vanish
|
||||
* the instant any one of them finishes. Preserving the working-but-absent
|
||||
* rows keeps them visible until their own turn persists and the server
|
||||
* starts returning them. Optimistic deletes/archives already drop the row
|
||||
* from `previous`, so a removed session can't be resurrected here. */
|
||||
export function mergeWorkingSessions(
|
||||
previous: SessionInfo[],
|
||||
incoming: SessionInfo[],
|
||||
workingIds: readonly string[]
|
||||
): SessionInfo[] {
|
||||
if (workingIds.length === 0) {
|
||||
return incoming
|
||||
}
|
||||
|
||||
const working = new Set(workingIds)
|
||||
const incomingIds = new Set(incoming.map(session => session.id))
|
||||
const survivors = previous.filter(session => working.has(session.id) && !incomingIds.has(session.id))
|
||||
|
||||
return survivors.length ? [...survivors, ...incoming] : incoming
|
||||
}
|
||||
|
||||
export const $connection = atom<HermesConnection | null>(null)
|
||||
export const $gatewayState = atom('idle')
|
||||
export const $sessions = atom<SessionInfo[]>([])
|
||||
@@ -39,7 +71,7 @@ export const $currentProvider = atom('')
|
||||
export const $currentReasoningEffort = atom('')
|
||||
export const $currentServiceTier = atom('')
|
||||
export const $currentFastMode = atom(false)
|
||||
export const $currentCwd = atom('')
|
||||
export const $currentCwd = atom(getRememberedWorkspaceCwd())
|
||||
export const $currentBranch = atom('')
|
||||
export const $currentUsage = atom<UsageStats>({
|
||||
calls: 0,
|
||||
@@ -73,7 +105,14 @@ export const setCurrentProvider = (next: Updater<string>) => updateAtom($current
|
||||
export const setCurrentReasoningEffort = (next: Updater<string>) => updateAtom($currentReasoningEffort, next)
|
||||
export const setCurrentServiceTier = (next: Updater<string>) => updateAtom($currentServiceTier, next)
|
||||
export const setCurrentFastMode = (next: Updater<boolean>) => updateAtom($currentFastMode, next)
|
||||
export const setCurrentCwd = (next: Updater<string>) => updateAtom($currentCwd, next)
|
||||
|
||||
export const setCurrentCwd = (next: Updater<string>) => {
|
||||
updateAtom($currentCwd, next)
|
||||
// Keep localStorage in sync with the atom: a real folder is remembered, an
|
||||
// empty cwd clears the key (|| null → removeItem).
|
||||
persistString(WORKSPACE_CWD_KEY, $currentCwd.get().trim() || null)
|
||||
}
|
||||
|
||||
export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)
|
||||
export const setCurrentUsage = (next: Updater<UsageStats>) => updateAtom($currentUsage, next)
|
||||
export const setSessionStartedAt = (next: Updater<number | null>) => updateAtom($sessionStartedAt, next)
|
||||
@@ -85,6 +124,53 @@ export const setIntroSeed = (next: Updater<number>) => updateAtom($introSeed, ne
|
||||
export const setContextSuggestions = (next: Updater<ContextSuggestion[]>) => updateAtom($contextSuggestions, next)
|
||||
export const setModelPickerOpen = (next: Updater<boolean>) => updateAtom($modelPickerOpen, next)
|
||||
|
||||
// Watchdog tracking — when does a "working" session count as stuck?
|
||||
// Long-running tool calls (LLM inference, long shell commands, web fetches)
|
||||
// can take a few minutes legitimately. We allow 8 minutes of complete
|
||||
// silence on the stream before clearing the working flag; in practice this
|
||||
// catches gateway hangs and dropped streams without false-positive-clearing
|
||||
// real long turns.
|
||||
const SESSION_WATCHDOG_TIMEOUT_MS = 8 * 60 * 1000
|
||||
const sessionWatchdogTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
function armSessionWatchdog(sessionId: string) {
|
||||
const existing = sessionWatchdogTimers.get(sessionId)
|
||||
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
sessionWatchdogTimers.delete(sessionId)
|
||||
// Re-check the latest state at fire-time. If the user already navigated
|
||||
// away or the session genuinely finished, the timer is a no-op.
|
||||
if ($workingSessionIds.get().includes(sessionId)) {
|
||||
setWorkingSessionIds(current => current.filter(id => id !== sessionId))
|
||||
}
|
||||
}, SESSION_WATCHDOG_TIMEOUT_MS)
|
||||
|
||||
sessionWatchdogTimers.set(sessionId, timer)
|
||||
}
|
||||
|
||||
function clearSessionWatchdog(sessionId: string) {
|
||||
const existing = sessionWatchdogTimers.get(sessionId)
|
||||
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
sessionWatchdogTimers.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Call when a streaming event for a session lands. Refreshes the watchdog
|
||||
* so the session keeps its "working" status as long as data keeps coming. */
|
||||
export function noteSessionActivity(sessionId: string | null | undefined) {
|
||||
if (!sessionId || !$workingSessionIds.get().includes(sessionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
armSessionWatchdog(sessionId)
|
||||
}
|
||||
|
||||
export function setSessionWorking(sessionId: string | null | undefined, working: boolean) {
|
||||
if (!sessionId) {
|
||||
return
|
||||
@@ -99,4 +185,13 @@ export function setSessionWorking(sessionId: string | null | undefined, working:
|
||||
|
||||
return alreadyWorking ? current.filter(id => id !== sessionId) : current
|
||||
})
|
||||
|
||||
// Bookend the watchdog: arm it whenever a session enters "working",
|
||||
// disarm it whenever it leaves. A subsequent noteSessionActivity() from
|
||||
// a streaming event will refresh the timer.
|
||||
if (working) {
|
||||
armSessionWatchdog(sessionId)
|
||||
} else {
|
||||
clearSessionWatchdog(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,34 @@ export function openUpdatesWindow(): void {
|
||||
void checkUpdates()
|
||||
}
|
||||
|
||||
/** Re-read the running app's version from the Electron main process and
|
||||
* publish it on `$desktopVersion`. Called when the About panel mounts, the
|
||||
* update flow finishes, and the window regains focus, so the About text
|
||||
* stays in sync with the just-installed binary instead of frozen at the
|
||||
* value captured at first-load. */
|
||||
export async function refreshDesktopVersion(): Promise<DesktopVersionInfo | null> {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Best-effort UI sync: callers (checkUpdates, startUpdatePoller, window
|
||||
// focus handler) all kick this off with `void refreshDesktopVersion()`,
|
||||
// so any rejection from the IPC bridge (e.g. main process shutting down
|
||||
// mid-reload, or the bridge not yet ready on first paint) would surface
|
||||
// as an unhandled promise rejection in the renderer. Swallow it.
|
||||
try {
|
||||
const next = await window.hermesDesktop?.getVersion?.()
|
||||
|
||||
if (next) {
|
||||
$desktopVersion.set(next)
|
||||
}
|
||||
|
||||
return next ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
|
||||
const bridge = window.hermesDesktop?.updates
|
||||
|
||||
@@ -158,6 +186,10 @@ export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
|
||||
const status = await bridge.check()
|
||||
$updateStatus.set(status)
|
||||
maybeNotifyUpdateAvailable(status)
|
||||
// The update check pulls the latest hermes_cli + bundled package metadata
|
||||
// into place. Re-read the running version so About reflects the now-fresh
|
||||
// checkout rather than the one captured at process start.
|
||||
void refreshDesktopVersion()
|
||||
|
||||
return status
|
||||
} catch (error) {
|
||||
@@ -249,7 +281,7 @@ export function startUpdatePoller(): void {
|
||||
|
||||
pollerStarted = true
|
||||
void checkUpdates()
|
||||
void window.hermesDesktop?.getVersion?.().then(info => $desktopVersion.set(info))
|
||||
void refreshDesktopVersion()
|
||||
bridge.onProgress(ingestProgress)
|
||||
|
||||
window.addEventListener('focus', onFocus)
|
||||
@@ -275,4 +307,8 @@ function onFocus() {
|
||||
|
||||
lastFocusAt = now
|
||||
void checkUpdates()
|
||||
// Cheap and safe to re-read on every (throttled) focus: the user may have
|
||||
// updated Hermes from another window/CLI between focuses, and About should
|
||||
// catch up without forcing a restart.
|
||||
void refreshDesktopVersion()
|
||||
}
|
||||
|
||||
+102
-19
@@ -76,8 +76,7 @@
|
||||
--shadow-header:
|
||||
0 0.0625rem 0 color-mix(in srgb, var(--dt-foreground) 7%, transparent),
|
||||
0 0.625rem 1.5rem -1.25rem color-mix(in srgb, #000 16%, transparent);
|
||||
--shadow-composer:
|
||||
0 0.0625rem 0.125rem color-mix(in srgb, #000 5%, transparent);
|
||||
--shadow-composer: 0 0.0625rem 0.125rem color-mix(in srgb, #000 5%, transparent);
|
||||
--shadow-composer-focus:
|
||||
0 0 0 0.125rem color-mix(in srgb, var(--dt-composer-ring) calc(10% * var(--composer-ring-strength)), transparent),
|
||||
0 0 0 0.0625rem color-mix(in srgb, var(--dt-composer-ring) calc(22% * var(--composer-ring-strength)), transparent),
|
||||
@@ -133,15 +132,23 @@
|
||||
--ui-cyan: #4c7f8c;
|
||||
--ui-blue: #0053fd;
|
||||
--ui-purple: #9e94d5;
|
||||
--ui-bg-chrome: color-mix(in srgb, var(--theme-background-seed) var(--theme-mix-chrome), var(--theme-neutral-chrome));
|
||||
--ui-bg-sidebar: color-mix(in srgb, var(--theme-sidebar-seed) var(--theme-mix-sidebar), var(--theme-neutral-sidebar));
|
||||
--ui-bg-editor: color-mix(in srgb, var(--theme-card-seed) var(--theme-mix-card), var(--theme-neutral-card));
|
||||
--ui-bg-elevated: color-mix(in srgb, var(--theme-elevated-seed) var(--theme-mix-elevated), var(--theme-neutral-card));
|
||||
--ui-bg-card: color-mix(
|
||||
--ui-bg-chrome: color-mix(
|
||||
in srgb,
|
||||
var(--ui-accent) 4%,
|
||||
color-mix(in srgb, var(--ui-base) 4%, transparent)
|
||||
var(--theme-background-seed) var(--theme-mix-chrome),
|
||||
var(--theme-neutral-chrome)
|
||||
);
|
||||
--ui-bg-sidebar: color-mix(
|
||||
in srgb,
|
||||
var(--theme-sidebar-seed) var(--theme-mix-sidebar),
|
||||
var(--theme-neutral-sidebar)
|
||||
);
|
||||
--ui-bg-editor: color-mix(in srgb, var(--theme-card-seed) var(--theme-mix-card), var(--theme-neutral-card));
|
||||
--ui-bg-elevated: color-mix(
|
||||
in srgb,
|
||||
var(--theme-elevated-seed) var(--theme-mix-elevated),
|
||||
var(--theme-neutral-card)
|
||||
);
|
||||
--ui-bg-card: color-mix(in srgb, var(--ui-accent) 4%, color-mix(in srgb, var(--ui-base) 4%, transparent));
|
||||
--ui-bg-input: #fcfcfc;
|
||||
--ui-bg-primary: color-mix(
|
||||
in srgb,
|
||||
@@ -218,7 +225,11 @@
|
||||
--ui-sidebar-surface-background: var(--ui-bg-sidebar);
|
||||
--ui-chat-surface-background: var(--ui-bg-chrome);
|
||||
--ui-editor-surface-background: var(--ui-bg-chrome);
|
||||
--ui-chat-bubble-background: color-mix(in srgb, var(--theme-bubble-seed) var(--theme-mix-bubble), var(--theme-neutral-card));
|
||||
--ui-chat-bubble-background: color-mix(
|
||||
in srgb,
|
||||
var(--theme-bubble-seed) var(--theme-mix-bubble),
|
||||
var(--theme-neutral-card)
|
||||
);
|
||||
--ui-chat-bubble-opaque-background: var(--ui-bg-editor);
|
||||
--ui-inline-code-background: color-mix(in srgb, #141414 5%, transparent);
|
||||
--ui-inline-code-border: color-mix(in srgb, #141414 8%, transparent);
|
||||
@@ -272,6 +283,7 @@
|
||||
--conversation-line-height: 1.125rem;
|
||||
--conversation-caption-line-height: 1rem;
|
||||
--conversation-turn-gap: 0.375rem;
|
||||
--sticky-human-top: 0.23rem;
|
||||
--file-tree-row-height: 1.375rem;
|
||||
|
||||
--composer-width: 48.75rem;
|
||||
@@ -626,7 +638,7 @@ canvas {
|
||||
.scrollbar-dt::-webkit-scrollbar-thumb,
|
||||
.scrollbar-dt *::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--dt-midground) 18%, transparent);
|
||||
border-radius: 9999rem;
|
||||
border-radius: 9999rem;
|
||||
border: 0.125rem solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
@@ -641,6 +653,41 @@ canvas {
|
||||
.scrollbar-dt *::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Variant for portaled overlays (Radix DropdownMenu, Popover, etc.) that
|
||||
render under document.body, outside the `.scrollbar-dt` scope on
|
||||
#root. Same visual treatment, applied directly to the overlay
|
||||
container so its (and only its) internal scrollbar is themed. */
|
||||
.dt-portal-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--dt-midground) 28%, transparent) transparent;
|
||||
}
|
||||
|
||||
.dt-portal-scrollbar::-webkit-scrollbar {
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
}
|
||||
|
||||
.dt-portal-scrollbar::-webkit-scrollbar-track,
|
||||
.dt-portal-scrollbar::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dt-portal-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--dt-midground) 28%, transparent);
|
||||
border-radius: 9999rem;
|
||||
border: 0.0625rem solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.dt-portal-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--dt-midground) 50%, transparent);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.dt-portal-scrollbar::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bottom clearance lives on [data-slot='aui_composer-clearance'] —
|
||||
@@ -669,11 +716,52 @@ canvas {
|
||||
padding-inline-start: var(--md-text-indent, 0.5rem);
|
||||
}
|
||||
|
||||
[data-slot='aui_user-message-root'] {
|
||||
top: var(--sticky-human-top);
|
||||
}
|
||||
|
||||
[data-slot='aui_user-message-root'],
|
||||
[data-slot='aui_edit-composer-root'] {
|
||||
font-size: var(--conversation-text-font-size);
|
||||
}
|
||||
|
||||
/* Sticky human bubbles clamp to ~2 lines with a soft bottom fade so a long
|
||||
prompt doesn't dominate the viewport while you read the response stuck
|
||||
beneath it. The clamp lifts on hover / focus (clicking the bubble opens the
|
||||
edit composer, which already shows the full text). --human-msg-full is the
|
||||
measured content height (set in UserMessage) so expand/collapse animates to
|
||||
the real height instead of overshooting the cap. */
|
||||
.sticky-human-clamp {
|
||||
max-height: calc(2 * var(--dt-line-height) * var(--conversation-text-font-size) + 0.15rem);
|
||||
overflow: hidden;
|
||||
transition: max-height 0.08s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.sticky-human-clamp[data-clamped='true'] {
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent);
|
||||
mask-image: linear-gradient(to bottom, #000 55%, transparent);
|
||||
}
|
||||
|
||||
.composer-human-message:hover .sticky-human-clamp,
|
||||
.composer-human-message:focus-within .sticky-human-clamp {
|
||||
max-height: min(var(--human-msg-full, 24rem), 24rem);
|
||||
overflow-y: auto;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
/* The thread renders items in natural document flow (padding spacers, not
|
||||
transforms) and @tanstack/react-virtual already adjusts scrollTop itself
|
||||
when an off-screen turn is measured and its real height differs from the
|
||||
220px estimate. The browser's native scroll anchoring (overflow-anchor:
|
||||
auto) would adjust scrollTop for that SAME size delta, so the two
|
||||
double-correct and the view lurches — most visibly on Windows mouse wheels,
|
||||
whose coarse notches mount/measure several under-estimated turns per tick.
|
||||
Opt out of native anchoring so only the virtualizer compensates. */
|
||||
[data-slot='aui_thread-viewport'] {
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
[data-slot='aui_thread-content'] {
|
||||
max-width: var(--composer-width);
|
||||
padding-inline: 1.5rem;
|
||||
@@ -862,8 +950,7 @@ canvas {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content']
|
||||
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
|
||||
[data-slot='aui_assistant-message-content'] > :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
|
||||
opacity: 0.67;
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
@@ -894,12 +981,8 @@ canvas {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content']
|
||||
[data-slot='aui_thinking-disclosure']
|
||||
+ [data-slot='tool-block'],
|
||||
[data-slot='aui_assistant-message-content']
|
||||
[data-slot='tool-block']
|
||||
+ [data-slot='aui_thinking-disclosure'] {
|
||||
[data-slot='aui_assistant-message-content'] [data-slot='aui_thinking-disclosure'] + [data-slot='tool-block'],
|
||||
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'] + [data-slot='aui_thinking-disclosure'] {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export interface OAuthProviderStatus {
|
||||
export interface OAuthProvider {
|
||||
cli_command: string
|
||||
docs_url: string
|
||||
flow: 'device_code' | 'external' | 'pkce'
|
||||
flow: 'device_code' | 'external' | 'loopback' | 'pkce'
|
||||
id: string
|
||||
name: string
|
||||
status: OAuthProviderStatus
|
||||
@@ -73,6 +73,12 @@ export type OAuthStartResponse =
|
||||
user_code: string
|
||||
verification_url: string
|
||||
}
|
||||
| {
|
||||
auth_url: string
|
||||
expires_in: number
|
||||
flow: 'loopback'
|
||||
session_id: string
|
||||
}
|
||||
|
||||
export interface OAuthSubmitResponse {
|
||||
message?: string
|
||||
@@ -210,6 +216,14 @@ export interface ModelOptionProvider {
|
||||
free_tier?: boolean
|
||||
/** Nous only: paid models a free-tier user cannot select (shown disabled). */
|
||||
unavailable_models?: string[]
|
||||
/** Per-model option support, keyed by model id (present when the picker
|
||||
* requested capabilities). Lets the UI gate fast/reasoning controls. */
|
||||
capabilities?: Record<string, ModelCapabilities>
|
||||
}
|
||||
|
||||
export interface ModelCapabilities {
|
||||
fast: boolean
|
||||
reasoning: boolean
|
||||
}
|
||||
|
||||
export interface ModelOptionsResponse {
|
||||
|
||||
@@ -1115,10 +1115,36 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
||||
|
||||
from tools.skills_tool import skill_view
|
||||
from tools.skill_usage import bump_use
|
||||
from agent.skill_bundles import build_bundle_invocation_message, resolve_bundle_command_key
|
||||
|
||||
parts = []
|
||||
skipped: list[str] = []
|
||||
for skill_name in skill_names:
|
||||
# Cron jobs historically accepted only skill names here, but the CLI/gateway
|
||||
# slash-command path lets bundles shadow skills with the same slug. Mirror
|
||||
# that behavior so `skills: ["my-bundle"]` expands bundle members instead
|
||||
# of being treated as a missing skill.
|
||||
bundle_key = resolve_bundle_command_key(skill_name.lstrip("/"))
|
||||
if bundle_key:
|
||||
bundle_payload = build_bundle_invocation_message(
|
||||
bundle_key,
|
||||
user_instruction="",
|
||||
task_id=str(job.get("id") or "") or None,
|
||||
)
|
||||
if bundle_payload:
|
||||
bundle_message, _loaded_bundle_skills, _missing_bundle_skills = bundle_payload
|
||||
if parts:
|
||||
parts.append("")
|
||||
parts.append(bundle_message)
|
||||
continue
|
||||
logger.warning(
|
||||
"Cron job '%s': bundle '%s' could not load any skills, skipping",
|
||||
job.get("name", job.get("id")),
|
||||
skill_name,
|
||||
)
|
||||
skipped.append(skill_name)
|
||||
continue
|
||||
|
||||
try:
|
||||
loaded = json.loads(skill_view(skill_name))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
|
||||
@@ -278,6 +278,38 @@ if [ ! -f "$HERMES_HOME/auth.json" ] && [ -n "${HERMES_AUTH_JSON_BOOTSTRAP:-}" ]
|
||||
chmod 600 "$HERMES_HOME/auth.json"
|
||||
fi
|
||||
|
||||
# gateway_state.json: declare the gateway's INITIAL supervised state on a
|
||||
# fresh volume. Same first-boot-only env-seed pattern as auth.json above.
|
||||
#
|
||||
# On a blank volume there is no gateway_state.json, so the boot reconciler
|
||||
# (cont-init.d/02-reconcile-profiles → container_boot.reconcile_profile_gateways)
|
||||
# registers the gateway-default s6 slot but leaves it DOWN — it only
|
||||
# auto-starts when the last recorded state was "running". That means a
|
||||
# freshly-provisioned container comes up with the gateway down until
|
||||
# someone starts it (e.g. from the dashboard). An orchestrator that
|
||||
# provisions a fresh volume and wants the gateway running from first boot
|
||||
# can set HERMES_GATEWAY_BOOTSTRAP_STATE=running; we seed the state file
|
||||
# here, BEFORE 02-reconcile-profiles runs (cont-init.d scripts run in
|
||||
# lexicographic order), so the reconciler sees prior_state=running and
|
||||
# brings the supervised slot up on the very first boot.
|
||||
#
|
||||
# This is a generic container contract, not specific to any host: it seeds
|
||||
# the SAME gateway_state.json the reconciler already consults, exactly as
|
||||
# HERMES_AUTH_JSON_BOOTSTRAP seeds auth.json. The [ ! -f ] guard is the
|
||||
# load-bearing part — on every subsequent boot the persisted state wins,
|
||||
# so a gateway the operator deliberately stopped stays stopped across
|
||||
# restarts and we never clobber real runtime state.
|
||||
#
|
||||
# Only a literal "running" is honoured (the sole value in the reconciler's
|
||||
# _AUTOSTART_STATES); any other value is ignored so a typo can't write a
|
||||
# bogus state the reconciler would treat as "no prior state" anyway.
|
||||
if [ ! -f "$HERMES_HOME/gateway_state.json" ] && \
|
||||
[ "${HERMES_GATEWAY_BOOTSTRAP_STATE:-}" = "running" ]; then
|
||||
printf '{"gateway_state":"running"}\n' > "$HERMES_HOME/gateway_state.json"
|
||||
chown hermes:hermes "$HERMES_HOME/gateway_state.json" 2>/dev/null || true
|
||||
chmod 644 "$HERMES_HOME/gateway_state.json"
|
||||
fi
|
||||
|
||||
# --- Sync bundled skills ---
|
||||
# Invoke the venv's python by absolute path so we don't need a `sh -c`
|
||||
# wrapper to source the activate script. This is safe because
|
||||
|
||||
@@ -4195,8 +4195,25 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
return False
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Stop the aiohttp web server."""
|
||||
"""Stop the aiohttp web server and release all owned resources.
|
||||
|
||||
Closes the ResponseStore SQLite connection in addition to stopping
|
||||
the aiohttp web server. Without this, every adapter instance leaks
|
||||
2 file descriptors (the database file and its WAL sidecar) — the
|
||||
reconnect loop in ``gateway.run`` constructs a fresh adapter on
|
||||
every retry, so 2 fds/retry × 300s backoff cap ≈ 12 fds/hour, which
|
||||
exhausts the default 2560 fd limit after ~12h of failed reconnects
|
||||
and turns the whole gateway into a zombie
|
||||
(OSError: [Errno 24] Too many open files, #37011).
|
||||
"""
|
||||
self._mark_disconnected()
|
||||
if self._response_store is not None:
|
||||
try:
|
||||
self._response_store.close()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to close response store for %s", self.name, exc_info=True,
|
||||
)
|
||||
if self._site:
|
||||
await self._site.stop()
|
||||
self._site = None
|
||||
|
||||
+113
-6
@@ -1755,6 +1755,60 @@ def _preserve_queued_followup_history_offset(
|
||||
return merged
|
||||
|
||||
|
||||
async def _dispose_unused_adapter(adapter: "BasePlatformAdapter | None") -> None:
|
||||
"""Best-effort dispose for an adapter that never made it onto ``self.adapters``.
|
||||
|
||||
The reconnect watcher in ``GatewayRunner._platform_reconnect_watcher``
|
||||
constructs a fresh adapter on every retry attempt. When the connect
|
||||
call fails — for any of the three reasons (non-retryable error,
|
||||
retryable error, exception during connect) — the adapter is dropped
|
||||
without ever being installed, so nothing else will call its
|
||||
``disconnect()``. Any resources the adapter opened in ``__init__``
|
||||
(e.g. ``APIServerAdapter`` opens a SQLite ``ResponseStore`` that
|
||||
holds 2 fds — the db file and its WAL sidecar) stay open until
|
||||
garbage collection sweeps the unreachable object, which Python's
|
||||
cyclic GC does not do promptly for asyncio-bound objects with
|
||||
native handles. The cumulative leak is 2 fds × every retry at the
|
||||
300s backoff cap ≈ 12 fds/hour, and the default 2560-fd ulimit
|
||||
is exhausted in ~12h of continuous failure, after which every
|
||||
open() call on the gateway raises ``OSError: [Errno 24] Too many
|
||||
open files`` and the gateway becomes a zombie (#37011).
|
||||
|
||||
This helper centralises the dispose-with-suppression so the three
|
||||
failure paths in the reconnect watcher can all call it without
|
||||
each one having to know that ``disconnect()`` may itself raise
|
||||
on a half-constructed adapter.
|
||||
|
||||
``adapter`` may be ``None``: the reconnect watcher initialises
|
||||
``adapter = None`` before the ``try`` so the ``except Exception``
|
||||
arm can dispose a half-constructed object, and also early-returns
|
||||
here when ``_create_adapter()`` returned ``None``.
|
||||
"""
|
||||
if adapter is None:
|
||||
return
|
||||
try:
|
||||
await adapter.disconnect()
|
||||
except Exception:
|
||||
# Half-constructed adapters (e.g. APIServerAdapter that
|
||||
# crashed during aiohttp app setup) can raise from
|
||||
# disconnect() on objects that never finished initializing.
|
||||
# We must not let that escape and abort the watcher loop.
|
||||
#
|
||||
# On Python 3.8+, ``asyncio.CancelledError`` inherits from
|
||||
# ``BaseException`` (not ``Exception``), so this ``except
|
||||
# Exception`` does not swallow task cancellation. We don't
|
||||
# re-raise explicitly because the watcher loop intentionally
|
||||
# treats dispose failures as best-effort: a failed ``disconnect``
|
||||
# call should not take down the reconnect watcher that
|
||||
# itself is what's keeping the gateway alive during a partial
|
||||
# outage.
|
||||
logger.debug(
|
||||
"Adapter dispose raised on unowned adapter %r",
|
||||
getattr(adapter, "name", type(adapter).__name__),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
class GatewayRunner:
|
||||
"""
|
||||
Main gateway controller.
|
||||
@@ -6175,6 +6229,7 @@ class GatewayRunner:
|
||||
platform.value, attempt,
|
||||
)
|
||||
|
||||
adapter = None
|
||||
try:
|
||||
adapter = self._create_adapter(platform, platform_config)
|
||||
if not adapter:
|
||||
@@ -6224,6 +6279,15 @@ class GatewayRunner:
|
||||
"Reconnect %s: non-retryable error (%s), removing from retry queue",
|
||||
platform.value, adapter.fatal_error_message,
|
||||
)
|
||||
# The adapter is about to be dropped from the queue
|
||||
# without ever being installed on self.adapters, so
|
||||
# nothing else will call disconnect() on it. We must
|
||||
# dispose it here, otherwise the resource owners it
|
||||
# constructed in __init__ (ResponseStore for
|
||||
# APIServerAdapter, etc.) leak 2 fds each. The
|
||||
# gateway hits the 2560-fd limit after ~12h of
|
||||
# failed reconnects at the 300s backoff cap (#37011).
|
||||
await _dispose_unused_adapter(adapter)
|
||||
del self._failed_platforms[platform]
|
||||
else:
|
||||
self._update_platform_runtime_status(
|
||||
@@ -6239,6 +6303,14 @@ class GatewayRunner:
|
||||
"Reconnect %s failed, next retry in %ds",
|
||||
platform.value, backoff,
|
||||
)
|
||||
# Same fd-leak concern as the non-retryable branch
|
||||
# above: the adapter failed to connect and is being
|
||||
# thrown away. Without an explicit dispose call, the
|
||||
# resources it opened in __init__ stay open until
|
||||
# the next GC pass — and aiohttp/SQLite handles
|
||||
# don't get GC'd promptly, so 2 fds/retry leak at
|
||||
# 300s backoff cap = ~12 fds/hour (#37011).
|
||||
await _dispose_unused_adapter(adapter)
|
||||
# Retryable failures (network/DNS blips) keep retrying
|
||||
# at the backoff cap indefinitely — they self-heal once
|
||||
# connectivity returns. We do NOT auto-pause them: a
|
||||
@@ -6248,6 +6320,14 @@ class GatewayRunner:
|
||||
# `not fatal_error_retryable` branch above, so anything
|
||||
# reaching here is by definition retryable.
|
||||
except Exception as e:
|
||||
if adapter is not None:
|
||||
# An exception escaping the connect call path
|
||||
# (DNS timeout, aiohttp server.start() crash, etc.)
|
||||
# leaves the adapter in the same unowned state as
|
||||
# the two branches above. Dispose so __init__
|
||||
# resources don't accumulate while the watcher
|
||||
# keeps retrying.
|
||||
await _dispose_unused_adapter(adapter)
|
||||
self._update_platform_runtime_status(
|
||||
platform.value,
|
||||
platform_state="retrying",
|
||||
@@ -12508,14 +12588,41 @@ class GatewayRunner:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Send media files
|
||||
# Send media files, routing each by type so a TTS clip
|
||||
# arrives as a voice bubble / a clip as a video rather than
|
||||
# a generic document. Mirrors the streaming + kanban paths.
|
||||
from gateway.platforms.base import (
|
||||
should_send_media_as_audio as _should_send_media_as_audio,
|
||||
)
|
||||
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
|
||||
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"}
|
||||
for media_path, _is_voice in (media_files or []):
|
||||
_ext = os.path.splitext(media_path)[1].lower()
|
||||
try:
|
||||
await adapter.send_document(
|
||||
chat_id=source.chat_id,
|
||||
file_path=media_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
if _should_send_media_as_audio(source.platform, _ext, _is_voice):
|
||||
await adapter.send_voice(
|
||||
chat_id=source.chat_id,
|
||||
audio_path=media_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
elif _ext in _VIDEO_EXTS:
|
||||
await adapter.send_video(
|
||||
chat_id=source.chat_id,
|
||||
video_path=media_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
elif _ext in _IMAGE_EXTS:
|
||||
await adapter.send_image_file(
|
||||
chat_id=source.chat_id,
|
||||
image_path=media_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
else:
|
||||
await adapter.send_document(
|
||||
chat_id=source.chat_id,
|
||||
file_path=media_path,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
|
||||
@@ -107,14 +107,17 @@ def set_session_vars(
|
||||
user_name: str = "",
|
||||
session_key: str = "",
|
||||
message_id: str = "",
|
||||
cwd: str = "",
|
||||
) -> list:
|
||||
"""Set all session context variables and return reset tokens.
|
||||
|
||||
Call ``clear_session_vars(tokens)`` in a ``finally`` block to restore
|
||||
the previous values when the handler exits.
|
||||
Call ``clear_session_vars(tokens)`` in a ``finally`` block when the handler
|
||||
exits. Note ``clear_session_vars`` resets every var to ``""`` (to suppress
|
||||
the ``os.environ`` fallback) rather than restoring prior values — these
|
||||
helpers are not nestable/stack-safe, and the returned tokens are accepted
|
||||
only for API compatibility.
|
||||
|
||||
Returns a list of ``Token`` objects (one per variable) that can be
|
||||
passed to ``clear_session_vars``.
|
||||
``cwd`` pins the logical working directory for this context.
|
||||
"""
|
||||
tokens = [
|
||||
_SESSION_PLATFORM.set(platform),
|
||||
@@ -126,6 +129,12 @@ def set_session_vars(
|
||||
_SESSION_KEY.set(session_key),
|
||||
_SESSION_MESSAGE_ID.set(message_id),
|
||||
]
|
||||
try:
|
||||
from agent.runtime_cwd import set_session_cwd
|
||||
|
||||
set_session_cwd(cwd)
|
||||
except Exception:
|
||||
pass
|
||||
return tokens
|
||||
|
||||
|
||||
@@ -151,6 +160,12 @@ def clear_session_vars(tokens: list) -> None:
|
||||
_SESSION_MESSAGE_ID,
|
||||
):
|
||||
var.set("")
|
||||
try:
|
||||
from agent.runtime_cwd import clear_session_cwd
|
||||
|
||||
clear_session_cwd()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_session_env(name: str, default: str = "") -> str:
|
||||
|
||||
@@ -41,6 +41,8 @@ _EPILOGUE = """
|
||||
Examples:
|
||||
hermes Start interactive chat
|
||||
hermes chat -q "Hello" Single query mode
|
||||
hermes --tui Launch the modern TUI (or set display.interface: tui)
|
||||
hermes --cli Force the classic REPL (overrides display.interface: tui)
|
||||
hermes -c Resume the most recent session
|
||||
hermes -c "my project" Resume a session by name (latest in lineage)
|
||||
hermes --resume <session_id> Resume a specific session by ID
|
||||
@@ -218,6 +220,13 @@ def build_top_level_parser():
|
||||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--cli",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Force the classic prompt_toolkit REPL (overrides display.interface=tui)",
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--dev",
|
||||
@@ -369,6 +378,13 @@ def build_top_level_parser():
|
||||
default=False,
|
||||
help="Launch the modern TUI instead of the classic REPL",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--cli",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Force the classic prompt_toolkit REPL (overrides display.interface=tui)",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--dev",
|
||||
|
||||
+3
-1
@@ -3370,7 +3370,7 @@ def _sync_codex_pool_entries(
|
||||
entry["last_error_reset_at"] = None
|
||||
|
||||
|
||||
def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None:
|
||||
def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label: str = None) -> None:
|
||||
"""Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json)."""
|
||||
if last_refresh is None:
|
||||
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
@@ -3380,6 +3380,8 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None
|
||||
state["tokens"] = tokens
|
||||
state["last_refresh"] = last_refresh
|
||||
state["auth_mode"] = "chatgpt"
|
||||
if label and str(label).strip():
|
||||
state["label"] = str(label).strip()
|
||||
_save_provider_state(auth_store, "openai-codex", state)
|
||||
_sync_codex_pool_entries(auth_store, tokens, last_refresh)
|
||||
_save_auth_store(auth_store)
|
||||
|
||||
@@ -307,28 +307,20 @@ def auth_add_command(args) -> None:
|
||||
return
|
||||
|
||||
if provider == "openai-codex":
|
||||
# Clear any existing suppression marker so a re-link after `hermes auth
|
||||
# remove openai-codex` works without the new tokens being skipped.
|
||||
auth_mod.unsuppress_credential_source(provider, "device_code")
|
||||
creds = auth_mod._codex_device_code_login()
|
||||
label = (getattr(args, "label", None) or "").strip() or label_from_token(
|
||||
creds["tokens"]["access_token"],
|
||||
_oauth_default_label(provider, len(pool.entries()) + 1),
|
||||
)
|
||||
entry = PooledCredential(
|
||||
provider=provider,
|
||||
id=uuid.uuid4().hex[:6],
|
||||
label=label,
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source=f"{SOURCE_MANUAL}:device_code",
|
||||
access_token=creds["tokens"]["access_token"],
|
||||
refresh_token=creds["tokens"].get("refresh_token"),
|
||||
base_url=creds.get("base_url"),
|
||||
auth_mod._save_codex_tokens(
|
||||
creds["tokens"],
|
||||
last_refresh=creds.get("last_refresh"),
|
||||
label=label,
|
||||
)
|
||||
pool.add_entry(entry)
|
||||
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
|
||||
pool = load_pool(provider)
|
||||
entry = next((item for item in pool.entries() if item.source == "device_code"), None)
|
||||
shown_label = entry.label if entry is not None else label
|
||||
print(f'Saved {provider} OAuth device-code credentials: "{shown_label}"')
|
||||
return
|
||||
|
||||
if provider == "xai-oauth":
|
||||
|
||||
@@ -1283,6 +1283,12 @@ DEFAULT_CONFIG = {
|
||||
# behavior of showing tool-call summaries inline.
|
||||
"resume_skip_tool_only": True,
|
||||
"busy_input_mode": "interrupt", # interrupt | queue | steer
|
||||
# Which interface bare `hermes` (and `hermes chat`) launches by default:
|
||||
# "cli" — the classic prompt_toolkit REPL (default, preserves prior behavior)
|
||||
# "tui" — the modern Ink TUI (same as passing `--tui`)
|
||||
# Explicit flags always win over this setting: `--cli` forces the classic
|
||||
# REPL and `--tui` (or HERMES_TUI=1) forces the TUI regardless of config.
|
||||
"interface": "cli",
|
||||
# When true, `hermes --tui` auto-resumes the most recent human-
|
||||
# facing session on launch instead of forging a fresh one.
|
||||
# Mirrors `hermes -c` muscle memory. Default off so existing
|
||||
@@ -2284,7 +2290,7 @@ DEFAULT_CONFIG = {
|
||||
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 25,
|
||||
"_config_version": 26,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
"""Short-lived single-use tickets for WS-upgrade auth in gated mode.
|
||||
"""WS-upgrade auth credentials for gated mode.
|
||||
|
||||
Browsers cannot set ``Authorization`` on a WebSocket upgrade. In loopback
|
||||
mode the legacy ``?token=<_SESSION_TOKEN>`` query param works because the
|
||||
token is injected into the SPA bundle. In gated mode there is no injected
|
||||
token — the SPA gets a fresh ticket via the authenticated REST endpoint
|
||||
``POST /api/auth/ws-ticket`` and passes that as ``?ticket=`` on the
|
||||
WS upgrade.
|
||||
token — so this module provides two credential shapes:
|
||||
|
||||
Tickets are single-use, TTL = 30 seconds. In-memory; the dashboard is a
|
||||
single process so no distributed coordination is needed. The module
|
||||
exposes a small functional API rather than a class so tests can patch
|
||||
``time.time`` cleanly.
|
||||
1. **Single-use browser tickets** (``mint_ticket`` / ``consume_ticket``).
|
||||
The SPA gets a fresh ticket via the authenticated REST endpoint
|
||||
``POST /api/auth/ws-ticket`` and passes it as ``?ticket=`` on the WS
|
||||
upgrade. Single-use, TTL = 30 seconds — a leaked ticket is uninteresting.
|
||||
|
||||
2. **A process-lifetime internal credential** (``internal_ws_credential`` /
|
||||
``consume_internal_credential``). This authenticates *server-spawned*
|
||||
WS clients — specifically the embedded-TUI PTY child, which attaches to
|
||||
``/api/ws`` (JSON-RPC gateway) and ``/api/pub`` (event sidecar) over
|
||||
loopback. A single-use 30s ticket is the wrong shape for that link: the
|
||||
child reads its attach URL once at startup and **reuses it on every
|
||||
reconnect**, and on a slow cold boot the child may not dial within 30s.
|
||||
The internal credential is minted once per process, never expires, is
|
||||
multi-use, and — critically — is **never injected into any HTML/SPA**:
|
||||
it only ever leaves the process via the spawned child's environment, so
|
||||
browser-side XSS cannot read it. A leaked internal credential grants no
|
||||
more than a single-use ticket already does (the same two internal WS
|
||||
endpoints), and the same Origin / host guards still apply downstream.
|
||||
|
||||
In-memory; the dashboard is a single process so no distributed coordination
|
||||
is needed. The module exposes a small functional API rather than a class so
|
||||
tests can patch ``time.time`` cleanly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,7 +34,7 @@ from __future__ import annotations
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, Tuple
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
#: Time-to-live for newly-minted tickets in seconds. 30 s is long enough
|
||||
#: that the SPA can call ``getWsTicket()`` and immediately open the WS,
|
||||
@@ -28,6 +44,16 @@ TTL_SECONDS = 30
|
||||
_lock = threading.Lock()
|
||||
_tickets: Dict[str, Tuple[int, Dict[str, Any]]] = {} # ticket -> (expires_at, info)
|
||||
|
||||
#: The process-lifetime internal credential (see module docstring). Lazily
|
||||
#: minted on first ``internal_ws_credential()`` call and stable for the life
|
||||
#: of the process. Guarded by ``_lock``.
|
||||
_internal_credential: Optional[str] = None
|
||||
|
||||
#: Identity recorded for connections that authenticate via the internal
|
||||
#: credential, so audit logs distinguish them from browser-initiated tickets.
|
||||
INTERNAL_USER_ID = "server-internal"
|
||||
INTERNAL_PROVIDER = "server-internal"
|
||||
|
||||
|
||||
class TicketInvalid(Exception):
|
||||
"""Ticket missing, expired, or already consumed."""
|
||||
@@ -81,7 +107,55 @@ def _gc_expired_locked() -> None:
|
||||
_tickets.pop(t, None)
|
||||
|
||||
|
||||
def internal_ws_credential() -> str:
|
||||
"""Return the process-lifetime internal WS credential, minting it once.
|
||||
|
||||
Used by the server to authenticate WS clients it spawns itself (the
|
||||
embedded-TUI PTY child). The value is stable for the life of the process,
|
||||
multi-use, and never expires — so a server-spawned child can reconnect
|
||||
its ``/api/ws`` / ``/api/pub`` sockets indefinitely without re-minting.
|
||||
|
||||
The credential is never injected into the SPA HTML or returned over any
|
||||
REST endpoint; it is only ever passed to a child process via its
|
||||
environment. See the module docstring for the threat-model rationale.
|
||||
"""
|
||||
global _internal_credential
|
||||
with _lock:
|
||||
if _internal_credential is None:
|
||||
_internal_credential = secrets.token_urlsafe(32)
|
||||
return _internal_credential
|
||||
|
||||
|
||||
def consume_internal_credential(value: str) -> Dict[str, Any]:
|
||||
"""Validate an internal credential. Raises :class:`TicketInvalid` on mismatch.
|
||||
|
||||
Unlike :func:`consume_ticket` this is **not** single-use — the value is
|
||||
not removed on success, so a server-spawned child can present it on every
|
||||
(re)connect. Returns the fixed server-internal identity ``info`` dict
|
||||
(``{user_id, provider}``), mirroring the ``info`` shape ``consume_ticket``
|
||||
returns, so a caller that wants to record the connecting identity can; the
|
||||
current ``_ws_auth_ok`` caller validates for the boolean outcome only and
|
||||
discards the dict.
|
||||
|
||||
A constant-time compare against the (lazily-minted) credential avoids
|
||||
leaking length / prefix information on mismatch. If no internal
|
||||
credential has been minted yet, any value is rejected.
|
||||
"""
|
||||
with _lock:
|
||||
expected = _internal_credential
|
||||
if not value or expected is None:
|
||||
raise TicketInvalid("no internal credential")
|
||||
if not secrets.compare_digest(value.encode(), expected.encode()):
|
||||
raise TicketInvalid("internal credential mismatch")
|
||||
return {
|
||||
"user_id": INTERNAL_USER_ID,
|
||||
"provider": INTERNAL_PROVIDER,
|
||||
}
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Test-only: drop all tickets."""
|
||||
"""Test-only: drop all tickets and the internal credential."""
|
||||
global _internal_credential
|
||||
with _lock:
|
||||
_tickets.clear()
|
||||
_internal_credential = None
|
||||
|
||||
@@ -115,6 +115,7 @@ def build_models_payload(
|
||||
picker_hints: bool = False,
|
||||
canonical_order: bool = False,
|
||||
pricing: bool = False,
|
||||
capabilities: bool = False,
|
||||
max_models: int = 50,
|
||||
) -> dict:
|
||||
"""Build the ``{providers, model, provider}`` shape every consumer
|
||||
@@ -134,6 +135,10 @@ def build_models_payload(
|
||||
show $/Mtok columns and gate paid models on free accounts —
|
||||
mirroring the ``hermes model`` CLI picker. Adds network calls
|
||||
(pricing fetch + Nous tier check); only set for interactive pickers.
|
||||
- ``capabilities``: add a per-row ``capabilities`` map
|
||||
``{model: {fast, reasoning}}`` so pickers can gate the model-options
|
||||
controls (fast toggle / reasoning) to what each model actually
|
||||
supports, instead of offering knobs the backend would reject.
|
||||
"""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
@@ -154,6 +159,8 @@ def build_models_payload(
|
||||
rows = _reorder_canonical(rows)
|
||||
if pricing:
|
||||
_apply_pricing(rows)
|
||||
if capabilities:
|
||||
_apply_capabilities(rows)
|
||||
|
||||
return {
|
||||
"providers": rows,
|
||||
@@ -162,6 +169,44 @@ def build_models_payload(
|
||||
}
|
||||
|
||||
|
||||
def _apply_capabilities(rows: list[dict]) -> None:
|
||||
"""Attach a ``{model: {fast, reasoning}}`` map to each provider row.
|
||||
|
||||
`fast` mirrors ``model_supports_fast_mode`` (the same gate the runtime
|
||||
enforces). `reasoning` comes from the models.dev catalog when known and
|
||||
defaults to True otherwise — the effort dial is broadly accepted and a
|
||||
no-op on models that ignore it, whereas hiding it from a capable-but-
|
||||
uncatalogued model is the worse failure.
|
||||
"""
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
try:
|
||||
from agent.models_dev import get_model_capabilities
|
||||
except Exception:
|
||||
get_model_capabilities = None # type: ignore[assignment]
|
||||
|
||||
for row in rows:
|
||||
slug = row.get("slug") or ""
|
||||
caps: dict[str, dict[str, bool]] = {}
|
||||
|
||||
for model in row.get("models") or []:
|
||||
reasoning = True
|
||||
if get_model_capabilities is not None and slug:
|
||||
try:
|
||||
meta = get_model_capabilities(slug, model)
|
||||
if meta is not None:
|
||||
reasoning = bool(meta.supports_reasoning)
|
||||
except Exception:
|
||||
reasoning = True
|
||||
|
||||
caps[model] = {
|
||||
"fast": bool(model_supports_fast_mode(model)),
|
||||
"reasoning": reasoning,
|
||||
}
|
||||
|
||||
row["capabilities"] = caps
|
||||
|
||||
|
||||
# ─── Internal: row post-processing ──────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
+427
-81
@@ -105,6 +105,58 @@ def _set_process_title() -> None:
|
||||
pass
|
||||
|
||||
|
||||
# Cheap, dependency-free read of `display.interface` from config.yaml for the
|
||||
# earliest hot-path decisions (mouse-residue suppression, Termux fast launch)
|
||||
# that run *before* hermes_cli.config is importable. Mirrors the explicit
|
||||
# precedence used everywhere else: `--cli` always wins, then `--tui`/env, then
|
||||
# this config value. Cached so the multiple early callers don't re-parse YAML.
|
||||
_EARLY_INTERFACE_CACHE: "list | None" = None
|
||||
|
||||
|
||||
def _config_default_interface_early() -> str:
|
||||
"""Return the configured default interface ("cli"/"tui") via a minimal
|
||||
YAML read. Best-effort: any error falls back to "cli" (legacy behavior)."""
|
||||
global _EARLY_INTERFACE_CACHE
|
||||
if _EARLY_INTERFACE_CACHE is not None:
|
||||
return _EARLY_INTERFACE_CACHE[0]
|
||||
value = "cli"
|
||||
try:
|
||||
home = os.environ.get("HERMES_HOME")
|
||||
if home:
|
||||
cfg_path = os.path.join(home, "config.yaml")
|
||||
else:
|
||||
cfg_path = os.path.join(os.path.expanduser("~"), ".hermes", "config.yaml")
|
||||
if os.path.exists(cfg_path):
|
||||
import yaml as _yaml_iface
|
||||
|
||||
with open(cfg_path, encoding="utf-8") as _f:
|
||||
raw = _yaml_iface.safe_load(_f) or {}
|
||||
disp = raw.get("display", {})
|
||||
if isinstance(disp, dict):
|
||||
iface = disp.get("interface")
|
||||
if isinstance(iface, str) and iface.strip().lower() == "tui":
|
||||
value = "tui"
|
||||
except Exception:
|
||||
value = "cli" # best-effort — default to classic REPL on any error
|
||||
_EARLY_INTERFACE_CACHE = [value]
|
||||
return value
|
||||
|
||||
|
||||
def _wants_tui_early(argv: "list[str] | None" = None) -> bool:
|
||||
"""Earliest TUI decision, usable before argparse/config imports.
|
||||
|
||||
Precedence: explicit ``--cli`` wins (forces classic REPL), then
|
||||
``--tui``/``HERMES_TUI=1``, then ``display.interface`` in config.
|
||||
"""
|
||||
if argv is None:
|
||||
argv = sys.argv[1:]
|
||||
if "--cli" in argv:
|
||||
return False
|
||||
if os.environ.get("HERMES_TUI") == "1" or "--tui" in argv:
|
||||
return True
|
||||
return _config_default_interface_early() == "tui"
|
||||
|
||||
|
||||
# Mouse-tracking residue suppression — runs BEFORE every other import on the
|
||||
# TUI hot path so the terminal stops emitting SGR/X10 mouse reports while the
|
||||
# Python launcher is still doing imports (≈100–300ms in cooked + echo mode,
|
||||
@@ -116,7 +168,7 @@ def _set_process_title() -> None:
|
||||
def _suppress_mouse_residue_early() -> None:
|
||||
if os.environ.get("HERMES_TUI_NO_EARLY_DISABLE") == "1":
|
||||
return
|
||||
if not (os.environ.get("HERMES_TUI") == "1" or "--tui" in sys.argv[1:]):
|
||||
if not _wants_tui_early():
|
||||
return
|
||||
try:
|
||||
# Skip when stdout is redirected (`hermes --tui … >log`, CI capture):
|
||||
@@ -201,8 +253,10 @@ if _try_termux_ultrafast_version():
|
||||
raise SystemExit(0)
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -1183,6 +1237,33 @@ to avoid false-positive reinstalls on every launch.
|
||||
"""
|
||||
|
||||
|
||||
def _workspace_root(dir: Path) -> Path:
|
||||
"""Return the npm workspace root for *dir*.
|
||||
|
||||
In a workspace checkout the single ``package-lock.json`` and hoisted
|
||||
``node_modules/`` live at the workspace root (the parent of the
|
||||
sub-package directory). Heuristic: if *dir* has a ``package.json``
|
||||
but **no** ``package-lock.json``, and its **parent** has a
|
||||
``package-lock.json``, the parent is the workspace root.
|
||||
Otherwise *dir* itself is the root (standalone project or
|
||||
prebuilt-bundle layout).
|
||||
|
||||
Used by ``_tui_need_npm_install``, ``_make_tui_argv``, and
|
||||
``_build_web_ui`` so that lockfile/node_modules resolution and
|
||||
``npm install`` cwd stay consistent — a single helper prevents
|
||||
the checks from diverging if someone accidentally creates a
|
||||
sub-package lockfile (e.g. running ``npm install`` in the wrong
|
||||
directory).
|
||||
"""
|
||||
if (
|
||||
(dir / "package.json").is_file()
|
||||
and not (dir / "package-lock.json").is_file()
|
||||
and (dir.parent / "package-lock.json").is_file()
|
||||
):
|
||||
return dir.parent
|
||||
return dir
|
||||
|
||||
|
||||
def _tui_need_npm_install(root: Path) -> bool:
|
||||
"""True when @hermes/ink is missing or node_modules is behind package-lock.json.
|
||||
|
||||
@@ -1191,6 +1272,12 @@ def _tui_need_npm_install(root: Path) -> bool:
|
||||
``package.json``), skip reinstall entirely — the bundle is self-contained
|
||||
and there is nothing to install.
|
||||
|
||||
With npm workspaces the single ``package-lock.json`` and the hoisted
|
||||
``node_modules/`` live at the workspace root (the parent of the
|
||||
``ui-tui/`` directory). The lockfile / ink / marker checks use that
|
||||
workspace root; only the prebuilt-bundle sentinel stays relative to
|
||||
*root* (``ui-tui/dist/entry.js``).
|
||||
|
||||
Compares ``package-lock.json`` against ``node_modules/.package-lock.json``
|
||||
(npm's hidden lockfile) by **content**, not mtime: git checkouts and npm
|
||||
rewrites can bump the root lockfile's timestamp even when installed deps
|
||||
@@ -1208,19 +1295,21 @@ def _tui_need_npm_install(root: Path) -> bool:
|
||||
we'd rather not force a reinstall for them. Falls back to mtime
|
||||
comparison if either lockfile is unparseable.
|
||||
"""
|
||||
lock = root / "package-lock.json"
|
||||
entry = root / "dist" / "entry.js"
|
||||
# Prebuilt self-contained bundle (nix / packaged release): no lockfile
|
||||
# shipped, dist/entry.js is the single runtime artefact.
|
||||
entry = root / "dist" / "entry.js"
|
||||
# With npm workspaces the lockfile lives at the workspace root.
|
||||
ws_root = _workspace_root(root)
|
||||
lock = ws_root / "package-lock.json"
|
||||
if entry.is_file() and not lock.is_file():
|
||||
return False
|
||||
|
||||
ink = root / "node_modules" / "@hermes" / "ink" / "package.json"
|
||||
ink = ws_root / "node_modules" / "@hermes" / "ink" / "package.json"
|
||||
if not ink.is_file():
|
||||
return True
|
||||
if not lock.is_file():
|
||||
return False
|
||||
marker = root / "node_modules" / ".package-lock.json"
|
||||
marker = ws_root / "node_modules" / ".package-lock.json"
|
||||
if not marker.is_file():
|
||||
return True
|
||||
|
||||
@@ -1269,7 +1358,6 @@ _TUI_BUILD_INPUT_FILES = (
|
||||
"babel.compiler.config.cjs",
|
||||
"scripts/build.mjs",
|
||||
"packages/hermes-ink/package.json",
|
||||
"packages/hermes-ink/package-lock.json",
|
||||
"packages/hermes-ink/index.js",
|
||||
"packages/hermes-ink/text-input.js",
|
||||
)
|
||||
@@ -1436,6 +1524,8 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
|
||||
# 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js.
|
||||
# --dev flow: npm install if needed, then tsx src/entry.tsx.
|
||||
# npm install runs from the workspace root (where package-lock.json lives);
|
||||
# npm workspaces resolves ui-tui deps automatically.
|
||||
did_install = False
|
||||
if _tui_need_npm_install(tui_dir):
|
||||
npm = _node_bin("npm")
|
||||
@@ -1443,7 +1533,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
print("Installing TUI dependencies…")
|
||||
result = subprocess.run(
|
||||
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
|
||||
cwd=str(tui_dir),
|
||||
cwd=str(_workspace_root(tui_dir)),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
@@ -1730,9 +1820,34 @@ def _sync_bundled_skills_quietly() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_use_tui(args) -> bool:
|
||||
"""Decide whether to launch the TUI for a chat/bare invocation.
|
||||
|
||||
Precedence (highest first):
|
||||
1. ``--cli`` flag → always classic REPL
|
||||
2. ``--tui`` flag / ``HERMES_TUI=1`` → always TUI
|
||||
3. ``display.interface`` config value ("cli" | "tui")
|
||||
4. default → classic REPL
|
||||
|
||||
Explicit flags always win over config so muscle memory and scripts keep
|
||||
working regardless of the configured default.
|
||||
"""
|
||||
if getattr(args, "cli", False):
|
||||
return False
|
||||
if getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1":
|
||||
return True
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
iface = (load_config().get("display", {}) or {}).get("interface", "cli")
|
||||
return isinstance(iface, str) and iface.strip().lower() == "tui"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def cmd_chat(args):
|
||||
"""Run interactive chat CLI."""
|
||||
use_tui = getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1"
|
||||
use_tui = _resolve_use_tui(args)
|
||||
|
||||
# Resolve --continue into --resume with the latest session or by name
|
||||
continue_val = getattr(args, "continue_last", None)
|
||||
@@ -6603,7 +6718,6 @@ def _web_ui_build_needed(web_dir: Path) -> bool:
|
||||
return True
|
||||
for meta in (
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"yarn.lock",
|
||||
"pnpm-lock.yaml",
|
||||
"vite.config.ts",
|
||||
@@ -6612,6 +6726,10 @@ def _web_ui_build_needed(web_dir: Path) -> bool:
|
||||
mp = web_dir / meta
|
||||
if mp.exists() and mp.stat().st_mtime > dist_mtime:
|
||||
return True
|
||||
# Workspace root lockfile (single package-lock.json covers all workspaces).
|
||||
root_lock = project_root / "package-lock.json"
|
||||
if root_lock.exists() and root_lock.stat().st_mtime > dist_mtime:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -6808,7 +6926,11 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
if text:
|
||||
_say(text)
|
||||
|
||||
r1 = _run_npm_install_deterministic(npm, web_dir, extra_args=("--silent",))
|
||||
r1 = _run_npm_install_deterministic(
|
||||
npm,
|
||||
_workspace_root(web_dir),
|
||||
extra_args=("--silent",),
|
||||
)
|
||||
if r1.returncode != 0:
|
||||
_say(
|
||||
f" {'✗' if fatal else '⚠'} Web UI npm install failed"
|
||||
@@ -6869,6 +6991,147 @@ def _desktop_dist_exists(desktop_dir: Path) -> bool:
|
||||
return (desktop_dir / "dist" / "index.html").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Desktop build stamp — content-hash based skip logic
|
||||
# ---------------------------------------------------------------------------
|
||||
# The desktop Electron build is expensive.
|
||||
# Unlike the web UI (which uses mtime comparison), the desktop uses a
|
||||
# SHA-256 content hash of the source tree so that:
|
||||
# - ``git checkout`` / ``git pull`` that touch mtimes but not content
|
||||
# don't trigger a rebuild
|
||||
# - ``hermes update`` can unconditionally call ``hermes desktop --build-only``
|
||||
# and it will skip if nothing actually changed
|
||||
# - ``hermes desktop`` (interactive launch) skips the build when the
|
||||
# stamp matches, making repeated launches fast
|
||||
#
|
||||
# Stamp file: $HERMES_HOME/desktop-build-stamp.json
|
||||
# Schema:
|
||||
# {
|
||||
# "contentHash": "<sha256 hex of source files>",
|
||||
# "sourceMode": true | false,
|
||||
# "builtAt": "<ISO 8601>"
|
||||
# }
|
||||
|
||||
def _compute_desktop_content_hash(project_root: Path) -> str:
|
||||
"""Return a SHA-256 hex digest of all source files that feed the desktop build.
|
||||
|
||||
Covers ``apps/desktop/`` (excluding anything matched by .gitignore)
|
||||
plus the root ``package.json`` / ``package-lock.json`` (workspace config
|
||||
that determines dependency resolution for the desktop workspace).
|
||||
|
||||
Parses the repo-root ``.gitignore`` via *pathspec* so we automatically
|
||||
skip ``node_modules/``, ``dist/``, ``*.pyc``, etc. without maintaining
|
||||
a hardcoded skip-list.
|
||||
"""
|
||||
h = hashlib.sha256()
|
||||
|
||||
def _hash_file(path: Path) -> None:
|
||||
rel = str(path.relative_to(project_root))
|
||||
h.update(rel.encode())
|
||||
h.update(b"\0")
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
h.update(b"\0")
|
||||
|
||||
|
||||
from pathspec import PathSpec
|
||||
|
||||
gitignore = project_root / ".gitignore"
|
||||
lines: list[str] = []
|
||||
if gitignore.is_file():
|
||||
lines = gitignore.read_text(encoding="utf-8").splitlines()
|
||||
spec = PathSpec.from_lines("gitignore", lines)
|
||||
|
||||
# Root workspace config
|
||||
for name in ("package.json", "package-lock.json"):
|
||||
p = project_root / name
|
||||
if p.is_file():
|
||||
rel = str(p.relative_to(project_root))
|
||||
if not spec.match_file(rel):
|
||||
_hash_file(p)
|
||||
|
||||
# Walk apps/desktop/ — prune ignored directories in-place
|
||||
desktop_dir = project_root / "apps" / "desktop"
|
||||
for dirpath, dirnames, filenames in os.walk(desktop_dir, topdown=True):
|
||||
# Prune ignored directories so we never descend into them
|
||||
dirnames[:] = [
|
||||
d for d in dirnames
|
||||
if not spec.match_file(str((Path(dirpath) / d).relative_to(project_root)))
|
||||
]
|
||||
|
||||
for fn in sorted(filenames):
|
||||
fp = Path(dirpath) / fn
|
||||
rel = str(fp.relative_to(project_root))
|
||||
if not spec.match_file(rel):
|
||||
_hash_file(fp)
|
||||
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _desktop_stamp_path() -> Path:
|
||||
"""Return the path to the desktop build stamp file under $HERMES_HOME."""
|
||||
from hermes_constants import get_hermes_home
|
||||
return get_hermes_home() / "desktop-build-stamp.json"
|
||||
|
||||
|
||||
def _desktop_build_needed(desktop_dir: Path, project_root: Path, *, source_mode: bool) -> bool:
|
||||
"""Return True when the desktop build output is stale or missing.
|
||||
|
||||
Compares the current content hash against the saved stamp. Also returns
|
||||
True if the expected build artifact doesn't exist (e.g. first run after
|
||||
``hermes update`` that pulled new source but hasn't built yet).
|
||||
"""
|
||||
# If there's no build output at all, we definitely need to build
|
||||
if source_mode:
|
||||
if not _desktop_dist_exists(desktop_dir):
|
||||
return True
|
||||
else:
|
||||
if _desktop_packaged_executable(desktop_dir) is None:
|
||||
return True
|
||||
|
||||
stamp_file = _desktop_stamp_path()
|
||||
if not stamp_file.is_file():
|
||||
return True
|
||||
|
||||
try:
|
||||
stamp_data = json.loads(stamp_file.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, KeyError):
|
||||
return True
|
||||
|
||||
# If the mode changed (source vs packaged), force a rebuild
|
||||
if stamp_data.get("sourceMode") != source_mode:
|
||||
return True
|
||||
|
||||
saved_hash = stamp_data.get("contentHash")
|
||||
if not saved_hash:
|
||||
return True
|
||||
|
||||
current_hash = _compute_desktop_content_hash(project_root)
|
||||
return current_hash != saved_hash
|
||||
|
||||
|
||||
def _write_desktop_build_stamp(project_root: Path, *, source_mode: bool) -> None:
|
||||
"""Write the desktop build stamp after a successful build."""
|
||||
stamp_file = _desktop_stamp_path()
|
||||
try:
|
||||
stamp_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
content_hash = _compute_desktop_content_hash(project_root)
|
||||
from datetime import datetime, timezone
|
||||
stamp_data = {
|
||||
"contentHash": content_hash,
|
||||
"sourceMode": source_mode,
|
||||
"builtAt": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
stamp_file.write_text(json.dumps(stamp_data, indent=2) + "\n", encoding="utf-8")
|
||||
except Exception as exc:
|
||||
# Never let stamp-writing block or fail a build
|
||||
logger.debug("Failed to write desktop build stamp: %s", exc)
|
||||
|
||||
|
||||
def _desktop_packaged_executable(desktop_dir: Path) -> Optional[Path]:
|
||||
"""Return the current platform's unpacked Electron app executable."""
|
||||
release_dir = desktop_dir / "release"
|
||||
@@ -6929,7 +7192,45 @@ def _desktop_macos_relaunchable_fixup(desktop_dir: Path) -> None:
|
||||
print(f" (warning: macOS relaunch fixup skipped: {exc})")
|
||||
|
||||
|
||||
def cmd_gui(args):
|
||||
def _desktop_linux_sandbox_fixup(packaged_executable: Path) -> bool:
|
||||
"""Configure Electron's Linux SUID sandbox helper when required."""
|
||||
if sys.platform != "linux":
|
||||
return True
|
||||
|
||||
sandbox = packaged_executable.parent / "chrome-sandbox"
|
||||
if not sandbox.exists():
|
||||
print(f"✗ Hermes Desktop is missing Electron's Linux sandbox helper: {sandbox}")
|
||||
return False
|
||||
|
||||
# Reject symlinks — chown/chmod must not follow an attacker-controlled
|
||||
# link to an arbitrary path. Use lstat() so we inspect the link itself
|
||||
# rather than the target, and require a regular file.
|
||||
try:
|
||||
sandbox_lstat = sandbox.lstat()
|
||||
except OSError:
|
||||
print(f"✗ Cannot stat Electron's Linux sandbox helper: {sandbox}")
|
||||
return False
|
||||
if not stat.S_ISREG(sandbox_lstat.st_mode):
|
||||
print(f"✗ Electron's Linux sandbox helper is not a regular file: {sandbox}")
|
||||
return False
|
||||
|
||||
if sandbox_lstat.st_uid == 0 and stat.S_IMODE(sandbox_lstat.st_mode) == 0o4755:
|
||||
return True
|
||||
|
||||
sudo = shutil.which("sudo")
|
||||
if not sudo:
|
||||
print("✗ Hermes Desktop requires sudo to configure Electron's Linux sandbox helper.")
|
||||
return False
|
||||
|
||||
print("→ Configuring Electron Linux sandbox helper (sudo required)...")
|
||||
for command in ([sudo, "chown", "root:root", str(sandbox)], [sudo, "chmod", "4755", str(sandbox)]):
|
||||
if subprocess.run(command, check=False).returncode != 0:
|
||||
print(f"✗ Failed to configure Electron's Linux sandbox helper: {sandbox}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def cmd_gui(args: argparse.Namespace):
|
||||
"""Build and launch the native Electron desktop GUI."""
|
||||
desktop_dir = PROJECT_ROOT / "apps" / "desktop"
|
||||
if not (desktop_dir / "package.json").exists():
|
||||
@@ -6954,6 +7255,8 @@ def cmd_gui(args):
|
||||
|
||||
source_mode = getattr(args, "source", False)
|
||||
skip_build = getattr(args, "skip_build", False)
|
||||
force_build = getattr(args, "force_build", False)
|
||||
|
||||
packaged_executable = _desktop_packaged_executable(desktop_dir)
|
||||
|
||||
if source_mode or not skip_build:
|
||||
@@ -6965,7 +7268,7 @@ def cmd_gui(args):
|
||||
else:
|
||||
npm = None
|
||||
|
||||
if getattr(args, "skip_build", False):
|
||||
if skip_build:
|
||||
if source_mode:
|
||||
if not _desktop_dist_exists(desktop_dir):
|
||||
print(f"✗ --skip-build --source was passed but no desktop dist found at: {desktop_dir / 'dist'}")
|
||||
@@ -6986,27 +7289,41 @@ def cmd_gui(args):
|
||||
else:
|
||||
print(f"→ Skipping desktop package build (--skip-build); using {packaged_executable}")
|
||||
else:
|
||||
print("→ Installing desktop workspace dependencies...")
|
||||
install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False)
|
||||
if install_result.returncode != 0:
|
||||
print("✗ Desktop dependency install failed")
|
||||
print(f" Run manually: cd {PROJECT_ROOT} && npm ci")
|
||||
sys.exit(install_result.returncode or 1)
|
||||
# Check the content-hash stamp before doing any build work.
|
||||
# If the source tree hasn't changed since the last successful build,
|
||||
# skip the npm install + build entirely (saves a ton of useless work).
|
||||
# --force-build overrides the stamp and always rebuilds.
|
||||
build_needed = force_build or _desktop_build_needed(
|
||||
desktop_dir, PROJECT_ROOT, source_mode=source_mode
|
||||
)
|
||||
if not build_needed:
|
||||
build_label = "source build" if source_mode else "packaged app"
|
||||
print(f"✓ Desktop {build_label} is up to date (content stamp matches)")
|
||||
else:
|
||||
print("→ Installing desktop workspace dependencies...")
|
||||
install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False)
|
||||
if install_result.returncode != 0:
|
||||
print("✗ Desktop dependency install failed")
|
||||
print(f" Run manually: cd {PROJECT_ROOT} && npm ci")
|
||||
sys.exit(install_result.returncode or 1)
|
||||
|
||||
build_label = "source build" if source_mode else "packaged app"
|
||||
print(f"→ Building desktop {build_label}...")
|
||||
build_script = "build" if source_mode else "pack"
|
||||
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
|
||||
if build_result.returncode != 0:
|
||||
print("✗ Desktop GUI build failed")
|
||||
print(f" Run manually: cd apps/desktop && npm run {build_script}")
|
||||
sys.exit(build_result.returncode or 1)
|
||||
packaged_executable = _desktop_packaged_executable(desktop_dir)
|
||||
if not source_mode:
|
||||
# Locally-built apps are ad-hoc signed; make them relaunchable after
|
||||
# an in-place self-update (otherwise macOS reports "Hermes is
|
||||
# damaged"). No-op on non-macOS and on real-identity builds.
|
||||
_desktop_macos_relaunchable_fixup(desktop_dir)
|
||||
build_label = "source build" if source_mode else "packaged app"
|
||||
print(f"→ Building desktop {build_label}...")
|
||||
build_script = "build" if source_mode else "pack"
|
||||
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
|
||||
if build_result.returncode != 0:
|
||||
print("✗ Desktop GUI build failed")
|
||||
print(f" Run manually: cd apps/desktop && npm run {build_script}")
|
||||
sys.exit(build_result.returncode or 1)
|
||||
packaged_executable = _desktop_packaged_executable(desktop_dir)
|
||||
if not source_mode:
|
||||
# Locally-built apps are ad-hoc signed; make them relaunchable after
|
||||
# an in-place self-update (otherwise macOS reports "Hermes is
|
||||
# damaged"). No-op on non-macOS and on real-identity builds.
|
||||
_desktop_macos_relaunchable_fixup(desktop_dir)
|
||||
|
||||
# Build succeeded — write the stamp so next run can skip
|
||||
_write_desktop_build_stamp(PROJECT_ROOT, source_mode=source_mode)
|
||||
|
||||
# --build-only: produce the artifact but do NOT launch. The installer's
|
||||
# --update flow drives the rebuild headlessly and then launches the desktop
|
||||
@@ -7038,6 +7355,9 @@ def cmd_gui(args):
|
||||
print(" Expected an unpacked Electron app for the current OS.")
|
||||
sys.exit(1)
|
||||
|
||||
if not _desktop_linux_sandbox_fixup(packaged_executable):
|
||||
sys.exit(1)
|
||||
|
||||
print(f"→ Launching packaged Hermes Desktop: {packaged_executable}")
|
||||
launch_result = subprocess.run([str(packaged_executable)], cwd=desktop_dir, env=env, check=False)
|
||||
sys.exit(launch_result.returncode)
|
||||
@@ -7489,9 +7809,7 @@ def _update_via_zip(args):
|
||||
# may point to a Python without FTS5. Rebuild it so the new managed
|
||||
# uv provides a fresh interpreter with FTS5 guaranteed.
|
||||
if fresh_bootstrap and uv_bin:
|
||||
if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"):
|
||||
print("✗ Failed to rebuild venv with managed uv. Re-run `hermes update` or install manually.")
|
||||
sys.exit(1)
|
||||
rebuild_venv(uv_bin, PROJECT_ROOT / "venv")
|
||||
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
if not uv_bin:
|
||||
@@ -8623,45 +8941,48 @@ def _update_node_dependencies() -> None:
|
||||
if not npm:
|
||||
return
|
||||
|
||||
paths = (
|
||||
("repo root", PROJECT_ROOT),
|
||||
("ui-tui", PROJECT_ROOT / "ui-tui"),
|
||||
)
|
||||
if not any((path / "package.json").exists() for _, path in paths):
|
||||
if not (PROJECT_ROOT / "package.json").exists():
|
||||
return
|
||||
|
||||
# With a single workspace lockfile the root install would cover ALL
|
||||
# workspaces — but apps/desktop pulls in Electron as a devDependency,
|
||||
# and its postinstall downloads a ~200MB binary. Most users don't
|
||||
# need desktop during `hermes update`, so we install root-only first
|
||||
# then add just the workspaces the CLI/TUI/web build actually requires.
|
||||
# Desktop deps are installed on demand by the desktop launcher
|
||||
# (see _desktop_build_needed).
|
||||
print("→ Updating Node.js dependencies...")
|
||||
for label, path in paths:
|
||||
if not (path / "package.json").exists():
|
||||
continue
|
||||
extra_args = ["--no-fund", "--no-audit", "--progress=false"]
|
||||
|
||||
# Stream npm output (no `--silent`, no `capture_output`) so any
|
||||
# optional dependency postinstall scripts (e.g. `agent-browser`'s
|
||||
# Chromium fetch on first install) print progress instead of
|
||||
# appearing to hang silently for minutes (#18840). The
|
||||
# `_UpdateOutputStream` wrapper installed by the updater mirrors
|
||||
# streamed output to ``~/.hermes/logs/update.log`` so nothing is lost.
|
||||
#
|
||||
# The repo root install also passes `--workspaces=false` so npm
|
||||
# does not recursively install every `apps/*` workspace (dashboard,
|
||||
# desktop, shared) — those are installed/built on demand via
|
||||
# `_build_web_ui()` and the desktop launchers.
|
||||
extra_args = ["--no-fund", "--no-audit", "--progress=false"]
|
||||
if path == PROJECT_ROOT:
|
||||
extra_args.append("--workspaces=false")
|
||||
# Step 1: root install (no workspace recursion).
|
||||
root_args = [*extra_args, "--workspaces=false"]
|
||||
root_result = _run_npm_install_deterministic(
|
||||
npm,
|
||||
PROJECT_ROOT,
|
||||
extra_args=tuple(root_args),
|
||||
capture_output=False,
|
||||
)
|
||||
if root_result.returncode != 0:
|
||||
print(" ⚠ npm install failed in repo root")
|
||||
stderr = (root_result.stderr or "").strip() if root_result.stderr else ""
|
||||
if stderr:
|
||||
print(f" {stderr.splitlines()[-1]}")
|
||||
return
|
||||
|
||||
result = _run_npm_install_deterministic(
|
||||
npm,
|
||||
path,
|
||||
extra_args=tuple(extra_args),
|
||||
capture_output=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print(f" ✓ {label}")
|
||||
continue
|
||||
|
||||
print(f" ⚠ npm install failed in {label}")
|
||||
stderr = (result.stderr or "").strip() if result.stderr else ""
|
||||
# Step 2: install only the workspaces update needs (ui-tui, web).
|
||||
# --workspace selects specific workspaces; the rest (desktop) are skipped.
|
||||
ws_args = [*extra_args, "--workspace", "ui-tui", "--workspace", "web"]
|
||||
ws_result = _run_npm_install_deterministic(
|
||||
npm,
|
||||
PROJECT_ROOT,
|
||||
extra_args=tuple(ws_args),
|
||||
capture_output=False,
|
||||
)
|
||||
if ws_result.returncode == 0:
|
||||
print(" ✓ repo root + ui-tui, web workspaces (desktop skipped)")
|
||||
else:
|
||||
print(" ⚠ npm workspace install failed")
|
||||
stderr = (ws_result.stderr or "").strip() if ws_result.stderr else ""
|
||||
if stderr:
|
||||
print(f" {stderr.splitlines()[-1]}")
|
||||
|
||||
@@ -9690,9 +10011,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
# may point to a Python without FTS5. Rebuild it so the new managed
|
||||
# uv provides a fresh interpreter with FTS5 guaranteed.
|
||||
if fresh_bootstrap and uv_bin:
|
||||
if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"):
|
||||
print("✗ Failed to rebuild venv with managed uv. Re-run `hermes update` or install manually.")
|
||||
sys.exit(1)
|
||||
rebuild_venv(uv_bin, PROJECT_ROOT / "venv")
|
||||
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
if not uv_bin:
|
||||
@@ -9744,6 +10063,25 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
_update_node_dependencies()
|
||||
_build_web_ui(PROJECT_ROOT / "web")
|
||||
|
||||
# Rebuild the desktop app if the source tree changed since the last
|
||||
# build. ``hermes desktop --build-only`` uses the content-hash stamp
|
||||
# internally, so this is effectively a no-op when nothing changed.
|
||||
# Only bother if the user has a desktop app installed (indicated by
|
||||
# an existing packaged executable or desktop dist); people who have
|
||||
# never run ``hermes desktop`` shouldn't be forced into a full
|
||||
# Electron build by ``hermes update``.
|
||||
desktop_dir = PROJECT_ROOT / "apps" / "desktop"
|
||||
has_desktop_app = _desktop_packaged_executable(desktop_dir) is not None or _desktop_dist_exists(desktop_dir)
|
||||
if (desktop_dir / "package.json").exists() and shutil.which("npm") and has_desktop_app:
|
||||
print("→ Checking if desktop app needs rebuilding...")
|
||||
build_result = subprocess.run(
|
||||
[sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=False,
|
||||
)
|
||||
if build_result.returncode != 0:
|
||||
print(" ⚠ Desktop build failed (non-fatal; run `hermes desktop` to retry)")
|
||||
|
||||
print()
|
||||
print("✓ Code updated!")
|
||||
|
||||
@@ -11411,7 +11749,7 @@ def cmd_dashboard(args):
|
||||
if not _build_web_ui(PROJECT_ROOT / "web", fatal=True):
|
||||
sys.exit(1)
|
||||
elif getattr(args, "skip_build", False):
|
||||
# --skip-build trusts the caller to have pre-built the web UI.
|
||||
# --build-mode skip trusts the caller to have pre-built the web UI.
|
||||
# Verify the dist actually exists; otherwise the server will start
|
||||
# and serve 404s with no obvious cause (issue #23817).
|
||||
_dist_root = (
|
||||
@@ -11723,7 +12061,10 @@ def _try_termux_fast_cli_launch() -> bool:
|
||||
argv = sys.argv[1:]
|
||||
if "-h" in argv or "--help" in argv:
|
||||
return False
|
||||
if os.environ.get("HERMES_TUI") == "1" or "--tui" in argv:
|
||||
# Let the TUI fast path (or full dispatch) handle anything that resolves to
|
||||
# the TUI — explicit --tui/env or display.interface=tui. `--cli` forces this
|
||||
# to stay False so the classic fast path still runs.
|
||||
if _wants_tui_early(argv):
|
||||
return False
|
||||
|
||||
if _is_termux_fast_version_argv(argv):
|
||||
@@ -11798,7 +12139,7 @@ def _try_termux_fast_tui_launch() -> bool:
|
||||
if "-h" in sys.argv[1:] or "--help" in sys.argv[1:]:
|
||||
return False
|
||||
|
||||
wants_tui = os.environ.get("HERMES_TUI") == "1" or "--tui" in sys.argv[1:]
|
||||
wants_tui = _wants_tui_early(sys.argv[1:])
|
||||
if not wants_tui:
|
||||
return False
|
||||
|
||||
@@ -11817,7 +12158,7 @@ def _try_termux_fast_tui_launch() -> bool:
|
||||
return False
|
||||
if getattr(args, "command", None) not in {None, "chat"}:
|
||||
return False
|
||||
if not (getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1"):
|
||||
if not _resolve_use_tui(args):
|
||||
return False
|
||||
|
||||
cmd_chat(args)
|
||||
@@ -14780,11 +15121,6 @@ Examples:
|
||||
"Electron app, then launches that packaged artifact."
|
||||
),
|
||||
)
|
||||
gui_parser.add_argument(
|
||||
"--skip-build",
|
||||
action="store_true",
|
||||
help="Skip npm install/package and launch the existing unpacked app from apps/desktop/release",
|
||||
)
|
||||
gui_parser.add_argument(
|
||||
"--source",
|
||||
action="store_true",
|
||||
@@ -14813,6 +15149,16 @@ Examples:
|
||||
"--cwd",
|
||||
help="Initial project directory for Desktop chat sessions (sets HERMES_DESKTOP_CWD)",
|
||||
)
|
||||
gui_parser.add_argument(
|
||||
"--skip-build",
|
||||
action="store_true",
|
||||
help="Skip npm install/package and launch the existing unpacked app from apps/desktop/release",
|
||||
)
|
||||
gui_parser.add_argument(
|
||||
"--force-build",
|
||||
action="store_true",
|
||||
help="Force a full rebuild even if the content stamp matches",
|
||||
)
|
||||
gui_parser.set_defaults(func=cmd_gui)
|
||||
|
||||
# =========================================================================
|
||||
|
||||
@@ -136,9 +136,8 @@ def update_managed_uv() -> Optional[str]:
|
||||
"""Run ``uv self update`` on the managed uv binary.
|
||||
|
||||
Call this during ``hermes update`` so the managed copy stays current.
|
||||
Returns the managed path if a managed uv is present (always — even when
|
||||
``uv self update`` fails, the old binary still works). Returns ``None``
|
||||
only when no managed uv exists yet (``ensure_uv()`` handles that case).
|
||||
Returns the managed path on success, ``None`` if uv isn't available or
|
||||
the self-update fails (non-fatal — the old version still works).
|
||||
"""
|
||||
existing = resolve_uv()
|
||||
if not existing:
|
||||
|
||||
+15
-7
@@ -241,6 +241,12 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"google-gemini-cli": [
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-pro-preview",
|
||||
# Code Assist serves two flash slugs with different access gates
|
||||
# (gemini-cli models.ts): gemini-3-flash-preview is the preview flash
|
||||
# that subscription/free-tier OAuth users actually reach, while
|
||||
# gemini-3.5-flash is GA-channel-gated. Offer both so non-GA users
|
||||
# aren't stuck with a slug cloudcode-pa 404s for them.
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.5-flash",
|
||||
],
|
||||
"zai": [
|
||||
@@ -1862,19 +1868,21 @@ def model_supports_fast_mode(model_id: Optional[str]) -> bool:
|
||||
|
||||
|
||||
def _is_anthropic_fast_model(model_id: Optional[str]) -> bool:
|
||||
"""Return True if the model is a Claude model eligible for Anthropic Fast Mode.
|
||||
"""Return True if the model accepts the Anthropic Fast Mode ``speed`` param.
|
||||
|
||||
Fast mode is currently supported on Claude Opus 4.6 only. Per Anthropic's
|
||||
docs (https://platform.claude.com/docs/en/build-with-claude/fast-mode):
|
||||
"Fast mode is currently supported on Opus 4.6 only. Sending speed: fast
|
||||
with an unsupported model returns an error." Opus 4.7 explicitly rejects
|
||||
the ``speed`` parameter with HTTP 400.
|
||||
This gates the *speed=fast request parameter*, which Anthropic supports on
|
||||
Opus 4.6 only (Opus 4.7 explicitly 400s). It is deliberately NOT a general
|
||||
"is this a fast model" check: for Opus 4.8 the fast offering is a SEPARATE
|
||||
model id (``…-opus-4.8-fast``) selected via the model field, not the speed
|
||||
parameter — see ``agent.anthropic_adapter._supports_fast_mode`` and its
|
||||
test. Keep this in lock-step with that adapter gate so the UI never shows a
|
||||
Fast toggle that the runtime would silently drop.
|
||||
"""
|
||||
raw = _strip_vendor_prefix(str(model_id or ""))
|
||||
base = raw.split(":")[0]
|
||||
if not base.startswith("claude-"):
|
||||
return False
|
||||
# Only Opus 4.6 supports fast mode at present.
|
||||
# Only Opus 4.6 supports the speed=fast parameter at present.
|
||||
return "opus-4-6" in base or "opus-4.6" in base
|
||||
|
||||
|
||||
|
||||
+20
-17
@@ -202,6 +202,13 @@ TOOL_CATEGORIES = {
|
||||
"name": "Text-to-Speech",
|
||||
"icon": "🔊",
|
||||
"providers": [
|
||||
{
|
||||
"name": "Microsoft Edge TTS",
|
||||
"badge": "★ recommended · free",
|
||||
"tag": "Good quality, no API key needed",
|
||||
"env_vars": [],
|
||||
"tts_provider": "edge",
|
||||
},
|
||||
{
|
||||
"name": "Nous Subscription",
|
||||
"badge": "subscription",
|
||||
@@ -212,13 +219,6 @@ TOOL_CATEGORIES = {
|
||||
"managed_nous_feature": "tts",
|
||||
"override_env_vars": ["VOICE_TOOLS_OPENAI_KEY", "OPENAI_API_KEY"],
|
||||
},
|
||||
{
|
||||
"name": "Microsoft Edge TTS",
|
||||
"badge": "★ recommended · free",
|
||||
"tag": "Good quality, no API key needed",
|
||||
"env_vars": [],
|
||||
"tts_provider": "edge",
|
||||
},
|
||||
{
|
||||
"name": "OpenAI TTS",
|
||||
"badge": "paid",
|
||||
@@ -406,15 +406,26 @@ TOOL_CATEGORIES = {
|
||||
# Per-provider rows for Browserbase, Browser Use, and Firecrawl are
|
||||
# injected at runtime from plugins.browser.<vendor>.provider via
|
||||
# _plugin_browser_providers() in _visible_providers(). Only
|
||||
# non-provider UX setup-flow rows remain here:
|
||||
# non-provider UX setup-flow rows remain here. "Local Browser" is
|
||||
# listed FIRST so it is the default-highlighted (index 0) choice on a
|
||||
# fresh install — pressing Enter must land on the free, no-key local
|
||||
# backend, never on the paid Nous Subscription gateway row:
|
||||
# - "Local Browser" — non-cloud option, no CloudBrowserProvider.
|
||||
# - "Nous Subscription (Browser Use cloud)" — managed Browser Use
|
||||
# billed via Nous subscription (requires_nous_auth +
|
||||
# override_env_vars). Uses the browser-use plugin as the
|
||||
# underlying backend but has a distinct setup UX.
|
||||
# - "Local Browser" — non-cloud option, no CloudBrowserProvider.
|
||||
# - "Camofox" — anti-detection local Firefox; short-circuits the
|
||||
# cloud-provider dispatch path via _is_camofox_mode().
|
||||
"providers": [
|
||||
{
|
||||
"name": "Local Browser",
|
||||
"badge": "★ recommended · free",
|
||||
"tag": "Headless Chromium, no API key needed",
|
||||
"env_vars": [],
|
||||
"browser_provider": "local",
|
||||
"post_setup": "agent_browser",
|
||||
},
|
||||
{
|
||||
"name": "Nous Subscription (Browser Use cloud)",
|
||||
"badge": "subscription",
|
||||
@@ -426,14 +437,6 @@ TOOL_CATEGORIES = {
|
||||
"override_env_vars": ["BROWSER_USE_API_KEY"],
|
||||
"post_setup": "agent_browser",
|
||||
},
|
||||
{
|
||||
"name": "Local Browser",
|
||||
"badge": "★ recommended · free",
|
||||
"tag": "Headless Chromium, no API key needed",
|
||||
"env_vars": [],
|
||||
"browser_provider": "local",
|
||||
"post_setup": "agent_browser",
|
||||
},
|
||||
{
|
||||
"name": "Camofox",
|
||||
"badge": "free · local",
|
||||
|
||||
+439
-70
@@ -9,6 +9,8 @@ Usage:
|
||||
python -m hermes_cli.main web --port 8080
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
@@ -84,7 +86,43 @@ except ImportError:
|
||||
WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist"
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="Hermes Agent", version=__version__)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard)
|
||||
# and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id
|
||||
# the chat tab generates on mount; entries auto-evict when the last subscriber
|
||||
# drops AND the publisher has disconnected.
|
||||
#
|
||||
# State lives on app.state (not module-level globals) so that asyncio.Lock is
|
||||
# created on the running event loop during lifespan startup. A module-level
|
||||
# asyncio.Lock() binds to whatever loop was active at import time, which breaks
|
||||
# when the same module is used across TestClient instances or uvicorn reloads.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: "FastAPI"):
|
||||
app.state.event_channels = {} # dict[str, set]
|
||||
app.state.event_lock = asyncio.Lock()
|
||||
yield
|
||||
|
||||
|
||||
def _get_event_state(app: "FastAPI"):
|
||||
"""Return (event_channels, event_lock) from app.state.
|
||||
|
||||
Lazily initialises the state if the lifespan hasn't run (e.g. when
|
||||
TestClient is constructed without a ``with`` block). The lifespan
|
||||
path is preferred because it guarantees the Lock is created on the
|
||||
correct event loop, but the lazy path lets existing non-``with``
|
||||
TestClient usages keep working.
|
||||
"""
|
||||
try:
|
||||
return app.state.event_channels, app.state.event_lock
|
||||
except AttributeError:
|
||||
app.state.event_channels = {}
|
||||
app.state.event_lock = asyncio.Lock()
|
||||
return app.state.event_channels, app.state.event_lock
|
||||
|
||||
|
||||
app = FastAPI(title="Hermes Agent", version=__version__, lifespan=_lifespan)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session token for protecting sensitive endpoints (reveal).
|
||||
@@ -1627,7 +1665,9 @@ def get_model_options():
|
||||
try:
|
||||
from hermes_cli.inventory import build_models_payload, load_picker_context
|
||||
|
||||
return build_models_payload(load_picker_context(), max_models=50, pricing=True)
|
||||
return build_models_payload(
|
||||
load_picker_context(), max_models=50, pricing=True, capabilities=True
|
||||
)
|
||||
except Exception:
|
||||
_log.exception("GET /api/model/options failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to list model options")
|
||||
@@ -2935,6 +2975,17 @@ _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
|
||||
"docs_url": "https://www.minimax.io",
|
||||
"status_fn": None, # dispatched via auth.get_minimax_oauth_auth_status
|
||||
},
|
||||
{
|
||||
"id": "xai-oauth",
|
||||
"name": "xAI Grok OAuth (SuperGrok / Premium+)",
|
||||
# Loopback PKCE: the desktop's local backend binds a 127.0.0.1
|
||||
# callback server, the client opens the browser, and the redirect
|
||||
# lands back on the loopback listener — no code to copy/paste.
|
||||
"flow": "loopback",
|
||||
"cli_command": "hermes auth add xai-oauth",
|
||||
"docs_url": "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth",
|
||||
"status_fn": None, # dispatched via auth.get_xai_oauth_auth_status
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -2988,6 +3039,20 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
|
||||
"expires_at": raw.get("expires_at"),
|
||||
"has_refresh_token": True,
|
||||
}
|
||||
if provider_id == "xai-oauth":
|
||||
raw = hauth.get_xai_oauth_auth_status()
|
||||
# source_label is meant to be a human-readable origin (auth-store
|
||||
# path / credential source), not the internal auth_mode string
|
||||
# ("oauth_pkce"). Prefer the store path, then the source slug.
|
||||
return {
|
||||
"logged_in": bool(raw.get("logged_in")),
|
||||
"source": raw.get("source") or "xai_oauth",
|
||||
"source_label": raw.get("auth_store") or raw.get("source") or "xAI Grok OAuth",
|
||||
"token_preview": _truncate_token(raw.get("api_key")),
|
||||
"expires_at": None,
|
||||
"has_refresh_token": True,
|
||||
"last_refresh": raw.get("last_refresh"),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"logged_in": False, "error": str(e)}
|
||||
return {"logged_in": False}
|
||||
@@ -3000,7 +3065,7 @@ async def list_oauth_providers():
|
||||
Response shape (per provider):
|
||||
id stable identifier (used in DELETE path)
|
||||
name human label
|
||||
flow "pkce" | "device_code" | "external"
|
||||
flow "pkce" | "device_code" | "external" | "loopback"
|
||||
cli_command fallback CLI command for users to run manually
|
||||
docs_url external docs/portal link for the "Learn more" link
|
||||
status:
|
||||
@@ -3100,6 +3165,19 @@ async def disconnect_oauth_provider(provider_id: str, request: Request):
|
||||
# 4. On "approved" the background thread has already saved creds; UI
|
||||
# refreshes the providers list.
|
||||
#
|
||||
# Loopback PKCE (xAI Grok):
|
||||
# 1. POST /api/providers/oauth/xai-oauth/start
|
||||
# → server binds a 127.0.0.1 callback listener, builds the xAI
|
||||
# authorize URL, spawns a background worker waiting on the redirect
|
||||
# → returns { session_id, flow: "loopback", auth_url, expires_in }
|
||||
# 2. UI opens auth_url in the browser. There is NO user_code/code to
|
||||
# paste — the redirect lands back on the loopback listener.
|
||||
# 3. UI polls GET /api/providers/oauth/{provider}/poll/{session_id}
|
||||
# (same endpoint as device_code) until status != "pending".
|
||||
# 4. The worker exchanges the code, persists creds, sets "approved".
|
||||
# DELETE /sessions/{id} cancels: the worker bails before persisting
|
||||
# and the callback server is shut down to free the port immediately.
|
||||
#
|
||||
# Sessions are kept in-memory only (single-process FastAPI) and time out
|
||||
# after 15 minutes. A periodic cleanup runs on each /start call to GC
|
||||
# expired sessions so the dict doesn't grow without bound.
|
||||
@@ -3483,6 +3561,220 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
|
||||
raise HTTPException(status_code=400, detail=f"Provider {provider_id} does not support device-code flow")
|
||||
|
||||
|
||||
# xAI Grok OAuth uses a loopback-redirect PKCE flow (RFC 8252). Unlike the
|
||||
# device-code providers there is no user_code to display: the local backend
|
||||
# binds a 127.0.0.1 callback server, the client opens the authorize URL in
|
||||
# the browser, and the redirect lands back on the loopback listener. The
|
||||
# background worker waits for that callback, exchanges the code, and persists
|
||||
# the tokens exactly like `hermes auth add xai-oauth`.
|
||||
_XAI_LOOPBACK_TIMEOUT_SECONDS = 300.0
|
||||
|
||||
|
||||
def _start_xai_loopback_flow() -> Dict[str, Any]:
|
||||
"""Begin the xAI loopback PKCE flow.
|
||||
|
||||
Binds the local callback server, builds the authorize URL, and spawns a
|
||||
background worker that waits for the redirect and finishes the exchange.
|
||||
Returns the authorize URL for the client to open in the browser.
|
||||
"""
|
||||
from hermes_cli import auth as hauth
|
||||
|
||||
discovery = hauth._xai_oauth_discovery()
|
||||
server, thread, callback_result, redirect_uri = hauth._xai_start_callback_server()
|
||||
try:
|
||||
hauth._xai_validate_loopback_redirect_uri(redirect_uri)
|
||||
verifier = hauth._oauth_pkce_code_verifier()
|
||||
challenge = hauth._oauth_pkce_code_challenge(verifier)
|
||||
state = secrets.token_hex(16)
|
||||
nonce = secrets.token_hex(16)
|
||||
authorize_url = hauth._xai_oauth_build_authorize_url(
|
||||
authorization_endpoint=discovery["authorization_endpoint"],
|
||||
redirect_uri=redirect_uri,
|
||||
code_challenge=challenge,
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
)
|
||||
except Exception:
|
||||
# Binding succeeded but URL construction failed — release the socket
|
||||
# and join the serving thread so we don't leak a listener (or a
|
||||
# lingering daemon thread) on the loopback port.
|
||||
try:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
thread.join(timeout=1.0)
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
sid, sess = _new_oauth_session("xai-oauth", "loopback")
|
||||
sess["server"] = server
|
||||
sess["thread"] = thread
|
||||
sess["callback_result"] = callback_result
|
||||
sess["redirect_uri"] = redirect_uri
|
||||
sess["verifier"] = verifier
|
||||
sess["challenge"] = challenge
|
||||
sess["state"] = state
|
||||
sess["token_endpoint"] = discovery["token_endpoint"]
|
||||
sess["discovery"] = discovery
|
||||
sess["expires_at"] = time.time() + _XAI_LOOPBACK_TIMEOUT_SECONDS
|
||||
threading.Thread(
|
||||
target=_xai_loopback_worker, args=(sid,), daemon=True,
|
||||
name=f"oauth-xai-{sid[:6]}",
|
||||
).start()
|
||||
return {
|
||||
"session_id": sid,
|
||||
"flow": "loopback",
|
||||
"auth_url": authorize_url,
|
||||
"expires_in": int(_XAI_LOOPBACK_TIMEOUT_SECONDS),
|
||||
}
|
||||
|
||||
|
||||
def _xai_loopback_worker(session_id: str) -> None:
|
||||
"""Wait for the xAI loopback callback, exchange the code, persist tokens."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from hermes_cli import auth as hauth
|
||||
|
||||
with _oauth_sessions_lock:
|
||||
sess = _oauth_sessions.get(session_id)
|
||||
if not sess:
|
||||
return
|
||||
|
||||
def _fail(message: str) -> None:
|
||||
with _oauth_sessions_lock:
|
||||
s = _oauth_sessions.get(session_id)
|
||||
if s is not None:
|
||||
s["status"] = "error"
|
||||
s["error_message"] = message
|
||||
|
||||
def _cancelled() -> bool:
|
||||
# The session is removed from the registry when the user cancels
|
||||
# (DELETE /sessions/{id}). If that happened while we were blocked on
|
||||
# the callback or token exchange, abort instead of persisting tokens
|
||||
# the user no longer wants.
|
||||
with _oauth_sessions_lock:
|
||||
return session_id not in _oauth_sessions
|
||||
|
||||
try:
|
||||
callback = hauth._xai_wait_for_callback(
|
||||
sess["server"],
|
||||
sess["thread"],
|
||||
sess["callback_result"],
|
||||
timeout_seconds=_XAI_LOOPBACK_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception as exc:
|
||||
_fail(f"xAI authorization timed out: {exc}")
|
||||
return
|
||||
|
||||
if _cancelled():
|
||||
return
|
||||
|
||||
if callback.get("error"):
|
||||
detail = callback.get("error_description") or callback["error"]
|
||||
_fail(f"xAI authorization failed: {detail}")
|
||||
return
|
||||
if callback.get("state") != sess["state"]:
|
||||
_fail("xAI authorization failed: state mismatch.")
|
||||
return
|
||||
code = str(callback.get("code") or "").strip()
|
||||
if not code:
|
||||
_fail("xAI authorization failed: missing authorization code.")
|
||||
return
|
||||
|
||||
try:
|
||||
payload = hauth._xai_oauth_exchange_code_for_tokens(
|
||||
token_endpoint=sess["token_endpoint"],
|
||||
code=code,
|
||||
redirect_uri=sess["redirect_uri"],
|
||||
code_verifier=sess["verifier"],
|
||||
code_challenge=sess["challenge"],
|
||||
)
|
||||
access_token = str(payload.get("access_token", "") or "").strip()
|
||||
refresh_token = str(payload.get("refresh_token", "") or "").strip()
|
||||
if not access_token or not refresh_token:
|
||||
_fail("xAI token exchange did not return the expected tokens.")
|
||||
return
|
||||
base_url = hauth._xai_validate_inference_base_url(
|
||||
os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/")
|
||||
or os.getenv("XAI_BASE_URL", "").strip().rstrip("/"),
|
||||
fallback=hauth.DEFAULT_XAI_OAUTH_BASE_URL,
|
||||
)
|
||||
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
tokens = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"id_token": str(payload.get("id_token", "") or "").strip(),
|
||||
"expires_in": payload.get("expires_in"),
|
||||
"token_type": str(payload.get("token_type") or "Bearer").strip() or "Bearer",
|
||||
}
|
||||
if _cancelled():
|
||||
return
|
||||
hauth._save_xai_oauth_tokens(
|
||||
tokens,
|
||||
discovery=sess.get("discovery"),
|
||||
redirect_uri=sess["redirect_uri"],
|
||||
last_refresh=last_refresh,
|
||||
)
|
||||
_add_xai_oauth_pool_entry(access_token, refresh_token, base_url, last_refresh)
|
||||
except Exception as exc:
|
||||
_fail(f"xAI token exchange failed: {exc}")
|
||||
return
|
||||
|
||||
with _oauth_sessions_lock:
|
||||
s = _oauth_sessions.get(session_id)
|
||||
if s is not None:
|
||||
s["status"] = "approved"
|
||||
_log.info("oauth/loopback: xai-oauth login completed (session=%s)", session_id)
|
||||
|
||||
|
||||
def _add_xai_oauth_pool_entry(
|
||||
access_token: str, refresh_token: str, base_url: str, last_refresh: str
|
||||
) -> None:
|
||||
"""Mirror `hermes auth add xai-oauth`'s credential-pool insert.
|
||||
|
||||
Best-effort: the auth-store write in _save_xai_oauth_tokens is the source
|
||||
of truth for runtime resolution; the pool entry only matters for the
|
||||
rotation strategy.
|
||||
"""
|
||||
try:
|
||||
import uuid
|
||||
|
||||
from agent.credential_pool import (
|
||||
PooledCredential,
|
||||
load_pool,
|
||||
AUTH_TYPE_OAUTH,
|
||||
SOURCE_MANUAL,
|
||||
)
|
||||
pool = load_pool("xai-oauth")
|
||||
existing = [
|
||||
e for e in pool.entries()
|
||||
if getattr(e, "source", "").startswith(f"{SOURCE_MANUAL}:dashboard_xai_pkce")
|
||||
]
|
||||
for e in existing:
|
||||
try:
|
||||
pool.remove_entry(getattr(e, "id", ""))
|
||||
except Exception:
|
||||
pass
|
||||
entry = PooledCredential(
|
||||
provider="xai-oauth",
|
||||
id=uuid.uuid4().hex[:6],
|
||||
label="dashboard PKCE",
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source=f"{SOURCE_MANUAL}:dashboard_xai_pkce",
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
base_url=base_url,
|
||||
last_refresh=last_refresh,
|
||||
)
|
||||
pool.add_entry(entry)
|
||||
except Exception as e:
|
||||
_log.warning("xai-oauth pool add (dashboard) failed: %s", e)
|
||||
|
||||
|
||||
def _nous_poller(session_id: str) -> None:
|
||||
"""Background poller that drives a Nous device-code flow to completion."""
|
||||
from hermes_cli.auth import (
|
||||
@@ -3729,31 +4021,12 @@ def _codex_full_login_worker(session_id: str) -> None:
|
||||
if not access_token:
|
||||
raise RuntimeError("token exchange did not return access_token")
|
||||
|
||||
# Persist via credential pool — same shape as auth_commands.add_command
|
||||
from agent.credential_pool import (
|
||||
PooledCredential,
|
||||
load_pool,
|
||||
AUTH_TYPE_OAUTH,
|
||||
SOURCE_MANUAL,
|
||||
)
|
||||
import uuid as _uuid
|
||||
pool = load_pool("openai-codex")
|
||||
base_url = (
|
||||
os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/")
|
||||
or DEFAULT_CODEX_BASE_URL
|
||||
)
|
||||
entry = PooledCredential(
|
||||
provider="openai-codex",
|
||||
id=_uuid.uuid4().hex[:6],
|
||||
label="dashboard device_code",
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source=f"{SOURCE_MANUAL}:dashboard_device_code",
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
base_url=base_url,
|
||||
)
|
||||
pool.add_entry(entry)
|
||||
from hermes_cli.auth import _save_codex_tokens
|
||||
|
||||
_save_codex_tokens({
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
})
|
||||
with _oauth_sessions_lock:
|
||||
sess["status"] = "approved"
|
||||
_log.info("oauth/device: openai-codex login completed (session=%s)", session_id)
|
||||
@@ -3791,6 +4064,10 @@ async def start_oauth_login(provider_id: str, request: Request):
|
||||
return _start_anthropic_pkce()
|
||||
if catalog_entry["flow"] == "device_code":
|
||||
return await _start_device_code_flow(provider_id)
|
||||
if catalog_entry["flow"] == "loopback" and provider_id == "xai-oauth":
|
||||
return await asyncio.get_running_loop().run_in_executor(
|
||||
None, _start_xai_loopback_flow
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -3817,7 +4094,13 @@ async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Re
|
||||
|
||||
@app.get("/api/providers/oauth/{provider_id}/poll/{session_id}")
|
||||
async def poll_oauth_session(provider_id: str, session_id: str):
|
||||
"""Poll a device-code session's status (no auth — read-only state)."""
|
||||
"""Poll a session's status (no auth — read-only state).
|
||||
|
||||
Shared by the device-code flows (Nous, OpenAI Codex, MiniMax) and the
|
||||
loopback flow (xAI Grok). Both surface progress through the same
|
||||
background-worker-updated ``status`` field, so a single poll endpoint
|
||||
serves them all.
|
||||
"""
|
||||
with _oauth_sessions_lock:
|
||||
sess = _oauth_sessions.get(session_id)
|
||||
if not sess:
|
||||
@@ -3840,6 +4123,33 @@ async def cancel_oauth_session(session_id: str, request: Request):
|
||||
sess = _oauth_sessions.pop(session_id, None)
|
||||
if sess is None:
|
||||
return {"ok": False, "message": "session not found"}
|
||||
# Loopback sessions own a bound 127.0.0.1 callback server. Without an
|
||||
# explicit shutdown the worker would keep that port held until
|
||||
# _xai_wait_for_callback times out (up to 5 min). Free it immediately so
|
||||
# an orphaned listener can't block a subsequent sign-in attempt.
|
||||
if sess.get("flow") == "loopback":
|
||||
# The worker is blocked in _xai_wait_for_callback, which polls
|
||||
# callback_result rather than the server state. Flag the result as
|
||||
# cancelled so that loop returns on its next tick instead of spinning
|
||||
# until the timeout — otherwise repeated cancel/retry piles up daemon
|
||||
# threads. (_cancelled() in the worker then short-circuits before any
|
||||
# persist.)
|
||||
result = sess.get("callback_result")
|
||||
if isinstance(result, dict):
|
||||
result["error"] = result.get("error") or "cancelled"
|
||||
server = sess.get("server")
|
||||
thread = sess.get("thread")
|
||||
try:
|
||||
if server is not None:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if thread is not None:
|
||||
thread.join(timeout=1.0)
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "session_id": session_id}
|
||||
|
||||
|
||||
@@ -6295,13 +6605,26 @@ def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool:
|
||||
|
||||
parsed = urllib.parse.urlparse(origin)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
# Packaged Electron loads the desktop renderer over file://, so its
|
||||
# WebSocket handshake carries a non-web Origin such as file:// or null.
|
||||
# DNS-rebinding attacks originate from an http(s) site; they cannot
|
||||
# forge a file:// origin and still hold the loopback session token.
|
||||
# Public/gated binds have no legitimate non-web client, so keep
|
||||
# rejecting these origins there.
|
||||
return bound_host.lower() in _LOOPBACK_HOST_VALUES
|
||||
# Packaged Electron loads the desktop renderer over a non-web origin
|
||||
# such as file://, null, or a custom app:// scheme. This helper is
|
||||
# called only AFTER _ws_auth_ok has already accepted the WS credential,
|
||||
# which is the real auth boundary in every mode:
|
||||
# * loopback bind → legacy dashboard session token
|
||||
# * non-loopback --insecure → legacy session token (Tailscale / LAN)
|
||||
# * OAuth-gated public bind → single-use, 30s-TTL, identity-bound
|
||||
# ?ticket= minted at the cookie-authed POST /api/auth/ws-ticket
|
||||
# A non-web origin can only be produced by a native client (the desktop
|
||||
# shell); a DNS-rebinding attack always arrives from an http(s) origin
|
||||
# and is still match-checked against the bound host below. So once the
|
||||
# credential check upstream has passed, the Origin guard adds nothing
|
||||
# for a non-web origin — trust it in every mode.
|
||||
#
|
||||
# (Earlier revisions restricted this to loopback, then to non-gated
|
||||
# binds; both excluded the packaged desktop talking to a remote
|
||||
# OAuth-gated gateway, whose file:// renderer origin then got rejected
|
||||
# at the WS upgrade even with a valid ticket. The ticket is the gate,
|
||||
# not the origin.)
|
||||
return True
|
||||
|
||||
if not parsed.netloc:
|
||||
return False
|
||||
@@ -6320,10 +6643,21 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
|
||||
Loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>`` query
|
||||
parameter, constant-time compared.
|
||||
|
||||
Gated (public bind, no ``--insecure``): ``?ticket=<single-use>`` query
|
||||
parameter consumed against the dashboard-auth ticket store. The legacy
|
||||
token path is unconditionally rejected in this mode (the SPA bundle
|
||||
isn't carrying the token any longer).
|
||||
Gated (public bind, no ``--insecure``): one of two credentials —
|
||||
|
||||
* ``?ticket=<single-use>`` — a browser-minted, single-use, 30s-TTL ticket
|
||||
consumed against the dashboard-auth ticket store. This is what the SPA
|
||||
(and native clients) use.
|
||||
* ``?internal=<process-credential>`` — the process-lifetime internal
|
||||
credential, used only by WS clients the server spawns itself (the
|
||||
embedded-TUI PTY child attaching to ``/api/ws`` and ``/api/pub``). It
|
||||
is multi-use and never expires so the child can reconnect, and is never
|
||||
injected into the SPA — see ``dashboard_auth.ws_tickets`` for the
|
||||
threat model.
|
||||
|
||||
The legacy ``?token=`` path is unconditionally rejected in gated mode
|
||||
(the SPA bundle isn't carrying the token any longer, and a leaked
|
||||
``_SESSION_TOKEN`` must not grant WS access once the gate is engaged).
|
||||
|
||||
Returns True if the WS should be accepted; callers close with the
|
||||
appropriate WS code (4401) on False. Audit-logs the rejection so
|
||||
@@ -6331,17 +6665,36 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
|
||||
"""
|
||||
auth_required = bool(getattr(app.state, "auth_required", False))
|
||||
if auth_required:
|
||||
ticket = ws.query_params.get("ticket", "")
|
||||
if not ticket:
|
||||
return False
|
||||
# Lazy import — keeps this function importable in test harnesses
|
||||
# that don't bring in the dashboard_auth layer.
|
||||
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
|
||||
from hermes_cli.dashboard_auth.ws_tickets import (
|
||||
TicketInvalid,
|
||||
consume_internal_credential,
|
||||
consume_ticket,
|
||||
)
|
||||
|
||||
# Server-spawned children (PTY child → /api/ws, /api/pub) present the
|
||||
# multi-use internal credential rather than a single-use ticket, so
|
||||
# they survive reconnects and slow cold boots.
|
||||
internal = ws.query_params.get("internal", "")
|
||||
if internal:
|
||||
try:
|
||||
consume_internal_credential(internal)
|
||||
return True
|
||||
except TicketInvalid as exc:
|
||||
audit_log(
|
||||
AuditEvent.WS_TICKET_REJECTED,
|
||||
reason=f"internal: {exc}",
|
||||
ip=(ws.client.host if ws.client else ""),
|
||||
path=ws.url.path,
|
||||
)
|
||||
return False
|
||||
|
||||
ticket = ws.query_params.get("ticket", "")
|
||||
if not ticket:
|
||||
return False
|
||||
|
||||
try:
|
||||
consume_ticket(ticket)
|
||||
return True
|
||||
@@ -6361,8 +6714,7 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
|
||||
# and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id
|
||||
# the chat tab generates on mount; entries auto-evict when the last subscriber
|
||||
# drops AND the publisher has disconnected.
|
||||
_event_channels: dict[str, set] = {}
|
||||
_event_lock = asyncio.Lock()
|
||||
# (State is initialised in _lifespan on app startup — see above.)
|
||||
|
||||
|
||||
def _resolve_chat_argv(
|
||||
@@ -6418,7 +6770,16 @@ def _resolve_chat_argv(
|
||||
|
||||
|
||||
def _build_gateway_ws_url() -> Optional[str]:
|
||||
"""ws:// URL the PTY child should attach to for JSON-RPC gateway traffic."""
|
||||
"""ws:// URL the PTY child should attach to for JSON-RPC gateway traffic.
|
||||
|
||||
Loopback / ``--insecure``: ``?token=<_SESSION_TOKEN>``.
|
||||
|
||||
Gated mode: the legacy token path is rejected by ``_ws_auth_ok``, so the
|
||||
server-spawned PTY child authenticates with the process-lifetime internal
|
||||
credential (``?internal=``). It must NOT use a single-use browser ticket:
|
||||
the child reads this URL once at startup and reuses it on every reconnect,
|
||||
and a 30s-TTL ticket can expire before a slow cold boot even dials.
|
||||
"""
|
||||
host = getattr(app.state, "bound_host", None)
|
||||
port = getattr(app.state, "bound_port", None)
|
||||
|
||||
@@ -6430,7 +6791,13 @@ def _build_gateway_ws_url() -> Optional[str]:
|
||||
if ":" in host and not host.startswith("[")
|
||||
else f"{host}:{port}"
|
||||
)
|
||||
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN})
|
||||
|
||||
if getattr(app.state, "auth_required", False):
|
||||
from hermes_cli.dashboard_auth.ws_tickets import internal_ws_credential
|
||||
|
||||
qs = urllib.parse.urlencode({"internal": internal_ws_credential()})
|
||||
else:
|
||||
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN})
|
||||
|
||||
return f"ws://{netloc}/api/ws?{qs}"
|
||||
|
||||
@@ -6440,16 +6807,14 @@ def _build_sidecar_url(channel: str) -> Optional[str]:
|
||||
|
||||
Loopback / ``--insecure``: uses ``?token=<_SESSION_TOKEN>``.
|
||||
|
||||
Gated mode: mints a single-use ticket via the dashboard-auth ticket
|
||||
store (server-side mint, no HTTP round trip — the PTY child is a
|
||||
server-spawned process and we trust it). The ticket binds to the
|
||||
pseudo-user ``"pty-sidecar"`` so audit logs can distinguish these from
|
||||
browser-initiated tickets.
|
||||
|
||||
The single-use lifetime means the PTY child cannot reconnect without a
|
||||
new sidecar URL. PTY children open ``/api/pub`` once at startup; if
|
||||
reconnect semantics ever become important, this should be upgraded to
|
||||
a long-lived process-scoped token.
|
||||
Gated mode: authenticates with the process-lifetime internal credential
|
||||
(``?internal=``), the same one ``_build_gateway_ws_url`` uses. The PTY
|
||||
child is a server-spawned process we trust; the credential is multi-use
|
||||
and never expires, so the child can reconnect ``/api/pub`` without a new
|
||||
URL. (This previously minted a single-use 30s ticket, which meant the
|
||||
child could not reconnect and could miss the window on a slow cold boot.)
|
||||
Connections authenticated this way are recorded under the
|
||||
``server-internal`` identity in the audit log.
|
||||
"""
|
||||
host = getattr(app.state, "bound_host", None)
|
||||
port = getattr(app.state, "bound_port", None)
|
||||
@@ -6460,21 +6825,24 @@ def _build_sidecar_url(channel: str) -> Optional[str]:
|
||||
netloc = f"[{host}]:{port}" if ":" in host and not host.startswith("[") else f"{host}:{port}"
|
||||
|
||||
if getattr(app.state, "auth_required", False):
|
||||
# Gated mode — mint a ticket so the WS upgrade survives _ws_auth_ok.
|
||||
from hermes_cli.dashboard_auth.ws_tickets import mint_ticket
|
||||
# Gated mode — use the internal credential so the WS upgrade survives
|
||||
# _ws_auth_ok and the child can reconnect.
|
||||
from hermes_cli.dashboard_auth.ws_tickets import internal_ws_credential
|
||||
|
||||
ticket = mint_ticket(user_id="pty-sidecar", provider="server-internal")
|
||||
qs = urllib.parse.urlencode({"ticket": ticket, "channel": channel})
|
||||
qs = urllib.parse.urlencode(
|
||||
{"internal": internal_ws_credential(), "channel": channel}
|
||||
)
|
||||
else:
|
||||
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN, "channel": channel})
|
||||
|
||||
return f"ws://{netloc}/api/pub?{qs}"
|
||||
|
||||
|
||||
async def _broadcast_event(channel: str, payload: str) -> None:
|
||||
async def _broadcast_event(app: Any, channel: str, payload: str) -> None:
|
||||
"""Fan out one publisher frame to every subscriber on `channel`."""
|
||||
async with _event_lock:
|
||||
subs = list(_event_channels.get(channel, ()))
|
||||
event_channels, event_lock = _get_event_state(app)
|
||||
async with event_lock:
|
||||
subs = list(event_channels.get(channel, ()))
|
||||
|
||||
for sub in subs:
|
||||
try:
|
||||
@@ -6665,7 +7033,7 @@ async def pub_ws(ws: WebSocket) -> None:
|
||||
|
||||
try:
|
||||
while True:
|
||||
await _broadcast_event(channel, await ws.receive_text())
|
||||
await _broadcast_event(ws.app, channel, await ws.receive_text())
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
|
||||
@@ -6691,8 +7059,9 @@ async def events_ws(ws: WebSocket) -> None:
|
||||
|
||||
await ws.accept()
|
||||
|
||||
async with _event_lock:
|
||||
_event_channels.setdefault(channel, set()).add(ws)
|
||||
event_channels, event_lock = _get_event_state(ws.app)
|
||||
async with event_lock:
|
||||
event_channels.setdefault(channel, set()).add(ws)
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -6703,14 +7072,14 @@ async def events_ws(ws: WebSocket) -> None:
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
async with _event_lock:
|
||||
subs = _event_channels.get(channel)
|
||||
async with event_lock:
|
||||
subs = event_channels.get(channel)
|
||||
|
||||
if subs is not None:
|
||||
subs.discard(ws)
|
||||
|
||||
if not subs:
|
||||
_event_channels.pop(channel, None)
|
||||
event_channels.pop(channel, None)
|
||||
|
||||
|
||||
def _normalise_prefix(raw: Optional[str]) -> str:
|
||||
|
||||
+2
-1
@@ -453,7 +453,8 @@ class SessionDB:
|
||||
logger.warning(
|
||||
"SQLite FTS5 unavailable for %s; full-text session search "
|
||||
"disabled. Run `hermes update` to rebuild the venv with a "
|
||||
"current Python. (underlying error: %s)",
|
||||
"current Python (managed uv guarantees FTS5). "
|
||||
"(underlying error: %s)",
|
||||
self.db_path,
|
||||
exc,
|
||||
)
|
||||
|
||||
@@ -58,6 +58,22 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
|
||||
echo "ok" > $out/result
|
||||
''
|
||||
);
|
||||
|
||||
# Verify the default package builds successfully (cross-platform).
|
||||
# On Linux the runtime checks below already depend on the package,
|
||||
# but this ensures darwin builders also build it during flake check.
|
||||
build-package = pkgs.runCommand "hermes-build-package" { } ''
|
||||
echo "PASS: package built at ${hermes-agent}"
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# Verify the devShell builds successfully (cross-platform).
|
||||
build-devshell = pkgs.runCommand "hermes-build-devshell" { } ''
|
||||
echo "PASS: devShell built at ${self'.devShells.default}"
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
} // lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
|
||||
# Verify binaries exist and are executable
|
||||
package-contents = pkgs.runCommand "hermes-package-contents" { } ''
|
||||
|
||||
+26
-33
@@ -8,37 +8,20 @@
|
||||
# No reimplementation of the agent resolution in this wrapper.
|
||||
{ pkgs, lib, stdenv, makeWrapper, hermesNpmLib, electron, hermesAgent, ... }:
|
||||
let
|
||||
src = ../apps;
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
src = ../apps/desktop;
|
||||
# buildNpmPackage uses `npm ci` which is strict — peer deps not in the
|
||||
# lockfile cause network fetch attempts. Fetcher v2 stages the full
|
||||
# cache (including peer-only deps) so `npm ci` can resolve them offline.
|
||||
fetcherVersion = 2;
|
||||
hash = "sha256-7W9ObYz08yDMtybY8+RkUXkKVsJXINLl0qBUB91hpao=";
|
||||
};
|
||||
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "apps/desktop"; attr = "desktop"; pname = "hermes-desktop"; };
|
||||
|
||||
packageJson = builtins.fromJSON (builtins.readFile (src + "/desktop/package.json"));
|
||||
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/apps/desktop/package.json"));
|
||||
version = packageJson.version;
|
||||
|
||||
# Build the renderer (dist/ + electron/ + package.json).
|
||||
renderer = pkgs.buildNpmPackage (npm // {
|
||||
pname = "hermes-desktop-renderer";
|
||||
inherit src npmDeps version;
|
||||
sourceRoot = "apps/desktop";
|
||||
inherit version;
|
||||
|
||||
doCheck = false;
|
||||
# buildNpmPackage uses `npm ci` which fails on peer deps not in the
|
||||
# lockfile. npmDepsFetcherVersion=2 stages the full cache (peer deps
|
||||
# included) so the offline `npm ci` resolves them.
|
||||
npmDepsFetcherVersion = 2;
|
||||
# `--ignore-scripts` skips the electron prebuild download (we use nixpkgs
|
||||
# electron instead). `--legacy-peer-deps` matches the dev workflow —
|
||||
# apps/desktop has conflicting peer deps (zod, @testing-library) that
|
||||
# the package.json relies on npm 7+ to relax.
|
||||
npmFlags = [ "--ignore-scripts" "--legacy-peer-deps" ];
|
||||
# The workspace lockfile resolves all peer deps
|
||||
# correctly so --legacy-peer-deps is not needed.
|
||||
# --ignore-scripts comes from mkNpmPassthru (shared).
|
||||
makeCacheWritable = true;
|
||||
|
||||
buildPhase = ''
|
||||
@@ -47,21 +30,23 @@ let
|
||||
# write-build-stamp.cjs replacement. Packaged Electron reads this
|
||||
# at first-launch to pin the install.ps1 git ref; informational in
|
||||
# nix builds (the backend comes from the derivation directly).
|
||||
mkdir -p build
|
||||
echo '{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix"}' > build/install-stamp.json
|
||||
mkdir -p apps/desktop/build
|
||||
echo '{"schemaVersion":1,"commit":"nix","branch":"nix","dirty":false,"source":"nix"}' > apps/desktop/build/install-stamp.json
|
||||
|
||||
# The vite config aliases react/react-dom to ../../node_modules/react
|
||||
# (workspace root, where npm dedups them in dev). In the standalone
|
||||
# nix build there is no workspace root, so the deps are installed
|
||||
# locally — rewrite the aliases to point at the local copy.
|
||||
substituteInPlace vite.config.ts \
|
||||
--replace-quiet '../../node_modules/' './node_modules/'
|
||||
# Build from apps/desktop/ so vite.config.ts resolves correctly.
|
||||
# The workspace root's node_modules/ is accessible as ../../node_modules/.
|
||||
cd apps/desktop
|
||||
|
||||
# vite handles TS transpilation via esbuild — no type-checking.
|
||||
# We skip `tsc -b` to avoid type errors in test files that don't
|
||||
# ship in the bundle (real upstream peer-dep version mismatches
|
||||
# in @testing-library/react v16 — not blocking the build).
|
||||
npx vite build --outDir dist
|
||||
# Call vite directly from root node_modules to avoid npx resolving
|
||||
# through unpatched workspace symlinks.
|
||||
node ../../node_modules/vite/bin/vite.js build --outDir dist
|
||||
|
||||
# Return to source root so installPhase paths are correct.
|
||||
cd ../..
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
@@ -69,8 +54,12 @@ let
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out
|
||||
cp -r dist electron build $out/
|
||||
cp package.json $out/
|
||||
# vite writes to apps/desktop/dist/ (we cd'd there in buildPhase).
|
||||
# apps/desktop/build was created before the cd. electron/ is source.
|
||||
cp -r apps/desktop/dist $out/
|
||||
cp -r apps/desktop/electron $out/
|
||||
cp -r apps/desktop/build $out/
|
||||
cp apps/desktop/package.json $out/
|
||||
runHook postInstall
|
||||
'';
|
||||
});
|
||||
@@ -106,6 +95,10 @@ stdenv.mkDerivation {
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
passthru = {
|
||||
inherit (renderer.passthru) packageJsonPath;
|
||||
};
|
||||
|
||||
meta = with lib; {
|
||||
description = "Native Electron desktop shell for Hermes Agent";
|
||||
homepage = "https://github.com/NousResearch/hermes-agent";
|
||||
|
||||
+23
-12
@@ -1,13 +1,28 @@
|
||||
# nix/devShell.nix — Dev shell that delegates setup to each package
|
||||
#
|
||||
# Each package in inputsFrom might expose passthru.devShellHook — a bash snippet
|
||||
# with stamp-checked setup logic. This file collects and runs them all.
|
||||
# Each npm workspace package exposes passthru.packageJsonPath (e.g.
|
||||
# "ui-tui/package.json"). This file collects them all and passes the
|
||||
# list to mkNpmDevShellHook, which stamps all package.jsons at once,
|
||||
# then runs a single `npm i --package-lock-only` if any changed and
|
||||
# `npm ci` if the lockfile changed.
|
||||
{ ... }:
|
||||
{
|
||||
perSystem =
|
||||
{ pkgs, self', ... }:
|
||||
let
|
||||
packages = builtins.attrValues self'.packages;
|
||||
hermesNpmLib = self'.packages.default.passthru.hermesNpmLib;
|
||||
fixLockfilesExe = pkgs.lib.getExe self'.packages.fix-lockfiles;
|
||||
|
||||
# Collect all packageJsonPath values from npm workspace packages.
|
||||
npmPackageJsonPaths = builtins.filter (p: p != null) (
|
||||
map (p: p.passthru.packageJsonPath or null) packages
|
||||
);
|
||||
|
||||
# Non-npm packages may have their own devShellHook (e.g. hermes-agent
|
||||
# stamps pyproject.toml + uv.lock for Python venv setup).
|
||||
nonNpmHooks = map (p: p.passthru.devShellHook or "") packages;
|
||||
combinedNonNpm = pkgs.lib.concatStringsSep "\n" (builtins.filter (h: h != "") nonNpmHooks);
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
@@ -15,16 +30,12 @@
|
||||
packages = with pkgs; [
|
||||
uv
|
||||
];
|
||||
shellHook =
|
||||
let
|
||||
hooks = map (p: p.passthru.devShellHook or "") packages;
|
||||
combined = pkgs.lib.concatStringsSep "\n" (builtins.filter (h: h != "") hooks);
|
||||
in
|
||||
''
|
||||
echo "Hermes Agent dev shell"
|
||||
${combined}
|
||||
echo "Ready. Run 'hermes' to start."
|
||||
'';
|
||||
shellHook = ''
|
||||
echo "Hermes Agent dev shell"
|
||||
${combinedNonNpm}
|
||||
${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths fixLockfilesExe}
|
||||
echo "Ready. Run 'hermes' to start."
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+175
-121
@@ -1,15 +1,40 @@
|
||||
# nix/lib.nix — Shared helpers for nix stuff
|
||||
#
|
||||
# All npm packages in this repo are workspace members sharing a single
|
||||
# root package-lock.json. mkNpmPassthru provides the shared src, npmDeps,
|
||||
# npmRoot, and npmDepsFetcherVersion so individual .nix files don't
|
||||
# duplicate them. One hash to rule them all.
|
||||
#
|
||||
# mkNpmPassthru returns packageJsonPath (e.g. "ui-tui/package.json")
|
||||
# instead of a per-package devShellHook. The root devshell hook
|
||||
# (mkNpmDevShellHook) collects all package.json paths, stamps them,
|
||||
# and if any changed, runs a single `npm i --package-lock-only` from
|
||||
# root to update the lockfile, then `npm ci` if the lockfile changed.
|
||||
{
|
||||
pkgs,
|
||||
npm-lockfile-fix,
|
||||
nodejs,
|
||||
}:
|
||||
let
|
||||
# The workspace root — where the single package-lock.json lives.
|
||||
src = ../.;
|
||||
|
||||
# Single npm deps fetch from the workspace root lockfile.
|
||||
# All workspace packages share this derivation.
|
||||
npmDepsHash = "sha256-WudVthIvvyqaKDr3SwRAswd8csvByzUb+T8jCqeai6g=";
|
||||
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
fetcherVersion = 2;
|
||||
hash = npmDepsHash;
|
||||
};
|
||||
in
|
||||
{
|
||||
# Returns a buildNpmPackage-compatible attrs set that provides:
|
||||
# patchPhase — ensures lockfile has exactly one trailing newline
|
||||
# src, npmDeps, npmRoot, npmDepsFetcherVersion
|
||||
# patchPhase — ensures root lockfile has exactly one trailing newline
|
||||
# nativeBuildInputs — [ updateLockfileScript ] (list, prepend with ++ for more)
|
||||
# passthru.devShellHook — stamp-checked npm install + hash auto-update
|
||||
# passthru.npmLockfile — metadata for mkFixLockfiles
|
||||
# passthru.packageJsonPath — relative path to this workspace's package.json
|
||||
# nodejs — fixed nodejs version for all packages we use in the repo
|
||||
#
|
||||
# NOTE: npmConfigHook runs `diff` between the source lockfile and the
|
||||
@@ -19,22 +44,38 @@
|
||||
#
|
||||
# Usage:
|
||||
# npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
|
||||
# pkgs.buildNpmPackage (npm // { ... } # or:
|
||||
# pkgs.buildNpmPackage ({ ... } // npm)
|
||||
# pkgs.buildNpmPackage (npm // {
|
||||
# sourceRoot = "ui-tui";
|
||||
# buildPhase = '' ... '';
|
||||
# installPhase = '' ... '';
|
||||
# })
|
||||
mkNpmPassthru =
|
||||
{
|
||||
folder, # repo-relative folder with package.json, e.g. "ui-tui"
|
||||
attr, # flake package attr, e.g. "tui"
|
||||
pname, # e.g. "hermes-tui"
|
||||
nixFile ? "nix/${attr}.nix", # defaults to nix/<attr>.nix
|
||||
}:
|
||||
let
|
||||
# No sourceRoot — the workspace root (with the single package-lock.json)
|
||||
# is auto-detected as sourceRoot by nix. npmRoot stays at "."
|
||||
# so npmConfigHook finds the lockfile there.
|
||||
in
|
||||
{
|
||||
inherit nodejs;
|
||||
inherit src npmDeps nodejs;
|
||||
npmRoot = ".";
|
||||
npmDepsFetcherVersion = 2;
|
||||
|
||||
# --ignore-scripts: the workspace includes electron (apps/desktop)
|
||||
# which has a postinstall that tries to download from github.com.
|
||||
# nix builds are offline, so all scripts must be skipped. Each
|
||||
# package sets up its own build commands in buildPhase instead.
|
||||
npmFlags = [ "--ignore-scripts" ];
|
||||
|
||||
patchPhase = ''
|
||||
runHook prePatch
|
||||
# Normalize trailing newlines so source and npm-deps always match,
|
||||
# regardless of what fetchNpmDeps preserves.
|
||||
sed -i -z 's/\n*$/\n/' package-lock.json
|
||||
# Normalize trailing newlines on the root lockfile so source and
|
||||
# npm-deps always match, regardless of what fetchNpmDeps preserves.
|
||||
sed -i -z 's/\\n*$/\\n/' package-lock.json
|
||||
|
||||
# Make npmConfigHook's byte-for-byte diff newline-agnostic by
|
||||
# replacing its hardcoded /nix/store/.../diff with a wrapper that
|
||||
@@ -42,11 +83,11 @@
|
||||
mkdir -p "$TMPDIR/bin"
|
||||
cat > "$TMPDIR/bin/diff" << DIFFWRAP
|
||||
#!/bin/sh
|
||||
f1=\$(mktemp) && sed -z 's/\n*$/\n/' "\$1" > "\$f1"
|
||||
f2=\$(mktemp) && sed -z 's/\n*$/\n/' "\$2" > "\$f2"
|
||||
${pkgs.diffutils}/bin/diff "\$f1" "\$f2" && rc=0 || rc=\$?
|
||||
rm -f "\$f1" "\$f2"
|
||||
exit \$rc
|
||||
f1=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$1" > "\\$f1"
|
||||
f2=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$2" > "\\$f2"
|
||||
${pkgs.diffutils}/bin/diff "\\$f1" "\\$f2" && rc=0 || rc=\\$?
|
||||
rm -f "\\$f1" "\\$f2"
|
||||
exit \\$rc
|
||||
DIFFWRAP
|
||||
chmod +x "$TMPDIR/bin/diff"
|
||||
export PATH="$TMPDIR/bin:$PATH"
|
||||
@@ -60,62 +101,71 @@
|
||||
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
|
||||
cd "$REPO_ROOT/${folder}"
|
||||
# All workspace packages share the root lockfile.
|
||||
cd "$REPO_ROOT"
|
||||
rm -rf node_modules/
|
||||
${pkgs.lib.getExe' nodejs "npm"} cache clean --force
|
||||
CI=true ${pkgs.lib.getExe' nodejs "npm"} install
|
||||
CI=true ${pkgs.lib.getExe' nodejs "npm"} install --workspaces
|
||||
${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json
|
||||
|
||||
NIX_FILE="$REPO_ROOT/${nixFile}"
|
||||
sed -i "s/hash = \"[^\"]*\";/hash = \"\";/" $NIX_FILE
|
||||
NIX_OUTPUT=$(nix build .#${attr} 2>&1 || true)
|
||||
NEW_HASH=$(echo "$NIX_OUTPUT" | grep 'got:' | awk '{print $2}')
|
||||
echo got new hash $NEW_HASH
|
||||
sed -i "s|hash = \"[^\"]*\";|hash = \"$NEW_HASH\";|" $NIX_FILE
|
||||
# Hash lives in lib.nix — just rebuild to verify.
|
||||
nix build .#${attr}
|
||||
echo "Updated npm hash in $NIX_FILE to $NEW_HASH"
|
||||
echo "Lockfile updated and build verified for .#${attr}"
|
||||
'')
|
||||
];
|
||||
|
||||
passthru = {
|
||||
devShellHook = pkgs.writeShellScript "npm-dev-hook-${pname}" ''
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
|
||||
_hermes_npm_stamp() {
|
||||
sha256sum "${folder}/package.json" "${folder}/package-lock.json" \
|
||||
2>/dev/null | sha256sum | awk '{print $1}'
|
||||
}
|
||||
STAMP=".nix-stamps/${pname}"
|
||||
STAMP_VALUE="$(_hermes_npm_stamp)"
|
||||
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
|
||||
echo "${pname}: installing npm dependencies..."
|
||||
( cd ${folder} && CI=true ${pkgs.lib.getExe' nodejs "npm"} install --silent --no-fund --no-audit 2>/dev/null )
|
||||
|
||||
# Auto-update the nix hash so it stays in sync with the lockfile
|
||||
echo "${pname}: prefetching npm deps..."
|
||||
NIX_FILE="$REPO_ROOT/${nixFile}"
|
||||
if NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "${folder}/package-lock.json" 2>/dev/null); then
|
||||
sed -i "s|hash = \"sha256-[A-Za-z0-9+/=]+\"|hash = \"$NEW_HASH\";|" "$NIX_FILE"
|
||||
echo "${pname}: updated hash to $NEW_HASH"
|
||||
else
|
||||
echo "${pname}: warning: prefetch failed, run 'nix run .#fix-lockfiles' manually" >&2
|
||||
fi
|
||||
|
||||
mkdir -p .nix-stamps
|
||||
_hermes_npm_stamp > "$STAMP"
|
||||
fi
|
||||
unset -f _hermes_npm_stamp
|
||||
'';
|
||||
|
||||
npmLockfile = {
|
||||
inherit attr folder nixFile;
|
||||
};
|
||||
packageJsonPath = "${folder}/package.json";
|
||||
};
|
||||
};
|
||||
|
||||
# Aggregate `fix-lockfiles` bin from a list of packages carrying
|
||||
# passthru.npmLockfile = { attr; folder; nixFile; };
|
||||
# Invocations:
|
||||
# Single devshell hook for all npm workspace packages.
|
||||
#
|
||||
# Takes a list of package.json relative paths (from mkNpmPassthru .passthru.packageJsonPath),
|
||||
# stamps all of them, and if any changed:
|
||||
# 1. Runs `npm i --package-lock-only` from root to update the lockfile
|
||||
# 2. If the lockfile changed, runs `npm ci` + fix-lockfiles
|
||||
#
|
||||
# fixLockfilesExe: absolute path to the fix-lockfiles binary
|
||||
# (from pkgs.lib.getExe self'.packages.fix-lockfiles in devShell.nix).
|
||||
mkNpmDevShellHook =
|
||||
packageJsonPaths: fixLockfilesExe:
|
||||
pkgs.writeShellScript "npm-dev-hook" ''
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
|
||||
# Stamp all workspace package.jsons into one file.
|
||||
STAMP_DIR=".nix-stamps"
|
||||
STAMP="$STAMP_DIR/npm-package-jsons"
|
||||
STAMP_VALUE=$(
|
||||
${pkgs.coreutils}/bin/sha256sum ${
|
||||
pkgs.lib.concatMapStringsSep " " (p: "\"$REPO_ROOT/${p}\"") packageJsonPaths
|
||||
} 2>/dev/null | ${pkgs.coreutils}/bin/sort | ${pkgs.coreutils}/bin/sha256sum | awk '{print $1}'
|
||||
)
|
||||
|
||||
PKG_CHANGED=false
|
||||
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
|
||||
PKG_CHANGED=true
|
||||
echo "npm: package.json changed, updating lockfile..."
|
||||
( cd "$REPO_ROOT" && ${pkgs.lib.getExe' nodejs "npm"} i --package-lock-only --silent --no-fund --no-audit 2>/dev/null )
|
||||
mkdir -p "$STAMP_DIR"
|
||||
echo "$STAMP_VALUE" > "$STAMP"
|
||||
fi
|
||||
|
||||
# Check if lockfile changed (either from the npm i above or from an
|
||||
# external edit). Runs npm ci + fix-lockfiles if so.
|
||||
LOCK_STAMP="$STAMP_DIR/root-lockfile"
|
||||
LOCK_STAMP_VALUE=$(sha256sum "$REPO_ROOT/package-lock.json" 2>/dev/null | awk '{print $1}')
|
||||
if [ ! -f "$LOCK_STAMP" ] || [ "$(cat "$LOCK_STAMP")" != "$LOCK_STAMP_VALUE" ]; then
|
||||
echo "npm: package-lock.json changed, running npm ci..."
|
||||
( cd "$REPO_ROOT" && CI=true ${pkgs.lib.getExe' nodejs "npm"} ci --silent --no-fund --no-audit 2>/dev/null )
|
||||
echo "npm: updating nix hash..."
|
||||
${fixLockfilesExe} || echo "npm: warning: fix-lockfiles failed, run it manually" >&2
|
||||
mkdir -p "$STAMP_DIR"
|
||||
echo "$LOCK_STAMP_VALUE" > "$LOCK_STAMP"
|
||||
fi
|
||||
'';
|
||||
|
||||
# Build `fix-lockfiles` bin that checks/updates the single npmDepsHash
|
||||
# fix-lockfiles --check # exit 1 if any hash is stale
|
||||
# fix-lockfiles --apply # rewrite stale hashes in place
|
||||
# fix-lockfiles # alias of --apply
|
||||
@@ -123,12 +173,8 @@
|
||||
# when set, so CI workflows can post a sticky PR comment directly.
|
||||
mkFixLockfiles =
|
||||
{
|
||||
packages, # list of packages with passthru.npmLockfile
|
||||
attr, # flake package attr for fallback verification build, e.g. "tui"
|
||||
}:
|
||||
let
|
||||
entries = map (p: p.passthru.npmLockfile) packages;
|
||||
entryArgs = pkgs.lib.concatMapStringsSep " " (e: "\"${e.attr}:${e.folder}:${e.nixFile}\"") entries;
|
||||
in
|
||||
pkgs.writeShellScriptBin "fix-lockfiles" ''
|
||||
set -uox pipefail
|
||||
MODE="''${1:---apply}"
|
||||
@@ -142,8 +188,6 @@
|
||||
exit 2 ;;
|
||||
esac
|
||||
|
||||
ENTRIES=(${entryArgs})
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
@@ -160,66 +204,76 @@
|
||||
FIXED=0
|
||||
REPORT=""
|
||||
|
||||
for entry in "''${ENTRIES[@]}"; do
|
||||
IFS=":" read -r ATTR FOLDER NIX_FILE <<< "$entry"
|
||||
echo "==> .#$ATTR ($FOLDER -> $NIX_FILE)"
|
||||
|
||||
# Compute the actual hash from the lockfile directly using
|
||||
# prefetch-npm-deps. This avoids false "ok" from nix build when
|
||||
# an old derivation is cached in a substituter (cachix/cache.nixos.org).
|
||||
LOCK_FILE="$FOLDER/package-lock.json"
|
||||
NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "$LOCK_FILE" 2>/dev/null)
|
||||
# All workspace packages share the root package-lock.json, so
|
||||
# we only need to check the hash once.
|
||||
LOCK_FILE="package-lock.json"
|
||||
LIB_FILE="nix/lib.nix"
|
||||
NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "$LOCK_FILE" 2>/dev/null)
|
||||
if [ -z "$NEW_HASH" ]; then
|
||||
echo "prefetch-npm-deps failed, falling back to nix build" >&2
|
||||
OUTPUT=$(nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>&1)
|
||||
STATUS=$?
|
||||
if [ "$STATUS" -eq 0 ]; then
|
||||
echo "ok (via nix build)"
|
||||
exit 0
|
||||
fi
|
||||
NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}')
|
||||
if [ -z "$NEW_HASH" ]; then
|
||||
echo " prefetch-npm-deps failed, falling back to nix build" >&2
|
||||
OUTPUT=$(nix build ".#$ATTR.npmDeps" --no-link --print-build-logs 2>&1)
|
||||
STATUS=$?
|
||||
if [ "$STATUS" -eq 0 ]; then
|
||||
echo " ok (via nix build)"
|
||||
continue
|
||||
if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
|
||||
echo "skipped (transient cache failure — see primary nix build for real status)" >&2
|
||||
echo "$OUTPUT" | tail -8 >&2
|
||||
exit 0
|
||||
fi
|
||||
NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}')
|
||||
if [ -z "$NEW_HASH" ]; then
|
||||
if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then
|
||||
echo " skipped (transient cache failure — see primary nix build for real status)" >&2
|
||||
echo "$OUTPUT" | tail -8 >&2
|
||||
continue
|
||||
echo "build failed with no hash mismatch:" >&2
|
||||
echo "$OUTPUT" | tail -40 >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
OLD_HASH=$(grep -oE 'npmDepsHash = "sha256-[^"]+"' "$LIB_FILE" | head -1 \
|
||||
| sed -E 's/npmDepsHash = "(.*)"/\1/')
|
||||
|
||||
if [ "$NEW_HASH" = "$OLD_HASH" ]; then
|
||||
echo "ok"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
HASH_LINE=$(grep -n 'npmDepsHash = "sha256-' "$LIB_FILE" | head -1 | cut -d: -f1)
|
||||
echo "stale: $LIB_FILE:$HASH_LINE $OLD_HASH -> $NEW_HASH"
|
||||
STALE=1
|
||||
|
||||
if [ -n "$LINK_REPO" ] && [ -n "$LINK_SHA" ]; then
|
||||
LIB_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LIB_FILE#L$HASH_LINE"
|
||||
LOCK_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LOCK_FILE"
|
||||
REPORT="- [\`$LIB_FILE:$HASH_LINE\`]($LIB_URL): \`$OLD_HASH\` → \`$NEW_HASH\` — lockfile: [\`$LOCK_FILE\`]($LOCK_URL)"$'\\n'
|
||||
else
|
||||
REPORT="- \`$LIB_FILE:$HASH_LINE\`: \`$OLD_HASH\` → \`$NEW_HASH\`"$'\\n'
|
||||
fi
|
||||
|
||||
if [ "$MODE" = "--apply" ]; then
|
||||
sed -i -E "s|npmDepsHash = \"sha256-[^\"]+\";|npmDepsHash = \"$NEW_HASH\";|" "$LIB_FILE"
|
||||
if ! nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>/dev/null; then
|
||||
# prefetch-npm-deps may disagree with fetchNpmDeps (it hashes
|
||||
# the lockfile contents, not the full source tree). Extract the
|
||||
# correct hash from the nix build error and retry.
|
||||
RETRY_OUTPUT=$(nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>&1)
|
||||
CORRECT_HASH=$(echo "$RETRY_OUTPUT" | awk '/got:/ {print $2; exit}')
|
||||
if [ -n "$CORRECT_HASH" ]; then
|
||||
echo "prefetch-npm-deps gave $NEW_HASH but nix wants $CORRECT_HASH — retrying" >&2
|
||||
sed -i -E "s|npmDepsHash = \"sha256-[^\"]+\";|npmDepsHash = \"$CORRECT_HASH\";|" "$LIB_FILE"
|
||||
if ! nix build ".#${attr}.npmDeps" --no-link --print-build-logs; then
|
||||
echo "verification build failed after hash retry" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " build failed with no hash mismatch:" >&2
|
||||
echo "$OUTPUT" | tail -40 >&2
|
||||
NEW_HASH="$CORRECT_HASH"
|
||||
else
|
||||
echo "verification build failed after hash update" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
OLD_HASH=$(grep -oE 'hash = "sha256-[^"]+"' "$NIX_FILE" | head -1 \
|
||||
| sed -E 's/hash = "(.*)"/\1/')
|
||||
|
||||
if [ "$NEW_HASH" = "$OLD_HASH" ]; then
|
||||
echo " ok"
|
||||
continue
|
||||
fi
|
||||
|
||||
HASH_LINE=$(grep -n 'hash = "sha256-' "$NIX_FILE" | head -1 | cut -d: -f1)
|
||||
echo " stale: $NIX_FILE:$HASH_LINE $OLD_HASH -> $NEW_HASH"
|
||||
STALE=1
|
||||
|
||||
if [ -n "$LINK_REPO" ] && [ -n "$LINK_SHA" ]; then
|
||||
NIX_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$NIX_FILE#L$HASH_LINE"
|
||||
LOCK_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LOCK_FILE"
|
||||
REPORT+="- [\`$NIX_FILE:$HASH_LINE\`]($NIX_URL) (\`.#$ATTR\`): \`$OLD_HASH\` → \`$NEW_HASH\` — lockfile: [\`$LOCK_FILE\`]($LOCK_URL)"$'\n'
|
||||
else
|
||||
REPORT+="- \`$NIX_FILE:$HASH_LINE\` (\`.#$ATTR\`): \`$OLD_HASH\` → \`$NEW_HASH\`"$'\n'
|
||||
fi
|
||||
|
||||
if [ "$MODE" = "--apply" ]; then
|
||||
sed -i "s|hash = \"sha256-[^\"]*\";|hash = \"$NEW_HASH\";|" "$NIX_FILE"
|
||||
if ! nix build ".#$ATTR.npmDeps" --no-link --print-build-logs; then
|
||||
echo " verification build failed after hash update" >&2
|
||||
exit 1
|
||||
fi
|
||||
FIXED=1
|
||||
echo " fixed"
|
||||
fi
|
||||
done
|
||||
FIXED=1
|
||||
echo "fixed"
|
||||
fi
|
||||
|
||||
if [ -n "''${GITHUB_OUTPUT:-}" ]; then
|
||||
{
|
||||
@@ -235,7 +289,7 @@
|
||||
|
||||
if [ "$STALE" -eq 1 ] && [ "$MODE" = "--check" ]; then
|
||||
echo
|
||||
echo "Stale lockfile hashes detected. Run:"
|
||||
echo "Stale lockfile hash detected. Run:"
|
||||
echo " nix run .#fix-lockfiles"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+1
-3
@@ -51,9 +51,7 @@
|
||||
web = hermesAgent.hermesWeb;
|
||||
desktop = hermesAgent.hermesDesktop;
|
||||
|
||||
fix-lockfiles = hermesAgent.hermesNpmLib.mkFixLockfiles {
|
||||
packages = [ hermesAgent.hermesTui hermesAgent.hermesWeb hermesAgent.hermesDesktop ];
|
||||
};
|
||||
fix-lockfiles = hermesAgent.hermesNpmLib.mkFixLockfiles { attr = "tui"; };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+11
-13
@@ -1,34 +1,32 @@
|
||||
# nix/tui.nix — Hermes TUI (Ink/React) compiled with tsc and bundled
|
||||
{ pkgs, hermesNpmLib, ... }:
|
||||
let
|
||||
src = ../ui-tui;
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
hash = "sha256-F6/MzZOWc0zhW9mIfnaY+PrllPvJcsA/OdFdEM+NpLY=";
|
||||
};
|
||||
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
|
||||
|
||||
packageJson = builtins.fromJSON (builtins.readFile (src + "/package.json"));
|
||||
packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/ui-tui/package.json"));
|
||||
version = packageJson.version;
|
||||
in
|
||||
pkgs.buildNpmPackage (npm // {
|
||||
pname = "hermes-tui";
|
||||
inherit src npmDeps version;
|
||||
inherit version;
|
||||
|
||||
doCheck = false;
|
||||
npmFlags = [ "--legacy-peer-deps" ];
|
||||
|
||||
buildPhase = ''
|
||||
# esbuild bundles everything — no need for tsc or vite.
|
||||
# Run from the workspace root where node_modules/ lives.
|
||||
node ui-tui/scripts/build.mjs
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/lib/hermes-tui
|
||||
|
||||
# Single self-contained bundle built by scripts/build.mjs (esbuild).
|
||||
cp -r dist $out/lib/hermes-tui/dist
|
||||
# esbuild writes to ui-tui/dist/ from the source root (no cd).
|
||||
cp -r ui-tui/dist $out/lib/hermes-tui/dist
|
||||
|
||||
# package.json kept for "type": "module" resolution on `node dist/entry.js`.
|
||||
cp package.json $out/lib/hermes-tui/
|
||||
cp ui-tui/package.json $out/lib/hermes-tui/
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user