Compare commits

..
Author SHA1 Message Date
Ben Barclay 73dccbe9c1 Merge remote-tracking branch 'origin/main' into fix/discord-interaction-command-normalization 2026-07-25 07:58:00 +10:00
Ben Barclay ceed22d2af fix(relay): normalize forwarded Discord interactions to leading-slash commands
A real APPLICATION_COMMAND interaction forwarded over the relay arrived
slash-less: _discord_interaction_to_event set text = data['name'] ("new",
not "/new"), MessageType.TEXT, and dropped options entirely — so a
registered /new dispatched as plain chat instead of a command
(MessageEvent.is_command() is text.startswith("/")).

Port the connector's Slack slash-command precedent (normalizeSlackCommand
builds `${command} ${args}`.trim() with a leading slash and explicit
command type): for type-2 interactions build "/" + name, append rendered
options space-separated (scalar options contribute their value, matching
the native adapter's f"/model {name}" shape; SUB_COMMAND/
SUB_COMMAND_GROUP contribute their name then recurse into nested
options), and set MessageType.COMMAND. Type-3 (custom_id) and other
interaction types are unchanged. This implements the interaction->command
sub-design previously flagged as deferred in the _on_passthrough
docstring.

Companion connector fix in gateway-gateway: fix(relay): strip own-mention
prefix so addressed slash commands dispatch.
2026-07-24 11:22:19 +10:00
378 changed files with 3229 additions and 27539 deletions
-6
View File
@@ -53,9 +53,6 @@ jobs:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Write install stamp
run: python3 scripts/write_install_stamp.py --output install-stamp.json
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
@@ -144,9 +141,6 @@ jobs:
- name: Checkout trusted source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Write install stamp
run: python3 scripts/write_install_stamp.py --output install-stamp.json
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
+22 -24
View File
@@ -1,19 +1,3 @@
# ---------- Install stamp stages ----------
# CI pre-builds install-stamp.json (scripts/write_install_stamp.py) with full
# git provenance before `docker build`. The stamp is COPY'd into the image
# so version_info.py can read it at runtime — .dockerignore excludes .git,
# so the stamp can't be resolved inside the image.
#
# The stamp arrives via the bulk `COPY . .` below (line ~222) as
# /opt/hermes/install-stamp.json. A late RUN moves it to the canonical
# .hermes_build_info.json path so it doesn't bust the docker cache for
# the expensive build layers above — only the final metadata layer
# changes when the stamp changes.
#
# If the file is absent (local `docker build` without CI), the mv is
# a no-op and runtime falls through to "unknown" source with no crash.
# ---------- Base image ----------
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
# Node 22 LTS source stage. Debian trixie's bundled nodejs is pinned to 20.x
# which reached EOL in April 2026 — we copy node + npm + corepack from the
@@ -234,14 +218,6 @@ RUN mkdir -p /opt/hermes/bin && \
cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \
chmod 0755 /opt/hermes/bin/hermes && \
printf 'docker\n' > /opt/hermes/.install_method
# Move the pre-built install stamp (if CI provided one) to the canonical
# path. Placed late so a stamp change doesn't bust the docker cache for
# the expensive build layers above — only this final layer re-runs.
# If install-stamp.json is absent (local build without CI), this is a no-op.
RUN if [ -f /opt/hermes/install-stamp.json ] && grep -q '"commit"' /opt/hermes/install-stamp.json; then \
mv /opt/hermes/install-stamp.json /opt/hermes/.hermes_build_info.json; \
fi
# The ``.install_method`` stamp is baked next to the running code (the install
# tree), NOT into $HERMES_HOME. $HERMES_HOME (/opt/data) is a shared data
# volume that is commonly bind-mounted from the host and even shared with a
@@ -253,6 +229,28 @@ RUN if [ -f /opt/hermes/install-stamp.json ] && grep -q '"commit"' /opt/hermes/i
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
# run as the default hermes user (UID 10000).
# ---------- Bake build-time git revision ----------
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
# container always returns nothing — meaning `hermes dump` reports
# "(unknown)" and the startup banner drops its `· upstream <sha>` suffix.
# That makes support triage from container bug reports impossible:
# we can't tell which commit the user is actually running.
#
# Fix: write the commit SHA passed via the HERMES_GIT_SHA build-arg to
# /opt/hermes/.hermes_build_sha at build time, and have
# hermes_cli/build_info.py read it at runtime. Both `hermes dump` and
# banner.get_git_banner_state() try the baked SHA first, then fall back
# to live `git rev-parse` for source installs (unchanged behaviour).
#
# The arg is optional — local `docker build` without --build-arg simply
# omits the file, and the runtime falls back to live-git lookup. CI
# (.github/workflows/docker.yml) passes ${{ github.sha }} so
# every published image has it.
ARG HERMES_GIT_SHA=
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha; \
fi
# ---------- s6-overlay service wiring ----------
# Static services declared at build time: main-hermes + dashboard.
# Per-profile gateway services are registered dynamically at runtime by
-7
View File
@@ -1341,13 +1341,6 @@ def init_agent(
print("⚠️ Warning: API key appears invalid or missing")
except Exception as e:
raise RuntimeError(f"Failed to initialize OpenAI client: {e}")
# Keep a stable identity for the pool entry that supplied this runtime.
# OAuth refreshes can replace the runtime token before a failed request is
# recovered, so the mutable API-key value alone cannot reliably attribute
# the failure to its source entry.
from agent.agent_runtime_helpers import sync_credential_pool_entry_id
sync_credential_pool_entry_id(agent)
# Provider fallback chain — ordered list of backup providers tried
# when the primary is exhausted (rate-limit, overload, connection
+23 -61
View File
@@ -850,25 +850,6 @@ def strip_think_blocks(agent, content: str) -> str:
def sync_credential_pool_entry_id(agent) -> None:
"""Rebind ``agent._credential_pool_entry_id`` from the current pool + key.
OAuth refreshes can replace the runtime token before a failed request is
recovered, so the mutable API-key value alone cannot reliably attribute
the failure to its source entry. This resolves the stable pool-entry ID
for the agent's current ``api_key`` and clears it when no pool is bound.
"""
pool = getattr(agent, "_credential_pool", None)
try:
agent._credential_pool_entry_id = (
pool.entry_id_for_api_key(getattr(agent, "api_key", None))
if pool is not None
else None
)
except Exception:
agent._credential_pool_entry_id = None
def recover_with_credential_pool(
agent,
*,
@@ -953,30 +934,10 @@ def recover_with_credential_pool(
# failing entry exactly; fall back to current()'s key only when the agent
# carries no key at all.
_api_key_hint = getattr(agent, "api_key", None) or None
_raw_credential_id = getattr(agent, "_credential_pool_entry_id", None)
_credential_id = (
_raw_credential_id
if isinstance(_raw_credential_id, str) and _raw_credential_id
else None
)
if not _api_key_hint:
_cur = pool.current()
if _cur:
_api_key_hint = getattr(_cur, "runtime_api_key", None)
if not _credential_id:
_current_id = getattr(_cur, "id", None)
if isinstance(_current_id, str) and _current_id:
_credential_id = _current_id
def _rotate_failed_credential(rotate_status: int):
kwargs = {
"status_code": rotate_status,
"error_context": error_context,
"api_key_hint": _api_key_hint,
}
if _credential_id:
kwargs["credential_id"] = _credential_id
return pool.mark_exhausted_and_rotate(**kwargs)
effective_reason = classified_reason
if effective_reason is None:
@@ -1011,7 +972,11 @@ def recover_with_credential_pool(
# Runtime credentials can be resolved by a separate pool instance,
# leaving this recovery pool without ``current_id``. Match the key
# that actually failed instead of quarantining a different account.
next_entry = _rotate_failed_credential(rotate_status)
next_entry = pool.mark_exhausted_and_rotate(
status_code=rotate_status,
error_context=error_context,
api_key_hint=_api_key_hint,
)
if next_entry is not None:
_ra().logger.info(
"Credential %s (billing) — rotated to pool entry %s",
@@ -1030,13 +995,8 @@ def recover_with_credential_pool(
# Prefer the entry matching the failing key over the shared current()
# pointer, for the same attribution reason as above.
current_entry = None
if _credential_id:
current_entry = next(
(e for e in pool.entries() if e.id == _credential_id),
None,
)
if _api_key_hint:
current_entry = current_entry or next(
current_entry = next(
(e for e in pool.entries() if e.runtime_api_key == _api_key_hint),
None,
)
@@ -1049,7 +1009,11 @@ def recover_with_credential_pool(
current_last_status,
)
rotate_status = status_code if status_code is not None else 429
next_entry = _rotate_failed_credential(rotate_status)
next_entry = pool.mark_exhausted_and_rotate(
status_code=rotate_status,
error_context=error_context,
api_key_hint=_api_key_hint,
)
if next_entry is not None:
_ra().logger.info(
"Credential %s (rate limit, pre-exhausted) — rotated to pool entry %s",
@@ -1073,7 +1037,11 @@ def recover_with_credential_pool(
if not has_retried_429 and not usage_limit_reached:
return False, True
rotate_status = status_code if status_code is not None else 429
next_entry = _rotate_failed_credential(rotate_status)
next_entry = pool.mark_exhausted_and_rotate(
status_code=rotate_status,
error_context=error_context,
api_key_hint=_api_key_hint,
)
if next_entry is not None:
_ra().logger.info(
"Credential %s (rate limit) — rotated to pool entry %s",
@@ -1145,10 +1113,7 @@ def recover_with_credential_pool(
# the shared pointer can reference a different, healthy entry, and
# refreshing it would consume that entry's single-use refresh token
# (or mark it exhausted on failure) for a failure it never had.
refresh_kwargs = {"api_key_hint": _api_key_hint}
if _credential_id:
refresh_kwargs["credential_id"] = _credential_id
refreshed = pool.try_refresh_matching(**refresh_kwargs)
refreshed = pool.try_refresh_matching(api_key_hint=_api_key_hint)
if refreshed is not None:
# ``try_refresh_matching()`` re-mints a fresh OAuth token and reports
# success even when the upstream keeps rejecting it — a single-entry
@@ -1180,7 +1145,11 @@ def recover_with_credential_pool(
# Refresh failed — rotate to next credential instead of giving up.
# The failed entry is already marked exhausted by the refresh attempt.
rotate_status = status_code if status_code is not None else 401
next_entry = _rotate_failed_credential(rotate_status)
next_entry = pool.mark_exhausted_and_rotate(
status_code=rotate_status,
error_context=error_context,
api_key_hint=_api_key_hint,
)
if next_entry is not None:
_ra().logger.info(
"Credential %s (auth refresh failed) — rotated to pool entry %s",
@@ -1491,7 +1460,6 @@ def restore_primary_runtime(agent) -> bool:
pool_matches_primary = False
if pool is not None and pool_provider and not pool_matches_primary:
agent._credential_pool = None
agent._credential_pool_entry_id = None
try:
from agent.credential_pool import load_pool
@@ -1511,7 +1479,6 @@ def restore_primary_runtime(agent) -> bool:
# the pool for its current best entry and swap the live credential in.
# When the pool is absent, empty, or the entry has no usable key, we
# keep the snapshot key (the existing behavior). Fixes #25205.
agent._credential_pool_entry_id = None
pool = getattr(agent, "_credential_pool", None)
if pool is not None and pool.has_available():
entry = pool.select()
@@ -2108,9 +2075,6 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# restore the original pool (issue #52727: pool reload is part of this
# switch and must be reversible on rollback).
_snapshot["_credential_pool"] = getattr(agent, "_credential_pool", _MISSING)
_snapshot["_credential_pool_entry_id"] = getattr(
agent, "_credential_pool_entry_id", _MISSING
)
try:
# Clear the per-config context_length override so the new model's
@@ -2167,7 +2131,6 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# A pool bound to the old provider is worse than no pool: the
# recovery guard rejects it and every later 401/429 skips rotation.
agent._credential_pool = None
agent._credential_pool_entry_id = None
try:
from agent.credential_pool import load_pool
agent._credential_pool = load_pool(new_provider)
@@ -2177,6 +2140,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
"continuing without pool rotation this turn",
new_provider, _pool_exc,
)
# ── Build new client ──
if (new_provider or "").strip().lower() == "moa":
from agent.moa_loop import build_moa_facade
@@ -2272,8 +2236,6 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
reason="switch_model",
shared=True,
)
sync_credential_pool_entry_id(agent)
except Exception:
# Rollback every mutated field to the pre-swap snapshot so the agent
# is left consistent (old model + old provider + old client) and the
+2 -2
View File
@@ -368,7 +368,7 @@ def _detect_claude_code_version() -> str:
try:
result = _sp.run(
[cmd, "--version"],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5,
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
# Output is like "2.1.74 (Claude Code)" or just "2.1.74"
@@ -914,7 +914,7 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
"-s", "Claude Code-credentials",
"-w"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
text=True,
timeout=5,
stdin=subprocess.DEVNULL,
)
+6 -53
View File
@@ -1513,45 +1513,6 @@ def _fallback_entry_key(fb: dict) -> tuple[str, str, str]:
)
def _fallback_entry_is_same_backend_by_base_url(
*,
current_provider: str,
fb_provider: str,
current_base_url: str,
fb_base_url: str,
current_model: str,
fb_model: str,
) -> bool:
"""True when base_url+model identity means the fallback is the same backend.
Issue #22548: two ``custom_providers`` aliases that point at the same shim
URL with the same model must be skipped, or failover loops on the dead
backend. First-class providers that share a host while using different
auth (``xai-oauth`` vs ``xai``, ``openai-codex`` vs ``openai-api``) are
distinct credential surfaces — skipping them strands configured failover
when primary and fallback reuse the same model slug on that host.
"""
if not (
fb_base_url
and current_base_url
and fb_base_url == current_base_url
and fb_model == current_model
):
return False
if fb_provider == current_provider:
return True
try:
from hermes_cli.auth import PROVIDER_REGISTRY
# Both sides are registered first-class providers → different auth
# identities even when the inference host matches. Allow failover.
if current_provider in PROVIDER_REGISTRY and fb_provider in PROVIDER_REGISTRY:
return False
except Exception:
pass
return True
def _fallback_entry_unavailable_without_network(agent, fb: dict) -> Optional[str]:
"""Return a skip reason for fallback entries known to be unusable locally."""
fb_provider = (fb.get("provider") or "").strip().lower()
@@ -1640,9 +1601,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
# Skip entries that resolve to the current (provider, model) — falling
# back to the same backend that just failed loops the failure. Compare
# base_url too so two distinct custom_providers entries pointing at the
# same shim/proxy URL also dedup. See issue #22548. Do NOT treat
# first-class providers that share a host (xai-oauth vs xai) as the same
# backend — they use different credentials.
# same shim/proxy URL also dedup. See issue #22548.
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
current_model = (getattr(agent, "model", "") or "").strip()
current_base_url = str(getattr(agent, "base_url", "") or "").rstrip("/").lower()
@@ -1653,13 +1612,11 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
fb_provider, fb_model,
)
return agent._try_activate_fallback(reason)
if _fallback_entry_is_same_backend_by_base_url(
current_provider=current_provider,
fb_provider=fb_provider,
current_base_url=current_base_url,
fb_base_url=fb_base_url_for_dedup,
current_model=current_model,
fb_model=fb_model,
if (
fb_base_url_for_dedup
and current_base_url
and fb_base_url_for_dedup == current_base_url
and fb_model == current_model
):
logger.warning(
"Fallback skip: chain entry base_url %s matches current backend",
@@ -1782,7 +1739,6 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
fb_provider, fb_model, _pool_provider,
)
agent._credential_pool = None
agent._credential_pool_entry_id = None
if getattr(agent, "_credential_pool", None) is None:
try:
from agent.credential_pool import load_pool
@@ -1845,9 +1801,6 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
# not only after a later credential-rotation rebuild.
agent._replace_primary_openai_client(reason="fallback_timeout_apply")
from agent.agent_runtime_helpers import sync_credential_pool_entry_id
sync_credential_pool_entry_id(agent)
# Re-evaluate prompt caching for the new provider/model
agent._use_prompt_caching, agent._use_native_cache_layout = (
agent._anthropic_prompt_cache_policy(
+2 -2
View File
@@ -308,7 +308,7 @@ def _expand_git_reference(
["git", *args],
cwd=cwd,
capture_output=True,
text=True, encoding='utf-8', errors='replace',
text=True,
timeout=30,
stdin=subprocess.DEVNULL,
**_popen_kwargs,
@@ -534,7 +534,7 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
["rg", "--files", str(path.relative_to(cwd))],
cwd=cwd,
capture_output=True,
text=True, encoding='utf-8', errors='replace',
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
**_popen_kwargs,
-14
View File
@@ -1908,19 +1908,6 @@ def compress_context(
except Exception:
pass
agent._session_db_created = False
# The rotation child must stay on the parent's profile —
# mirror _ensure_db_session's stamp ("default" persists as
# NULL). _insert_session_row's parent backfill additionally
# COALESCEs from the parent row, covering app-global remote
# sessions whose thread lacks the HERMES_HOME context.
try:
from hermes_cli.profiles import get_active_profile_name
_profile_for_child = get_active_profile_name()
if _profile_for_child == "default":
_profile_for_child = None
except Exception:
_profile_for_child = None
try:
agent._session_db.create_session(
session_id=agent.session_id,
@@ -1928,7 +1915,6 @@ def compress_context(
model=agent.model,
model_config=agent._session_init_model_config,
parent_session_id=old_session_id,
profile_name=_profile_for_child,
)
except Exception as _cs_err:
# The child row could not be created (e.g. FK constraint,
+1 -1
View File
@@ -512,7 +512,7 @@ class CopilotACPClient:
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True, encoding='utf-8', errors='replace',
text=True,
bufsize=1,
cwd=self._acp_cwd,
env=_build_subprocess_env(),
+22 -72
View File
@@ -622,28 +622,6 @@ class CredentialPool:
with self._lock:
return self._current_unlocked()
def entry_id_for_api_key(self, api_key_hint: Any = None) -> Optional[str]:
"""Return the stable id for the runtime credential in use.
Prefer the current selection when it still supplies ``api_key_hint``.
If the cursor was cleared, fall back to an unambiguous key match.
"""
with self._lock:
current = self._current_unlocked()
if current is not None and (
api_key_hint is None
or current.runtime_api_key == api_key_hint
):
return current.id
if api_key_hint is None:
return None
matches = [
entry
for entry in self._entries
if entry.runtime_api_key == api_key_hint
]
return matches[0].id if len(matches) == 1 else None
def _replace_entry(self, old: PooledCredential, new: PooledCredential) -> None:
"""Swap an entry in-place by id, preserving sort order."""
for idx, entry in enumerate(self._entries):
@@ -1785,17 +1763,10 @@ class CredentialPool:
status_code: Optional[int],
error_context: Optional[Dict[str, Any]] = None,
api_key_hint: Optional[str] = None,
credential_id: Optional[str] = None,
) -> Optional[PooledCredential]:
with self._lock:
entry = None
identity_supplied = bool(credential_id or api_key_hint)
if credential_id:
entry = next(
(e for e in self._entries if e.id == credential_id),
None,
)
if entry is None and api_key_hint:
if api_key_hint:
# Prefer the specific entry whose API key matches the one that
# actually failed. When this pool was freshly loaded from disk
# (another process already rotated), current() is None and
@@ -1804,26 +1775,20 @@ class CredentialPool:
(e for e in self._entries if e.runtime_api_key == api_key_hint),
None,
)
if entry is None and identity_supplied:
# The failed credential is identifiable but matches no entry
# (rotated away, or a wrapper whose runtime key differs).
# Falling through to current()/_select_unlocked() would mark an
# innocent healthy key exhausted for the full cooldown TTL.
logger.info(
"credential pool: failed credential identity matched no %s "
"entry; rotating without marking any credential exhausted",
self.provider,
)
self._current_id = None
next_entry = self._select_unlocked()
if next_entry is not None and len(self._available_entries()) == 1:
# A single-entry pool cannot rotate. Returning its only
# entry reports a successful recovery without changing
# the credential, so the caller retries the same 401
# indefinitely. Let fallback/error propagation proceed.
if entry is None:
# The failed key is identifiable but matches no entry
# (rotated away, or a wrapper whose runtime key differs).
# Falling through to current()/_select_unlocked() would
# mark an INNOCENT healthy key exhausted for the full
# cooldown TTL. Don't guess — just hand back a fresh
# selection so the caller can retry.
logger.info(
"credential pool: failed key hint matched no %s entry; "
"rotating without marking any credential exhausted",
self.provider,
)
self._current_id = None
return None
return next_entry
return self._select_unlocked()
if entry is None:
entry = self._current_unlocked() or self._select_unlocked()
if entry is None:
@@ -1841,13 +1806,12 @@ class CredentialPool:
# disconnects (a ~2.5min hang with no error surfaced to the user).
# Mark every entry sharing the failed key so the pool can reach the
# "no available entries" state and let the error propagate.
failed_runtime_key = getattr(entry, "runtime_api_key", None)
if identity_supplied and failed_runtime_key:
if api_key_hint:
siblings_marked = False
for sibling in self._entries:
if sibling.id == entry.id:
continue
if sibling.runtime_api_key == failed_runtime_key:
if sibling.runtime_api_key == api_key_hint:
self._mark_exhausted(
sibling, status_code, error_context, persist=False
)
@@ -1921,11 +1885,9 @@ class CredentialPool:
return self._try_refresh_current_unlocked()
def try_refresh_matching(
self,
api_key_hint: Optional[str] = None,
credential_id: Optional[str] = None,
self, api_key_hint: Optional[str] = None
) -> Optional[PooledCredential]:
"""Force-refresh the entry that supplied the failed request.
"""Force-refresh the entry that supplied ``api_key_hint``.
Direct provider integrations may reload the pool after a request has
already failed, so they cannot rely on ``current_id`` identifying the
@@ -1935,29 +1897,17 @@ class CredentialPool:
"""
with self._lock:
entry = None
if credential_id:
if api_key_hint:
entry = next(
(
candidate
for candidate in self._entries
if candidate.id == credential_id
if candidate.runtime_api_key == api_key_hint
),
None,
)
if entry is None:
if api_key_hint:
entry = next(
(
candidate
for candidate in self._entries
if candidate.runtime_api_key == api_key_hint
),
None,
)
else:
entry = self._current_unlocked() or self._select_unlocked(
refresh=False
)
else:
entry = self._current_unlocked() or self._select_unlocked(refresh=False)
if entry is None:
return None
self._current_id = entry.id
+2 -6
View File
@@ -25,8 +25,7 @@ Language resolution order:
3. ``display.language`` from config.yaml
4. ``"en"`` (baseline)
Supported languages: en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga,
pt, ru, hu, ar. Unknown values fall back to en.
Supported languages: en, zh, ja, de, es, fr, tr, uk. Unknown values fall back to en.
"""
from __future__ import annotations
@@ -42,7 +41,7 @@ logger = logging.getLogger(__name__)
SUPPORTED_LANGUAGES: tuple[str, ...] = (
"en", "zh", "zh-hant", "ja", "de", "es", "fr", "tr", "uk",
"af", "ko", "it", "ga", "pt", "ru", "hu", "ar",
"af", "ko", "it", "ga", "pt", "ru", "hu",
)
DEFAULT_LANGUAGE = "en"
@@ -79,9 +78,6 @@ _LANGUAGE_ALIASES: dict[str, str] = {
"russian": "ru", "русский": "ru", "ru-ru": "ru",
# Hungarian
"hungarian": "hu", "magyar": "hu", "hu-hu": "hu",
# Arabic — bare "arabic"/endonym plus the common regional BCP-47 tags.
"arabic": "ar", "العربية": "ar",
"ar-sa": "ar", "ar-eg": "ar", "ar-ae": "ar", "ar-ma": "ar", "ar-dz": "ar",
}
_catalog_cache: dict[str, dict[str, str]] = {}
+2 -2
View File
@@ -267,7 +267,7 @@ def _install_npm(
[npm, "install", "--prefix", str(staging), "--silent", "--no-fund", "--no-audit", *install_targets],
check=False,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
text=True,
timeout=300,
stdin=subprocess.DEVNULL,
creationflags=windows_hide_flags(),
@@ -316,7 +316,7 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]:
[go, "install", pkg],
check=False,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
text=True,
timeout=600,
env=env,
stdin=subprocess.DEVNULL,
-1
View File
@@ -215,7 +215,6 @@ DEFAULT_CONTEXT_LENGTHS = {
# OpenRouter-prefixed models resolve via OpenRouter live API or models.dev.
"claude-fable-5": 1000000,
"claude-fable": 1000000,
"claude-opus-5": 1000000,
"claude-sonnet-5": 1000000,
"claude-opus-4-8": 1000000,
"claude-opus-4.8": 1000000,
-8
View File
@@ -1,8 +0,0 @@
"""Egress proxy integrations.
Currently ships an iron-proxy (ironsh/iron-proxy) wrapper that intercepts
outbound traffic from remote terminal sandboxes and swaps proxy tokens
for real upstream credentials at the network edge.
Design notes live in :mod:`agent.proxy_sources.iron_proxy`.
"""
File diff suppressed because it is too large Load Diff
-1
View File
@@ -102,7 +102,6 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = (
# ``claude-opus-4`` so non-thinking Claude 3.x or future
# non-reasoning Claude variants don't match.
("claude-opus-4", 240),
("claude-opus-5", 240),
("claude-sonnet-5", 180),
("claude-sonnet-4.5", 180),
("claude-sonnet-4.6", 180),
+1 -1
View File
@@ -295,7 +295,7 @@ def run_secret_cli(
list(argv),
env=env,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
text=True,
timeout=timeout,
stdin=subprocess.DEVNULL,
)
+2 -2
View File
@@ -200,7 +200,7 @@ def _platform_asset_name() -> str:
res = subprocess.run(
["ldd", "--version"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
text=True,
timeout=2,
stdin=subprocess.DEVNULL,
)
@@ -684,7 +684,7 @@ def _run_bws_list(
cmd,
env=env,
capture_output=True,
text=True, encoding='utf-8', errors='replace',
text=True,
timeout=_BWS_RUN_TIMEOUT,
stdin=subprocess.DEVNULL,
)
+1 -1
View File
@@ -464,7 +464,7 @@ def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]:
input=stdin_json,
capture_output=True,
timeout=spec.timeout,
text=True, encoding='utf-8', errors='replace',
text=True,
shell=False,
**_popen_kwargs,
)
+1 -1
View File
@@ -74,7 +74,7 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
["bash", "-c", command],
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True, encoding='utf-8', errors='replace',
text=True,
timeout=max(1, int(timeout)),
check=False,
stdin=subprocess.DEVNULL,
+1 -1
View File
@@ -162,7 +162,7 @@ def build_trace_jsonl(
if cwd:
r = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=3, cwd=cwd,
capture_output=True, text=True, timeout=3, cwd=cwd,
)
if r.returncode == 0:
git_branch = r.stdout.strip()
+1 -1
View File
@@ -394,7 +394,7 @@ def check_codex_binary(
proc = subprocess.run(
[codex_bin, "--version"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
)
+1 -14
View File
@@ -30,7 +30,7 @@ Already have the Hermes CLI? Just run:
hermes desktop
```
It builds and launches the GUI against your existing install — same config, keys, sessions, and skills. If Desktop cannot find a usable runtime or saved remote connection, first launch lets you connect to an existing Hermes gateway or install Hermes locally. Local onboarding then walks you through choosing a provider and model.
It builds and launches the GUI against your existing install — same config, keys, sessions, and skills. On first launch Hermes walks you through picking a provider and model; nothing else to configure.
### Prebuilt installers
@@ -134,19 +134,6 @@ Desktop supports a managed local backend, explicit remote gateways, and Hermes
Cloud connections. Remote and cloud modes use the same remote-capability path;
authentication and discovery differ, not the renderer feature model.
When no usable local runtime or saved remote connection exists, the first-run
screen offers **Connect to existing Hermes** before starting the local installer.
Desktop probes the gateway to discover token or OAuth authentication, requires a
successful HTTP and WebSocket connection test, and saves the connection using
the same encrypted Desktop configuration used by Settings. A saved remote
connection bypasses this choice on later launches. The regular Desktop build
still includes the local-install option; this is a remote operating mode, not a
separate client-only application.
In remote mode the gateway host is the execution boundary: agent tools,
terminal commands, and file operations run against the remote Hermes host, not
the computer displaying the Desktop UI.
Projects are the workspace abstraction. A project may own multiple folders,
repositories, worktrees, and sessions; a bare new chat remains detached unless
the user enters a project or configures a default project directory. Use the
@@ -1,7 +1,6 @@
async function applyConnectionChange({
cancelAndWait,
isPrimary,
rehomePrimary = null,
scope,
sendApplied,
stopPool,
@@ -17,12 +16,6 @@ async function applyConnectionChange({
return
}
if (rehomePrimary) {
await rehomePrimary()
return
}
await teardownPrimary()
sendApplied()
}
@@ -1,133 +0,0 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { createFirstRunSetupGate } from './first-run-setup-gate'
const bootstrapBackend = {
activeRoot: '/tmp/hermes-home/hermes-agent',
kind: 'bootstrap-needed',
platform: 'linux'
}
function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function settledState(promise: Promise<unknown>) {
return Promise.race([promise.then(() => 'resolved'), delay(10).then(() => 'pending')])
}
test('first-run setup gate skips non-bootstrap backends', async () => {
const prompts = []
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })
await gate.wait({ kind: 'remote' })
await gate.wait(null)
assert.deepEqual(prompts, [])
assert.equal(gate.hasWaiter(), false)
})
test('first-run setup gate prompts once for concurrent waits', async () => {
const prompts = []
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })
const first = gate.wait(bootstrapBackend)
const second = gate.wait(bootstrapBackend)
assert.equal(gate.hasWaiter(), true)
assert.equal(prompts.length, 1)
assert.equal(await settledState(first), 'pending')
gate.continueLocal()
assert.deepEqual(await Promise.all([first, second]), ['continue-local', 'continue-local'])
assert.equal(gate.hasWaiter(), false)
assert.equal(gate.isLocalBootstrapConfirmed(), true)
})
test('continueLocal keeps the setup choice visible until bootstrap owns the overlay', async () => {
let hidden = 0
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)
gate.continueLocal()
assert.equal(await pending, 'continue-local')
assert.equal(hidden, 0)
assert.equal(gate.isLocalBootstrapConfirmed(), true)
})
test('retry reset preserves the local install confirmation', async () => {
const prompts = []
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)
gate.continueLocal()
await pending
gate.resetForRetry()
await gate.wait(bootstrapBackend)
assert.equal(gate.isLocalBootstrapConfirmed(), true)
assert.equal(prompts.length, 1)
assert.equal(gate.hasWaiter(), false)
})
test('retry reset explicitly settles an active waiter without allowing local bootstrap', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)
gate.resetForRetry()
assert.equal(await pending, 'reset')
assert.equal(gate.hasWaiter(), false)
assert.equal(gate.isLocalBootstrapConfirmed(), false)
})
test('repair reset clears the local install confirmation and shows the gate again', async () => {
const prompts = []
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)
gate.continueLocal()
await pending
gate.resetForRepair()
const next = gate.wait(bootstrapBackend)
assert.equal(gate.isLocalBootstrapConfirmed(), false)
assert.equal(prompts.length, 2)
assert.equal(gate.hasWaiter(), true)
gate.continueLocal()
await next
})
test('remote apply settles the gated boot for remote re-resolution and hides the choice', async () => {
let hidden = 0
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)
const resumedWaiter = gate.abandonForRemoteApply()
assert.equal(resumedWaiter, true)
assert.equal(hidden, 1)
assert.equal(gate.hasWaiter(), false)
assert.equal(gate.isLocalBootstrapConfirmed(), false)
assert.equal(await pending, 'remote-applied')
})
test('remote apply without a waiter has no first-run side effects', async () => {
let hidden = 0
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)
gate.continueLocal()
await pending
assert.equal(gate.abandonForRemoteApply(), false)
assert.equal(hidden, 0)
assert.equal(gate.isLocalBootstrapConfirmed(), true)
})
@@ -1,146 +0,0 @@
interface FirstRunSetupBackend {
activeRoot?: string
kind?: string
platform?: string
}
interface FirstRunSetupGateOptions {
hideChoice?: () => void
log?: (message: string) => void
onStuck?: (backend: FirstRunSetupBackend, stuckAfterMs: number) => void
promptChoice?: (backend: FirstRunSetupBackend) => void
stuckAfterMs?: number
}
export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset'
export function createFirstRunSetupGate({
hideChoice,
log,
onStuck,
promptChoice,
stuckAfterMs = 120000
}: FirstRunSetupGateOptions = {}) {
let localBootstrapConfirmed = false
let waiter: {
promise: Promise<FirstRunSetupDecision>
resolve: (decision: FirstRunSetupDecision) => void
} | null = null
let stuckTimer: ReturnType<typeof setTimeout> | null = null
const clearStuckTimer = () => {
if (stuckTimer) {
clearTimeout(stuckTimer)
stuckTimer = null
}
}
const armStuckTimer = (backend: FirstRunSetupBackend) => {
clearStuckTimer()
if (!Number.isFinite(stuckAfterMs) || stuckAfterMs <= 0 || typeof log !== 'function') {
return
}
stuckTimer = setTimeout(() => {
onStuck?.(backend, stuckAfterMs)
log(
`[bootstrap] still waiting for first-run setup choice after ${Math.round(stuckAfterMs / 1000)}s ` +
`(platform=${backend?.platform || 'unknown'})`
)
}, stuckAfterMs)
if (typeof stuckTimer.unref === 'function') {
stuckTimer.unref()
}
}
const shouldGate = (backend?: FirstRunSetupBackend | null) =>
Boolean(backend && backend.kind === 'bootstrap-needed' && !localBootstrapConfirmed)
const wait = async (backend?: FirstRunSetupBackend | null) => {
if (!shouldGate(backend)) {
return 'continue-local' as const
}
if (waiter) {
return waiter.promise
}
promptChoice?.(backend)
armStuckTimer(backend)
let resolveWaiter: (decision: FirstRunSetupDecision) => void = () => {}
const promise = new Promise<FirstRunSetupDecision>(resolve => {
resolveWaiter = resolve
})
waiter = { promise, resolve: resolveWaiter }
return promise
}
const settleWaiter = (decision: FirstRunSetupDecision) => {
clearStuckTimer()
if (!waiter) {
return false
}
const activeWaiter = waiter
waiter = null
activeWaiter.resolve(decision)
return true
}
const continueLocal = () => {
localBootstrapConfirmed = true
settleWaiter('continue-local')
}
const resetForRetry = () => {
// Reset paths are followed by a renderer reload / fresh startHermes() call.
// Settle the old boot explicitly so it cannot fall through into local
// bootstrap and cannot leak a forever-pending connection promise.
settleWaiter('reset')
}
const resetForRepair = () => {
resetForRetry()
localBootstrapConfirmed = false
}
const abandonForRemoteApply = () => {
// Resume the gated startHermes() with an explicit remote decision. The
// caller re-resolves the newly-persisted remote config instead of falling
// through into local bootstrap or leaking the original connection promise.
const resumedWaiter = settleWaiter('remote-applied')
if (!resumedWaiter) {
return false
}
localBootstrapConfirmed = false
hideChoice?.()
return true
}
const isLocalBootstrapConfirmed = () => localBootstrapConfirmed
const hasWaiter = () => Boolean(waiter)
return {
abandonForRemoteApply,
continueLocal,
hasWaiter,
isLocalBootstrapConfirmed,
resetForRepair,
resetForRetry,
shouldGate,
wait
}
}
@@ -1,119 +0,0 @@
import assert from 'node:assert/strict'
import { test, vi } from 'vitest'
import { applyConnectionChange } from './connection-apply'
import { createFirstRunSetupGate } from './first-run-setup-gate'
import { runPrimaryBackendStartup } from './primary-backend-startup'
import { rehomePrimaryConnection } from './primary-connection-rehome'
test('a first-run bootstrap-needed remote apply connects without ensuring or bootstrapping locally', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const bootstrapBackend = {
activeRoot: '/tmp/hermes-home/hermes-agent',
kind: 'bootstrap-needed',
platform: 'linux'
}
const candidateRemote = {
authMode: 'token',
baseUrl: 'https://gateway.example.com/hermes',
source: 'settings',
token: 'secret',
wsUrl: 'wss://gateway.example.com/hermes/api/ws?token=secret'
}
let savedRemote: typeof candidateRemote | null = null
const resolveRemote = vi.fn(async () => savedRemote)
const connectRemote = vi.fn(async remote => ({ ...remote, mode: 'remote' as const }))
const runBootstrap = vi.fn()
const ensureLocalRuntime = vi.fn(async backend => {
await runBootstrap()
return { ...backend, command: 'hermes' }
})
const teardownPrimaryBackend = vi.fn(async () => {})
const cancelSshBootstrap = vi.fn(async () => {})
const teardownSsh = vi.fn(async () => {})
const clearLocalBootstrapFailure = vi.fn()
const notifyConnectionApplied = vi.fn()
const waitForLocalStart = vi.fn(async () => {})
const prepareLocalBackend = vi.fn(async () => bootstrapBackend)
const pendingConnection = runPrimaryBackendStartup({
connectRemote,
ensureLocalRuntime,
prepareLocalBackend,
resolveRemote,
waitForDecision: gate.wait,
waitForLocalStart
})
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
// Mirrors the IPC handler's production ordering: persist the tested config,
// then re-home. The pending start must re-resolve this saved value.
savedRemote = candidateRemote
await applyConnectionChange({
cancelAndWait: cancelSshBootstrap,
isPrimary: true,
rehomePrimary: () =>
rehomePrimaryConnection({
clearLocalBootstrapFailure,
mode: 'remote',
notifyConnectionApplied,
resumeFirstRunRemote: gate.abandonForRemoteApply,
teardownPrimaryBackend
}),
scope: '',
sendApplied: notifyConnectionApplied,
stopPool: vi.fn(),
teardownPrimary: teardownPrimaryBackend,
teardownSsh
})
assert.deepEqual(await pendingConnection, {
kind: 'remote',
connection: { ...candidateRemote, mode: 'remote' }
})
assert.deepEqual(resolveRemote.mock.calls, [[], []])
assert.deepEqual(connectRemote.mock.calls, [[candidateRemote]])
assert.deepEqual(waitForLocalStart.mock.calls, [[]])
assert.deepEqual(prepareLocalBackend.mock.calls, [[]])
assert.equal(ensureLocalRuntime.mock.calls.length, 0)
assert.equal(runBootstrap.mock.calls.length, 0)
assert.deepEqual(cancelSshBootstrap.mock.calls, [['']])
assert.deepEqual(teardownSsh.mock.calls, [['']])
assert.equal(teardownPrimaryBackend.mock.calls.length, 0)
assert.equal(clearLocalBootstrapFailure.mock.calls.length, 1)
assert.equal(notifyConnectionApplied.mock.calls.length, 0)
})
test('a primary apply without an active first-run gate tears down before reconnect notification', async () => {
const order: string[] = []
const clearLocalBootstrapFailure = vi.fn(() => order.push('clear-failure'))
const teardownPrimaryBackend = vi.fn(async () => {
order.push('teardown')
})
const notifyConnectionApplied = vi.fn(() => order.push('notify'))
assert.deepEqual(
await rehomePrimaryConnection({
clearLocalBootstrapFailure,
mode: 'remote',
notifyConnectionApplied,
resumeFirstRunRemote: () => false,
teardownPrimaryBackend
}),
{ resumedFirstRunRemote: false }
)
assert.deepEqual(teardownPrimaryBackend.mock.calls, [[{ soft: true }]])
assert.deepEqual(order, ['clear-failure', 'teardown', 'notify'])
})
+59 -209
View File
@@ -79,7 +79,6 @@ import {
import { installEmbedReferer } from './embed-referer'
import { createEventDeduper } from './event-dedupe'
import { findGitBash as _findGitBash } from './find-git-bash'
import { createFirstRunSetupGate } from './first-run-setup-gate'
import { readDirForIpc } from './fs-read-dir'
import { probeGatewayWebSocket } from './gateway-ws-probe'
import { scanGitRepos } from './git-repo-scan'
@@ -129,8 +128,6 @@ import {
import { runNativeLogin } from './native-oauth-login'
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
import { createKeepAwake } from './power-save'
import { FirstRunSetupResetError, runPrimaryBackendStartup } from './primary-backend-startup'
import { rehomePrimaryConnection } from './primary-connection-rehome'
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
import * as remoteLifecycle from './remote-lifecycle'
import { RemoteLivenessTracker, RemoteRevalidationCoordinator, revalidateRemoteConnection } from './remote-liveness'
@@ -392,10 +389,8 @@ const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..')
// build hasn't been invoked, or schema mismatch). Callers must handle null.
//
// Schema:
// schema 1: { commit, branch, builtAt, dirty, source }
// schema 2 adds immutable-package provenance: baseVersion, displayVersion,
// distance, and installMethod.
const INSTALL_STAMP_SCHEMA_VERSION = 2
// { schemaVersion: 1, commit, branch, builtAt, dirty, source }
const INSTALL_STAMP_SCHEMA_VERSION = 1
function loadInstallStamp() {
// Try packaged location first (resources/install-stamp.json), then the
@@ -413,7 +408,7 @@ function loadInstallStamp() {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object' && typeof parsed.commit === 'string' && parsed.commit.length >= 7) {
if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== INSTALL_STAMP_SCHEMA_VERSION) {
if (parsed.schemaVersion !== INSTALL_STAMP_SCHEMA_VERSION) {
console.warn(
`[hermes] install-stamp.json schemaVersion ${parsed.schemaVersion} != expected ${INSTALL_STAMP_SCHEMA_VERSION}; ignoring`
)
@@ -425,10 +420,6 @@ function loadInstallStamp() {
schemaVersion: parsed.schemaVersion,
commit: parsed.commit,
branch: parsed.branch || null,
baseVersion: typeof parsed.baseVersion === 'string' ? parsed.baseVersion : null,
displayVersion: typeof parsed.displayVersion === 'string' ? parsed.displayVersion : null,
distance: typeof parsed.distance === 'number' && parsed.distance >= 0 ? parsed.distance : null,
installMethod: typeof parsed.installMethod === 'string' ? parsed.installMethod : null,
builtAt: parsed.builtAt || null,
dirty: Boolean(parsed.dirty),
source: parsed.source || null,
@@ -1408,17 +1399,13 @@ let bootstrapState = {
log: [],
startedAt: null,
completedAt: null,
setupChoice: null,
unsupportedPlatform: null
}
let firstRunSetupGate = null
function broadcastBootstrapEvent(ev) {
if (ev.type === 'manifest') {
bootstrapState.manifest = ev
bootstrapState.active = true
bootstrapState.setupChoice = null
bootstrapState.startedAt = bootstrapState.startedAt || Date.now()
bootstrapState.stages = {}
@@ -1446,30 +1433,14 @@ function broadcastBootstrapEvent(ev) {
} else if (ev.type === 'failed') {
bootstrapState.active = false
bootstrapState.error = ev.error || 'unknown error'
bootstrapState.setupChoice = null
} else if (ev.type === 'unsupported-platform') {
bootstrapState.active = false
bootstrapState.setupChoice = null
bootstrapState.unsupportedPlatform = {
platform: ev.platform,
activeRoot: ev.activeRoot,
installCommand: ev.installCommand,
docsUrl: ev.docsUrl
}
} else if (ev.type === 'setup-choice') {
bootstrapState.active = false
bootstrapState.error = null
bootstrapState.manifest = null
bootstrapState.stages = {}
bootstrapState.setupChoice = ev.active
? {
platform: ev.platform,
activeRoot: ev.activeRoot
}
: null
bootstrapState.unsupportedPlatform = null
} else if (ev.type === 'dismissed') {
resetBootstrapSnapshot()
}
if (!mainWindow || mainWindow.isDestroyed()) {
@@ -1489,100 +1460,6 @@ function getBootstrapState() {
return bootstrapState
}
function resetBootstrapSnapshot() {
bootstrapState = {
active: false,
manifest: null,
stages: {},
error: null,
log: [],
startedAt: null,
completedAt: null,
setupChoice: null,
unsupportedPlatform: null
}
}
function promptFirstRunSetupChoice(backend) {
broadcastBootstrapEvent({
type: 'setup-choice',
active: true,
platform: backend.platform || process.platform,
activeRoot: backend.activeRoot || ACTIVE_HERMES_ROOT
})
}
function hideFirstRunSetupChoice() {
if (bootstrapState.setupChoice) {
broadcastBootstrapEvent({ type: 'setup-choice', active: false })
}
}
function getFirstRunSetupGate() {
if (!firstRunSetupGate) {
firstRunSetupGate = createFirstRunSetupGate({
hideChoice: hideFirstRunSetupChoice,
log: rememberLog,
onStuck: (_backend, stuckAfterMs) => {
updateBootProgress(
{
error: null,
message: `Still waiting for first-run setup choice after ${Math.round(stuckAfterMs / 1000)} seconds`,
phase: 'bootstrap.choice',
progress: 12,
running: true
},
{ allowDecrease: true }
)
},
promptChoice: promptFirstRunSetupChoice
})
}
return firstRunSetupGate
}
async function waitForFirstRunSetupChoice(backend) {
const gate = getFirstRunSetupGate()
if (!gate.shouldGate(backend)) {
return 'continue-local'
}
updateBootProgress(
{
error: null,
message: 'Waiting for first-run setup choice',
phase: 'bootstrap.choice',
progress: 12,
running: true
},
{ allowDecrease: true }
)
return gate.wait(backend)
}
function continueFirstRunLocalBootstrap() {
getFirstRunSetupGate().continueLocal()
}
function abandonFirstRunSetupChoiceForRemoteApply() {
const gate = getFirstRunSetupGate()
if (!gate.hasWaiter()) {
return false
}
const resumedGatedConnection = gate.abandonForRemoteApply()
if (resumedGatedConnection) {
broadcastBootstrapEvent({ type: 'dismissed' })
}
return resumedGatedConnection
}
function updateBootProgress(update, options: { allowDecrease?: boolean } = {}) {
const nextProgressRaw =
typeof update.progress === 'number' ? clampBootProgress(update.progress) : bootProgressState.progress
@@ -7890,7 +7767,14 @@ async function startHermes() {
let attemptedRemote = primaryBackendIsRemote()
const connectionPromise = (async () => {
const connectRemote = async remote => {
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
// Resolve for the desktop's primary profile so a per-profile remote
// override on the active profile is honored (falls back to env / global).
// Re-read once resolved so the classification tracks the value actually used.
attemptedRemote = primaryBackendIsRemote()
const remote = await resolveRemoteBackend(primaryProfileKey())
if (remote) {
await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24)
await waitForHermes(remote.baseUrl, remote.token)
updateBootProgress({
@@ -7916,9 +7800,14 @@ async function startHermes() {
}
}
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
// Resolve for the desktop's primary profile so a per-profile remote
// override on the active profile is honored (falls back to env / global).
// Mutual exclusion with an in-app update (#50238). If this instance was
// relaunched while the Tauri updater is still applying an update, spawning
// a local backend now re-locks the venv shim and gets killed by the
// updater's straggler cleanup — looping. Park until the update finishes (or
// is detected stale), THEN start the backend. Local backends only; remote
// connections returned above and never touch the install tree.
await waitForUpdateToFinish()
const token = crypto.randomBytes(32).toString('base64url')
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
const backendArgs = ['serve', '--host', '127.0.0.1', '--port', '0']
@@ -7933,32 +7822,8 @@ async function startHermes() {
backendArgs.unshift('--profile', activeProfile)
}
const setup = await runPrimaryBackendStartup({
connectRemote,
ensureLocalRuntime: ensureRuntime,
prepareLocalBackend: async () => {
await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28)
return resolveHermesBackend(backendArgs)
},
resolveRemote: () => {
// Classify immediately before each throwing resolve. This callback runs
// both for an already-saved remote and after first-run remote Apply.
attemptedRemote = primaryBackendIsRemote()
return resolveRemoteBackend(primaryProfileKey())
},
waitForDecision: waitForFirstRunSetupChoice,
// Mutual exclusion with an in-app update (#50238). Remote connections
// return before this waiter; local starts park until the updater exits.
waitForLocalStart: waitForUpdateToFinish
})
if (setup.kind === 'remote') {
return setup.connection
}
const backend = setup.backend
await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28)
const backend = await ensureRuntime(resolveHermesBackend(backendArgs))
// Route old runtimes (no `serve`) through the legacy `dashboard --no-open`.
backend.args = getBackendArgsForRuntime(backend)
const hermesCwd = resolveHermesCwd()
@@ -8114,10 +7979,6 @@ async function startHermes() {
throw error
}
if (error instanceof FirstRunSetupResetError) {
throw error
}
const message = error instanceof Error ? error.message : String(error)
// Only latch LOCAL boot failures. A remote failure (lapsed session / mint
@@ -8932,8 +8793,16 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
await teardownPrimaryBackendAndWait()
bootstrapFailure = null
backendStartFailure = null
getFirstRunSetupGate().resetForRetry()
resetBootstrapSnapshot()
bootstrapState = {
active: false,
manifest: null,
stages: {},
error: null,
log: [],
startedAt: null,
completedAt: null,
unsupportedPlatform: null
}
return { ok: true }
})
@@ -8954,17 +8823,10 @@ ipcMain.handle('hermes:bootstrap:repair', async () => {
bootstrapFailure = null
backendStartFailure = null
getFirstRunSetupGate().resetForRepair()
resetHermesConnection()
return { ok: true }
})
ipcMain.handle('hermes:bootstrap:continue-local', async () => {
rememberLog('[bootstrap] local install selected by renderer; continuing first-launch bootstrap')
continueFirstRunLocalBootstrap()
return { ok: true }
})
ipcMain.handle('hermes:bootstrap:cancel', async () => {
// Renderer's Cancel button during first-launch install. Abort the running
// install script (SIGTERM via the runner's abortSignal). runBootstrap
@@ -9143,18 +9005,6 @@ ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
await applyConnectionChange({
cancelAndWait: value => sshBootstrapCoordinator.cancelAndWait(value),
isPrimary: !key || key === primaryProfileKey(),
rehomePrimary: () =>
rehomePrimaryConnection({
clearLocalBootstrapFailure: () => {
// A remote connection bypasses local runtime/bootstrap failures. Clear
// the local-install latch so unsupported/failure escape paths can re-home.
bootstrapFailure = null
},
mode: config.mode,
notifyConnectionApplied: sendConnectionApplied,
resumeFirstRunRemote: abandonFirstRunSetupChoiceForRemoteApply,
teardownPrimaryBackend: teardownPrimaryBackendAndWait
}),
scope,
sendApplied: sendConnectionApplied,
stopPool: stopPoolBackend,
@@ -10365,16 +10215,35 @@ ipcMain.handle('hermes:updates:branch:set', async (_event, name) => {
return { branch }
})
// Resolve the canonical Hermes version from the build stamp rather than the
// Electron app's own package.json version, which historically drifted (stuck
// at 0.0.2). An unstamped legacy build falls back to app.getVersion().
// Resolve the canonical Hermes version (the one `release.py` bumps in
// hermes_cli/__init__.py + pyproject.toml) so the desktop About panel shows the
// real Hermes version instead of the Electron app's own package.json version,
// which historically drifted (stuck at 0.0.2). Falls back to app.getVersion()
// when the source tree can't be read (e.g. a packaged build without the repo).
function resolveHermesVersion() {
return INSTALL_STAMP?.displayVersion ?? INSTALL_STAMP?.baseVersion ?? app.getVersion()
try {
const root = resolveUpdateRoot()
const initPath = path.join(root, 'hermes_cli', '__init__.py')
if (fileExists(initPath)) {
const raw = fs.readFileSync(initPath, 'utf8')
const match = raw.match(/__version__\s*=\s*["']([^"']+)["']/)
if (match) {
return match[1]
}
}
} catch {
// Fall through to the Electron app version below.
}
return app.getVersion()
}
// The stamp is generated alongside the renderer, so the native About panel and
// the renderer report the same build identity. macOS only — `showAboutPanel()`
// is a no-op elsewhere, and the other platforms don't use this menu item.
// Re-resolve the live Hermes version and push it into the native About panel
// just before showing it, so an in-place `hermes update` is reflected without
// an app restart. macOS only — `showAboutPanel()` is a no-op elsewhere, and the
// other platforms don't use this menu item.
function showAboutPanelFresh() {
app.setAboutPanelOptions({
applicationName: APP_NAME,
@@ -10384,27 +10253,8 @@ function showAboutPanelFresh() {
app.showAboutPanel()
}
function resolveHermesVersionInfo() {
if (INSTALL_STAMP) {
return {
appVersion: resolveHermesVersion(),
baseVersion: INSTALL_STAMP.baseVersion ?? undefined,
distance: INSTALL_STAMP.distance ?? undefined,
commit: INSTALL_STAMP.commit,
branch: INSTALL_STAMP.branch ?? undefined,
source: INSTALL_STAMP.source ?? undefined,
installMethod: INSTALL_STAMP.installMethod ?? undefined,
dirty: INSTALL_STAMP.dirty
}
}
const appVersion = app.getVersion()
return { appVersion, baseVersion: appVersion }
}
ipcMain.handle('hermes:version', () => ({
...resolveHermesVersionInfo(),
ipcMain.handle('hermes:version', async () => ({
appVersion: resolveHermesVersion(),
electronVersion: process.versions.electron,
nodeVersion: process.versions.node,
platform: process.platform,
-1
View File
@@ -237,7 +237,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
// current snapshot via getBootstrapState() to recover after a devtools
// reload mid-bootstrap.
getBootstrapState: () => ipcRenderer.invoke('hermes:bootstrap:get'),
continueBootstrapLocal: () => ipcRenderer.invoke('hermes:bootstrap:continue-local'),
resetBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:reset'),
repairBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:repair'),
cancelBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:cancel'),
@@ -1,110 +0,0 @@
import assert from 'node:assert/strict'
import { test, vi } from 'vitest'
import { createFirstRunSetupGate } from './first-run-setup-gate'
import { FirstRunSetupResetError, runPrimaryBackendStartup } from './primary-backend-startup'
const bootstrapBackend = {
activeRoot: '/tmp/hermes-home/hermes-agent',
kind: 'bootstrap-needed',
platform: 'linux'
}
function startupOptions(overrides: Record<string, unknown> = {}) {
return {
connectRemote: vi.fn(async remote => ({ baseUrl: remote.baseUrl, mode: 'remote' as const })),
ensureLocalRuntime: vi.fn(async backend => ({ ...backend, command: 'hermes' })),
prepareLocalBackend: vi.fn(async () => bootstrapBackend),
resolveRemote: vi.fn(async () => null),
waitForDecision: vi.fn(async () => 'continue-local' as const),
waitForLocalStart: vi.fn(async () => {}),
...overrides
}
}
test('remote apply re-resolves the saved connection without ensuring a local runtime', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const savedRemote = { baseUrl: 'https://gateway.example.com/hermes' }
let configuredRemote: typeof savedRemote | null = null
const options = startupOptions({
resolveRemote: vi.fn(async () => configuredRemote),
waitForDecision: gate.wait
})
const pending = runPrimaryBackendStartup(options)
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
configuredRemote = savedRemote
assert.equal(gate.abandonForRemoteApply(), true)
assert.deepEqual(await pending, {
kind: 'remote',
connection: { baseUrl: savedRemote.baseUrl, mode: 'remote' }
})
assert.deepEqual(options.resolveRemote.mock.calls, [[], []])
assert.deepEqual(options.connectRemote.mock.calls, [[savedRemote]])
assert.equal(options.ensureLocalRuntime.mock.calls.length, 0)
})
test('an already-saved remote bypasses every local startup step', async () => {
const savedRemote = { baseUrl: 'https://gateway.example.com/hermes' }
const options = startupOptions({ resolveRemote: vi.fn(async () => savedRemote) })
assert.deepEqual(await runPrimaryBackendStartup(options), {
kind: 'remote',
connection: { baseUrl: savedRemote.baseUrl, mode: 'remote' }
})
assert.equal(options.waitForLocalStart.mock.calls.length, 0)
assert.equal(options.prepareLocalBackend.mock.calls.length, 0)
assert.equal(options.waitForDecision.mock.calls.length, 0)
assert.equal(options.ensureLocalRuntime.mock.calls.length, 0)
})
test('remote apply fails clearly when no saved remote can be resolved', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const options = startupOptions({ waitForDecision: gate.wait })
const pending = runPrimaryBackendStartup(options)
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
gate.abandonForRemoteApply()
await assert.rejects(pending, /without a saved remote backend/)
assert.equal(options.connectRemote.mock.calls.length, 0)
assert.equal(options.ensureLocalRuntime.mock.calls.length, 0)
})
test('continue local waits for update exclusion and ensures the prepared runtime exactly once', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const runtimeBackend = { ...bootstrapBackend, command: 'hermes' }
const options = startupOptions({
ensureLocalRuntime: vi.fn(async () => runtimeBackend),
waitForDecision: gate.wait
})
const pending = runPrimaryBackendStartup(options)
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
gate.continueLocal()
assert.deepEqual(await pending, { kind: 'local', backend: runtimeBackend })
assert.deepEqual(options.waitForLocalStart.mock.calls, [[]])
assert.deepEqual(options.prepareLocalBackend.mock.calls, [[]])
assert.deepEqual(options.ensureLocalRuntime.mock.calls, [[bootstrapBackend]])
assert.deepEqual(options.resolveRemote.mock.calls, [[]])
})
test('reset rejects with a typed error and never enters either backend', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const options = startupOptions({ waitForDecision: gate.wait })
const pending = runPrimaryBackendStartup(options)
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
gate.resetForRetry()
await assert.rejects(pending, error => error instanceof FirstRunSetupResetError && error.firstRunSetupReset)
assert.equal(options.connectRemote.mock.calls.length, 0)
assert.equal(options.ensureLocalRuntime.mock.calls.length, 0)
})
@@ -1,66 +0,0 @@
import type { FirstRunSetupDecision } from './first-run-setup-gate'
export interface PrimaryBackendStartupOptions<Backend, RuntimeBackend, Remote, Connection> {
connectRemote: (remote: Remote) => Promise<Connection>
ensureLocalRuntime: (backend: Backend) => Promise<RuntimeBackend>
prepareLocalBackend: () => Backend | Promise<Backend>
resolveRemote: () => Promise<Remote | null>
waitForDecision: (backend: Backend) => Promise<FirstRunSetupDecision>
waitForLocalStart: () => Promise<unknown>
}
export type PrimaryBackendStartupResult<RuntimeBackend, Connection> =
| { kind: 'local'; backend: RuntimeBackend }
| { kind: 'remote'; connection: Connection }
export class FirstRunSetupResetError extends Error {
readonly firstRunSetupReset = true
constructor() {
super('First-run setup was reset before a choice completed.')
this.name = 'FirstRunSetupResetError'
}
}
// Owns the production startHermes path up to the local process spawn. Keeping
// the full ordering here makes the first-run remote boundary executable in a
// test: an already-saved remote wins immediately; otherwise update exclusion
// and local backend resolution happen before the setup gate, and a remote Apply
// re-resolves persisted config without ever entering ensureRuntime/bootstrap.
export async function runPrimaryBackendStartup<Backend, RuntimeBackend, Remote, Connection>({
connectRemote,
ensureLocalRuntime,
prepareLocalBackend,
resolveRemote,
waitForDecision,
waitForLocalStart
}: PrimaryBackendStartupOptions<Backend, RuntimeBackend, Remote, Connection>): Promise<
PrimaryBackendStartupResult<RuntimeBackend, Connection>
> {
const savedRemote = await resolveRemote()
if (savedRemote) {
return { kind: 'remote', connection: await connectRemote(savedRemote) }
}
await waitForLocalStart()
const backend = await prepareLocalBackend()
const decision = await waitForDecision(backend)
if (decision === 'remote-applied') {
const appliedRemote = await resolveRemote()
if (!appliedRemote) {
throw new Error('First-run remote setup completed without a saved remote backend.')
}
return { kind: 'remote', connection: await connectRemote(appliedRemote) }
}
if (decision === 'reset') {
throw new FirstRunSetupResetError()
}
return { kind: 'local', backend: await ensureLocalRuntime(backend) }
}
@@ -1,35 +0,0 @@
export interface PrimaryConnectionRehomeOptions {
clearLocalBootstrapFailure: () => void
mode: string
notifyConnectionApplied: () => void
resumeFirstRunRemote: () => boolean
teardownPrimaryBackend: (options: { soft: boolean }) => Promise<void>
}
// Production seam shared by the connection-config IPC handler and the
// first-run integration test. A remote apply that resumes the active setup
// gate must keep that connection attempt alive; ordinary mode changes tear the
// current backend down before the renderer is told to reconnect.
export async function rehomePrimaryConnection({
clearLocalBootstrapFailure,
mode,
notifyConnectionApplied,
resumeFirstRunRemote,
teardownPrimaryBackend
}: PrimaryConnectionRehomeOptions): Promise<{ resumedFirstRunRemote: boolean }> {
let resumedFirstRunRemote = false
if (mode === 'remote') {
resumedFirstRunRemote = resumeFirstRunRemote()
clearLocalBootstrapFailure()
}
if (resumedFirstRunRemote) {
return { resumedFirstRunRemote: true }
}
await teardownPrimaryBackend({ soft: true })
notifyConnectionApplied()
return { resumedFirstRunRemote: false }
}
+2 -2
View File
@@ -24,7 +24,7 @@
"profile:main:cpu": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
"prebuild": "npm run clean",
"build": "node scripts/assert-root-install.mjs && python3 ../../scripts/write_install_stamp.py --output build/install-stamp.json && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs",
"build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs",
"postbuild": "node scripts/assert-dist-built.mjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.mjs",
@@ -111,7 +111,7 @@
"motion": "^12.38.0",
"nanostores": "^1.3.0",
"node-pty": "1.1.0",
"radix-ui": "^1.6.5",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react-arborist": "^3.5.0",
"react-dnd-html5-backend": "^14.0.3",
+6 -76
View File
@@ -6,15 +6,12 @@
*
* Schema (subject to bump via STAMP_SCHEMA_VERSION):
* {
* "schemaVersion": 2,
* "schemaVersion": 1,
* "commit": "<40-char SHA>",
* "branch": "<branch name>",
* "builtAt": "<ISO 8601 UTC timestamp>",
* "dirty": true|false,
* "source": "ci" | "local" | "fallback",
* "baseVersion": "<SemVer release version, e.g. 0.19.0>" | null,
* "displayVersion":"<baseVersion, or baseVersion+distance>" | null,
* "distance": <commits since baseVersion's release tag> | null
* "source": "ci" | "local" | "fallback"
* }
*
* Source preference order:
@@ -29,17 +26,13 @@
* commit as unpinned and follows the branch instead of fetching a fake SHA.
*/
import { mkdirSync, readFileSync, writeFileSync } from "fs"
import { mkdirSync, writeFileSync } from "fs"
import { resolve, join, relative } from "path"
import { execSync } from "child_process"
import { isMain } from "./utils.mjs"
const STAMP_SCHEMA_VERSION = 2
// Hermes's historical tags use a four-digit calendar year as their major
// component (for example v2026.7.20). Restrict release majors to three digits
// so these date tags cannot masquerade as the v0.x.y SemVer boundaries.
const SEMVER_TAG = /^v(0|[1-9]\d{0,2})\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
const STAMP_SCHEMA_VERSION = 1
/** All-zero placeholder used when no real commit can be resolved. */
export const FALLBACK_COMMIT = "0000000000000000000000000000000000000000"
@@ -121,68 +114,8 @@ export function isFallbackCommit(commit) {
return typeof commit === "string" && /^0{7,40}$/.test(commit)
}
function parseReleaseMetadata(repoRoot, readFile = readFileSync) {
try {
const init = readFile(join(repoRoot, 'hermes_cli', '__init__.py'), 'utf8')
const baseVersion = init.match(/__version__\s*=\s*["']([^"']+)["']/)?.[1]
const releaseDate = init.match(/__release_date__\s*=\s*["']([^"']+)["']/)?.[1]
return { baseVersion: baseVersion || null, releaseDate: releaseDate || null }
} catch {
return { baseVersion: null, releaseDate: null }
}
}
function splitLines(value) {
return value ? value.split(/\r?\n/).map(line => line.trim()).filter(Boolean) : []
}
/**
* Add user-facing release topology to a build stamp. This runs while Git is
* available to the build, so Electron never needs to inspect a live checkout.
*/
export function deriveVersionMetadata(stamp, {
repoRoot = REPO_ROOT,
execFn = tryExec,
readFile = readFileSync
} = {}) {
const { baseVersion, releaseDate } = parseReleaseMetadata(repoRoot, readFile)
if (!baseVersion) {
return stamp
}
const mergedTags = splitLines(execFn('git tag --merged HEAD --format=%(refname:short)', { cwd: repoRoot }))
const candidates = mergedTags.filter(tag => SEMVER_TAG.test(tag))
// Hermes has historical CalVer tags. Keep the release-date tag as a
// temporary fallback until the first v0.x.y tag exists; never parse a
// broad v[0-9]* match as SemVer.
if (releaseDate) {
candidates.push(`v${releaseDate}`)
}
let distance = null
let releaseTag = null
for (const tag of candidates) {
const raw = execFn(`git rev-list --count ${tag}..HEAD`, { cwd: repoRoot })
const value = raw === null ? Number.NaN : Number(raw)
if (Number.isInteger(value) && value >= 0 && (distance === null || value < distance)) {
distance = value
releaseTag = tag
}
}
const semver = releaseTag?.match(SEMVER_TAG)
const taggedVersion = semver ? `${semver[1]}.${semver[2]}.${semver[3]}` : baseVersion
const displayVersion =
distance !== null && distance > 0 ? `${taggedVersion}+${distance}`
: stamp.dirty && distance === null ? `${taggedVersion}+?`
: taggedVersion
return { ...stamp, baseVersion: taggedVersion, displayVersion, distance }
}
function main() {
const stamp = deriveVersionMetadata(resolveStamp())
const stamp = resolveStamp()
if (!stamp || !stamp.commit) {
// Should not happen — fromFallback() always provides a commit.
console.error(
@@ -222,10 +155,7 @@ function main() {
branch: stamp.branch,
builtAt: new Date().toISOString(),
dirty: stamp.dirty,
source: stamp.source,
baseVersion: stamp.baseVersion ?? null,
displayVersion: stamp.displayVersion ?? null,
distance: stamp.distance ?? null
source: stamp.source
}
mkdirSync(OUT_DIR, { recursive: true })
@@ -5,7 +5,6 @@ import {
FALLBACK_BRANCH,
FALLBACK_COMMIT,
fromCI,
deriveVersionMetadata,
fromFallback,
fromLocalGit,
isFallbackCommit,
@@ -85,70 +84,3 @@ test('resolveStamp falls back when neither CI nor git is available', () => {
source: 'fallback'
})
})
test('deriveVersionMetadata prefers a strict SemVer tag over historical CalVer tags', () => {
const stamp = deriveVersionMetadata(
{ commit: 'a'.repeat(40), branch: 'feature', dirty: true, source: 'local' },
{
readFile: () => '__version__ = "0.20.0"\n__release_date__ = "2026.7.20"\n',
execFn: command => {
if (command.startsWith('git tag --merged')) return 'v2026.7.20\nv0.19.0\n'
if (command === 'git rev-list --count v0.19.0..HEAD') return '7'
if (command === 'git rev-list --count v2026.7.20..HEAD') return '20'
return null
}
}
)
assert.deepEqual(stamp, {
commit: 'a'.repeat(40), branch: 'feature', dirty: true, source: 'local',
baseVersion: '0.19.0', displayVersion: '0.19.0+7', distance: 7
})
})
test('deriveVersionMetadata uses the historical release tag only as a transition fallback', () => {
const stamp = deriveVersionMetadata(
{ commit: 'a'.repeat(40), branch: 'feature', dirty: false, source: 'local' },
{
readFile: () => '__version__ = "0.19.0"\n__release_date__ = "2026.7.20"\n',
execFn: command => command === 'git rev-list --count v2026.7.20..HEAD' ? '3' : ''
}
)
assert.equal(stamp.baseVersion, '0.19.0')
assert.equal(stamp.displayVersion, '0.19.0+3')
assert.equal(stamp.distance, 3)
})
test('deriveVersionMetadata accepts three-digit SemVer majors but rejects four-digit CalVer years', () => {
const stamp = deriveVersionMetadata(
{ commit: 'a'.repeat(40), branch: 'feature', dirty: false, source: 'local' },
{
readFile: () => '__version__ = "999.1.2"\n__release_date__ = "2026.7.20"\n',
execFn: command => {
if (command.startsWith('git tag --merged')) return 'v2026.7.20\nv999.1.2\n'
if (command === 'git rev-list --count v999.1.2..HEAD') return '4'
if (command === 'git rev-list --count v2026.7.20..HEAD') return '8'
return null
}
}
)
assert.equal(stamp.baseVersion, '999.1.2')
assert.equal(stamp.displayVersion, '999.1.2+4')
assert.equal(stamp.distance, 4)
})
test('deriveVersionMetadata shows +? for dirty builds with unknown distance', () => {
const stamp = deriveVersionMetadata(
{ commit: 'a'.repeat(40), branch: 'feature', dirty: true, source: 'local' },
{
readFile: () => '__version__ = "0.19.0"\n__release_date__ = "2026.7.20"\n',
execFn: () => null
}
)
assert.equal(stamp.baseVersion, '0.19.0')
assert.equal(stamp.displayVersion, '0.19.0+?')
assert.equal(stamp.distance, null)
})
@@ -38,8 +38,7 @@ import {
planEdit,
planReload,
planRestore,
runRewindSubmit,
truncateSubmitParams
runRewindSubmit
} from '../session/hooks/use-prompt-actions/rewind'
import { useSubmitPrompt } from '../session/hooks/use-prompt-actions/submit'
import { type SubmitTextOptions } from '../session/hooks/use-prompt-actions/utils'
@@ -275,11 +274,7 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
try {
await requestGateway(
'prompt.submit',
{
session_id: runtimeIdRef.current,
text: plan.text,
...truncateSubmitParams(plan.truncateOrdinal)
},
{ session_id: runtimeIdRef.current, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal },
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)
} catch (err) {
@@ -43,22 +43,6 @@ export function SidebarRowNest({ className, ...props }: React.ComponentProps<'di
return <SidebarRowStack className={cn('pb-1 pl-4', className)} {...props} />
}
/**
* Chronological date-bucket separator ("Yesterday" / "Last week" / "June") for
* the session list. One flat row — a small caption plus a hairline rule — so it
* groups sessions by recency without adding a level of indentation.
*/
export function SidebarDateDivider({ className, label, ...props }: React.ComponentProps<'div'> & { label: string }) {
return (
<div className={cn('flex select-none items-center gap-2 px-2 pb-0.5 pt-2', className)} {...props}>
<span className="shrink-0 text-[0.64rem] font-semibold uppercase tracking-[0.12em] text-(--ui-text-quaternary)">
{label}
</span>
<span aria-hidden="true" className="h-px flex-1 bg-(--ui-stroke-tertiary)" />
</div>
)
}
/** Outer grid — sole owner of row height. */
export function SidebarRowShell({
actions,
+1 -2
View File
@@ -1239,7 +1239,7 @@ export function ChatSidebar({
{!trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName={cn('flex max-h-[50vh] flex-col gap-px rounded-lg pb-2 pt-1', GROUP_BODY)}
contentClassName={cn('flex max-h-44 flex-col gap-px rounded-lg pb-2 pt-1', GROUP_BODY)}
dndSensors={dndSensors}
emptyState={<SidebarPinnedEmptyState />}
label={s.pinned}
@@ -1275,7 +1275,6 @@ export function ChatSidebar({
// virtualized long list, which must keep its own scroller.
!recentsVirtualizes && COMPACT_FLAT
)}
dateGrouped={inProject || !agentOrderManual}
dndSensors={dndSensors}
emptyState={
showSessionSkeletons ? (
@@ -7,14 +7,11 @@ import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar'
import type { HermesGitWorktree } from '@/global'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { flattenSessionsWithBranches } from '@/lib/session-branch-tree'
import { groupEntriesByRecency, type SidebarListRow, toSessionRows } from '@/lib/session-date-groups'
import { sessionBucketLabel } from '@/lib/time'
import { cn } from '@/lib/utils'
import { sessionPinId } from '@/store/session'
import { SidebarCount, SidebarDateDivider } from './chrome'
import { SidebarCount } from './chrome'
import {
EnteredProjectContent,
ProjectOverviewRow,
@@ -142,11 +139,6 @@ interface SidebarSessionsSectionProps {
// lists (Pinned / search results) in the All-profiles view, where no group
// header communicates ownership (#66003).
showProfileTags?: boolean
// Insert "Yesterday" / "Last week" date dividers into the chronological
// session list (flat recents + entered-project lanes). Off for hand-ordered
// lists, pinned, messaging groups, and the project overview, where the order
// isn't strictly by recency so a date bucket would be misleading.
dateGrouped?: boolean
}
export function SidebarSessionsSection({
@@ -187,11 +179,8 @@ export function SidebarSessionsSection({
onReorderProjects,
projectBackRow,
dndSensors,
showProfileTags = false,
dateGrouped = false
showProfileTags = false
}: SidebarSessionsSectionProps) {
const { t } = useI18n()
const dividerLabels = t.sidebar.dateDivider
const sectionOpen = collapsible ? open : true
const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0))
// A defined project list is itself content (even an empty project should
@@ -230,30 +219,10 @@ export function SidebarSessionsSection({
)
}
// A single flat/virtual/lane list row — either a date divider or a session.
const renderListRow = (row: SidebarListRow, draggable: boolean) =>
row.kind === 'divider' ? (
<SidebarDateDivider key={row.key} label={sessionBucketLabel(row.bucket, dividerLabels)} />
) : (
renderRow(row.entry.session, draggable, row.entry.branchStem)
)
// Sessions inside repos/worktrees are date-ordered and static.
const renderRows = (items: SessionInfo[]) =>
flattenSessionsWithBranches(items).map(({ branchStem, session }) => renderRow(session, false, branchStem))
// Same as `renderRows`, but with date dividers folded in — used for
// entered-project lanes so a lane spanning multiple days reads
// chronologically, matching the flat recents list.
const renderRowsDated = (items: SessionInfo[]) => {
const entries = flattenSessionsWithBranches(items)
return (dateGrouped ? groupEntriesByRecency(entries) : toSessionRows(entries)).map(row => renderListRow(row, false))
}
// Flat recents as list rows: grouped by recency when enabled, plain otherwise.
const flatRows: SidebarListRow[] = dateGrouped ? groupEntriesByRecency(displayEntries) : toSessionRows(displayEntries)
const flatVirtualized =
!showEmptyState &&
!groups?.length &&
@@ -285,7 +254,7 @@ export function SidebarSessionsSection({
onNewSession={onNewSessionInWorkspace}
project={projectContent}
removedSessionIds={removedSessionIds}
renderRows={renderRowsDated}
renderRows={renderRows}
repoWorktrees={projectRepoWorktrees}
/>
) : (
@@ -341,13 +310,13 @@ export function SidebarSessionsSection({
<VirtualSessionList
activeSessionId={activeSessionId}
className={contentClassName}
entries={displayEntries}
onArchiveSession={onArchiveSession}
onBranchSession={onBranchSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onTogglePin={onTogglePin}
pinned={pinned}
rows={flatRows}
showProfileTags={showProfileTags}
sortable={sessionsDraggable}
workingSessionIdSet={workingSessionIdSet}
@@ -365,11 +334,11 @@ export function SidebarSessionsSection({
} else if (sessionsDraggable && onReorderSessions) {
inner = (
<ReorderableList ids={sessions.map(s => s.id)} onReorder={onReorderSessions} sensors={dndSensors}>
{flatRows.map(row => renderListRow(row, true))}
{displayEntries.map(({ branchStem, session }) => renderRow(session, true, branchStem))}
</ReorderableList>
)
} else {
inner = flatRows.map(row => renderListRow(row, false))
inner = displayEntries.map(({ branchStem, session }) => renderRow(session, false, branchStem))
}
// The virtualizer owns its own scroller, so suppress the wrapper's overflow
@@ -4,13 +4,10 @@ import { useVirtualizer } from '@tanstack/react-virtual'
import { type FC, useCallback, useRef } from 'react'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { type SidebarListRow } from '@/lib/session-date-groups'
import { sessionBucketLabel } from '@/lib/time'
import { type SidebarSessionEntry } from '@/lib/session-branch-tree'
import { cn } from '@/lib/utils'
import { sessionPinId } from '@/store/session'
import { SidebarDateDivider } from './chrome'
import { SidebarSessionRow } from './session-row'
interface SessionRowCommonProps {
@@ -30,7 +27,7 @@ interface SessionRowCommonProps {
interface VirtualSessionListProps {
activeSessionId: null | string
className?: string
rows: SidebarListRow[]
entries: SidebarSessionEntry[]
onArchiveSession: (sessionId: string) => void
onBranchSession?: (sessionId: string, profile?: string) => void
onDeleteSession: (sessionId: string) => void
@@ -48,7 +45,7 @@ const OVERSCAN_ROWS = 12
export const VirtualSessionList: FC<VirtualSessionListProps> = ({
activeSessionId,
className,
rows: listRows,
entries,
onArchiveSession,
onBranchSession,
onDeleteSession,
@@ -59,18 +56,12 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
sortable,
workingSessionIdSet
}) => {
const { t } = useI18n()
const dividerLabels = t.sidebar.dateDivider
const scrollerRef = useRef<HTMLDivElement | null>(null)
const virtualizer = useVirtualizer({
count: listRows.length,
count: entries.length,
estimateSize: () => ROW_ESTIMATE_PX,
getItemKey: index => {
const row = listRows[index]
return row ? (row.kind === 'divider' ? row.key : row.entry.session.id) : index
},
getItemKey: index => entries[index]?.session.id ?? index,
getScrollElement: () => scrollerRef.current,
// jsdom-friendly default; the real rect takes over on first observe.
initialRect: { height: 600, width: 240 },
@@ -83,25 +74,13 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
const paddingBottom = Math.max(0, totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0))
const rows = virtualItems.map(virtualItem => {
const row = listRows[virtualItem.index]
const entry = entries[virtualItem.index]
if (!row) {
if (!entry) {
return null
}
// Dividers are non-sortable, self-measured rows interleaved with sessions.
if (row.kind === 'divider') {
return (
<SidebarDateDivider
data-index={virtualItem.index}
key={row.key}
label={sessionBucketLabel(row.bucket, dividerLabels)}
ref={virtualizer.measureElement}
/>
)
}
const { branchStem, session } = row.entry
const { branchStem, session } = entry
const reorderable = sortable && !branchStem
const commonProps: SessionRowCommonProps = {
@@ -245,7 +245,7 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{
},
{
icon: KeyRound,
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens', 'egress', 'iron proxy', 'sandbox proxy'],
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'],
labelKey: 'providerApiKeys',
tab: 'providers&pview=keys'
},
@@ -258,7 +258,7 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{
},
{
icon: Settings2,
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env', 'egress proxy', 'iron proxy'],
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env'],
labelKey: 'keysSettings',
tab: 'keys&kview=settings'
},
@@ -54,7 +54,6 @@ import {
import { $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview'
import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review'
import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session'
import { watchSessionPins } from '@/store/session-pin-sync'
import type { SessionDragPayload } from '../chat/composer/inline-refs'
import { watchRouteTiles } from '../chat/route-tile'
@@ -398,10 +397,6 @@ watchContributedPanes()
watchSessionTiles()
watchRouteTiles()
// Mirror sidebar pins into the backend keep-flag so the auto-archive sweep
// never hides a pinned chat (and pre-existing pins migrate transparently).
watchSessionPins()
// The main tab reads as its SESSION (the loaded title, "New session" on a
// fresh draft) — a stack of main + tiles is then just a row of session names.
// register() replaces same-id in place; the render fn is the shared constant
@@ -189,7 +189,6 @@ export const ChatRoutesSurface = memo(function ChatRoutesSurface({
<Route element={null} path="profiles" />
<Route element={null} path="settings" />
<Route element={null} path="starmap" />
<Route element={null} path="webhooks" />
{/* Registry-contributed pages (core features + plugins) render in the
workspace pane like any built-in view — behind the same blast wall
as every other contribution mount. */}
+1 -9
View File
@@ -111,7 +111,6 @@ import type { WiringActions, WiringApi } from './types'
const AgentsView = lazy(async () => ({ default: (await import('../agents')).AgentsView }))
const CommandCenterView = lazy(async () => ({ default: (await import('../command-center')).CommandCenterView }))
const CronView = lazy(async () => ({ default: (await import('../cron')).CronView }))
const WebhooksView = lazy(async () => ({ default: (await import('../webhooks')).WebhooksView }))
const ProfilesView = lazy(async () => ({ default: (await import('../profiles')).ProfilesView }))
const SettingsView = lazy(async () => ({ default: (await import('../settings')).SettingsView }))
const StarmapView = lazy(async () => ({ default: (await import('../starmap')).StarmapView }))
@@ -198,8 +197,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
resetOverlayReturnRoute,
settingsOpen,
starmapOpen,
toggleCommandCenter,
webhooksOpen
toggleCommandCenter
} = useOverlayRouting()
const {
@@ -1008,12 +1006,6 @@ export function ContribWiring({ children }: { children: ReactNode }) {
</Suspense>
)}
{webhooksOpen && (
<Suspense fallback={null}>
<WebhooksView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{profilesOpen && (
<Suspense fallback={null}>
<ProfilesView onClose={closeOverlayToPreviousRoute} />
@@ -1,105 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { AutomationBlueprint } from '@/hermes'
import { initialBlueprintValues } from './blueprints'
function blueprint(fields: AutomationBlueprint['fields']): AutomationBlueprint {
return {
key: 'test',
title: 'Test',
description: '',
category: 'general',
tags: [],
command: '',
appUrl: '',
fields
}
}
describe('initialBlueprintValues', () => {
it('seeds each field from its default', () => {
const values = initialBlueprintValues(
blueprint([
{ name: 'time', type: 'time', label: 'Time', default: '08:00', options: [], optional: false, help: '' },
{
name: 'topic',
type: 'enum',
label: 'Topic',
default: 'news',
options: ['news', 'sports'],
optional: false,
help: ''
}
])
)
expect(values).toEqual({ time: '08:00', topic: 'news' })
})
it('falls back to an empty string when a field has no default', () => {
const values = initialBlueprintValues(
blueprint([{ name: 'topic', type: 'text', label: 'Topic', default: null, options: [], optional: true, help: '' }])
)
expect(values).toEqual({ topic: '' })
})
it('returns an empty object for a blueprint with no fields', () => {
expect(initialBlueprintValues(blueprint([]))).toEqual({})
})
it("seeds the deliver slot to 'local' when its default is the dashboard-only 'origin'", () => {
const values = initialBlueprintValues(
blueprint([
{
name: 'deliver',
type: 'enum',
label: 'Deliver',
default: 'origin',
options: ['origin', 'local', 'telegram'],
optional: false,
help: ''
}
])
)
expect(values).toEqual({ deliver: 'local' })
})
it("seeds the deliver slot to 'local' when it has no default", () => {
const values = initialBlueprintValues(
blueprint([
{
name: 'deliver',
type: 'enum',
label: 'Deliver',
default: null,
options: ['origin', 'local'],
optional: false,
help: ''
}
])
)
expect(values).toEqual({ deliver: 'local' })
})
it('leaves a non-origin deliver default untouched', () => {
const values = initialBlueprintValues(
blueprint([
{
name: 'deliver',
type: 'enum',
label: 'Deliver',
default: 'telegram',
options: ['origin', 'local', 'telegram'],
optional: false,
help: ''
}
])
)
expect(values).toEqual({ deliver: 'telegram' })
})
})
-164
View File
@@ -1,164 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { getAutomationBlueprints } from '@/hermes'
import type { AutomationBlueprint, AutomationBlueprintField } from '@/hermes'
import { useI18n } from '@/i18n'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
import { PanelDetail, PanelEmpty, PanelPill } from '../overlays/panel'
// The blueprint catalog is shared with the dashboard, so its deliver slot
// defaults to "origin" (the chat/home-channel a dashboard or gateway job was
// created from). Desktop has no origin chat, so seed the deliver slot to the
// desktop's native target ("local" = This desktop) instead. The dialog then
// renders that slot with the shared DeliverSelect (backend-sourced targets), so
// the raw "origin" option never reaches the desktop UI.
const DELIVER_FIELD = 'deliver'
const DESKTOP_DELIVER_DEFAULT = 'local'
function isDeliverField(field: AutomationBlueprintField): boolean {
return field.name === DELIVER_FIELD
}
// Initial form state for a blueprint = each field's default (or ''). Pure so the
// suite can assert the form seeds correctly without mounting React. The deliver
// slot is special-cased: an "origin" default (or empty) becomes "local" so a
// desktop-created job delivers to This desktop instead of nowhere.
export function initialBlueprintValues(blueprint: AutomationBlueprint): Record<string, string> {
const out: Record<string, string> = {}
for (const field of blueprint.fields) {
const seeded = field.default ?? ''
out[field.name] = isDeliverField(field) && (seeded === '' || seeded === 'origin') ? DESKTOP_DELIVER_DEFAULT : seeded
}
return out
}
// A slot-level validation error from the backend arrives as "422: <message>"
// (or "<code>: <message>"); strip the leading numeric code for inline display.
export function cleanBlueprintFieldError(message: string): string {
return message.replace(/^\d+:\s*/, '')
}
// Help text to show under a slot control. The backend deliver help is
// origin/dashboard-centric and even contradicts desktop semantics ("local =
// save only" vs. This desktop), and the DeliverSelect is self-explanatory —
// skip it for the deliver slot.
export function blueprintSlotHelp(field: AutomationBlueprintField): string | undefined {
return field.help && field.type !== 'text' && !isDeliverField(field) ? field.help : undefined
}
// Renders one blueprint slot's control (enum/weekdays → Select, time → time
// input, else text). The deliver slot is handled separately by the dialog's
// shared DeliverSelect, so it's not rendered here.
export function BlueprintSlotControl({
field,
id,
onChange,
value
}: {
field: AutomationBlueprintField
id: string
onChange: (next: string) => void
value: string
}) {
if (field.type === 'enum' || field.type === 'weekdays') {
return (
<Select onValueChange={onChange} value={value}>
<SelectTrigger className="h-9 rounded-md" id={id}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{field.options.map(option => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
if (field.type === 'time') {
return <Input id={id} onChange={event => onChange(event.target.value)} type="time" value={value} />
}
return (
<Input
id={id}
onChange={event => onChange(event.target.value)}
placeholder={field.help || field.label}
type="text"
value={value}
/>
)
}
// A clickable blueprint card — mirrors the app's other selectable cards
// (theme/pet/gateway/profile pickers) via selectableCardClass. Clicking opens
// the shared cron editor dialog pre-filled with this blueprint's slots; there's
// no inline expand form or divider.
function BlueprintCard({ blueprint, onSetUp }: { blueprint: AutomationBlueprint; onSetUp: () => void }) {
return (
<button
className={cn(selectableCardClass({ prominent: true }), 'w-full p-2 text-left')}
onClick={onSetUp}
type="button"
>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{blueprint.title}</p>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">{blueprint.description}</p>
{blueprint.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{blueprint.tags.map(tag => (
<PanelPill key={tag}>{tag}</PanelPill>
))}
</div>
)}
</div>
</button>
)
}
// Automation Blueprints gallery — the desktop counterpart to the dashboard's
// blueprint tab. Each card opens the shared cron editor dialog pre-filled with
// the blueprint's typed slots; submitting POSTs to
// /api/cron/blueprints/instantiate, which fills the blueprint and creates the
// job via the same create_job path as a hand-written cron.
export function BlueprintsPanel({ onSetUp }: { onSetUp: (blueprint: AutomationBlueprint) => void }) {
const { t } = useI18n()
const c = t.cron
const blueprints = useQuery({
queryKey: ['cron-blueprints'],
queryFn: async () => (await getAutomationBlueprints()).blueprints
})
const cards = useMemo(() => blueprints.data ?? [], [blueprints.data])
if (blueprints.isLoading) {
return <PageLoader label={c.blueprints.loading} />
}
if (blueprints.isError) {
return <PanelEmpty description={c.blueprints.failedLoad} icon="warning" title={c.blueprints.failedLoad} />
}
if (cards.length === 0) {
return <PanelEmpty description={c.blueprints.emptyDesc} icon="lightbulb" title={c.blueprints.emptyTitle} />
}
return (
<PanelDetail>
{cards.map(blueprint => (
<BlueprintCard blueprint={blueprint} key={blueprint.key} onSetUp={() => onSetUp(blueprint)} />
))}
</PanelDetail>
)
}
+179 -380
View File
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Codicon, codiconIcon } from '@/components/ui/codicon'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
DialogContent,
@@ -15,7 +15,6 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { SegmentedControl } from '@/components/ui/segmented-control'
import {
Select,
SelectContent,
@@ -27,15 +26,11 @@ import {
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
type AutomationBlueprint,
createCronJob,
type CronDeliveryTarget,
type CronJob,
deleteCronJob,
getCronDeliveryTargets,
getCronJobRuns,
getCronJobs,
instantiateAutomationBlueprint,
pauseCronJob,
resumeCronJob,
type SessionInfo,
@@ -70,27 +65,17 @@ import {
} from '../overlays/panel'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import {
BlueprintSlotControl,
blueprintSlotHelp,
BlueprintsPanel,
cleanBlueprintFieldError,
initialBlueprintValues
} from './blueprints'
import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model'
import { jobState, jobTitle, STATE_DOT } from './job-state'
const DEFAULT_DELIVER = 'local'
// Two surfaces share the cron panel: the live Jobs list and the Blueprints
// gallery (parameterized templates that instantiate a real job). The active tab
// is pure view state — it lives here, not in a store.
type CronTab = 'blueprints' | 'jobs'
// Radix <SelectItem> rejects empty-string values, so the "no override" row in
// the model picker carries this sentinel and is mapped back to '' on save.
const MODEL_DEFAULT_VALUE = '__default__'
const DELIVERY_VALUES: readonly string[] = ['local', 'telegram', 'discord', 'slack', 'email']
const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
{ expr: '0 9 * * *', value: 'daily' },
{ expr: '0 9 * * 1-5', value: 'weekdays' },
@@ -305,7 +290,6 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
const pendingScrollRef = useRef<null | string>(null)
const focusJobId = useStore($cronFocusJobId)
const [tab, setTab] = useState<CronTab>('jobs')
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
const [deleting, setDeleting] = useState(false)
@@ -453,61 +437,9 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
setEditor({ mode: 'closed' })
}
// Blueprint instantiation is a distinct backend path (fills typed slots, then
// creates the job) so it can't share the raw-cron onSave contract. Merge the
// created job into $cronJobs like every other create path.
async function handleBlueprintCreate(
blueprint: AutomationBlueprint,
values: Record<string, string>,
profile: string
) {
const job = await instantiateAutomationBlueprint({ blueprint: blueprint.key, values }, profile)
updateCronJobs(rows => {
const rest = rows.filter(row => row.id !== job.id)
return [...rest, job]
})
notify({ kind: 'success', title: c.blueprints.scheduled, message: asText(job.schedule_display) || blueprint.title })
setEditor({ mode: 'closed' })
}
const tabToggle = (
<SegmentedControl
onChange={setTab}
options={[
{ icon: codiconIcon('watch'), id: 'jobs', label: c.tabs.jobs },
{ icon: codiconIcon('lightbulb'), id: 'blueprints', label: c.blueprints.tab }
]}
value={tab}
/>
)
return (
<Panel closeLabel={c.close} onClose={onClose}>
<PanelHeader
actions={tabToggle}
subtitle={tab === 'jobs' ? c.count(totalCount) : c.blueprints.subtitle}
title={c.title}
/>
{tab === 'blueprints' ? (
// A blueprint instantiates a real per-profile job, and "all" is not a
// writable target — collapse it to 'default', matching the create path
// in handleEditorSave. A user scoped to all profiles gets the job in
// 'default'. The gallery is a single scroll column, so it renders
// directly (BlueprintsPanel uses PanelDetail) rather than in PanelBody's
// master/detail row.
<BlueprintsPanel
onSetUp={blueprint =>
setEditor({
blueprint,
mode: 'blueprint',
profile: profileScope === ALL_PROFILES ? 'default' : profileScope
})
}
/>
) : loading && jobs.length === 0 ? (
{loading && jobs.length === 0 ? (
<PageLoader label={c.loading} />
) : totalCount === 0 ? (
<PanelEmpty
@@ -521,61 +453,59 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
title={c.emptyTitleNew}
/>
) : (
<PanelBody>
<PanelList
onSearchChange={setQuery}
searchHints={jobs
.map(jobTitle)
.filter(Boolean)
.slice(0, 5)
.map(title => t.common.tryHint(title))}
searchLabel={c.search}
searchPlaceholder={c.search}
searchValue={query}
>
{visibleJobs.map(job => (
<CronJobListRow
active={selectedJob?.id === job.id}
job={job}
key={job.id}
menu={
<PanelRowMenu
items={[
{ icon: 'edit', label: c.edit, onSelect: () => setEditor({ mode: 'edit', job }) },
{ icon: 'trash', label: t.common.delete, onSelect: () => setPendingDelete(job), tone: 'danger' }
]}
/>
}
onSelect={() => setSelectedJobId(job.id)}
/>
))}
{visibleJobs.length === 0 && (
<p className="px-2 py-4 text-center text-xs text-muted-foreground">{c.emptyTitleSearch}</p>
)}
<PanelAddButton label={c.newCron} onClick={() => setEditor({ mode: 'create' })} />
</PanelList>
<>
<PanelHeader subtitle={c.count(totalCount)} title={c.title} />
<PanelBody>
<PanelList
onSearchChange={setQuery}
searchHints={jobs
.map(jobTitle)
.filter(Boolean)
.slice(0, 5)
.map(title => t.common.tryHint(title))}
searchLabel={c.search}
searchPlaceholder={c.search}
searchValue={query}
>
{visibleJobs.map(job => (
<CronJobListRow
active={selectedJob?.id === job.id}
job={job}
key={job.id}
menu={
<PanelRowMenu
items={[
{ icon: 'edit', label: c.edit, onSelect: () => setEditor({ mode: 'edit', job }) },
{ icon: 'trash', label: t.common.delete, onSelect: () => setPendingDelete(job), tone: 'danger' }
]}
/>
}
onSelect={() => setSelectedJobId(job.id)}
/>
))}
{visibleJobs.length === 0 && (
<p className="px-2 py-4 text-center text-xs text-muted-foreground">{c.emptyTitleSearch}</p>
)}
<PanelAddButton label={c.newCron} onClick={() => setEditor({ mode: 'create' })} />
</PanelList>
{selectedJob ? (
<CronJobDetail
busy={busyJobId === selectedJob.id}
c={c}
job={selectedJob}
onOpenSession={onOpenSession}
onPauseResume={() => void handlePauseResume(selectedJob)}
onTrigger={() => void handleTrigger(selectedJob)}
/>
) : (
<PanelEmpty description={c.emptyDescSearch} icon="search" />
)}
</PanelBody>
{selectedJob ? (
<CronJobDetail
busy={busyJobId === selectedJob.id}
c={c}
job={selectedJob}
onOpenSession={onOpenSession}
onPauseResume={() => void handlePauseResume(selectedJob)}
onTrigger={() => void handleTrigger(selectedJob)}
/>
) : (
<PanelEmpty description={c.emptyDescSearch} icon="search" />
)}
</PanelBody>
</>
)}
<CronEditorDialog
editor={editor}
onBlueprintCreate={handleBlueprintCreate}
onClose={() => setEditor({ mode: 'closed' })}
onSave={handleEditorSave}
/>
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
<DialogContent className="max-w-md">
@@ -797,57 +727,12 @@ function CronJobRuns({
)
}
// Label a cron delivery target: 'local' → localized "This desktop", known
// platforms → their delivery label, anything else → the backend name. Configured
// platforms without a cron home channel get a "set a home channel first" hint.
function deliverTargetLabel(target: CronDeliveryTarget, c: Translations['cron']): string {
const base = target.id === 'local' ? c.deliveryLabels.local : (c.deliveryLabels[target.id] ?? target.name)
return target.id !== 'local' && !target.home_target_set ? `${base} — ${c.deliverNeedsHomeChannel}` : base
}
// The delivery-target dropdown, shared by the manual cron editor and the
// blueprint form so both offer exactly the connected platforms (never a
// hardcoded list). While the targets load, keep the current value selectable.
function DeliverSelect({
c,
id,
onChange,
targets,
value
}: {
c: Translations['cron']
id: string
onChange: (next: string) => void
targets: CronDeliveryTarget[]
value: string
}) {
const options = targets.length > 0 ? targets : [{ home_env_var: null, home_target_set: true, id: value, name: value }]
return (
<Select onValueChange={onChange} value={value}>
<SelectTrigger className="h-9 rounded-md" id={id}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map(target => (
<SelectItem key={target.id} value={target.id}>
{deliverTargetLabel(target, c)}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
function CronEditorDialog({
editor,
onBlueprintCreate,
onClose,
onSave
}: {
editor: EditorState
onBlueprintCreate: (blueprint: AutomationBlueprint, values: Record<string, string>, profile: string) => Promise<void>
onClose: () => void
onSave: (values: EditorValues) => Promise<void>
}) {
@@ -855,8 +740,6 @@ function CronEditorDialog({
const c = t.cron
const open = editor.mode !== 'closed'
const isEdit = editor.mode === 'edit'
const isBlueprint = editor.mode === 'blueprint'
const blueprint = isBlueprint ? editor.blueprint : null
const initial = isEdit ? editor.job : null
const scriptOnlyJob = initial ? jobIsScriptOnly(initial) : false
@@ -868,28 +751,16 @@ function CronEditorDialog({
// Per-job model override, encoded as `${providerSlug}:${model}` (split on the
// first ':' when saving). MODEL_DEFAULT_VALUE = follow the global default.
const [modelChoice, setModelChoice] = useState(MODEL_DEFAULT_VALUE)
// Blueprint mode fills typed slots (time/enum/weekdays/text) instead of the
// raw cron fields; the backend renders the prompt + schedule from them.
const [slotValues, setSlotValues] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
// Same catalog the chat model picker uses: configured providers and their
// actually-available models only. Script-only + blueprint dialogs never pick a
// model here, so skip the fetch entirely for them.
// actually-available models only. Script-only jobs never run an agent, so
// skip the fetch entirely for them.
const modelOptions = useQuery({
queryKey: ['model-options', 'global'],
queryFn: () => requestModelOptions({}),
enabled: open && !scriptOnlyJob && !isBlueprint
})
// Single source of truth for where a cron can deliver (local + configured
// gateways) — same endpoint the dashboard uses, so no dialog offers a platform
// that isn't connected. Shared by the manual editor and the blueprint form.
const deliveryTargets = useQuery({
queryKey: ['cron-delivery-targets'],
queryFn: getCronDeliveryTargets,
enabled: open
enabled: open && !scriptOnlyJob
})
useEffect(() => {
@@ -903,10 +774,9 @@ function CronEditorDialog({
setSchedulePreset(initial ? scheduleOptionForExpr(jobScheduleExpr(initial)).value : 'daily')
setDeliver(initial ? jobDeliver(initial) : DEFAULT_DELIVER)
setModelChoice(initial && jobModel(initial) ? `${jobProvider(initial)}:${jobModel(initial)}` : MODEL_DEFAULT_VALUE)
setSlotValues(blueprint ? initialBlueprintValues(blueprint) : {})
setError(null)
setSaving(false)
}, [blueprint, initial, open])
}, [initial, open])
const selectedScheduleOption =
SCHEDULE_OPTIONS.find(candidate => candidate.value === schedulePreset) ?? SCHEDULE_OPTIONS[0]
@@ -985,208 +855,141 @@ function CronEditorDialog({
}
}
async function handleBlueprintSubmit(event: React.FormEvent) {
event.preventDefault()
if (!isBlueprint) {
return
}
setSaving(true)
setError(null)
try {
await onBlueprintCreate(editor.blueprint, slotValues, editor.profile)
} catch (err) {
// 422 carries the slot-level validation message; surface it inline.
setError(cleanBlueprintFieldError(err instanceof Error ? err.message : String(err)))
} finally {
setSaving(false)
}
}
return (
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{isBlueprint ? blueprint?.title : isEdit ? c.editTitle : c.createTitle}</DialogTitle>
<DialogDescription>
{isBlueprint ? blueprint?.description || c.blueprints.dialogDesc : isEdit ? c.editDesc : c.createDesc}
</DialogDescription>
<DialogTitle>{isEdit ? c.editTitle : c.createTitle}</DialogTitle>
<DialogDescription>{isEdit ? c.editDesc : c.createDesc}</DialogDescription>
</DialogHeader>
{isBlueprint && blueprint ? (
<form className="grid gap-4" onSubmit={handleBlueprintSubmit}>
{blueprint.fields.map(field => {
const fieldId = `blueprint-${blueprint.key}-${field.name}`
const help = blueprintSlotHelp(field)
<form className="grid gap-4" onSubmit={handleSubmit}>
{scriptOnlyJob && initial && (
<FieldHint>
{c.scriptOnlyEditHint} <span className="font-mono">{initial.id}</span>
</FieldHint>
)}
return (
<Field htmlFor={fieldId} key={field.name} label={field.label}>
{field.name === 'deliver' ? (
// Use the shared, backend-sourced delivery targets (same as the
// manual editor) rather than the blueprint's static field.options,
// so both dialogs offer exactly the connected platforms.
<DeliverSelect
c={c}
id={fieldId}
onChange={next => setSlotValues(prev => ({ ...prev, [field.name]: next }))}
targets={deliveryTargets.data ?? []}
value={slotValues[field.name] ?? DEFAULT_DELIVER}
/>
) : (
<BlueprintSlotControl
field={field}
id={fieldId}
onChange={next => setSlotValues(prev => ({ ...prev, [field.name]: next }))}
value={slotValues[field.name] ?? ''}
/>
<Field htmlFor="cron-name" label={c.nameLabel} optional optionalLabel={c.optional}>
<Input
autoFocus
id="cron-name"
onChange={event => setName(event.target.value)}
placeholder={c.namePlaceholder}
value={name}
/>
</Field>
<Field htmlFor="cron-prompt" label={c.promptLabel} optional={scriptOnlyJob} optionalLabel={c.optional}>
<Textarea
className="min-h-24 font-mono"
id="cron-prompt"
onChange={event => setPrompt(event.target.value)}
placeholder={c.promptPlaceholder}
value={prompt}
/>
</Field>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="cron-frequency" label={c.frequencyLabel}>
<Select onValueChange={handleSchedulePresetChange} value={schedulePreset}>
<SelectTrigger className="h-9 rounded-md" id="cron-frequency">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCHEDULE_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value}>
{c.scheduleLabels[option.value]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field htmlFor="cron-deliver" label={c.deliverLabel}>
<Select onValueChange={setDeliver} value={deliver}>
<SelectTrigger className="h-9 rounded-md" id="cron-deliver">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVERY_VALUES.map(value => (
<SelectItem key={value} value={value}>
{c.deliveryLabels[value]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
{!scriptOnlyJob && (
<Field htmlFor="cron-model" label={c.modelLabel} optional optionalLabel={c.optional}>
<Select onValueChange={setModelChoice} value={modelChoice}>
<SelectTrigger className="h-9 rounded-md" id="cron-model">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={MODEL_DEFAULT_VALUE}>{c.modelDefault}</SelectItem>
{!modelChoiceKnown && (
<SelectItem className="font-mono" value={modelChoice}>
{modelChoice.slice(modelChoice.indexOf(':') + 1)}
</SelectItem>
)}
{help && <FieldHint>{help}</FieldHint>}
</Field>
)
})}
{modelProviders.map(provider => (
<SelectGroup key={provider.slug}>
<SelectLabel>{provider.name}</SelectLabel>
{(provider.models ?? []).map(model => (
<SelectItem
className="font-mono"
key={`${provider.slug}:${model}`}
value={`${provider.slug}:${model}`}
>
{model}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</Field>
)}
{error && (
<div className="flex items-start gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
{t.common.cancel}
</Button>
<Button disabled={saving} type="submit">
{saving ? c.blueprints.scheduling : c.blueprints.scheduleIt}
</Button>
</DialogFooter>
</form>
) : (
<form className="grid gap-4" onSubmit={handleSubmit}>
{scriptOnlyJob && initial && (
<FieldHint>
{c.scriptOnlyEditHint} <span className="font-mono">{initial.id}</span>
</FieldHint>
)}
<Field htmlFor="cron-name" label={c.nameLabel} optional optionalLabel={c.optional}>
{schedulePreset === 'custom' ? (
<Field htmlFor="cron-schedule" label={c.customScheduleLabel}>
<Input
autoFocus
id="cron-name"
onChange={event => setName(event.target.value)}
placeholder={c.namePlaceholder}
value={name}
className="font-mono"
id="cron-schedule"
onChange={event => setSchedule(event.target.value)}
placeholder={c.customPlaceholder}
value={schedule}
/>
<FieldHint>{c.customHint}</FieldHint>
</Field>
<Field htmlFor="cron-prompt" label={c.promptLabel} optional={scriptOnlyJob} optionalLabel={c.optional}>
<Textarea
className="min-h-24 font-mono"
id="cron-prompt"
onChange={event => setPrompt(event.target.value)}
placeholder={c.promptPlaceholder}
value={prompt}
/>
</Field>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="cron-frequency" label={c.frequencyLabel}>
<Select onValueChange={handleSchedulePresetChange} value={schedulePreset}>
<SelectTrigger className="h-9 rounded-md" id="cron-frequency">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCHEDULE_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value}>
{c.scheduleLabels[option.value]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field htmlFor="cron-deliver" label={c.deliverLabel}>
<DeliverSelect
c={c}
id="cron-deliver"
onChange={setDeliver}
targets={deliveryTargets.data ?? []}
value={deliver}
/>
</Field>
) : (
<div className="rounded-md bg-(--ui-bg-quinary) px-3 py-2">
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
<span className="font-medium text-foreground">{scheduleHint}</span>
<span className="font-mono text-muted-foreground">{schedule}</span>
</div>
</div>
)}
{!scriptOnlyJob && (
<Field htmlFor="cron-model" label={c.modelLabel} optional optionalLabel={c.optional}>
<Select onValueChange={setModelChoice} value={modelChoice}>
<SelectTrigger className="h-9 rounded-md" id="cron-model">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={MODEL_DEFAULT_VALUE}>{c.modelDefault}</SelectItem>
{!modelChoiceKnown && (
<SelectItem className="font-mono" value={modelChoice}>
{modelChoice.slice(modelChoice.indexOf(':') + 1)}
</SelectItem>
)}
{modelProviders.map(provider => (
<SelectGroup key={provider.slug}>
<SelectLabel>{provider.name}</SelectLabel>
{(provider.models ?? []).map(model => (
<SelectItem
className="font-mono"
key={`${provider.slug}:${model}`}
value={`${provider.slug}:${model}`}
>
{model}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</Field>
)}
{error && (
<div className="flex items-start gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
{schedulePreset === 'custom' ? (
<Field htmlFor="cron-schedule" label={c.customScheduleLabel}>
<Input
className="font-mono"
id="cron-schedule"
onChange={event => setSchedule(event.target.value)}
placeholder={c.customPlaceholder}
value={schedule}
/>
<FieldHint>{c.customHint}</FieldHint>
</Field>
) : (
<div className="rounded-md bg-(--ui-bg-quinary) px-3 py-2">
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
<span className="font-medium text-foreground">{scheduleHint}</span>
<span className="font-mono text-muted-foreground">{schedule}</span>
</div>
</div>
)}
{error && (
<div className="flex items-start gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
{t.common.cancel}
</Button>
<Button disabled={saving} type="submit">
{saving ? t.common.saving : isEdit ? c.saveChanges : c.createAction}
</Button>
</DialogFooter>
</form>
)}
<DialogFooter>
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
{t.common.cancel}
</Button>
<Button disabled={saving} type="submit">
{saving ? t.common.saving : isEdit ? c.saveChanges : c.createAction}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
@@ -1220,11 +1023,7 @@ function FieldHint({ children }: { children: React.ReactNode }) {
return <p className="text-[0.66rem] leading-4 text-muted-foreground">{children}</p>
}
type EditorState =
| { blueprint: AutomationBlueprint; mode: 'blueprint'; profile: string }
| { job: CronJob; mode: 'edit' }
| { mode: 'closed' }
| { mode: 'create' }
type EditorState = { mode: 'closed' } | { mode: 'create' } | { job: CronJob; mode: 'edit' }
interface EditorValues {
deliver: string
+1 -5
View File
@@ -71,11 +71,7 @@ interface PanelHeaderProps {
export function PanelHeader({ actions, subtitle, title }: PanelHeaderProps) {
return (
// The overlay's close (X) is absolutely positioned at right-3 and costs no
// layout space, so header actions would otherwise slide right up against it.
// Reserve clearance (button footprint from the card edge + a small gap) on
// the right whenever actions are present.
<header className={cn('mb-3 flex shrink-0 items-start justify-between gap-3', actions ? 'pr-8' : undefined)}>
<header className="mb-3 flex shrink-0 items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
{subtitle ? <p className="truncate text-xs text-muted-foreground/80">{subtitle}</p> : null}
+1 -6
View File
@@ -9,7 +9,6 @@ export const SETTINGS_ROUTE = '/settings'
export const COMMAND_CENTER_ROUTE = '/command-center'
export const SKILLS_ROUTE = '/skills'
export const MESSAGING_ROUTE = '/messaging'
export const WEBHOOKS_ROUTE = '/webhooks'
export const ARTIFACTS_ROUTE = '/artifacts'
export const CRON_ROUTE = '/cron'
export const PROFILES_ROUTE = '/profiles'
@@ -32,7 +31,6 @@ export type AppView =
| 'settings'
| 'skills'
| 'starmap'
| 'webhooks'
export type AppRouteId =
| 'agents'
@@ -45,7 +43,6 @@ export type AppRouteId =
| 'settings'
| 'skills'
| 'starmap'
| 'webhooks'
export interface AppRoute {
id: AppRouteId
@@ -59,7 +56,6 @@ export const APP_ROUTES = [
{ id: 'command-center', path: COMMAND_CENTER_ROUTE, view: 'command-center' },
{ id: 'skills', path: SKILLS_ROUTE, view: 'skills' },
{ id: 'messaging', path: MESSAGING_ROUTE, view: 'messaging' },
{ id: 'webhooks', path: WEBHOOKS_ROUTE, view: 'webhooks' },
{ id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' },
{ id: 'cron', path: CRON_ROUTE, view: 'cron' },
{ id: 'profiles', path: PROFILES_ROUTE, view: 'profiles' },
@@ -125,8 +121,7 @@ export const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set([
'cron',
'profiles',
'settings',
'starmap',
'webhooks'
'starmap'
])
export function isOverlayView(view: AppView): boolean {
@@ -1450,8 +1450,7 @@ describe('usePromptActions restoreToMessage', () => {
{
session_id: RUNTIME_SESSION_ID,
text: 'first prompt',
truncate_before_user_ordinal: 0,
confirm_empty_truncate: true
truncate_before_user_ordinal: 0
},
1_800_000
)
@@ -1518,8 +1517,7 @@ describe('usePromptActions restoreToMessage', () => {
{
session_id: RUNTIME_SESSION_ID,
text: 'first prompt',
truncate_before_user_ordinal: 0,
confirm_empty_truncate: true
truncate_before_user_ordinal: 0
},
1_800_000
)
@@ -1564,8 +1562,7 @@ describe('usePromptActions restoreToMessage', () => {
{
session_id: RUNTIME_SESSION_ID,
text: 'first prompt',
truncate_before_user_ordinal: 0,
confirm_empty_truncate: true
truncate_before_user_ordinal: 0
},
1_800_000
)
@@ -53,8 +53,7 @@ import {
planEdit,
planReload,
planRestore,
runRewindSubmit,
truncateSubmitParams
runRewindSubmit
} from './rewind'
import { useSlashCommand } from './slash'
import { useSubmitPrompt } from './submit'
@@ -757,11 +756,7 @@ export function usePromptActions({
try {
await requestGateway(
'prompt.submit',
{
session_id: activeSessionId,
text: plan.text,
...truncateSubmitParams(plan.truncateOrdinal)
},
{ session_id: activeSessionId, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal },
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)
} catch (err) {
@@ -1,19 +0,0 @@
import { describe, expect, it } from 'vitest'
import { truncateSubmitParams } from './rewind'
describe('truncateSubmitParams', () => {
it('omits truncation fields when no ordinal is set', () => {
expect(truncateSubmitParams(undefined)).toEqual({})
})
it('requires confirm_empty_truncate only for ordinal 0', () => {
expect(truncateSubmitParams(0)).toEqual({
truncate_before_user_ordinal: 0,
confirm_empty_truncate: true
})
expect(truncateSubmitParams(1)).toEqual({
truncate_before_user_ordinal: 1
})
})
})
@@ -25,23 +25,6 @@ import {
type RequestGateway = <T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
/**
* Build `prompt.submit` truncation params. Ordinal 0 truncates to an empty
* transcript (restore/regenerate the first user turn) — the gateway refuses
* that edge unless `confirm_empty_truncate` is set, so stale clients cannot
* silently wipe a session via a leftover ordinal.
*/
export function truncateSubmitParams(truncateOrdinal: number | undefined): Record<string, unknown> {
if (truncateOrdinal === undefined) {
return {}
}
return {
truncate_before_user_ordinal: truncateOrdinal,
...(truncateOrdinal === 0 ? { confirm_empty_truncate: true } : {})
}
}
/**
* Rewind a turn: `prompt.submit` with an optional `truncate_before_user_ordinal`
* (drops that user turn + everything after). Idle rewinds submit directly
@@ -70,7 +53,7 @@ export async function runRewindSubmit(
{
session_id: sessionId,
text,
...truncateSubmitParams(truncateOrdinal)
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
},
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)
@@ -1102,12 +1102,8 @@ describe('branchStoredSession desktop source tagging', () => {
session_id: 'stored-parent'
} as never)
let createParams: Record<string, unknown> | undefined
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.create') {
createParams = params
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
}
@@ -1124,71 +1120,9 @@ describe('branchStoredSession desktop source tagging', () => {
expect(ensureGatewayProfile).toHaveBeenCalledWith('work')
expect(getSessionMessages).toHaveBeenCalledWith('stored-parent', 'work')
// The create itself must carry the owning profile: in app-global remote
// mode the soft gateway swap alone is not enough — an omitted profile
// lands the branch on the launch (default) profile's state.db.
expect(createParams).toMatchObject({ parent_session_id: 'stored-parent', profile: 'work' })
vi.mocked(getSession).mockReset()
})
it('creates the branch on the cached parent session profile', async () => {
setSessions([storedSession({ id: 'stored-parent', message_count: 1, profile: 'work' })])
vi.mocked(getSessionMessages).mockResolvedValue({
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
session_id: 'stored-parent'
} as never)
let createParams: Record<string, unknown> | undefined
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.create') {
createParams = params
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
}
return {} as never
})
let branchStoredSession: ((storedSessionId: string) => Promise<boolean>) | null = null
render(<BranchHarness onReady={branch => (branchStoredSession = branch)} requestGateway={requestGateway} />)
await waitFor(() => expect(branchStoredSession).not.toBeNull())
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
expect(ensureGatewayProfile).toHaveBeenCalledWith('work')
expect(createParams).toMatchObject({ profile: 'work' })
})
it('omits profile for a profile-less parent so single-profile users are unchanged', async () => {
setSessions([storedSession({ id: 'stored-parent', message_count: 1 })])
vi.mocked(getSessionMessages).mockResolvedValue({
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
session_id: 'stored-parent'
} as never)
let createParams: Record<string, unknown> | undefined
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.create') {
createParams = params
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
}
return {} as never
})
let branchStoredSession: ((storedSessionId: string) => Promise<boolean>) | null = null
render(<BranchHarness onReady={branch => (branchStoredSession = branch)} requestGateway={requestGateway} />)
await waitFor(() => expect(branchStoredSession).not.toBeNull())
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
expect(createParams).toBeDefined()
expect(createParams).not.toHaveProperty('profile')
})
})
// ── Warm-cache mapping integrity (the "open chat A, chat B loads" bug) ─────────
@@ -82,7 +82,6 @@ import {
patchSessionWorkspace,
preserveLocalPendingTurnMessages,
reconcileResumeMessages,
resolveSessionProfile,
resolveStoredSession,
sessionMatchesStoredId,
sessionShouldHaveTranscript,
@@ -1057,30 +1056,15 @@ export function useSessionActions({
// `parentStoredId` so it nests under its parent, then open it as its own tab
// and switch to it — the parent chat stays put (mirrors openNewSessionTile).
const forkBranch = useCallback(
async (
branchMessages: BranchMessage[],
parentStoredId: null | string,
cwd?: string,
profile?: null | string
): Promise<boolean> => {
async (branchMessages: BranchMessage[], parentStoredId: null | string, cwd?: string): Promise<boolean> => {
creatingSessionRef.current = true
try {
// A branch belongs to its parent's OWNING profile. Swapping the live
// gateway first AND passing `profile` on the create mirrors
// desktopSessionCreateParams/resumeSession: in app-global remote mode
// one backend serves every profile, so an omitted profile silently
// lands the branch on the launch (default) profile — the "session
// jumps between profiles after branching" bug. The swap also makes
// upsertOptimisticSession's $activeGatewayProfile stamp correct.
await ensureGatewayProfile(profile)
// No title: the backend auto-names the branch from its parent's lineage.
const branched = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
source: 'desktop',
...(cwd && { cwd }),
...(profile ? { profile } : {}),
messages: branchMessages.map(({ content, role }) => ({ content, role })),
...(parentStoredId && { parent_session_id: parentStoredId })
})
@@ -1181,12 +1165,7 @@ export function useSessionActions({
clearNotifications()
// The open chat's owning profile, NOT the picker's / launch profile —
// /profile only retargets new chats, so a branch of an existing thread
// must stay on that thread's backend (cache hit for an open session).
const profile = await resolveSessionProfile(selectedStoredSessionIdRef.current)
return forkBranch(branchMessages, selectedStoredSessionIdRef.current, $currentCwd.get().trim(), profile)
return forkBranch(branchMessages, selectedStoredSessionIdRef.current, $currentCwd.get().trim())
},
[activeSessionIdRef, busyRef, copy, forkBranch, selectedStoredSessionIdRef]
)
@@ -1218,7 +1197,7 @@ export function useSessionActions({
return false
}
return await forkBranch(branchMessages, stored?.id ?? storedSessionId, stored?.cwd?.trim(), profile)
return await forkBranch(branchMessages, stored?.id ?? storedSessionId, stored?.cwd?.trim())
} catch (err) {
notifyError(err, copy.branchFailed)
@@ -4,7 +4,6 @@ import { useEffect, useState } from 'react'
import { BrandMark } from '@/components/brand-mark'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { VersionDetails } from '@/components/version-details'
import { type Translations, useI18n } from '@/i18n'
import { CheckCircle2, ExternalLink, Loader2, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -171,10 +170,9 @@ export function AboutSettings() {
</div>
</div>
{version && <VersionDetails version={version} />}
<ListRow
description={a.automaticUpdatesDesc}
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
title={a.automaticUpdates}
/>
@@ -1,15 +1,8 @@
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tip } from '@/components/ui/tooltip'
import {
deleteSession,
getHermesConfigRecord,
listAllProfileSessions,
saveHermesConfig,
setSessionArchived
} from '@/hermes'
import { deleteSession, listAllProfileSessions, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
@@ -17,13 +10,11 @@ import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import { untombstoneSessions } from '@/store/projects'
import { applyConfiguredDefaultProjectDir, ensureDefaultWorkspaceCwd, setSessions } from '@/store/session'
import type { HermesConfigRecord, SessionInfo } from '@/types/hermes'
import type { SessionInfo } from '@/types/hermes'
import { EmptyState, ListRow, SectionHeading, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
import { EmptyState, ListRow, SectionHeading, SettingsContent, SettingsSkeleton } from './primitives'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
const DEFAULT_AUTO_ARCHIVE_DAYS = 3
const ARCHIVED_FETCH_LIMIT = 200
function workspaceLabel(cwd: null | string | undefined): string {
@@ -123,8 +114,6 @@ export function SessionsSettings() {
<SettingsContent>
<DefaultProjectDirSetting />
<AutoArchiveSetting />
<SectionHeading
icon={Archive}
meta={sessions.length ? String(sessions.length) : undefined}
@@ -185,112 +174,6 @@ export function SessionsSettings() {
)
}
// Opt-in retention: soft-hide chats untouched for N days. The policy itself
// (last-activity sweep, pin exemption) lives in the backend
// (sessions.auto_archive in config.yaml + SessionDB.maybe_auto_archive); this
// just toggles the config keys, so CLI / gateway / Desktop all honour one
// setting. Pins are exempt on the backend, so pinned chats survive regardless.
function AutoArchiveSetting() {
const { t } = useI18n()
const s = t.settings.sessions
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const [enabled, setEnabled] = useState(false)
const [days, setDays] = useState(DEFAULT_AUTO_ARCHIVE_DAYS)
useEffect(() => {
// Config REST is only reachable through the Electron bridge; skip in
// non-Electron contexts (tests/storybook) rather than throwing.
if (!window.hermesDesktop) {
return
}
let alive = true
void getHermesConfigRecord()
.then(record => {
if (!alive) {
return
}
const sessions = (record.sessions ?? {}) as Record<string, unknown>
const parsedDays = Number(sessions.auto_archive_days)
setConfig(record)
setEnabled(Boolean(sessions.auto_archive))
setDays(Number.isFinite(parsedDays) && parsedDays > 0 ? Math.round(parsedDays) : DEFAULT_AUTO_ARCHIVE_DAYS)
})
.catch(() => {
// Leave the control unmounted if config can't be read.
})
return () => {
alive = false
}
}, [])
const persist = useCallback(
async (autoArchive: boolean, archiveDays: number) => {
if (!config) {
return
}
const sessions = {
...((config.sessions ?? {}) as Record<string, unknown>),
auto_archive: autoArchive,
auto_archive_days: archiveDays
}
const updated = { ...config, sessions }
setConfig(updated)
try {
await saveHermesConfig(updated)
} catch (err) {
notifyError(err, s.autoArchiveFailed)
}
},
[config, s.autoArchiveFailed]
)
if (!config) {
return null
}
return (
<div className="mb-6">
<ToggleRow
checked={enabled}
description={s.autoArchiveDesc}
label={s.autoArchiveTitle}
onChange={on => {
setEnabled(on)
void persist(on, days)
}}
/>
{enabled && (
<ListRow
action={
<div className="flex items-center gap-2">
<Input
aria-label={s.autoArchiveDaysLabel}
className="w-20"
min={1}
onBlur={() => void persist(true, days)}
onChange={e => setDays(Math.max(1, Math.round(Number(e.target.value) || 1)))}
type="number"
value={days}
/>
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{s.autoArchiveDaysUnit}
</span>
</div>
}
title={s.autoArchiveDaysLabel}
/>
)}
</div>
)
}
// 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.
@@ -24,7 +24,6 @@ export function useOverlayRouting() {
const starmapOpen = currentView === 'starmap'
const cronOpen = currentView === 'cron'
const profilesOpen = currentView === 'profiles'
const webhooksOpen = currentView === 'webhooks'
const chatOpen = currentView === 'chat'
const overlayOpen = isOverlayView(currentView)
@@ -83,7 +82,6 @@ export function useOverlayRouting() {
resetOverlayReturnRoute,
settingsOpen,
starmapOpen,
toggleCommandCenter,
webhooksOpen
toggleCommandCenter
}
}
@@ -9,7 +9,7 @@ import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
import { Activity, AlertCircle, Clock, Command, FolderOpen, Globe, Hash, Loader2, Terminal } from '@/lib/icons'
import { Activity, AlertCircle, Clock, Command, FolderOpen, Hash, Loader2, Terminal } from '@/lib/icons'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
import { cn } from '@/lib/utils'
@@ -43,7 +43,7 @@ import {
} from '@/store/updates'
import type { StatusResponse, UsageStats } from '@/types/hermes'
import { CRON_ROUTE, SETTINGS_ROUTE, WEBHOOKS_ROUTE } from '../../routes'
import { CRON_ROUTE, SETTINGS_ROUTE } from '../../routes'
import type { StatusbarItem } from '../statusbar-controls'
const EMPTY_USAGE = { calls: 0, input: 0, output: 0, total: 0 } as const
@@ -203,8 +203,7 @@ export function useStatusbarItems({
const clientVersionItem = useMemo<StatusbarItem>(() => {
const appVersion = desktopVersion?.appVersion
const sha = desktopVersion?.commit?.slice(0, 7) ?? updateStatus?.currentSha?.slice(0, 7) ?? null
const branch = desktopVersion?.branch ?? updateStatus?.branch ?? null
const sha = updateStatus?.currentSha?.slice(0, 7) ?? null
const behind = updateStatus?.behind ?? 0
const applying = updateApply.applying || updateApply.stage === 'restart'
const remote = connection?.mode === 'remote'
@@ -219,16 +218,17 @@ export function useStatusbarItems({
const tooltip = [
applying ? updateApply.message || copy.updateInProgress : null,
!applying && behind > 0 && copy.commitsBehind(behind, branch ?? '...'),
!applying && behind > 0 && copy.commitsBehind(behind, updateStatus?.branch ?? '...'),
appVersion && copy.desktopVersion(appVersion),
sha && copy.commit(sha),
branch && copy.branch(branch)
updateStatus?.branch && copy.branch(updateStatus.branch)
]
.filter(Boolean)
.join('\n')
.join(' · ')
return {
className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined,
detail: appVersion && sha && !applying && !remote ? sha : undefined,
hidden: !appVersion && !sha,
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
id: 'version-client',
@@ -239,8 +239,6 @@ export function useStatusbarItems({
}
}, [
desktopVersion?.appVersion,
desktopVersion?.commit,
desktopVersion?.branch,
connection?.mode,
copy,
updateApply.applying,
@@ -277,7 +275,7 @@ export function useStatusbarItems({
backendVersion && copy.backendVersion(backendVersion)
]
.filter(Boolean)
.join('\n')
.join(' · ')
return {
className: !applying && updateAvailable ? 'text-primary hover:text-primary' : undefined,
@@ -422,14 +420,6 @@ export function useStatusbarItems({
title: copy.openCron,
to: CRON_ROUTE,
variant: 'action'
},
{
icon: <Globe className="size-3" />,
id: 'webhooks',
label: copy.webhooks,
title: copy.openWebhooks,
to: WEBHOOKS_ROUTE,
variant: 'action'
}
],
[
+14 -50
View File
@@ -14,8 +14,7 @@ import {
import { ErrorIcon, ErrorState } from '@/components/ui/error-state'
import { Loader } from '@/components/ui/loader'
import { Progress } from '@/components/ui/progress'
import { VersionDetails } from '@/components/version-details'
import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus, DesktopVersionInfo } from '@/global'
import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global'
import { useI18n } from '@/i18n'
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
import { AlertCircle, Check, Copy, Terminal } from '@/lib/icons'
@@ -25,7 +24,6 @@ import {
$backendUpdateApply,
$backendUpdateChecking,
$backendUpdateStatus,
$desktopVersion,
$updateApply,
$updateChecking,
$updateOverlayOpen,
@@ -54,7 +52,6 @@ export function UpdatesOverlay() {
const backendStatus = useStore($backendUpdateStatus)
const backendChecking = useStore($backendUpdateChecking)
const backendApply = useStore($backendUpdateApply)
const desktopVersion = useStore($desktopVersion)
const isBackend = target === 'backend'
const status = isBackend ? backendStatus : clientStatus
@@ -123,9 +120,7 @@ export function UpdatesOverlay() {
<ErrorView message={apply.message} onDismiss={() => handleClose(false)} onRetry={handleInstall} />
)}
{phase === 'idle' && !isBackend && desktopVersion && status?.supported === false ? (
<ManagedInstallDetailsView onDone={() => handleClose(false)} version={desktopVersion} />
) : phase === 'idle' ? (
{phase === 'idle' && (
<IdleView
behind={behind}
checking={checking}
@@ -136,33 +131,13 @@ export function UpdatesOverlay() {
status={status}
target={target}
updateAvailable={updateAvailable}
version={desktopVersion}
/>
) : null}
)}
</DialogContent>
</Dialog>
)
}
function ManagedInstallDetailsView({ onDone, version }: { onDone: () => void; version: DesktopVersionInfo }) {
const { t } = useI18n()
const u = t.updates
return (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<BrandMark className="size-16" />
<DialogTitle className="text-center text-xl">{u.versionDetailsTitle}</DialogTitle>
<DialogDescription className="text-center text-sm">{u.versionDetailsBody}</DialogDescription>
</div>
<VersionDetails version={version} />
<Button className="font-medium" onClick={onDone} type="button" variant="text">
{u.done}
</Button>
</div>
)
}
function IdleView({
behind,
checking,
@@ -172,8 +147,7 @@ function IdleView({
onRetryCheck,
status,
target,
updateAvailable,
version
updateAvailable
}: {
behind: number
checking: boolean
@@ -184,7 +158,6 @@ function IdleView({
status: DesktopUpdateStatus | null
target: UpdateTarget
updateAvailable: boolean
version: DesktopVersionInfo | null
}) {
const { t } = useI18n()
const u = t.updates
@@ -212,18 +185,13 @@ function IdleView({
)
}
const details = version ? <VersionDetails version={version} /> : null
if (!status.supported) {
return (
<div className="grid gap-4 px-6 pb-6 pt-7 pr-8">
<CenteredStatus
body={status.message ?? u.unsupportedMessage}
icon={<AlertCircle className="size-6 text-muted-foreground" />}
title={u.notAvailableTitle}
/>
{details}
</div>
<CenteredStatus
body={status.message ?? u.unsupportedMessage}
icon={<AlertCircle className="size-6 text-muted-foreground" />}
title={u.notAvailableTitle}
/>
)
}
@@ -244,14 +212,11 @@ function IdleView({
if (!updateAvailable) {
return (
<div className="grid gap-4 px-6 pb-6 pt-7 pr-8">
<CenteredStatus
body={target === 'backend' ? u.latestBodyBackend : u.latestBody}
icon={<BrandMark className="size-12" />}
title={u.allSetTitle}
/>
{details}
</div>
<CenteredStatus
body={target === 'backend' ? u.latestBodyBackend : u.latestBody}
icon={<BrandMark className="size-12" />}
title={u.allSetTitle}
/>
)
}
@@ -267,7 +232,6 @@ function IdleView({
return (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
{details}
<div className="flex flex-col items-center gap-3 text-center">
<BrandMark className="size-16" />
-625
View File
@@ -1,625 +0,0 @@
import { useStore } from '@nanostores/react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { CopyButton } from '@/components/ui/copy-button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
createWebhook,
deleteWebhook,
enableWebhooks,
getWebhooks,
setWebhookEnabled,
type WebhookRoute,
type WebhooksResponse
} from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle, Globe, Plus, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $profileScope } from '@/store/profile'
import { runGatewayRestart } from '@/store/system-actions'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import {
Panel,
PanelAddButton,
PanelBlock,
PanelBody,
PanelDetail,
PanelEmpty,
PanelHeader,
PanelList,
PanelListRow,
PanelMeta,
PanelPill,
PanelRowMenu,
PanelSectionLabel
} from '../overlays/panel'
import { ListRow, ToggleRow } from '../settings/primitives'
const DELIVER_OPTIONS: readonly string[] = ['log', 'telegram', 'discord', 'slack', 'email', 'github_comment']
interface CreatedWebhook {
secret: string
url: string
}
// One affordance for "value + CopyButton": flat, token-backed (no border, no
// raw literals). DESIGN.md Principle 1 (flat, not boxed) + 4 (tokens, not
// literals). Reused by the detail URL row and the create-result URL/secret so
// there is a single copyable-value chrome in this file.
function CopyValueRow({ copyLabel, mono = true, value }: { copyLabel: string; mono?: boolean; value: string }) {
return (
<div className="flex items-center gap-1 rounded bg-foreground/5 px-2.5 py-1.5 text-[0.7rem]">
<span className={cn('min-w-0 flex-1 truncate text-foreground/80', mono && 'font-mono')}>{value}</span>
<CopyButton appearance="icon" buttonSize="icon-sm" label={copyLabel} text={value} />
</div>
)
}
interface WebhooksViewProps {
onClose: () => void
}
export function WebhooksView({ onClose }: WebhooksViewProps) {
const { t } = useI18n()
const w = t.webhooks
// Re-load when the active profile changes so REST routes to the right backend.
const profileScope = useStore($profileScope)
const queryClient = useQueryClient()
const queryKey = useMemo(() => ['webhooks', profileScope] as const, [profileScope])
const [query, setQuery] = useState('')
const [enabling, setEnabling] = useState(false)
const [restartNeeded, setRestartNeeded] = useState(false)
const [restartError, setRestartError] = useState<null | string>(null)
const [restarting, setRestarting] = useState(false)
// Master/detail: the subscription whose config fills the right pane.
const [selectedName, setSelectedName] = useState<null | string>(null)
const [createOpen, setCreateOpen] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [events, setEvents] = useState('')
const [deliver, setDeliver] = useState('log')
const [deliverOnly, setDeliverOnly] = useState(false)
const [prompt, setPrompt] = useState('')
const [skills, setSkills] = useState('')
const [creating, setCreating] = useState(false)
const [created, setCreated] = useState<CreatedWebhook | null>(null)
const [pendingDelete, setPendingDelete] = useState<null | string>(null)
const {
data,
error,
isPending: loading,
refetch
} = useQuery({
queryKey,
queryFn: getWebhooks
})
// React Query v5 dropped useQuery onError; surface a load failure toast once
// per error object instead.
useEffect(() => {
if (error) {
notifyError(error, w.loadFailed)
}
}, [error, w.loadFailed])
const enabled = data?.enabled ?? false
const subscriptions = useMemo(() => data?.subscriptions ?? [], [data])
// Pull fresh backend truth into the cache. `silent` swallows the error toast
// for post-mutation reconciles (the mutation already reported success/failure).
const reload = useCallback(
async (silent = false) => {
try {
await queryClient.invalidateQueries({ queryKey })
} catch (err) {
if (!silent) {
notifyError(err, w.loadFailed)
}
}
},
[queryClient, queryKey, w.loadFailed]
)
useRefreshHotkey(() => void refetch())
const restartGatewayNow = useCallback(async () => {
setRestarting(true)
try {
await runGatewayRestart()
setRestartNeeded(false)
setRestartError(null)
// Give the receiver a moment to bind before re-reading state.
window.setTimeout(() => void reload(true), 4000)
} catch (err) {
setRestartNeeded(true)
setRestartError(String(err))
notifyError(err, w.restartFailed(''))
} finally {
setRestarting(false)
}
}, [reload, w])
const handleEnable = useCallback(async () => {
setEnabling(true)
setRestartNeeded(false)
setRestartError(null)
try {
const result = await enableWebhooks()
await reload(true)
if (result.restart_started) {
notify({ kind: 'success', message: w.enabledRestarting })
window.setTimeout(() => void reload(true), 4000)
} else {
const detail = result.restart_error ? `: ${result.restart_error}` : '.'
setRestartNeeded(true)
setRestartError(w.restartFailed(detail))
notify({ kind: 'error', message: w.restartFailed(detail) })
}
} catch (err) {
notifyError(err, w.restartFailed(''))
} finally {
setEnabling(false)
}
}, [reload, w])
const resetForm = useCallback(() => {
setName('')
setDescription('')
setEvents('')
setDeliver('log')
setDeliverOnly(false)
setPrompt('')
setSkills('')
}, [])
const closeCreate = useCallback(() => {
if (creating) {
return
}
setCreateOpen(false)
setCreated(null)
}, [creating])
const handleCreate = useCallback(async () => {
if (!name.trim()) {
notify({ kind: 'error', message: w.nameRequired })
return
}
setCreating(true)
try {
const eventsList = events
.split(',')
.map(e => e.trim())
.filter(Boolean)
const skillsList = skills
.split(',')
.map(s => s.trim())
.filter(Boolean)
const res = await createWebhook({
deliver,
deliver_only: deliverOnly,
description: description.trim() || undefined,
events: eventsList.length ? eventsList : undefined,
name: name.trim(),
prompt: prompt.trim() || undefined,
skills: skillsList.length ? skillsList : undefined
})
notify({ kind: 'success', message: w.created })
setCreated({ secret: res.secret, url: res.url })
resetForm()
void reload(true)
} catch (err) {
notifyError(err, w.createFailed(''))
} finally {
setCreating(false)
}
}, [deliver, deliverOnly, description, events, name, prompt, reload, resetForm, skills, w])
const handleToggle = useCallback(
async (subName: string, nextEnabled: boolean) => {
// Optimistic cache paint; the invalidate below lets backend truth win.
queryClient.setQueryData<WebhooksResponse>(queryKey, current =>
current
? {
...current,
subscriptions: current.subscriptions.map(s => (s.name === subName ? { ...s, enabled: nextEnabled } : s))
}
: current
)
try {
await setWebhookEnabled(subName, nextEnabled)
notify({ kind: 'success', message: nextEnabled ? w.enabled(subName) : w.disabled(subName) })
void reload(true)
} catch (err) {
await reload(true)
notifyError(err, w.toggleFailed(subName))
}
},
[queryClient, queryKey, reload, w]
)
// ConfirmDialog owns the pending→done→close beat; throw to surface its inline
// error and keep the dialog open. Success toast matches the cron delete idiom
// (title + name).
const handleDelete = useCallback(async () => {
if (!pendingDelete) {
return
}
try {
await deleteWebhook(pendingDelete)
notify({ kind: 'success', title: w.deleted, message: pendingDelete })
void reload(true)
} catch (err) {
notifyError(err, w.deleteFailed(pendingDelete))
throw err
}
}, [pendingDelete, reload, w])
const visible = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) {
return subscriptions
}
return subscriptions.filter(s =>
[s.name, s.description, s.deliver, ...s.events].filter(Boolean).some(v => v.toLowerCase().includes(q))
)
}, [query, subscriptions])
// Detail always reflects a concrete sub: the explicitly selected one, else the
// first visible row, so the right pane is never empty while subs exist.
const selectedSub = useMemo(
() => visible.find(s => s.name === selectedName) ?? visible[0] ?? null,
[visible, selectedName]
)
const banners = (
<>
{!enabled && (
<Alert className="mb-4" variant="warning">
<Globe />
<AlertTitle>{w.disabledTitle}</AlertTitle>
<AlertDescription>
<p>{w.disabledBody}</p>
<Button className="mt-1" disabled={enabling} onClick={() => void handleEnable()} size="sm">
<Globe />
{enabling ? w.enabling : w.enable}
</Button>
</AlertDescription>
</Alert>
)}
{restartNeeded && (
<Alert className="mb-4" variant="warning">
<AlertTriangle />
<AlertDescription>
<p>{restartError ?? w.restartNeeded}</p>
<Button
className="mt-1"
disabled={restarting}
onClick={() => void restartGatewayNow()}
size="sm"
variant="secondary"
>
<RefreshCw />
{restarting ? w.restartingGateway : w.restartGateway}
</Button>
</AlertDescription>
</Alert>
)}
</>
)
return (
<Panel onClose={onClose}>
{loading ? (
<PageLoader label={w.loading} />
) : subscriptions.length === 0 ? (
<>
{banners}
<PanelEmpty
action={
<Button
disabled={!enabled || enabling}
onClick={() => {
setCreated(null)
setCreateOpen(true)
}}
size="sm"
>
<Plus />
{w.newSubscription}
</Button>
}
description={w.empty}
icon="globe"
/>
</>
) : (
<>
<PanelHeader subtitle={w.hint} title={w.subscriptions(subscriptions.length)} />
{banners}
<PanelBody>
<PanelList
onSearchChange={setQuery}
searchLabel={w.search}
searchPlaceholder={w.search}
searchValue={query}
>
{visible.map(sub => (
<PanelListRow
active={selectedSub?.name === sub.name}
dotClassName={sub.enabled ? 'bg-emerald-500' : 'bg-muted-foreground/50'}
key={sub.name}
menu={
<PanelRowMenu
items={[
{
icon: sub.enabled ? 'circle-slash' : 'check',
label: sub.enabled ? w.disableRow : w.enableRow,
onSelect: () => void handleToggle(sub.name, !sub.enabled)
},
{ icon: 'trash', label: w.delete, onSelect: () => setPendingDelete(sub.name), tone: 'danger' }
]}
/>
}
onSelect={() => setSelectedName(sub.name)}
title={sub.name}
/>
))}
{visible.length === 0 && <p className="px-2 py-4 text-center text-xs text-muted-foreground">{w.empty}</p>}
<PanelAddButton
label={w.newSubscription}
onClick={() => {
setCreated(null)
setCreateOpen(true)
}}
/>
</PanelList>
{selectedSub ? <WebhookDetail sub={selectedSub} /> : <PanelEmpty description={w.empty} icon="search" />}
</PanelBody>
</>
)}
{/* Create subscription dialog */}
<Dialog onOpenChange={open => !open && closeCreate()} open={createOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{created ? w.createdTitle : w.newSubscription}</DialogTitle>
{created && <DialogDescription>{w.createdSecretHint}</DialogDescription>}
</DialogHeader>
{created ? (
<div className="grid gap-2">
<ListRow action={<CopyValueRow copyLabel={w.copy} value={created.url} />} title={w.webhookUrl} wide />
<ListRow action={<CopyValueRow copyLabel={w.copy} value={created.secret} />} title={w.secretOnce} wide />
<DialogFooter>
<Button onClick={closeCreate} size="sm">
{w.done}
</Button>
</DialogFooter>
</div>
) : (
<div className="grid gap-1">
<div className="grid grid-cols-2 gap-4">
<ListRow
action={
<Input
autoFocus
id="webhook-name"
onChange={e => setName(e.target.value)}
placeholder={w.fieldNamePlaceholder}
value={name}
/>
}
title={<label htmlFor="webhook-name">{w.fieldName}</label>}
wide
/>
<ListRow
action={
<Input
id="webhook-description"
onChange={e => setDescription(e.target.value)}
placeholder={w.fieldDescriptionPlaceholder}
value={description}
/>
}
title={<label htmlFor="webhook-description">{w.fieldDescription}</label>}
wide
/>
</div>
<ListRow
action={
<Textarea
className="min-h-[80px]"
id="webhook-prompt"
onChange={e => setPrompt(e.target.value)}
placeholder={w.fieldPromptPlaceholder}
value={prompt}
/>
}
title={<label htmlFor="webhook-prompt">{w.fieldPrompt}</label>}
wide
/>
<div className="grid grid-cols-2 gap-4">
<ListRow
action={
<Input
id="webhook-events"
onChange={e => setEvents(e.target.value)}
placeholder={w.fieldEventsPlaceholder}
value={events}
/>
}
title={<label htmlFor="webhook-events">{w.fieldEvents}</label>}
wide
/>
<ListRow
action={
<Input
id="webhook-skills"
onChange={e => setSkills(e.target.value)}
placeholder={w.fieldSkillsPlaceholder}
value={skills}
/>
}
title={<label htmlFor="webhook-skills">{w.fieldSkills}</label>}
wide
/>
</div>
<div className="grid grid-cols-2 items-start gap-4">
<ListRow
action={
<Select onValueChange={setDeliver} value={deliver}>
<SelectTrigger className="h-9 rounded-md" id="webhook-deliver">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DELIVER_OPTIONS.map(opt => (
<SelectItem key={opt} value={opt}>
{w.deliverOptions[opt] ?? opt}
</SelectItem>
))}
</SelectContent>
</Select>
}
title={<label htmlFor="webhook-deliver">{w.fieldDeliver}</label>}
wide
/>
<ToggleRow checked={deliverOnly} label={w.fieldDeliverOnly} onChange={setDeliverOnly} />
</div>
<DialogFooter>
<Button disabled={creating} onClick={() => void handleCreate()} size="sm">
{creating ? w.creating : w.create}
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
<ConfirmDialog
busyLabel={w.deleting}
cancelLabel={t.common.cancel}
confirmLabel={w.delete}
description={
pendingDelete ? (
<>
{w.deleteDescPrefix}
<span className="font-medium text-foreground">{pendingDelete}</span>
{w.deleteDescSuffix}
</>
) : null
}
destructive
onClose={() => setPendingDelete(null)}
onConfirm={handleDelete}
open={pendingDelete !== null}
title={w.deleteTitle}
/>
</Panel>
)
}
function WebhookDetail({ sub }: { sub: WebhookRoute }) {
const { t } = useI18n()
const w = t.webhooks
return (
<PanelDetail>
<header className="space-y-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="text-[0.95rem] font-semibold tracking-tight text-foreground">{sub.name}</h3>
<PanelPill tone={sub.enabled ? 'good' : 'muted'}>
{sub.enabled ? t.messaging.states.enabled : t.messaging.states.disabled}
</PanelPill>
{sub.deliver_only && <PanelPill tone="warn">{w.deliverOnly}</PanelPill>}
</div>
<PanelMeta
rows={[
{ label: w.fieldDeliver, value: w.deliverOptions[sub.deliver] ?? sub.deliver },
{
label: w.fieldEvents,
value:
sub.events.length === 0 ? (
w.all
) : (
<span className="flex flex-wrap gap-1">
{sub.events.map(evt => (
<PanelPill key={evt}>{evt}</PanelPill>
))}
</span>
)
},
...(sub.skills.length > 0
? [
{
label: w.fieldSkills,
value: (
<span className="flex flex-wrap gap-1">
{sub.skills.map(skill => (
<PanelPill key={skill}>{skill}</PanelPill>
))}
</span>
)
}
]
: [])
]}
/>
<CopyValueRow copyLabel={w.copy} value={sub.url} />
</header>
{sub.description ? (
<div className="space-y-1.5">
<PanelSectionLabel>{w.fieldDescription}</PanelSectionLabel>
<p className="text-xs leading-relaxed text-foreground/80">{sub.description}</p>
</div>
) : null}
{sub.prompt ? (
<div className="space-y-1.5">
<PanelSectionLabel>{w.fieldPrompt}</PanelSectionLabel>
<PanelBlock>{sub.prompt}</PanelBlock>
</div>
) : null}
</PanelDetail>
)
}
@@ -1,5 +1,4 @@
import type { DesktopAuthProvider, DesktopConnectionConfig } from '@/global'
import { deriveRemoteAuthProviderShape } from '@/lib/desktop-remote-auth'
// Pure helpers for the boot-failure overlay's remote-reauth branch. Kept out
// of the .tsx so they can be unit-tested without a React/jsdom render (the
@@ -137,7 +136,18 @@ export function deriveProviderShape(providers: DesktopAuthProvider[] | null | un
isPassword: boolean
providerLabel: string
} {
return deriveRemoteAuthProviderShape(providers)
const list = providers ?? []
if (list.length === 0) {
return { isPassword: false, providerLabel: 'your identity provider' }
}
const isPassword = list.every(p => Boolean(p.supportsPassword))
const providerLabel =
list.length === 1 ? list[0].displayName || list[0].name : list.map(p => p.displayName || p.name).join(' / ')
return { isPassword, providerLabel }
}
// Button copy for the remote sign-in action.
@@ -1,499 +0,0 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DesktopBootstrapEvent, DesktopBootstrapState, DesktopConnectionProbeResult } from '@/global'
import { DesktopInstallOverlay } from './desktop-install-overlay'
function bootstrapState(overrides: Partial<DesktopBootstrapState> = {}): DesktopBootstrapState {
return {
active: false,
manifest: null,
stages: {},
error: null,
log: [],
startedAt: null,
completedAt: null,
setupChoice: null,
unsupportedPlatform: null,
...overrides
}
}
function installDesktopMock(state: DesktopBootstrapState) {
const bootstrapListeners = new Set<(event: DesktopBootstrapEvent) => void>()
const desktop = {
getBootstrapState: vi.fn().mockResolvedValue(state),
onBootstrapEvent: vi.fn((listener: (event: DesktopBootstrapEvent) => void) => {
bootstrapListeners.add(listener)
return () => bootstrapListeners.delete(listener)
}),
continueBootstrapLocal: vi.fn().mockResolvedValue({ ok: true }),
probeConnectionConfig: vi.fn(),
testConnectionConfig: vi.fn(),
applyConnectionConfig: vi.fn(),
oauthLoginConnectionConfig: vi.fn(),
openExternal: vi.fn(),
emitBootstrapEvent: (event: DesktopBootstrapEvent) => {
for (const listener of bootstrapListeners) {
listener(event)
}
}
}
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: desktop
})
return desktop
}
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
cleanup()
vi.useRealTimers()
Reflect.deleteProperty(window, 'hermesDesktop')
})
describe('DesktopInstallOverlay first-run setup', () => {
it('shows the remote/local choice without installer progress', async () => {
installDesktopMock(
bootstrapState({
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' }
})
)
render(<DesktopInstallOverlay />)
expect(await screen.findByText('Set up Hermes Desktop')).toBeTruthy()
expect(screen.getByText('Connect to existing Hermes')).toBeTruthy()
expect(screen.getByText('Install Hermes locally')).toBeTruthy()
expect(screen.queryByText(/steps complete/i)).toBeNull()
expect(screen.queryByText(/Fetching installer manifest/i)).toBeNull()
})
it('continues local bootstrap only when Install Hermes locally is selected', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' }
})
)
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Install Hermes locally'))
expect(desktop.continueBootstrapLocal).toHaveBeenCalledTimes(1)
expect(screen.getByText('Set up Hermes Desktop')).toBeTruthy()
act(() => {
desktop.emitBootstrapEvent({ type: 'manifest', protocolVersion: 1, stages: [] })
})
await waitFor(() => expect(screen.queryByText('Set up Hermes Desktop')).toBeNull())
expect(screen.getByText(/Fetching installer manifest/i)).toBeTruthy()
})
it('surfaces a recoverable error when the local-bootstrap bridge is unavailable', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' }
})
)
desktop.continueBootstrapLocal = undefined as never
render(<DesktopInstallOverlay />)
const install = (await screen.findByText('Install Hermes locally')).closest('button') as HTMLButtonElement
fireEvent.click(install)
expect(
await screen.findByText('Local installation could not start. Restart Hermes Desktop and try again.')
).toBeTruthy()
expect(install.disabled).toBe(false)
})
it('opens the remote connection form from the first-run choice', async () => {
installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
})
)
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
expect(await screen.findByText('Gateway URL')).toBeTruthy()
expect(screen.getByText('Test connection')).toBeTruthy()
expect(screen.getByText('Apply and reconnect')).toBeTruthy()
})
it('returns from the remote connection form to the first-run choice', async () => {
installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
})
)
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
expect(await screen.findByText('Gateway URL')).toBeTruthy()
fireEvent.click(screen.getByText('Back'))
expect(await screen.findByText('Set up Hermes Desktop')).toBeTruthy()
expect(screen.getByText('Install Hermes locally')).toBeTruthy()
})
it('requires a successful token connection test before applying remote config', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
})
)
desktop.probeConnectionConfig.mockResolvedValue({
authMode: 'token',
baseUrl: 'https://gateway.example.com/hermes',
error: null,
providers: [],
reachable: true,
version: '0.17.0'
})
desktop.testConnectionConfig.mockResolvedValue({
baseUrl: 'https://gateway.example.com/hermes',
ok: true,
version: '0.17.0'
})
desktop.applyConnectionConfig.mockImplementation(async () => {
desktop.emitBootstrapEvent({ type: 'dismissed' })
return { mode: 'remote' }
})
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
fireEvent.change(await screen.findByPlaceholderText('https://gateway.example.com/hermes'), {
target: { value: 'https://gateway.example.com/hermes' }
})
const apply = screen.getByText('Apply and reconnect').closest('button') as HTMLButtonElement
expect(apply.disabled).toBe(true)
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 550))
})
fireEvent.change(await screen.findByPlaceholderText('Paste session token'), {
target: { value: 'session-secret' }
})
fireEvent.click(screen.getByText('Test connection'))
await waitFor(() => {
expect(desktop.testConnectionConfig).toHaveBeenCalledWith({
mode: 'remote',
remoteAuthMode: 'token',
remoteToken: 'session-secret',
remoteUrl: 'https://gateway.example.com/hermes'
})
})
await screen.findByText('Connected to https://gateway.example.com/hermes (0.17.0).')
expect(apply.disabled).toBe(false)
fireEvent.click(screen.getByText('Apply and reconnect'))
await waitFor(() => {
expect(desktop.applyConnectionConfig).toHaveBeenCalledWith({
mode: 'remote',
remoteAuthMode: 'token',
remoteToken: 'session-secret',
remoteUrl: 'https://gateway.example.com/hermes'
})
})
await waitFor(() => expect(screen.queryByText('Gateway URL')).toBeNull())
})
it('ignores a completed probe after the gateway URL becomes invalid', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
})
)
let resolveProbe: ((result: DesktopConnectionProbeResult) => void) | undefined
const pendingProbe = new Promise<DesktopConnectionProbeResult>(resolve => {
resolveProbe = resolve
})
desktop.probeConnectionConfig.mockReturnValue(pendingProbe)
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
const urlInput = await screen.findByPlaceholderText('https://gateway.example.com/hermes')
fireEvent.change(urlInput, { target: { value: 'https://gateway.example.com/hermes' } })
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 550))
})
expect(desktop.probeConnectionConfig).toHaveBeenCalledTimes(1)
fireEvent.change(urlInput, { target: { value: 'not-a-url' } })
await act(async () => {
resolveProbe?.({
authMode: 'token',
baseUrl: 'https://gateway.example.com/hermes',
error: null,
providers: [],
reachable: true,
version: '0.17.0'
})
await pendingProbe
})
expect(screen.queryByPlaceholderText('Paste session token')).toBeNull()
expect((screen.getByText('Test connection').closest('button') as HTMLButtonElement).disabled).toBe(true)
expect((screen.getByText('Apply and reconnect').closest('button') as HTMLButtonElement).disabled).toBe(true)
})
it('does not enable Apply when credentials change during a connection test', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
})
)
desktop.probeConnectionConfig.mockResolvedValue({
authMode: 'token',
baseUrl: 'https://gateway.example.com/hermes',
error: null,
providers: [],
reachable: true,
version: '0.17.0'
})
let resolveTest: ((result: { baseUrl: string; ok: boolean; version: string }) => void) | undefined
const pendingTest = new Promise<{ baseUrl: string; ok: boolean; version: string }>(resolve => {
resolveTest = resolve
})
desktop.testConnectionConfig.mockReturnValue(pendingTest)
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
fireEvent.change(await screen.findByPlaceholderText('https://gateway.example.com/hermes'), {
target: { value: 'https://gateway.example.com/hermes' }
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 550))
})
const tokenInput = await screen.findByPlaceholderText('Paste session token')
const apply = screen.getByText('Apply and reconnect').closest('button') as HTMLButtonElement
fireEvent.change(tokenInput, { target: { value: 'token-a' } })
fireEvent.click(screen.getByText('Test connection'))
await waitFor(() => expect(desktop.testConnectionConfig).toHaveBeenCalledTimes(1))
fireEvent.change(tokenInput, { target: { value: 'token-b' } })
await act(async () => {
resolveTest?.({ baseUrl: 'https://gateway.example.com/hermes', ok: true, version: '0.17.0' })
await pendingTest
})
expect(screen.queryByText('Connected to https://gateway.example.com/hermes (0.17.0).')).toBeNull()
expect(apply.disabled).toBe(true)
})
it('restores remote apply controls when applying the tested connection fails', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
})
)
desktop.probeConnectionConfig.mockResolvedValue({
authMode: 'token',
baseUrl: 'https://gateway.example.com/hermes',
error: null,
providers: [],
reachable: true,
version: '0.17.0'
})
desktop.testConnectionConfig.mockResolvedValue({
baseUrl: 'https://gateway.example.com/hermes',
ok: true,
version: '0.17.0'
})
desktop.applyConnectionConfig.mockRejectedValue(new Error('remote apply failed'))
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
fireEvent.change(await screen.findByPlaceholderText('https://gateway.example.com/hermes'), {
target: { value: 'https://gateway.example.com/hermes' }
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 550))
})
fireEvent.change(await screen.findByPlaceholderText('Paste session token'), {
target: { value: 'session-secret' }
})
fireEvent.click(screen.getByText('Test connection'))
await screen.findByText('Connected to https://gateway.example.com/hermes (0.17.0).')
const apply = screen.getByText('Apply and reconnect').closest('button') as HTMLButtonElement
fireEvent.click(apply)
expect(await screen.findByText('remote apply failed')).toBeTruthy()
expect(apply.disabled).toBe(false)
expect(screen.getByText('Gateway URL')).toBeTruthy()
})
it('signs in, tests, and applies a password-style remote gateway', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
})
)
desktop.probeConnectionConfig.mockResolvedValue({
authMode: 'oauth',
baseUrl: 'https://gateway.example.com/hermes',
error: null,
providers: [{ displayName: 'Username & Password', name: 'password', supportsPassword: true }],
reachable: true,
version: '0.17.0'
})
desktop.oauthLoginConnectionConfig.mockResolvedValue({
baseUrl: 'https://gateway.example.com/hermes',
connected: true,
ok: true
})
desktop.testConnectionConfig.mockResolvedValue({
baseUrl: 'https://gateway.example.com/hermes',
ok: true,
version: null
})
desktop.applyConnectionConfig.mockResolvedValue({ mode: 'remote' })
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
fireEvent.change(await screen.findByPlaceholderText('https://gateway.example.com/hermes'), {
target: { value: 'https://gateway.example.com/hermes' }
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 550))
})
expect(screen.queryByText('Sign in with Username & Password')).toBeNull()
fireEvent.click(await screen.findByText('Sign in'))
await waitFor(() => {
expect(desktop.oauthLoginConnectionConfig).toHaveBeenCalledWith('https://gateway.example.com/hermes')
})
fireEvent.click(screen.getByText('Test connection'))
await waitFor(() => {
expect(desktop.testConnectionConfig).toHaveBeenCalledWith({
mode: 'remote',
remoteAuthMode: 'oauth',
remoteToken: undefined,
remoteUrl: 'https://gateway.example.com/hermes'
})
})
await screen.findByText('Connected to https://gateway.example.com/hermes.')
const apply = screen.getByText('Apply and reconnect').closest('button') as HTMLButtonElement
expect(apply.disabled).toBe(false)
fireEvent.click(apply)
await waitFor(() => {
expect(desktop.applyConnectionConfig).toHaveBeenCalledWith({
mode: 'remote',
remoteAuthMode: 'oauth',
remoteToken: undefined,
remoteUrl: 'https://gateway.example.com/hermes'
})
})
})
it('offers remote connection from the unsupported packaged install screen', async () => {
const desktop = installDesktopMock(
bootstrapState({
unsupportedPlatform: {
platform: 'darwin',
activeRoot: '/Users/me/.hermes/hermes-agent',
installCommand: 'curl -fsSL https://example.invalid/install.sh | sh',
docsUrl: 'https://example.invalid/docs'
}
})
)
render(<DesktopInstallOverlay />)
expect(await screen.findByText('Hermes needs a one-time install')).toBeTruthy()
fireEvent.click(screen.getByText('Connect existing'))
expect(await screen.findByText('Gateway URL')).toBeTruthy()
desktop.probeConnectionConfig.mockResolvedValue({
authMode: 'token',
baseUrl: 'https://gateway.example.com/hermes',
error: null,
providers: [],
reachable: true,
version: '0.17.0'
})
desktop.testConnectionConfig.mockResolvedValue({
baseUrl: 'https://gateway.example.com/hermes',
ok: true,
version: '0.17.0'
})
desktop.applyConnectionConfig.mockImplementation(async () => {
desktop.emitBootstrapEvent({ type: 'dismissed' })
return { mode: 'remote' }
})
fireEvent.change(screen.getByPlaceholderText('https://gateway.example.com/hermes'), {
target: { value: 'https://gateway.example.com/hermes' }
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 550))
})
fireEvent.change(await screen.findByPlaceholderText('Paste session token'), {
target: { value: 'session-secret' }
})
fireEvent.click(screen.getByText('Test connection'))
await screen.findByText('Connected to https://gateway.example.com/hermes (0.17.0).')
fireEvent.click(screen.getByText('Apply and reconnect'))
await waitFor(() => expect(screen.queryByText('Gateway URL')).toBeNull())
expect(screen.queryByText('Hermes needs a one-time install')).toBeNull()
})
})
@@ -15,12 +15,10 @@ import type {
DesktopBootstrapState
} from '@/global'
import { useI18n } from '@/i18n'
import { AlertCircle, ChevronDown, ChevronRight, Globe, iconSize, Loader2, Monitor } from '@/lib/icons'
import { ChevronDown, ChevronRight, iconSize } from '@/lib/icons'
import { capitalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { FirstRunRemoteForm } from './first-run-remote-form'
/**
* DesktopInstallOverlay
*
@@ -157,10 +155,6 @@ function StageRow({ descriptor, result, now }: StageRowProps) {
)
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err || 'Unknown error')
}
const EMPTY_STATE: DesktopBootstrapState = {
active: false,
manifest: null,
@@ -169,32 +163,10 @@ const EMPTY_STATE: DesktopBootstrapState = {
log: [],
startedAt: null,
completedAt: null,
setupChoice: null,
unsupportedPlatform: null
}
function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): DesktopBootstrapState {
if (ev.type === 'dismissed') {
return { ...EMPTY_STATE }
}
if (ev.type === 'setup-choice') {
return {
...state,
active: false,
manifest: null,
stages: {},
error: null,
setupChoice: ev.active
? {
platform: ev.platform || state.setupChoice?.platform || 'unknown',
activeRoot: ev.activeRoot || state.setupChoice?.activeRoot || ''
}
: null,
unsupportedPlatform: null
}
}
if (ev.type === 'manifest') {
const stages: Record<string, DesktopBootstrapStageResult> = {}
@@ -208,7 +180,6 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
manifest: { type: 'manifest', stages: ev.stages, protocolVersion: ev.protocolVersion },
stages,
error: null,
setupChoice: null,
startedAt: state.startedAt || Date.now()
}
}
@@ -248,14 +219,13 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
}
if (ev.type === 'failed') {
return { ...state, active: false, error: ev.error || 'unknown error', setupChoice: null }
return { ...state, active: false, error: ev.error || 'unknown error' }
}
if (ev.type === 'unsupported-platform') {
return {
...state,
active: false,
setupChoice: null,
unsupportedPlatform: {
platform: ev.platform,
activeRoot: ev.activeRoot,
@@ -276,9 +246,6 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
const [logOpen, setLogOpen] = useState(false)
const [copied, setCopied] = useState(false)
const [cancelling, setCancelling] = useState(false)
const [remoteOpen, setRemoteOpen] = useState(false)
const [localStarting, setLocalStarting] = useState(false)
const [localStartError, setLocalStartError] = useState<string | null>(null)
const [now, setNow] = useState(() => Date.now())
const logEndRef = useRef<HTMLDivElement | null>(null)
@@ -345,14 +312,6 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
}
}, [state.error])
// The choice remains mounted while main hands off to local bootstrap. Once
// a manifest/failure takes ownership (or a later repair presents a fresh
// choice), clear the transient button state so it cannot leak across phases.
useEffect(() => {
setLocalStarting(false)
setLocalStartError(null)
}, [state.setupChoice?.activeRoot])
// Mount logic: show whenever a bootstrap is in flight, completed-with-error,
// or actively running with a manifest. Hide entirely after a successful
// completion so the rest of the UI can take over.
@@ -373,96 +332,13 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
return true
}
if (state.setupChoice) {
return true
}
return false
}, [enabled, state.active, state.error, state.setupChoice, state.unsupportedPlatform])
}, [enabled, state.active, state.error, state.unsupportedPlatform])
if (!shouldShow) {
return null
}
if (remoteOpen) {
return <FirstRunRemoteForm onBack={() => setRemoteOpen(false)} />
}
if (state.setupChoice) {
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 p-4 backdrop-blur-md">
<div className="w-full max-w-2xl rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
<div className="flex items-start gap-4">
<BrandMark className="size-11 shrink-0" />
<div className="min-w-0">
<h2 className="text-xl font-semibold tracking-tight">{copy.setupChoiceTitle}</h2>
<p className="mt-1.5 text-sm text-muted-foreground">{copy.setupChoiceDesc}</p>
</div>
</div>
<div className="mt-6 grid gap-3 sm:grid-cols-2">
<button
className="rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-4 text-left transition hover:bg-(--chrome-action-hover)"
onClick={() => setRemoteOpen(true)}
type="button"
>
<div className="flex items-center gap-2 text-sm font-medium">
<Globe className="size-4 text-muted-foreground" />
<span>{copy.connectExistingTitle}</span>
</div>
<p className="mt-2 text-sm leading-5 text-muted-foreground">{copy.connectExistingDesc}</p>
</button>
<button
className="rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-4 text-left transition hover:bg-(--chrome-action-hover) disabled:cursor-wait disabled:opacity-60"
disabled={localStarting}
onClick={async () => {
setLocalStarting(true)
setLocalStartError(null)
try {
const desktop = window.hermesDesktop
if (!desktop || typeof desktop.continueBootstrapLocal !== 'function') {
throw new Error(copy.localStartUnavailable)
}
await desktop.continueBootstrapLocal()
} catch (err) {
setLocalStartError(errorMessage(err))
setLocalStarting(false)
}
}}
type="button"
>
<div className="flex items-center gap-2 text-sm font-medium">
{localStarting ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : (
<Monitor className="size-4 text-muted-foreground" />
)}
<span>{copy.installLocalTitle}</span>
</div>
<p className="mt-2 text-sm leading-5 text-muted-foreground">{copy.installLocalDesc}</p>
</button>
</div>
{localStartError ? (
<div className="mt-4 flex items-start gap-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{localStartError}</span>
</div>
) : null}
<div className="mt-6 text-xs text-muted-foreground">
{copy.installTo}{' '}
<code className="font-mono text-(--ui-text-secondary)">{state.setupChoice.activeRoot}</code>
</div>
</div>
</div>
)
}
// Unsupported-platform branch: macOS/Linux packaged builds hit this when
// there's no Hermes Agent installed yet and we can't drive install.sh
// (no stage protocol equivalent yet). Show a copy-paste install command
@@ -508,15 +384,9 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<span className="text-xs text-muted-foreground">
{copy.installTo} <code className="font-mono text-(--ui-text-secondary)">{ups.activeRoot}</code>
</span>
<div className="flex items-center gap-2">
<Button onClick={() => setRemoteOpen(true)} size="sm" variant="secondary">
<Globe className="size-4" />
{copy.connectExistingShort}
</Button>
<Button onClick={() => window.location.reload()} size="sm" variant="default">
{copy.retryAfterRun}
</Button>
</div>
<Button onClick={() => window.location.reload()} size="sm" variant="default">
{copy.retryAfterRun}
</Button>
</div>
</div>
</div>
@@ -1,345 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { BrandMark } from '@/components/brand-mark'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { DesktopConnectionProbeResult } from '@/global'
import { useI18n } from '@/i18n'
import { deriveRemoteAuthProviderShape } from '@/lib/desktop-remote-auth'
import { AlertCircle, Check, Loader2, LogIn } from '@/lib/icons'
type AuthMode = 'oauth' | 'token'
type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
interface FirstRunRemoteFormProps {
onBack: () => void
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err || 'Unknown error')
}
export function FirstRunRemoteForm({ onBack }: FirstRunRemoteFormProps) {
const { t } = useI18n()
const copy = t.install
const [remoteUrl, setRemoteUrl] = useState('')
const [remoteToken, setRemoteToken] = useState('')
const [probeStatus, setProbeStatus] = useState<ProbeStatus>('idle')
const [probe, setProbe] = useState<DesktopConnectionProbeResult | null>(null)
const [oauthConnected, setOauthConnected] = useState(false)
const [signingIn, setSigningIn] = useState(false)
const [testing, setTesting] = useState(false)
const [applying, setApplying] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [lastTestedPayloadKey, setLastTestedPayloadKey] = useState<string | null>(null)
const probeSeq = useRef(0)
const testSeq = useRef(0)
const trimmedUrl = remoteUrl.trim()
const invalidateTest = useCallback(() => {
testSeq.current += 1
setTesting(false)
setError(null)
setSuccess(null)
setLastTestedPayloadKey(null)
}, [])
useEffect(() => {
const seq = ++probeSeq.current
if (!trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
setProbeStatus('idle')
setProbe(null)
setOauthConnected(false)
return
}
const desktop = window.hermesDesktop
if (!desktop?.probeConnectionConfig) {
return
}
setProbeStatus('probing')
const timer = window.setTimeout(() => {
desktop
.probeConnectionConfig(trimmedUrl)
.then(result => {
if (seq !== probeSeq.current) {
return
}
invalidateTest()
setProbe(result)
setProbeStatus(result.reachable ? 'done' : 'error')
if (result.reachable && result.authMode !== 'oauth') {
setOauthConnected(false)
}
})
.catch(err => {
if (seq !== probeSeq.current) {
return
}
setProbe(null)
setProbeStatus('error')
setError(errorMessage(err))
})
}, 500)
return () => window.clearTimeout(timer)
}, [invalidateTest, trimmedUrl])
const authMode: AuthMode = probeStatus === 'done' && probe?.authMode === 'oauth' ? 'oauth' : 'token'
const authResolved = probeStatus === 'done' && probe?.authMode !== 'unknown'
const authProviderShape = deriveRemoteAuthProviderShape(probe?.providers, copy.identityProvider)
const { isPassword: isPasswordProvider, providerLabel } = authProviderShape
const canRetryProbe = Boolean(trimmedUrl && probeStatus === 'error')
const canTest = Boolean(
trimmedUrl && (canRetryProbe || (authResolved && (authMode === 'oauth' ? oauthConnected : remoteToken.trim())))
)
const payload = () => ({
mode: 'remote' as const,
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
})
const currentPayloadKey = JSON.stringify(payload())
const payloadKeyRef = useRef(currentPayloadKey)
payloadKeyRef.current = currentPayloadKey
const canApply = lastTestedPayloadKey === currentPayloadKey
const signIn = async () => {
if (!trimmedUrl) {
setError(copy.enterUrlFirst)
return
}
setSigningIn(true)
setError(null)
try {
// Unlike Settings, first-run intentionally does not pre-save remote mode:
// backing out must still allow local install without leaving a remote
// connection selected. The login IPC accepts the raw URL and stores only
// its OAuth cookies; config is persisted once the user applies.
const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
invalidateTest()
setOauthConnected(Boolean(result.connected))
if (!result.connected) {
setError(copy.signInIncomplete)
}
} catch (err) {
setError(errorMessage(err))
} finally {
setSigningIn(false)
}
}
const testRemote = async () => {
if (!canTest) {
setError(authMode === 'oauth' ? copy.incompleteSignInTest : copy.incompleteTokenTest)
return
}
const seq = ++testSeq.current
const testedPayload = payload()
const testedPayloadKey = JSON.stringify(testedPayload)
setTesting(true)
setError(null)
setSuccess(null)
setLastTestedPayloadKey(null)
try {
if (!authResolved) {
const result = await window.hermesDesktop.probeConnectionConfig(trimmedUrl)
if (seq !== testSeq.current || testedPayloadKey !== payloadKeyRef.current) {
return
}
setProbe(result)
setProbeStatus(result.reachable ? 'done' : 'error')
setError(result.reachable && result.authMode !== 'unknown' ? null : result.error || copy.probeError)
return
}
const result = await window.hermesDesktop.testConnectionConfig(testedPayload)
if (seq !== testSeq.current || testedPayloadKey !== payloadKeyRef.current) {
return
}
setSuccess(copy.testSucceeded(result.baseUrl || trimmedUrl, result.version ?? undefined))
setLastTestedPayloadKey(testedPayloadKey)
} catch (err) {
if (seq === testSeq.current && testedPayloadKey === payloadKeyRef.current) {
setError(errorMessage(err))
}
} finally {
if (seq === testSeq.current) {
setTesting(false)
}
}
}
const applyRemote = async () => {
if (!canApply) {
return
}
const testedPayload = payload()
setApplying(true)
setError(null)
let applied = false
try {
await window.hermesDesktop.applyConnectionConfig(testedPayload)
applied = true
} catch (err) {
setError(errorMessage(err))
} finally {
setApplying(false)
}
if (applied) {
onBack()
}
}
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 p-4 backdrop-blur-md">
<div className="flex w-full max-w-xl flex-col rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
<div className="flex items-start gap-4">
<BrandMark className="size-11 shrink-0" />
<div className="min-w-0">
<h2 className="text-xl font-semibold tracking-tight">{copy.remoteSetupTitle}</h2>
<p className="mt-1.5 text-sm text-muted-foreground">{copy.remoteSetupDesc}</p>
</div>
</div>
<div className="mt-6 grid gap-4">
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">{copy.remoteUrlTitle}</span>
<Input
autoComplete="url"
disabled={applying}
onChange={event => {
invalidateTest()
setRemoteUrl(event.target.value)
}}
placeholder={copy.remoteUrlPlaceholder}
value={remoteUrl}
/>
<span className="text-xs text-muted-foreground">{copy.remoteUrlDesc}</span>
</label>
{probeStatus === 'probing' ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{copy.probing}
</div>
) : null}
{probeStatus === 'error' ? (
<div className="flex items-start gap-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{probe?.error || copy.probeError}</span>
</div>
) : null}
{authResolved && authMode === 'oauth' ? (
<div className="rounded-md border border-(--ui-stroke-tertiary) p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{copy.authTitle}</div>
<p className="mt-1 text-xs text-muted-foreground">
{oauthConnected ? copy.authSignedIn : copy.authNeedsOauth(providerLabel)}
</p>
</div>
{oauthConnected ? (
<div className="flex items-center gap-1.5 text-sm text-primary">
<Check className="size-4" />
{copy.connected}
</div>
) : (
<Button disabled={signingIn || applying} onClick={() => void signIn()} size="sm">
{signingIn ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
{isPasswordProvider ? copy.signIn : copy.signInWith(providerLabel)}
</Button>
)}
</div>
</div>
) : null}
{authResolved && authMode === 'token' ? (
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">{copy.tokenTitle}</span>
<Input
autoComplete="off"
disabled={applying}
onChange={event => {
invalidateTest()
setRemoteToken(event.target.value)
}}
placeholder={copy.pasteSessionToken}
type="password"
value={remoteToken}
/>
<span className="text-xs text-muted-foreground">{copy.tokenDesc}</span>
</label>
) : null}
{error ? (
<div className="flex items-start gap-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
) : null}
{success ? (
<div className="flex items-center gap-2 text-sm text-primary">
<Check className="size-4" />
<span>{success}</span>
</div>
) : null}
</div>
<div className="mt-7 flex flex-wrap items-center justify-between gap-3">
<Button disabled={applying} onClick={onBack} size="sm" variant="ghost">
{copy.backToSetup}
</Button>
<div className="flex items-center gap-2">
<Button
disabled={testing || applying || !canTest}
onClick={() => void testRemote()}
size="sm"
variant="secondary"
>
{testing ? <Loader2 className="size-4 animate-spin" /> : null}
{copy.testConnection}
</Button>
<Button disabled={applying || !canApply} onClick={() => void applyRemote()} size="sm">
{applying ? <Loader2 className="size-4 animate-spin" /> : null}
{copy.applyRemote}
</Button>
</div>
</div>
</div>
</div>
)
}
@@ -1,87 +0,0 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Dialog, DialogContent, DialogTitle } from './dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select'
afterEach(cleanup)
// jsdom lacks these; Radix Select calls them on open.
window.HTMLElement.prototype.scrollIntoView = vi.fn()
window.HTMLElement.prototype.hasPointerCapture = vi.fn()
window.HTMLElement.prototype.releasePointerCapture = vi.fn()
const flushTimers = () => new Promise(resolve => setTimeout(resolve, 10))
describe('REPRO: dismissing an open Select inside a Dialog', () => {
it('closes only the select, not the dialog, when the press lands on the overlay', async () => {
// While a modal Select is open, the dialog content div is pointer-events:
// none, so a real click anywhere in the dialog body hit-tests through to
// the overlay. Simulate that by firing the pointer sequence on the overlay.
const onOpenChange = vi.fn()
render(
<Dialog onOpenChange={onOpenChange} open>
<DialogContent>
<DialogTitle>Test dialog</DialogTitle>
<Select defaultOpen>
<SelectTrigger aria-label="picker">
<SelectValue placeholder="pick" />
</SelectTrigger>
<SelectContent>
<SelectItem value="a">Option A</SelectItem>
</SelectContent>
</Select>
</DialogContent>
</Dialog>
)
// DismissableLayer registers its document pointerdown listener in a
// setTimeout(0) — flush it first.
await flushTimers()
const overlay = document.querySelector('[data-slot="dialog-overlay"]') as HTMLElement
expect(overlay).toBeTruthy()
expect(screen.getByText('Option A')).toBeTruthy()
fireEvent.pointerDown(overlay, { button: 0 })
fireEvent.pointerUp(overlay, { button: 0 })
fireEvent.click(overlay, { button: 0 })
// The deferred dismissal path resolves through another setTimeout(0).
await flushTimers()
// The select closed...
expect(screen.queryByText('Option A')).toBeNull()
// ...but the dialog must NOT have been asked to close.
expect(onOpenChange).not.toHaveBeenCalledWith(false)
})
it('still closes the dialog on a genuine overlay click when no dropdown is open', async () => {
const onOpenChange = vi.fn()
render(
<Dialog onOpenChange={onOpenChange} open>
<DialogContent>
<DialogTitle>Test dialog</DialogTitle>
<Select>
<SelectTrigger aria-label="picker">
<SelectValue placeholder="pick" />
</SelectTrigger>
<SelectContent>
<SelectItem value="a">Option A</SelectItem>
</SelectContent>
</Select>
</DialogContent>
</Dialog>
)
await flushTimers()
const overlay = document.querySelector('[data-slot="dialog-overlay"]') as HTMLElement
fireEvent.pointerDown(overlay, { button: 0 })
fireEvent.pointerUp(overlay, { button: 0 })
fireEvent.click(overlay, { button: 0 })
await flushTimers()
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})
@@ -1,29 +0,0 @@
import * as React from 'react'
// Layering system for portalled popovers (Select / Popover / DropdownMenu) that
// open INSIDE a Dialog.
//
// By default every Radix popover portals to `document.body`, i.e. as a sibling
// of the Dialog — OUTSIDE the dialog's DOM subtree. That breaks two things:
// 1. Dismissing the popover (clicking elsewhere in the dialog) moves focus and
// fires pointer/focus events the Dialog's DismissableLayer reads as
// "outside", so the whole dialog closes.
// 2. z-index across separate body-level portals is fragile — the popover and
// dialog live in different stacking siblings.
//
// The fix: a Dialog publishes its own content node here; any popover rendered
// inside that Dialog portals into that node instead of `document.body`. Now the
// popover is a real DOM descendant of the dialog — focus never leaves, the
// DismissableLayer sees it as inside, and it shares the dialog's stacking
// context so z-index is deterministic. Outside a dialog the value is null and
// popovers fall back to the default body portal.
export const DialogPortalContainerContext = React.createContext<HTMLElement | null>(null)
// The container a popover should portal into: an explicit `container` prop wins,
// then the enclosing dialog's content node, then undefined (Radix default:
// document.body).
export function usePopoverPortalContainer(explicit?: HTMLElement | null): HTMLElement | undefined {
const fromDialog = React.useContext(DialogPortalContainerContext)
return explicit ?? fromDialog ?? undefined
}
@@ -2,40 +2,9 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Dialog, DialogContent, DialogTitle, preventCloseButtonAutoFocus } from './dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select'
afterEach(cleanup)
describe('Select portalled inside a Dialog', () => {
it('renders the open dropdown inside the dialog DOM subtree, not document.body', () => {
// Force the Select open (defaultOpen) to sidestep jsdom's missing
// hasPointerCapture on the trigger. The dropdown content must land inside the
// dialog content node — that DOM nesting is what keeps focus in the dialog so
// dismissing the dropdown no longer closes the dialog.
render(
<Dialog open>
<DialogContent>
<DialogTitle>Test dialog</DialogTitle>
<Select defaultOpen>
<SelectTrigger aria-label="picker">
<SelectValue placeholder="pick" />
</SelectTrigger>
<SelectContent>
<SelectItem value="a">Option A</SelectItem>
</SelectContent>
</Select>
</DialogContent>
</Dialog>
)
const dialog = screen.getByRole('dialog')
const option = screen.getByText('Option A')
// The dropdown item is a descendant of the dialog, i.e. portalled into it.
expect(dialog.contains(option)).toBe(true)
})
})
describe('DialogContent close button', () => {
it('closes the dialog when clicked', () => {
const onOpenChange = vi.fn()
+19 -35
View File
@@ -2,7 +2,6 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { DialogPortalContainerContext } from '@/components/ui/dialog-portal-context'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { X } from '@/lib/icons'
@@ -86,15 +85,6 @@ function DialogContent({
const widthClass = fitContent ? 'w-auto max-w-[92vw]' : 'w-full max-w-lg'
// Publish the dialog's content node so popovers (Select / Popover /
// DropdownMenu) opened inside it portal INTO the dialog instead of
// document.body. That keeps them as DOM descendants — focus never leaves the
// dialog, so dismissing a dropdown (or clicking another field) no longer
// trips the Dialog's outside-interaction/focus-out close. See
// dialog-portal-context.ts. State (not just a ref) so consumers re-render once
// the node mounts.
const [contentNode, setContentNode] = React.useState<HTMLElement | null>(null)
// No default here — Radix's normal autofocus (first focusable element, often
// an input) is what most dialogs want. Dialogs with no input should pass
// `onOpenAutoFocus={preventCloseButtonAutoFocus}` explicitly instead.
@@ -139,28 +129,25 @@ function DialogContent({
)}
data-slot="dialog-content"
onOpenAutoFocus={onOpenAutoFocus}
ref={setContentNode}
{...props}
>
<DialogPortalContainerContext.Provider value={contentNode}>
{/* Scroll lives on an inner box so this shell keeps a painted bottom radius. */}
<div className="relative z-10 overflow-hidden rounded-xl border border-b-0 border-(--stroke-nous) bg-(--ui-chat-bubble-background)">
<div className="grid max-h-[calc(85vh-5rem)] min-h-0 gap-3 overflow-y-auto p-4">{children}</div>
</div>
<div
className={cn(
// Overlap by one corner radius so the white bottom lobes read clearly
// over the tint instead of meeting it on a straight seam.
'relative z-0 -mt-[var(--radius-xl)] px-4 pb-2.5 pt-[calc(var(--radius-xl)+0.625rem)] text-center text-[length:var(--conversation-tool-font-size)] leading-relaxed shadow-[inset_0_7px_7px_-4px_rgb(0_0_0/0.28)]',
DIALOG_BANNER_TONES[bannerTone]
)}
data-slot="dialog-banner"
role={bannerTone === 'error' ? 'alert' : 'status'}
>
{banner}
</div>
{closeButton}
</DialogPortalContainerContext.Provider>
{/* Scroll lives on an inner box so this shell keeps a painted bottom radius. */}
<div className="relative z-10 overflow-hidden rounded-xl border border-b-0 border-(--stroke-nous) bg-(--ui-chat-bubble-background)">
<div className="grid max-h-[calc(85vh-5rem)] min-h-0 gap-3 overflow-y-auto p-4">{children}</div>
</div>
<div
className={cn(
// Overlap by one corner radius so the white bottom lobes read clearly
// over the tint instead of meeting it on a straight seam.
'relative z-0 -mt-[var(--radius-xl)] px-4 pb-2.5 pt-[calc(var(--radius-xl)+0.625rem)] text-center text-[length:var(--conversation-tool-font-size)] leading-relaxed shadow-[inset_0_7px_7px_-4px_rgb(0_0_0/0.28)]',
DIALOG_BANNER_TONES[bannerTone]
)}
data-slot="dialog-banner"
role={bannerTone === 'error' ? 'alert' : 'status'}
>
{banner}
</div>
{closeButton}
</DialogPrimitive.Content>
</DialogPortal>
)
@@ -180,13 +167,10 @@ function DialogContent({
)}
data-slot="dialog-content"
onOpenAutoFocus={onOpenAutoFocus}
ref={setContentNode}
{...props}
>
<DialogPortalContainerContext.Provider value={contentNode}>
{children}
{closeButton}
</DialogPortalContainerContext.Provider>
{children}
{closeButton}
</DialogPrimitive.Content>
</DialogPortal>
)
@@ -2,7 +2,6 @@ import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'
import * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
import { usePopoverPortalContainer } from '@/components/ui/dialog-portal-context'
import { cn } from '@/lib/utils'
// Shared class tokens for edge-to-edge menus (use with `p-0` content): rows go
@@ -74,12 +73,8 @@ function DropdownMenuContent({
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
// Portal into the enclosing dialog when nested in one, so dismissing the menu
// doesn't close the dialog; document.body otherwise.
const container = usePopoverPortalContainer()
return (
<DropdownMenuPrimitive.Portal container={container}>
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
// `dt-portal-scrollbar` reproduces the thin themed scrollbar from
// `.scrollbar-dt` for portaled overlays (Radix renders this under
+1 -6
View File
@@ -1,7 +1,6 @@
import { Popover as PopoverPrimitive } from 'radix-ui'
import * as React from 'react'
import { usePopoverPortalContainer } from '@/components/ui/dialog-portal-context'
import { cn } from '@/lib/utils'
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
@@ -28,12 +27,8 @@ function PopoverContent({
sideOffset = 6,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
// Portal into the enclosing dialog when nested in one (keeps focus inside so
// the dialog doesn't close on dismiss); document.body otherwise.
const container = usePopoverPortalContainer()
return (
<PopoverPrimitive.Portal container={container}>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
arrowPadding={arrowPadding}
+1 -7
View File
@@ -3,7 +3,6 @@ import * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
import { type ControlVariantProps, controlVariants } from '@/components/ui/control'
import { usePopoverPortalContainer } from '@/components/ui/dialog-portal-context'
import { cn } from '@/lib/utils'
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
@@ -44,13 +43,8 @@ function SelectContent({
position = 'popper',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
// Portal into the enclosing dialog (if any) so the dropdown is a DOM
// descendant of the dialog — keeps focus inside and stops the dialog closing
// when the dropdown is dismissed. Falls back to document.body outside a dialog.
const container = usePopoverPortalContainer()
return (
<SelectPrimitive.Portal container={container}>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className={cn(
'relative z-[140] max-h-72 min-w-32 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=top]:slide-in-from-bottom-2 data-[side=right]:slide-in-from-left-2',
@@ -1,50 +0,0 @@
import type { DesktopVersionInfo } from '@/global'
import { useI18n } from '@/i18n'
import { ExternalLink } from '@/lib/external-link'
/**
* Shared build-provenance display. Reads from `$desktopVersion`
* (populated from the build stamp / `hermes:version` IPC), so every
* surface — the About settings page, the updates overlay — shows the
* same version, branch, commit, and dirty flag from one source of truth.
*/
export function VersionDetails({ version }: { version: DesktopVersionInfo }) {
const { t } = useI18n()
const u = t.updates
const unknownDistance = version.dirty && version.distance == null
return (
<dl className="grid gap-2 rounded-lg border border-border/70 bg-muted/20 px-3 py-3 text-sm">
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{u.versionDetailsVersion}</dt>
<dd>v{version.appVersion}</dd>
</div>
{version.baseVersion && (
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{u.versionDetailsBaseVersion}</dt>
<dd>{version.baseVersion}</dd>
</div>
)}
{version.branch && (
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{u.versionDetailsBranch}</dt>
<dd className="break-all text-right">{version.branch}</dd>
</div>
)}
{version.commit && (
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{u.versionDetailsCommit}</dt>
<ExternalLink
className="break-all font-mono text-xs"
href={`https://github.com/NousResearch/hermes-agent/commit/${version.commit}`}
>
{version.commit.slice(0, 14)}
</ExternalLink>
</div>
)}
{version.dirty && (
<div className="text-warning">{unknownDistance ? u.versionDetailsDirtyUnknown : u.versionDetailsDirty}</div>
)}
</dl>
)
}
-21
View File
@@ -211,7 +211,6 @@ declare global {
onPowerResume?: (callback: () => void) => () => void
onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void
getBootstrapState: () => Promise<DesktopBootstrapState>
continueBootstrapLocal: () => Promise<{ ok: boolean }>
resetBootstrap: () => Promise<{ ok: boolean }>
repairBootstrap: () => Promise<{ ok: boolean }>
cancelBootstrap: () => Promise<{ ok: boolean; cancelled: boolean }>
@@ -276,17 +275,10 @@ export interface HermesTerminalExit {
export interface DesktopVersionInfo {
appVersion: string
baseVersion?: string
branch?: string
commit?: string
distance?: number
dirty?: boolean
electronVersion: string
nodeVersion: string
platform: string
hermesRoot: string
installMethod?: string
source?: 'build' | 'ci' | 'docker' | 'fallback' | 'git' | 'local' | 'nix' | 'unknown'
}
export type DesktopUninstallMode = 'full' | 'gui' | 'lite'
@@ -635,11 +627,6 @@ export interface DesktopBootstrapUnsupportedPlatform {
docsUrl: string
}
export interface DesktopBootstrapSetupChoice {
platform: string
activeRoot: string
}
export interface DesktopBootstrapState {
active: boolean
manifest: { type: 'manifest'; stages: DesktopBootstrapStageDescriptor[]; protocolVersion: number | null } | null
@@ -648,18 +635,10 @@ export interface DesktopBootstrapState {
log: Array<{ ts: number; stage: string | null; line: string; stream?: 'stdout' | 'stderr' }>
startedAt: number | null
completedAt: number | null
setupChoice: DesktopBootstrapSetupChoice | null
unsupportedPlatform: DesktopBootstrapUnsupportedPlatform | null
}
export type DesktopBootstrapEvent =
| { type: 'dismissed' }
| {
type: 'setup-choice'
active: boolean
platform?: string
activeRoot?: string
}
| { type: 'manifest'; stages: DesktopBootstrapStageDescriptor[]; protocolVersion: number | null }
| {
type: 'stage'
+2 -121
View File
@@ -6,12 +6,10 @@ import type {
AnalyticsResponse,
AudioSpeakResponse,
AudioTranscriptionResponse,
AutomationBlueprint,
AuxiliaryModelsResponse,
BackendUpdateCheckResponse,
ComputerUseStatus,
ConfigSchemaResponse,
CronDeliveryTarget,
CronJob,
CronJobCreatePayload,
CronJobUpdates,
@@ -60,11 +58,7 @@ import type {
TerminalBackendsResponse,
ToolsetConfig,
ToolsetInfo,
ToolsetModelsResponse,
WebhookCreatePayload,
WebhookCreateResponse,
WebhookEnableResponse,
WebhooksResponse
ToolsetModelsResponse
} from '@/types/hermes'
// Desktop startup fires a burst of read-only data calls (config, profiles,
@@ -132,8 +126,6 @@ export type {
AnalyticsTotals,
AudioSpeakResponse,
AudioTranscriptionResponse,
AutomationBlueprint,
AutomationBlueprintField,
AuxiliaryModelsResponse,
BackendUpdateCheckResponse,
ComputerUseCheck,
@@ -141,7 +133,6 @@ export type {
ComputerUseStatus,
ConfigFieldSchema,
ConfigSchemaResponse,
CronDeliveryTarget,
CronJob,
CronJobCreatePayload,
CronJobSchedule,
@@ -211,12 +202,7 @@ export type {
ToolsetConfig,
ToolsetInfo,
ToolsetModel,
ToolsetModelsResponse,
WebhookCreatePayload,
WebhookCreateResponse,
WebhookEnableResponse,
WebhookRoute,
WebhooksResponse
ToolsetModelsResponse
} from '@/types/hermes'
export class HermesGateway extends JsonRpcGatewayClient {
@@ -560,19 +546,6 @@ export function setSessionArchived(id: string, archived: boolean, profile?: stri
})
}
// Mirror a sidebar pin to the backend "keep" flag so the sessions.auto_archive
// sweep (which runs backend-side, blind to Desktop localStorage) never hides a
// pinned chat. Best-effort: the sidebar stays localStorage-driven for its own
// display; this only feeds the backend policy.
export function setSessionPinnedRemote(id: string, pinned: boolean, profile?: string | null): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { pinned }
})
}
export function searchSessions(query: string): Promise<SessionSearchResponse> {
return window.hermesDesktop.api<SessionSearchResponse>({
path: `/api/sessions/search?q=${encodeURIComponent(query)}`
@@ -1136,55 +1109,6 @@ export function testMessagingPlatform(platformId: string): Promise<MessagingPlat
})
}
// -- Webhooks (subscription CRUD) --------------------------------------------
// The webhook receiver is its own gateway platform; subscriptions live in a
// shared JSON store the CLI/dashboard also drive. Enable mutates config and
// best-effort restarts the gateway; subscription changes hot-reload.
export function getWebhooks(): Promise<WebhooksResponse> {
return window.hermesDesktop.api<WebhooksResponse>({
...profileScoped(),
path: '/api/webhooks'
})
}
export function enableWebhooks(): Promise<WebhookEnableResponse> {
return window.hermesDesktop.api<WebhookEnableResponse>({
...profileScoped(),
path: '/api/webhooks/enable',
method: 'POST'
})
}
export function createWebhook(body: WebhookCreatePayload): Promise<WebhookCreateResponse> {
return window.hermesDesktop.api<WebhookCreateResponse>({
...profileScoped(),
path: '/api/webhooks',
method: 'POST',
body
})
}
export function deleteWebhook(name: string): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...profileScoped(),
path: `/api/webhooks/${encodeURIComponent(name)}`,
method: 'DELETE'
})
}
export function setWebhookEnabled(
name: string,
enabled: boolean
): Promise<{ enabled: boolean; name: string; ok: boolean }> {
return window.hermesDesktop.api<{ enabled: boolean; name: string; ok: boolean }>({
...profileScoped(),
path: `/api/webhooks/${encodeURIComponent(name)}/enabled`,
method: 'PUT',
body: { enabled }
})
}
// Cron jobs are stored per-profile (<HERMES_HOME>/cron/jobs.json), and the
// backend's list endpoint defaults to 'all'. Pass a concrete profile key to
// list just that profile's jobs, or 'all' for the unified cross-profile view.
@@ -1216,18 +1140,6 @@ export async function getCronJobRuns(jobId: string, limit = 20): Promise<Session
return runs ?? []
}
// The single source of truth for cron delivery targets (local + configured
// gateways). Both the manual cron editor and the blueprint dialog use this so
// they never offer a platform that isn't connected. Mirrors the dashboard.
export async function getCronDeliveryTargets(): Promise<CronDeliveryTarget[]> {
const { targets } = await window.hermesDesktop.api<{ targets: CronDeliveryTarget[] }>({
...profileScoped(),
path: '/api/cron/delivery-targets'
})
return targets ?? []
}
export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
...profileScoped(),
@@ -1278,37 +1190,6 @@ export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> {
})
}
// Automation Blueprints — parameterized cron templates the backend serves from
// cron/blueprint_catalog.py. getAutomationBlueprints returns the gallery
// (deliver options already rewritten to this machine's configured gateways);
// instantiateAutomationBlueprint fills the slots and creates a real cron job via
// the same create_job path as createCronJob.
//
// Profile-scoping is intentionally asymmetric: the GET catalog is global (the
// list endpoint takes no profile — only deliver options are rewritten from the
// configured gateways), so it carries only the profileScoped() header for
// routing. instantiate creates a real per-profile job, so it names the target
// profile explicitly via ?profile=. This mirrors the dashboard's api.ts.
export function getAutomationBlueprints(): Promise<{ blueprints: AutomationBlueprint[] }> {
return window.hermesDesktop.api<{ blueprints: AutomationBlueprint[] }>({
...profileScoped(),
path: '/api/cron/blueprints',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export function instantiateAutomationBlueprint(
body: { blueprint: string; values: Record<string, string> },
profile: string
): Promise<CronJob> {
return window.hermesDesktop.api<CronJob>({
...profileScoped(),
path: `/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`,
method: 'POST',
body
})
}
export function getProfiles(): Promise<ProfilesResponse> {
return window.hermesDesktop.api<ProfilesResponse>({
path: '/api/profiles',
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -1,4 +1,3 @@
import { ar } from './ar'
import { en } from './en'
import { ja } from './ja'
import type { Locale, Translations } from './types'
@@ -9,6 +8,5 @@ export const TRANSLATIONS: Record<Locale, Translations> = {
en,
zh,
'zh-hant': zhHant,
ja,
ar
ja
}
-18
View File
@@ -209,24 +209,6 @@ describe('I18nProvider', () => {
expect(screen.getByTestId('locale').textContent).toBe('ja')
})
it('applies RTL direction for Arabic and restores LTR on switch back', async () => {
render(
<I18nProvider configClient={null} initialLocale="ar">
<LanguageProbe target="en" />
</I18nProvider>
)
expect(screen.getByTestId('locale').textContent).toBe('ar')
expect(document.documentElement.dir).toBe('rtl')
expect(document.documentElement.lang).toBe('ar')
fireEvent.click(screen.getByRole('button', { name: 'switch' }))
await waitFor(() => expect(screen.getByTestId('locale').textContent).toBe('en'))
expect(document.documentElement.dir).toBe('ltr')
expect(document.documentElement.lang).toBe('en')
})
it('rolls back the visible locale when saving fails', async () => {
const configClient: I18nConfigClient = {
getConfig: vi.fn().mockResolvedValue({ display: { language: 'en' } }),
-12
View File
@@ -55,17 +55,6 @@ function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
const RTL_LOCALES = new Set<Locale>(['ar'])
function applyDocumentLocale(locale: Locale) {
if (typeof document === 'undefined') {
return
}
document.documentElement.lang = locale
document.documentElement.dir = RTL_LOCALES.has(locale) ? 'rtl' : 'ltr'
}
export interface I18nContextValue {
configLoadError: Error | null
isLoadingConfig: boolean
@@ -103,7 +92,6 @@ export function I18nProvider({ children, configClient = defaultConfigClient, ini
useEffect(() => {
localeRef.current = locale
setRuntimeI18nLocale(locale)
applyDocumentLocale(locale)
}, [locale])
useEffect(() => {
+2 -142
View File
@@ -520,6 +520,7 @@ export const en: Translations = {
automaticUpdates: 'Automatic updates',
automaticUpdatesDesc:
'Hermes checks for updates automatically in the background and lets you know when one is ready.',
branchCommit: (branch, commit) => `Branch ${branch} · Commit ${commit}`,
never: 'never',
justNow: 'just now',
minAgo: count => `${count} min ago`,
@@ -842,12 +843,6 @@ export const en: Translations = {
messages: count => `${count} ${count === 1 ? 'message' : 'messages'}`,
restored: 'Restored',
deleteConfirm: title => `Permanently delete "${title}"? This cannot be undone.`,
autoArchiveTitle: 'Auto-archive stale chats',
autoArchiveDesc:
"Automatically archive chats you haven't touched in a while. Pinned chats are never archived, and nothing is deleted — archived chats just move here.",
autoArchiveDaysLabel: 'Archive after',
autoArchiveDaysUnit: 'days of inactivity',
autoArchiveFailed: 'Could not update auto-archive',
defaultDirTitle: 'Default project directory',
defaultDirDesc:
'New sessions start in this folder unless you pick another. Leave it unset to use your home directory.',
@@ -1421,72 +1416,6 @@ export const en: Translations = {
platformIntro: {}
},
webhooks: {
search: 'Search webhooks...',
loading: 'Loading webhooks...',
loadFailed: 'Webhooks failed to load',
subscriptions: (count: number) => `Subscriptions (${count})`,
hint: 'Subscription changes hot-reload once the receiver is running. Disabled subscriptions reject incoming events.',
empty: 'No webhook subscriptions yet.',
disabledTitle: 'Webhook receiver disabled',
disabledBody:
'Webhooks are their own gateway platform. Enable them here to accept incoming HTTP events; chat channels are only needed when a subscription delivers to Telegram, Discord, Slack, or another channel.',
enable: 'Enable webhooks',
enabling: 'Enabling...',
enabled: (name: string) => `Enabled: "${name}"`,
disabled: (name: string) => `Disabled: "${name}"`,
enableRow: 'Enable',
disableRow: 'Disable',
delete: 'Delete',
deleting: 'Deleting...',
deleted: 'Webhook deleted',
deleteTitle: 'Delete webhook',
deleteDescPrefix: 'This will permanently remove ',
deleteDescSuffix: '. This cannot be undone.',
deleteFailed: (name: string) => `Failed to delete "${name}"`,
toggleFailed: (name: string) => `Failed to update "${name}"`,
newSubscription: 'New subscription',
restarting: 'Gateway restarting...',
restartNeeded: 'Webhooks are enabled, but the gateway still needs a restart before the receiver can come online.',
restartGateway: 'Restart gateway',
restartingGateway: 'Restarting...',
restartFailed: (detail: string) => `Gateway restart failed${detail}`,
enabledRestarting: 'Webhooks enabled; gateway restarting...',
all: '(all)',
deliverOnly: 'deliver only',
createdTitle: 'Subscription created',
createdSecretHint: 'Copy the secret now — it is only shown once.',
webhookUrl: 'Webhook URL',
secretOnce: 'Secret (shown once)',
done: 'Done',
fieldName: 'Name',
fieldNamePlaceholder: 'e.g. github-push',
fieldDescription: 'Description',
fieldDescriptionPlaceholder: 'What this webhook does (optional)',
fieldEvents: 'Events',
fieldEventsPlaceholder: 'comma-separated, leave empty for all',
fieldSkills: 'Skills',
fieldSkillsPlaceholder: 'comma-separated skill names (optional)',
fieldDeliver: 'Deliver to',
fieldDeliverOnly: 'Deliver payload only',
fieldPrompt: 'Prompt',
fieldPromptPlaceholder: 'Instructions for the agent when this webhook fires (optional)',
nameRequired: 'Name required',
create: 'Create',
creating: 'Creating...',
created: 'Created',
createFailed: (detail: string) => `Failed to create: ${detail}`,
copy: 'Copy',
deliverOptions: {
log: 'Log',
telegram: 'Telegram',
discord: 'Discord',
slack: 'Slack',
email: 'Email',
github_comment: 'GitHub comment'
}
},
profiles: {
close: 'Close profiles',
nameHint: 'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.',
@@ -1668,7 +1597,6 @@ export const en: Translations = {
promptPlaceholder: 'Summarize my unread Slack threads and email me the top 5...',
frequencyLabel: 'Frequency',
deliverLabel: 'Deliver to',
deliverNeedsHomeChannel: 'set a home channel first',
modelLabel: 'Model',
modelDefault: 'Default (global model)',
customScheduleLabel: 'Custom schedule',
@@ -1680,23 +1608,7 @@ export const en: Translations = {
scheduleRequired: 'Schedule is required.',
scriptOnlyEditHint: 'Script-only job (no AI prompt). Job id:',
saveChanges: 'Save changes',
createAction: 'Create cron',
tabs: {
jobs: 'Jobs',
blueprints: 'Blueprints'
},
blueprints: {
tab: 'Blueprints',
subtitle: 'Ready-made automations',
dialogDesc: 'Fill in the details and schedule it.',
scheduleIt: 'Schedule it',
scheduling: 'Scheduling...',
scheduled: 'Blueprint scheduled',
loading: 'Loading blueprints...',
failedLoad: 'Failed to load blueprints',
emptyTitle: 'No blueprints available',
emptyDesc: 'No automation blueprints are available on this backend.'
}
createAction: 'Create cron'
},
artifacts: {
@@ -1861,13 +1773,6 @@ export const en: Translations = {
ageDay: 'd',
ageHour: 'h',
ageMin: 'm'
},
dateDivider: {
today: 'Earlier today',
yesterday: 'Yesterday',
thisWeek: 'Earlier this week',
lastWeek: 'Last week',
thisMonth: 'Earlier this month'
}
},
@@ -2081,15 +1986,6 @@ export const en: Translations = {
tryAgain: 'Try again',
notAvailableTitle: 'Update not available',
unsupportedMessage: 'This version of Hermes can’t update itself from inside the app.',
versionDetailsTitle: 'Version details',
versionDetailsBody:
'This Hermes installation is managed outside the app. Update it with the same method you used to install it.',
versionDetailsVersion: 'Version',
versionDetailsBaseVersion: 'Release version',
versionDetailsBranch: 'Branch',
versionDetailsCommit: 'Commit',
versionDetailsDirty: 'This package was built from a dirty source tree.',
versionDetailsDirtyUnknown: 'Built from a dirty source tree. The number of changes since the last release is unknown.',
connectionRetry: 'Check your connection and try again.',
latestBody: 'You’re running the latest version.',
latestBodyBackend: 'The backend is running the latest version.',
@@ -2145,40 +2041,6 @@ export const en: Translations = {
viewDocs: 'View install docs',
installTo: 'Will install to',
retryAfterRun: 'I’ve run it -- retry',
setupChoiceTitle: 'Set up Hermes Desktop',
setupChoiceDesc:
'Connect this app to a Hermes gateway you already run, or install Hermes locally on this computer.',
connectExistingTitle: 'Connect to existing Hermes',
connectExistingShort: 'Connect existing',
connectExistingDesc: 'Use a remote backend with a session token or browser sign-in. No local install will start.',
installLocalTitle: 'Install Hermes locally',
installLocalDesc: 'Download Hermes, create its Python environment, and run the backend on this computer.',
localStartUnavailable: 'Local installation could not start. Restart Hermes Desktop and try again.',
remoteSetupTitle: 'Connect to existing Hermes',
remoteSetupDesc: 'Enter your gateway URL. Hermes Desktop will detect whether it needs a token or browser sign-in.',
remoteUrlTitle: 'Gateway URL',
remoteUrlDesc: 'Use the base URL of the Hermes gateway, including https:// when remote.',
remoteUrlPlaceholder: 'https://gateway.example.com/hermes',
probing: 'Detecting gateway authentication...',
probeError: 'Could not reach that Hermes gateway.',
identityProvider: 'your identity provider',
authTitle: 'Authentication',
authNeedsOauth: provider => `Sign in with ${provider} before testing this gateway.`,
authSignedIn: 'Browser sign-in completed.',
connected: 'Connected',
signIn: 'Sign in',
signInWith: provider => `Sign in with ${provider}`,
enterUrlFirst: 'Enter a gateway URL first.',
signInIncomplete: 'The sign-in window closed before authentication completed.',
tokenTitle: 'Session token',
tokenDesc: 'Paste the session token from the remote gateway .env file.',
pasteSessionToken: 'Paste session token',
incompleteSignInTest: 'Sign in before testing this OAuth-gated gateway.',
incompleteTokenTest: 'Enter a session token before testing this gateway.',
testConnection: 'Test connection',
testSucceeded: (baseUrl, version) => `Connected to ${baseUrl}${version ? ` (${version})` : ''}.`,
applyRemote: 'Apply and reconnect',
backToSetup: 'Back',
failedTitle: 'Installation failed',
settingUpTitle: 'Setting up Hermes Agent',
finishingTitle: 'Finishing up',
@@ -2394,8 +2256,6 @@ export const en: Translations = {
running: count => `${count} running`,
cron: 'Cron',
openCron: 'Open cron jobs',
webhooks: 'Webhooks',
openWebhooks: 'Open webhooks',
starmap: 'Memory Graph',
openStarmap: 'Open memory graph',
turnRunning: 'Running',
+2 -77
View File
@@ -627,6 +627,7 @@ export const ja = defineLocale({
justNowSuffix: ' · たった今',
automaticUpdates: '自動更新',
automaticUpdatesDesc: 'Hermes はバックグラウンドで自動的に更新を確認し、利用可能になったら通知します。',
branchCommit: (branch, commit) => `ブランチ ${branch} · コミット ${commit}`,
never: '未確認',
justNow: 'たった今',
minAgo: count => `${count} 分前`,
@@ -889,12 +890,6 @@ export const ja = defineLocale({
messages: count => `${count} 件のメッセージ`,
restored: '復元しました',
deleteConfirm: title => `"${title}" を完全に削除しますか?この操作は元に戻せません。`,
autoArchiveTitle: '古いチャットを自動アーカイブ',
autoArchiveDesc:
'しばらく操作していないチャットを自動的にアーカイブします。ピン留めしたチャットはアーカイブされず、削除もされません。アーカイブされたチャットはここに移動します。',
autoArchiveDaysLabel: 'アーカイブまでの日数',
autoArchiveDaysUnit: '日間操作なし',
autoArchiveFailed: '自動アーカイブを更新できませんでした',
defaultDirTitle: 'デフォルトのプロジェクトディレクトリ',
defaultDirDesc:
'別のフォルダーを選択しない限り、新しいセッションはこのフォルダーで開始します。未設定の場合はホームディレクトリが使用されます。',
@@ -1540,7 +1535,6 @@ export const ja = defineLocale({
promptPlaceholder: '実行ごとにエージェントが行う内容は?',
frequencyLabel: '頻度',
deliverLabel: '配信先',
deliverNeedsHomeChannel: '先にホームチャンネルを設定してください',
modelLabel: 'モデル',
modelDefault: 'デフォルト(グローバルモデル)',
customScheduleLabel: 'カスタムスケジュール',
@@ -1552,23 +1546,7 @@ export const ja = defineLocale({
scheduleRequired: 'スケジュールは必須です。',
scriptOnlyEditHint: 'スクリプトのみのジョブ(AI プロンプトなし)。ジョブ ID:',
saveChanges: '変更を保存',
createAction: 'Cron を作成',
tabs: {
jobs: 'ジョブ',
blueprints: 'ブレーンプリント'
},
blueprints: {
tab: 'ブレーンプリント',
subtitle: 'すぐに使える自動化',
dialogDesc: '詳細を入力してスケジュールします。',
scheduleIt: 'スケジュールする',
scheduling: 'スケジュール中...',
scheduled: 'ブレーンプリントをスケジュールしました',
loading: 'ブレーンプリントを読み込み中...',
failedLoad: 'ブレーンプリントの読み込みに失敗しました',
emptyTitle: '利用できるブレーンプリントはありません',
emptyDesc: 'このバックエンドで利用できる自動化ブレーンプリントはありません。'
}
createAction: 'Cron を作成'
},
artifacts: {
@@ -1728,13 +1706,6 @@ export const ja = defineLocale({
ageDay: '日',
ageHour: '時間',
ageMin: '分'
},
dateDivider: {
today: '今日の早い時間',
yesterday: '昨日',
thisWeek: '今週',
lastWeek: '先週',
thisMonth: '今月'
}
},
@@ -1948,15 +1919,6 @@ export const ja = defineLocale({
tryAgain: '再試行',
notAvailableTitle: '更新は利用できません',
unsupportedMessage: 'このバージョンの Hermes はアプリ内から自分を更新できません。',
versionDetailsTitle: 'バージョンの詳細',
versionDetailsBody:
'この Hermes インストールはアプリの外部で管理されています。インストール時と同じ方法で更新してください。',
versionDetailsVersion: 'バージョン',
versionDetailsBaseVersion: 'リリースバージョン',
versionDetailsBranch: 'ブランチ',
versionDetailsCommit: 'コミット',
versionDetailsDirty: 'このパッケージは変更のあるソースツリーからビルドされました。',
versionDetailsDirtyUnknown: '変更のあるソースツリーからビルドされました。最後のリリースからの変更数は不明です。',
connectionRetry: '接続を確認してもう一度試してください。',
latestBody: '最新バージョンを実行しています。',
latestBodyBackend: 'バックエンドは最新バージョンを実行しています。',
@@ -2014,43 +1976,6 @@ export const ja = defineLocale({
viewDocs: 'インストールドキュメントを見る',
installTo: 'インストール先',
retryAfterRun: '実行しました — 再試行',
setupChoiceTitle: 'Hermes Desktop をセットアップ',
setupChoiceDesc:
'すでに実行している Hermes ゲートウェイに接続するか、このコンピューターに Hermes をローカルインストールします。',
connectExistingTitle: '既存の Hermes に接続',
connectExistingShort: '既存環境に接続',
connectExistingDesc:
'セッショントークンまたはブラウザーサインインでリモートバックエンドを使用します。ローカルインストールは開始されません。',
installLocalTitle: 'Hermes をローカルにインストール',
installLocalDesc: 'Hermes をダウンロードし、Python 環境を作成して、このコンピューターでバックエンドを実行します。',
localStartUnavailable:
'ローカルインストールを開始できません。Hermes Desktop を再起動して、もう一度お試しください。',
remoteSetupTitle: '既存の Hermes に接続',
remoteSetupDesc:
'ゲートウェイ URL を入力してください。Hermes Desktop がトークンとブラウザーサインインのどちらが必要かを検出します。',
remoteUrlTitle: 'ゲートウェイ URL',
remoteUrlDesc: 'Hermes ゲートウェイのベース URL を使用します。リモートの場合は https:// を含めてください。',
remoteUrlPlaceholder: 'https://gateway.example.com/hermes',
probing: 'ゲートウェイ認証方式を検出中...',
probeError: 'その Hermes ゲートウェイに到達できませんでした。',
identityProvider: 'ID プロバイダー',
authTitle: '認証',
authNeedsOauth: provider => `このゲートウェイをテストする前に ${provider} でサインインしてください。`,
authSignedIn: 'ブラウザーサインインが完了しました。',
connected: '接続済み',
signIn: 'サインイン',
signInWith: provider => `${provider} でサインイン`,
enterUrlFirst: '先にゲートウェイ URL を入力してください。',
signInIncomplete: '認証が完了する前にサインインウィンドウが閉じられました。',
tokenTitle: 'セッショントークン',
tokenDesc: 'リモートゲートウェイの .env ファイルからセッショントークンを貼り付けます。',
pasteSessionToken: 'セッショントークンを貼り付け',
incompleteSignInTest: 'OAuth で保護されたこのゲートウェイをテストする前にサインインしてください。',
incompleteTokenTest: 'このゲートウェイをテストする前にセッショントークンを入力してください。',
testConnection: '接続をテスト',
testSucceeded: (baseUrl, version) => `${baseUrl}${version ? ` (${version})` : ''} に接続しました。`,
applyRemote: '適用して再接続',
backToSetup: '戻る',
failedTitle: 'インストールに失敗しました',
settingUpTitle: 'Hermes Agent を設定中',
finishingTitle: '仕上げ中',
-5
View File
@@ -15,9 +15,6 @@ describe('desktop i18n languages', () => {
expect(normalizeLocale('zh_HK')).toBe('zh-hant')
expect(normalizeLocale('ja')).toBe('ja')
expect(normalizeLocale('ja-JP')).toBe('ja')
expect(normalizeLocale('ar')).toBe('ar')
expect(normalizeLocale('AR-SA')).toBe('ar')
expect(normalizeLocale(' ar_eg ')).toBe('ar')
})
it('falls back to English for empty or unsupported values', () => {
@@ -35,7 +32,6 @@ describe('desktop i18n languages', () => {
expect(isLocale('zh')).toBe(true)
expect(isLocale('zh-hant')).toBe(true)
expect(isLocale('ja')).toBe(true)
expect(isLocale('ar')).toBe(true)
})
it('returns the persisted config value for supported locales', () => {
@@ -43,6 +39,5 @@ describe('desktop i18n languages', () => {
expect(localeConfigValue('zh')).toBe('zh')
expect(localeConfigValue('zh-hant')).toBe('zh-hant')
expect(localeConfigValue('ja')).toBe('ja')
expect(localeConfigValue('ar')).toBe('ar')
})
})
+1 -16
View File
@@ -28,12 +28,6 @@ export const LOCALE_OPTIONS = [
name: '日本語',
englishName: 'Japanese',
configValue: 'ja'
},
{
id: 'ar',
name: 'العربية',
englishName: 'Arabic',
configValue: 'ar'
}
] as const satisfies readonly { configValue: string; englishName: string; id: Locale; name: string }[]
@@ -70,16 +64,7 @@ const LOCALE_ALIASES: Record<string, Locale> = {
zh_hant_hk: 'zh-hant',
ja: 'ja',
'ja-jp': 'ja',
ja_jp: 'ja',
ar: 'ar',
'ar-sa': 'ar',
ar_sa: 'ar',
'ar-ae': 'ar',
ar_ae: 'ar',
'ar-eg': 'ar',
ar_eg: 'ar',
arabic: 'ar',
العربية: 'ar'
ja_jp: 'ja'
}
export function isLocale(value: unknown): value is Locale {
+2 -131
View File
@@ -5,7 +5,7 @@
// partial locales should use `defineLocale()` so missing desktop-only strings
// fall back to English while new keys remain type-checked.
export type Locale = 'en' | 'zh' | 'zh-hant' | 'ja' | 'ar'
export type Locale = 'en' | 'zh' | 'zh-hant' | 'ja'
export type ToolTitleKey =
| 'browser_click'
@@ -426,6 +426,7 @@ export interface Translations {
justNowSuffix: string
automaticUpdates: string
automaticUpdatesDesc: string
branchCommit: (branch: string, commit: string) => string
never: string
justNow: string
minAgo: (count: number) => string
@@ -722,11 +723,6 @@ export interface Translations {
messages: (count: number) => string
restored: string
deleteConfirm: (title: string) => string
autoArchiveTitle: string
autoArchiveDesc: string
autoArchiveDaysLabel: string
autoArchiveDaysUnit: string
autoArchiveFailed: string
defaultDirTitle: string
defaultDirDesc: string
defaultDirUpdated: string
@@ -1180,64 +1176,6 @@ export interface Translations {
platformIntro: Record<string, string>
}
webhooks: {
search: string
loading: string
loadFailed: string
subscriptions: (count: number) => string
hint: string
empty: string
disabledTitle: string
disabledBody: string
enable: string
enabling: string
enabled: (name: string) => string
disabled: (name: string) => string
enableRow: string
disableRow: string
delete: string
deleting: string
deleted: string
deleteTitle: string
deleteDescPrefix: string
deleteDescSuffix: string
deleteFailed: (name: string) => string
toggleFailed: (name: string) => string
newSubscription: string
restarting: string
restartNeeded: string
restartGateway: string
restartingGateway: string
restartFailed: (detail: string) => string
enabledRestarting: string
all: string
deliverOnly: string
createdTitle: string
createdSecretHint: string
webhookUrl: string
secretOnce: string
done: string
fieldName: string
fieldNamePlaceholder: string
fieldDescription: string
fieldDescriptionPlaceholder: string
fieldEvents: string
fieldEventsPlaceholder: string
fieldSkills: string
fieldSkillsPlaceholder: string
fieldDeliver: string
fieldDeliverOnly: string
fieldPrompt: string
fieldPromptPlaceholder: string
nameRequired: string
create: string
creating: string
created: string
createFailed: (detail: string) => string
copy: string
deliverOptions: Record<string, string>
}
profiles: {
close: string
nameHint: string
@@ -1379,7 +1317,6 @@ export interface Translations {
promptPlaceholder: string
frequencyLabel: string
deliverLabel: string
deliverNeedsHomeChannel: string
modelLabel: string
modelDefault: string
customScheduleLabel: string
@@ -1392,22 +1329,6 @@ export interface Translations {
scriptOnlyEditHint: string
saveChanges: string
createAction: string
tabs: {
jobs: string
blueprints: string
}
blueprints: {
tab: string
subtitle: string
dialogDesc: string
scheduleIt: string
scheduling: string
scheduled: string
loading: string
failedLoad: string
emptyTitle: string
emptyDesc: string
}
}
artifacts: {
@@ -1565,13 +1486,6 @@ export interface Translations {
ageHour: string
ageMin: string
}
dateDivider: {
today: string
yesterday: string
thisWeek: string
lastWeek: string
thisMonth: string
}
}
composer: {
@@ -1722,14 +1636,6 @@ export interface Translations {
tryAgain: string
notAvailableTitle: string
unsupportedMessage: string
versionDetailsTitle: string
versionDetailsBody: string
versionDetailsVersion: string
versionDetailsBaseVersion: string
versionDetailsBranch: string
versionDetailsCommit: string
versionDetailsDirty: string
versionDetailsDirtyUnknown: string
connectionRetry: string
latestBody: string
latestBodyBackend: string
@@ -1777,39 +1683,6 @@ export interface Translations {
viewDocs: string
installTo: string
retryAfterRun: string
setupChoiceTitle: string
setupChoiceDesc: string
connectExistingTitle: string
connectExistingShort: string
connectExistingDesc: string
installLocalTitle: string
installLocalDesc: string
localStartUnavailable: string
remoteSetupTitle: string
remoteSetupDesc: string
remoteUrlTitle: string
remoteUrlDesc: string
remoteUrlPlaceholder: string
probing: string
probeError: string
identityProvider: string
authTitle: string
authNeedsOauth: (provider: string) => string
authSignedIn: string
connected: string
signIn: string
signInWith: (provider: string) => string
enterUrlFirst: string
signInIncomplete: string
tokenTitle: string
tokenDesc: string
pasteSessionToken: string
incompleteSignInTest: string
incompleteTokenTest: string
testConnection: string
testSucceeded: (baseUrl: string, version?: string) => string
applyRemote: string
backToSetup: string
failedTitle: string
settingUpTitle: string
finishingTitle: string
@@ -2001,8 +1874,6 @@ export interface Translations {
running: (count: number) => string
cron: string
openCron: string
webhooks: string
openWebhooks: string
starmap: string
openStarmap: string
turnRunning: string
+2 -72
View File
@@ -615,6 +615,7 @@ export const zhHant = defineLocale({
justNowSuffix: ' · 剛剛',
automaticUpdates: '自動更新',
automaticUpdatesDesc: 'Hermes 會在背景自動檢查更新,並在有可用更新時通知你。',
branchCommit: (branch, commit) => `分支 ${branch} · 提交 ${commit}`,
never: '從未',
justNow: '剛剛',
minAgo: count => `${count} 分鐘前`,
@@ -862,12 +863,6 @@ export const zhHant = defineLocale({
messages: count => `${count} 則訊息`,
restored: '已還原',
deleteConfirm: title => `永久刪除「${title}」?此操作無法復原。`,
autoArchiveTitle: '自動封存閒置對話',
autoArchiveDesc:
'自動封存你一段時間未使用的對話。已釘選的對話永遠不會被封存,也不會刪除任何內容——封存的對話會移到這裡。',
autoArchiveDaysLabel: '封存前',
autoArchiveDaysUnit: '天無活動',
autoArchiveFailed: '無法更新自動封存設定',
defaultDirTitle: '預設專案目錄',
defaultDirDesc: '新工作階段預設從此資料夾開始,除非您選擇其他目錄。留空則使用您的家目錄。',
defaultDirUpdated: '預設專案目錄已更新',
@@ -1489,7 +1484,6 @@ export const zhHant = defineLocale({
promptPlaceholder: '代理每次執行時應做什麼?',
frequencyLabel: '頻率',
deliverLabel: '傳遞至',
deliverNeedsHomeChannel: '請先設定主頻道',
modelLabel: '模型',
modelDefault: '預設(全域模型)',
customScheduleLabel: '自訂排程',
@@ -1501,23 +1495,7 @@ export const zhHant = defineLocale({
scheduleRequired: '排程為必填項目。',
scriptOnlyEditHint: '僅腳本任務(無 AI 提示詞)。任務 ID:',
saveChanges: '儲存變更',
createAction: '建立排程工作',
tabs: {
jobs: '工作',
blueprints: '藍圖'
},
blueprints: {
tab: '藍圖',
subtitle: '現成的自動化',
dialogDesc: '填寫詳細資訊並進行排程。',
scheduleIt: '安排工作',
scheduling: '安排中...',
scheduled: '藍圖已安排',
loading: '正在載入藍圖...',
failedLoad: '載入藍圖失敗',
emptyTitle: '沒有可用的藍圖',
emptyDesc: '此後端上沒有可用的自動化藍圖。'
}
createAction: '建立排程工作'
},
artifacts: {
@@ -1674,13 +1652,6 @@ export const zhHant = defineLocale({
ageDay: '天',
ageHour: '時',
ageMin: '分'
},
dateDivider: {
today: '今天稍早',
yesterday: '昨天',
thisWeek: '本週',
lastWeek: '上週',
thisMonth: '本月'
}
},
@@ -1892,14 +1863,6 @@ export const zhHant = defineLocale({
tryAgain: '重試',
notAvailableTitle: '更新不可用',
unsupportedMessage: '此版本的 Hermes 無法在應用程式內自行更新。',
versionDetailsTitle: '版本詳細資料',
versionDetailsBody: '此 Hermes 安裝由應用程式外部管理。請使用安裝時採用的方法更新它。',
versionDetailsVersion: '版本',
versionDetailsBaseVersion: '發行版本',
versionDetailsBranch: '分支',
versionDetailsCommit: '提交',
versionDetailsDirty: '此套件從有未提交變更的來源樹建置。',
versionDetailsDirtyUnknown: '從有未提交變更的來源樹建置。自上次發布以來的變更數量未知。',
connectionRetry: '請檢查網路連線後重試。',
latestBody: '您正在執行最新版本。',
latestBodyBackend: '後端正在執行最新版本。',
@@ -1954,39 +1917,6 @@ export const zhHant = defineLocale({
viewDocs: '檢視安裝文件',
installTo: '將安裝至',
retryAfterRun: '我已執行 -- 重試',
setupChoiceTitle: '設定 Hermes Desktop',
setupChoiceDesc: '將此應用程式連線到您已執行的 Hermes 閘道,或在這台電腦上本機安裝 Hermes。',
connectExistingTitle: '連線到現有 Hermes',
connectExistingShort: '連線現有環境',
connectExistingDesc: '使用工作階段權杖或瀏覽器登入連線遠端後端。不會啟動本機安裝。',
installLocalTitle: '本機安裝 Hermes',
installLocalDesc: '下載 Hermes、建立 Python 環境,並在這台電腦上執行後端。',
localStartUnavailable: '無法啟動本機安裝。請重新啟動 Hermes Desktop 後再試一次。',
remoteSetupTitle: '連線到現有 Hermes',
remoteSetupDesc: '輸入閘道 URL。Hermes Desktop 會偵測需要權杖還是瀏覽器登入。',
remoteUrlTitle: '閘道 URL',
remoteUrlDesc: '使用 Hermes 閘道的基礎 URL;遠端位址請包含 https://。',
remoteUrlPlaceholder: 'https://gateway.example.com/hermes',
probing: '正在偵測閘道驗證方式...',
probeError: '無法連線到該 Hermes 閘道。',
identityProvider: '您的身分提供者',
authTitle: '驗證',
authNeedsOauth: provider => `測試此閘道前請先使用 ${provider} 登入。`,
authSignedIn: '瀏覽器登入已完成。',
connected: '已連線',
signIn: '登入',
signInWith: provider => `使用 ${provider} 登入`,
enterUrlFirst: '請先輸入閘道 URL。',
signInIncomplete: '驗證完成前登入視窗已關閉。',
tokenTitle: '工作階段權杖',
tokenDesc: '貼上遠端閘道 .env 檔案中的工作階段權杖。',
pasteSessionToken: '貼上工作階段權杖',
incompleteSignInTest: '測試此受 OAuth 保護的閘道前請先登入。',
incompleteTokenTest: '測試此閘道前請輸入工作階段權杖。',
testConnection: '測試連線',
testSucceeded: (baseUrl, version) => `已連線到 ${baseUrl}${version ? ` (${version})` : ''}。`,
applyRemote: '套用並重新連線',
backToSetup: '返回',
failedTitle: '安裝失敗',
settingUpTitle: '正在設定 Hermes Agent',
finishingTitle: '正在收尾',
+2 -140
View File
@@ -727,6 +727,7 @@ export const zh: Translations = {
justNowSuffix: ' · 刚刚',
automaticUpdates: '自动更新',
automaticUpdatesDesc: 'Hermes 会在后台自动检查更新,并在有可用更新时通知你。',
branchCommit: (branch, commit) => `分支 ${branch} · 提交 ${commit}`,
never: '从未',
justNow: '刚刚',
minAgo: count => `${count} 分钟前`,
@@ -1041,12 +1042,6 @@ export const zh: Translations = {
messages: count => `${count} 条消息`,
restored: '已恢复',
deleteConfirm: title => `永久删除“${title}”?此操作无法撤销。`,
autoArchiveTitle: '自动归档闲置会话',
autoArchiveDesc:
'自动归档你一段时间未使用的会话。已置顶的会话永远不会被归档,也不会删除任何内容——归档的会话会移动到这里。',
autoArchiveDaysLabel: '归档前',
autoArchiveDaysUnit: '天无活动',
autoArchiveFailed: '无法更新自动归档设置',
defaultDirTitle: '默认项目目录',
defaultDirDesc: '新会话默认从此文件夹开始,除非你选择其他目录。留空则使用你的 home 目录。',
defaultDirUpdated: '默认项目目录已更新',
@@ -1613,72 +1608,6 @@ export const zh: Translations = {
}
},
webhooks: {
search: '搜索 Webhook…',
loading: '正在加载 Webhook…',
loadFailed: 'Webhook 加载失败',
subscriptions: (count: number) => `订阅(${count})`,
hint: '接收器运行后,订阅更改会热重载。已禁用的订阅会拒绝传入事件。',
empty: '暂无 Webhook 订阅。',
disabledTitle: 'Webhook 接收器已禁用',
disabledBody:
'Webhook 是独立的网关平台。在此启用以接受传入的 HTTP 事件;仅当订阅投递到 Telegram、Discord、Slack 或其他渠道时才需要聊天渠道。',
enable: '启用 Webhook',
enabling: '正在启用…',
enabled: (name: string) => `已启用:“${name}”`,
disabled: (name: string) => `已禁用:“${name}”`,
enableRow: '启用',
disableRow: '禁用',
delete: '删除',
deleting: '删除中…',
deleted: 'Webhook 已删除',
deleteTitle: '删除 Webhook',
deleteDescPrefix: '这将永久移除 ',
deleteDescSuffix: '。此操作无法撤销。',
deleteFailed: (name: string) => `删除“${name}”失败`,
toggleFailed: (name: string) => `更新“${name}”失败`,
newSubscription: '新建订阅',
restarting: '网关正在重启…',
restartNeeded: 'Webhook 已启用,但网关仍需重启后接收器才能上线。',
restartGateway: '重启网关',
restartingGateway: '正在重启…',
restartFailed: (detail: string) => `网关重启失败${detail}`,
enabledRestarting: 'Webhook 已启用;网关正在重启…',
all: '(全部)',
deliverOnly: '仅投递',
createdTitle: '订阅已创建',
createdSecretHint: '请立即复制密钥 —— 它只显示一次。',
webhookUrl: 'Webhook URL',
secretOnce: '密钥(仅显示一次)',
done: '完成',
fieldName: '名称',
fieldNamePlaceholder: '例如 github-push',
fieldDescription: '描述',
fieldDescriptionPlaceholder: '此 Webhook 的用途(可选)',
fieldEvents: '事件',
fieldEventsPlaceholder: '以逗号分隔,留空表示全部',
fieldSkills: '技能',
fieldSkillsPlaceholder: '以逗号分隔的技能名称(可选)',
fieldDeliver: '投递到',
fieldDeliverOnly: '仅投递载荷',
fieldPrompt: '提示词',
fieldPromptPlaceholder: '此 Webhook 触发时给智能体的说明(可选)',
nameRequired: '需要名称',
create: '创建',
creating: '正在创建…',
created: '已创建',
createFailed: (detail: string) => `创建失败:${detail}`,
copy: '复制',
deliverOptions: {
log: '日志',
telegram: 'Telegram',
discord: 'Discord',
slack: 'Slack',
email: '电子邮件',
github_comment: 'GitHub 评论'
}
},
profiles: {
close: '关闭配置档案',
nameHint: '小写字母、数字、连字符和下划线。必须以字母或数字开头。',
@@ -1859,7 +1788,6 @@ export const zh: Translations = {
promptPlaceholder: '总结我未读的 Slack 话题,并把前 5 条邮件发给我…',
frequencyLabel: '频率',
deliverLabel: '投递至',
deliverNeedsHomeChannel: '请先设置主频道',
modelLabel: '模型',
modelDefault: '默认(全局模型)',
customScheduleLabel: '自定义排程',
@@ -1871,23 +1799,7 @@ export const zh: Translations = {
scheduleRequired: '排程为必填项。',
scriptOnlyEditHint: '仅脚本任务(无 AI 提示词)。任务 ID:',
saveChanges: '保存更改',
createAction: '创建定时任务',
tabs: {
jobs: '任务',
blueprints: '蓝图'
},
blueprints: {
tab: '蓝图',
subtitle: '现成的自动化',
dialogDesc: '填写详细信息并进行排程。',
scheduleIt: '安排任务',
scheduling: '安排中...',
scheduled: '蓝图已安排',
loading: '正在加载蓝图...',
failedLoad: '加载蓝图失败',
emptyTitle: '没有可用的蓝图',
emptyDesc: '此后端上没有可用的自动化蓝图。'
}
createAction: '创建定时任务'
},
artifacts: {
@@ -2050,13 +1962,6 @@ export const zh: Translations = {
ageDay: '天',
ageHour: '时',
ageMin: '分'
},
dateDivider: {
today: '今天早些时候',
yesterday: '昨天',
thisWeek: '本周',
lastWeek: '上周',
thisMonth: '本月'
}
},
@@ -2269,14 +2174,6 @@ export const zh: Translations = {
tryAgain: '重试',
notAvailableTitle: '更新不可用',
unsupportedMessage: '此版本的 Hermes 无法在应用内自行更新。',
versionDetailsTitle: '版本详情',
versionDetailsBody: '此 Hermes 安装由应用外部管理。请使用安装时采用的方法更新它。',
versionDetailsVersion: '版本',
versionDetailsBaseVersion: '发布版本',
versionDetailsBranch: '分支',
versionDetailsCommit: '提交',
versionDetailsDirty: '此软件包从有未提交更改的源代码树构建。',
versionDetailsDirtyUnknown: '从有未提交更改的源代码树构建。自上次发布以来的更改数量未知。',
connectionRetry: '请检查网络连接后重试。',
latestBody: '你正在运行最新版本。',
latestBodyBackend: '后端正在运行最新版本。',
@@ -2331,39 +2228,6 @@ export const zh: Translations = {
viewDocs: '查看安装文档',
installTo: '将安装到',
retryAfterRun: '我已运行 -- 重试',
setupChoiceTitle: '设置 Hermes Desktop',
setupChoiceDesc: '将此应用连接到你已运行的 Hermes 网关,或在这台电脑上本地安装 Hermes。',
connectExistingTitle: '连接到现有 Hermes',
connectExistingShort: '连接现有环境',
connectExistingDesc: '使用会话令牌或浏览器登录连接远程后端。不会启动本地安装。',
installLocalTitle: '本地安装 Hermes',
installLocalDesc: '下载 Hermes,创建 Python 环境,并在这台电脑上运行后端。',
localStartUnavailable: '无法启动本地安装。请重启 Hermes Desktop 后重试。',
remoteSetupTitle: '连接到现有 Hermes',
remoteSetupDesc: '输入网关 URL。Hermes Desktop 会检测需要令牌还是浏览器登录。',
remoteUrlTitle: '网关 URL',
remoteUrlDesc: '使用 Hermes 网关的基础 URL;远程地址请包含 https://。',
remoteUrlPlaceholder: 'https://gateway.example.com/hermes',
probing: '正在检测网关认证方式...',
probeError: '无法连接到该 Hermes 网关。',
identityProvider: '你的身份提供方',
authTitle: '认证',
authNeedsOauth: provider => `测试此网关前请先使用 ${provider} 登录。`,
authSignedIn: '浏览器登录已完成。',
connected: '已连接',
signIn: '登录',
signInWith: provider => `使用 ${provider} 登录`,
enterUrlFirst: '请先输入网关 URL。',
signInIncomplete: '认证完成前登录窗口已关闭。',
tokenTitle: '会话令牌',
tokenDesc: '粘贴远程网关 .env 文件中的会话令牌。',
pasteSessionToken: '粘贴会话令牌',
incompleteSignInTest: '测试此受 OAuth 保护的网关前请先登录。',
incompleteTokenTest: '测试此网关前请输入会话令牌。',
testConnection: '测试连接',
testSucceeded: (baseUrl, version) => `已连接到 ${baseUrl}${version ? ` (${version})` : ''}。`,
applyRemote: '应用并重新连接',
backToSetup: '返回',
failedTitle: '安装失败',
settingUpTitle: '正在设置 Hermes Agent',
finishingTitle: '正在收尾',
@@ -2570,8 +2434,6 @@ export const zh: Translations = {
running: count => `${count} 个运行中`,
cron: '排程',
openCron: '打开排程任务',
webhooks: 'Webhook',
openWebhooks: '打开 Webhook',
starmap: '记忆图谱',
openStarmap: '打开记忆图谱',
turnRunning: '运行中',
@@ -1,51 +0,0 @@
import { describe, expect, it } from 'vitest'
import { deriveRemoteAuthProviderShape } from './desktop-remote-auth'
describe('deriveRemoteAuthProviderShape', () => {
it('uses fallback copy when the gateway has not reported providers', () => {
expect(deriveRemoteAuthProviderShape(null)).toEqual({
isPassword: false,
providerLabel: 'your identity provider'
})
expect(deriveRemoteAuthProviderShape([], 'the configured gateway')).toEqual({
isPassword: false,
providerLabel: 'the configured gateway'
})
})
it('marks providers as password-style only when every provider supports password login', () => {
expect(
deriveRemoteAuthProviderShape([{ name: 'basic', displayName: 'Username & Password', supportsPassword: true }])
).toEqual({
isPassword: true,
providerLabel: 'Username & Password'
})
})
it('keeps OAuth copy for redirect providers and mixed deployments', () => {
expect(
deriveRemoteAuthProviderShape([{ name: 'nous', displayName: 'Nous Research', supportsPassword: false }])
).toEqual({
isPassword: false,
providerLabel: 'Nous Research'
})
expect(
deriveRemoteAuthProviderShape([
{ name: 'basic', displayName: 'Username & Password', supportsPassword: true },
{ name: 'nous', displayName: 'Nous Research', supportsPassword: false }
])
).toEqual({
isPassword: false,
providerLabel: 'Username & Password / Nous Research'
})
})
it('falls back to provider names when display names are missing', () => {
expect(deriveRemoteAuthProviderShape([{ name: 'basic', displayName: '', supportsPassword: true }])).toEqual({
isPassword: true,
providerLabel: 'basic'
})
})
})
@@ -1,26 +0,0 @@
import type { DesktopAuthProvider } from '@/global'
export interface RemoteAuthProviderShape {
isPassword: boolean
providerLabel: string
}
function providerDisplayName(provider: DesktopAuthProvider): string {
return provider.displayName || provider.name
}
export function deriveRemoteAuthProviderShape(
providers: DesktopAuthProvider[] | null | undefined,
fallback = 'your identity provider'
): RemoteAuthProviderShape {
const list = providers ?? []
if (list.length === 0) {
return { isPassword: false, providerLabel: fallback }
}
return {
isPassword: list.every(provider => Boolean(provider.supportsPassword)),
providerLabel: list.map(providerDisplayName).join(' / ')
}
}
@@ -1,216 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { SessionInfo } from '@/types/hermes'
import type { SidebarSessionEntry } from './session-branch-tree'
import { groupEntriesByRecency, toSessionRows } from './session-date-groups'
const session = (id: string, overrides: Partial<SessionInfo> = {}): SessionInfo =>
({
ended_at: null,
id,
input_tokens: 0,
is_active: false,
last_active: 0,
message_count: 1,
model: null,
output_tokens: 0,
preview: null,
source: 'cli',
started_at: 0,
title: id,
tool_call_count: 0,
...overrides
}) as SessionInfo
const entry = (s: SessionInfo, branchStem?: string): SidebarSessionEntry =>
branchStem ? { branchStem, session: s } : { session: s }
// Fixed "now": Thursday 18 Jun 2026, local noon (15 Jun 2026 is a Monday).
// All tests pin a Monday week start so calendar boundaries are deterministic.
const NOW = new Date(2026, 5, 18, 12, 0, 0).getTime()
const MONDAY = 1
const at = (year: number, month: number, day: number, hour = 10, minute = 0): number =>
Math.floor(new Date(year, month, day, hour, minute, 0).getTime() / 1000)
const group = (entries: SidebarSessionEntry[], nowMs = NOW) => groupEntriesByRecency(entries, nowMs, MONDAY)
const dividerKeys = (rows: ReturnType<typeof groupEntriesByRecency>): string[] =>
rows.flatMap(row => (row.kind === 'divider' ? [row.key] : []))
describe('groupEntriesByRecency', () => {
it('cuts the head after the most recent handful, then divides by coarse ranges', () => {
// The morning run (30m/30m/4h/30m gaps, then a 14h silence) is the
// unlabelled head; each older group gets one divider, coarsening with age.
const rows = group([
entry(session('a', { last_active: at(2026, 5, 18, 11) })),
entry(session('b', { last_active: at(2026, 5, 18, 10, 30) })),
entry(session('c', { last_active: at(2026, 5, 18, 10) })),
entry(session('d', { last_active: at(2026, 5, 18, 6) })),
entry(session('e', { last_active: at(2026, 5, 18, 5, 30) })),
entry(session('f', { last_active: at(2026, 5, 17, 15) })), // yesterday
entry(session('g', { last_active: at(2026, 5, 16, 15) })), // Tue this week
entry(session('h', { last_active: at(2026, 5, 14) })), // Sun last week
entry(session('i', { last_active: at(2026, 5, 3) })), // earlier in June
entry(session('j', { last_active: at(2026, 4, 28) })), // May
entry(session('k', { last_active: at(2025, 11, 3) })) // December 2025
])
expect(rows.slice(0, 5).every(row => row.kind === 'session')).toBe(true)
expect(rows[5]).toMatchObject({ key: 'yesterday', kind: 'divider' })
expect(dividerKeys(rows)).toEqual(['yesterday', 'this-week', 'last-week', 'this-month', 'm-2026-4', 'my-2025-11'])
})
it('labels the rest of the current day "earlier today" past a real break', () => {
// Five rapid-fire sessions, a ~4h pause, then more of the same day.
const rows = group([
entry(session('a', { last_active: at(2026, 5, 18, 11) })),
entry(session('b', { last_active: at(2026, 5, 18, 10, 58) })),
entry(session('c', { last_active: at(2026, 5, 18, 10, 56) })),
entry(session('d', { last_active: at(2026, 5, 18, 10, 54) })),
entry(session('e', { last_active: at(2026, 5, 18, 10, 52) })),
entry(session('f', { last_active: at(2026, 5, 18, 7) })),
entry(session('g', { last_active: at(2026, 5, 18, 6, 58) }))
])
expect(rows.findIndex(row => row.kind === 'divider')).toBe(5)
expect(dividerKeys(rows)).toEqual(['today'])
})
it('never slices a rapid-fire burst mid-run', () => {
// Eleven sessions two minutes apart: no gap qualifies as a break, so the
// whole burst stays in the head and the divider lands after it.
const burst = Array.from({ length: 11 }, (_, i) =>
entry(session(`s${i}`, { last_active: at(2026, 5, 18, 11) - i * 120 }))
)
const rows = group([...burst, entry(session('old', { last_active: at(2026, 5, 17, 15) }))])
expect(rows.findIndex(row => row.kind === 'divider')).toBe(11)
expect(dividerKeys(rows)).toEqual(['yesterday'])
})
it('chains the head run across midnight', () => {
// Viewed at 00:58: tonight plus last evening is one run; yesterday's
// afternoon (a different nominal day) opens the labelled groups.
const smallHours = new Date(2026, 5, 19, 0, 58).getTime()
const rows = group(
[
entry(session('a', { last_active: at(2026, 5, 19, 0, 30) })),
entry(session('b', { last_active: at(2026, 5, 18, 23, 50) })),
entry(session('c', { last_active: at(2026, 5, 18, 23, 20) })),
entry(session('d', { last_active: at(2026, 5, 17, 20) }))
],
smallHours
)
expect(rows.findIndex(row => row.kind === 'divider')).toBe(3)
expect(dividerKeys(rows)).toEqual(['yesterday'])
})
it('dissolves a stale head into its own calendar group (fuzzy merge)', () => {
// Newest session is 6 days old and the rows below it share its "last week"
// bucket: cutting there would strand near-identical neighbours around a
// divider, so no head is kept and the whole bucket leads unlabelled.
const rows = group([
entry(session('a', { last_active: at(2026, 5, 12) })),
entry(session('b', { last_active: at(2026, 5, 11, 15) })),
entry(session('c', { last_active: at(2026, 5, 11, 10) })),
entry(session('d', { last_active: at(2026, 5, 3) })),
entry(session('e', { last_active: at(2026, 4, 20) }))
])
expect(rows.slice(0, 3).every(row => row.kind === 'session')).toBe(true)
expect(dividerKeys(rows)).toEqual(['this-month', 'm-2026-4'])
})
it('keeps an isolated newest session as the head when its bucket differs', () => {
const rows = group([
entry(session('a', { last_active: at(2026, 5, 18, 9) })),
entry(session('b', { last_active: at(2026, 5, 17, 15) })),
entry(session('c', { last_active: at(2026, 5, 3) }))
])
expect(rows.findIndex(row => row.kind === 'divider')).toBe(1)
expect(dividerKeys(rows)).toEqual(['yesterday', 'this-month'])
})
it('emits no dividers when everything is one unbroken run', () => {
const rows = group([
entry(session('a', { last_active: at(2026, 5, 18, 11) })),
entry(session('b', { last_active: at(2026, 5, 18, 10, 50) })),
entry(session('c', { last_active: at(2026, 5, 18, 10, 40) }))
])
expect(rows.every(row => row.kind === 'session')).toBe(true)
})
it('collapses a big gap straight to the next month/year (empty ranges omitted)', () => {
const rows = group([
entry(session('t', { last_active: at(2026, 5, 18, 11) })),
entry(session('t2', { last_active: at(2026, 5, 18, 10, 30) })),
entry(session('j1', { last_active: at(2026, 0, 5) })),
entry(session('j2', { last_active: at(2026, 0, 3) })),
entry(session('old', { last_active: at(2024, 2, 9) }))
])
expect(dividerKeys(rows)).toEqual(['m-2026-0', 'my-2024-2'])
})
it('never labels the first rendered group, even when it is not recent', () => {
// Newest session is weeks old and alone in its month: it opens the list
// unlabelled; only the transitions below it are marked.
const rows = group([
entry(session('a', { last_active: at(2026, 4, 20) })),
entry(session('b', { last_active: at(2026, 2, 3) })),
entry(session('c', { last_active: at(2025, 11, 3) }))
])
expect(rows[0]).toMatchObject({ kind: 'session' })
expect(dividerKeys(rows)).toEqual(['m-2026-2', 'my-2025-11'])
})
it('keeps branch children in their parent cluster without opening a new bucket', () => {
const parent = session('parent', { last_active: at(2026, 5, 18, 11) })
const child = session('child', { last_active: at(2024, 0, 1), parent_session_id: 'parent' })
const rows = group([entry(parent), entry(child, '└─ ')])
expect(rows).toEqual([
{ entry: entry(parent), kind: 'session' },
{ entry: entry(child, '└─ '), kind: 'session' }
])
})
it('never emits a divider twice under a non-monotonic order', () => {
const rows = group([
entry(session('a', { last_active: at(2026, 5, 16) })), // head run
entry(session('b', { last_active: at(2026, 4, 5) })), // May — divider
entry(session('c', { last_active: at(2026, 5, 16) })) // head again — no repeat
])
expect(dividerKeys(rows)).toEqual(['m-2026-4'])
})
it('falls back to started_at when last_active is missing', () => {
const rows = group([
entry(session('head', { last_active: at(2026, 5, 18, 11) })),
entry(session('s', { last_active: 0, started_at: at(2026, 5, 10) }))
])
expect(dividerKeys(rows)).toEqual(['last-week'])
})
})
describe('toSessionRows', () => {
it('wraps entries as session rows with no dividers', () => {
const entries = [entry(session('a')), entry(session('b'), '└─ ')]
expect(toSessionRows(entries)).toEqual([
{ entry: entries[0], kind: 'session' },
{ entry: entries[1], kind: 'session' }
])
})
})
-151
View File
@@ -1,151 +0,0 @@
import { type SidebarSessionEntry } from '@/lib/session-branch-tree'
import { calendarBucket, HOUR, localeWeekStartDay, MINUTE, SECOND, type SessionBucket } from '@/lib/time'
// A flat list row is either a chronological date-bucket divider or a session
// entry. Interleaving these lets the flat list (and the virtualizer) render
// date separators inline without a second layer of nesting.
export type SidebarListRow =
| { bucket: SessionBucket; key: string; kind: 'divider' }
| { entry: SidebarSessionEntry; kind: 'session' }
// The row's own age label reads from `last_active || started_at`; bucket off the
// same value so a divider lines up with what the row actually shows.
const recencyMs = (entry: SidebarSessionEntry): number =>
(entry.session.last_active || entry.session.started_at || 0) * SECOND
// Aim the head at "the most recent handful". A break shorter than
// MIN_RUN_BREAK_MS never counts as one — that would slice a rapid-fire burst —
// and a silence longer than MAX_RUN_GAP_MS always ends the run: without that
// bound a sparse list (a project lane) would chain weeks of stale sessions
// into one giant "recent" head.
const TARGET_HEAD_SESSIONS = 5
const MIN_RUN_BREAK_MS = 30 * MINUTE
const MAX_RUN_GAP_MS = 8 * HOUR
// The unlabelled head is the newest run of sessions, cut at a *real* break in
// activity. Candidate cut points are every gap of at least MIN_RUN_BREAK_MS
// inside the contiguous run (gaps ≤ MAX_RUN_GAP_MS), plus the run's own end;
// among them we pick the one whose head size lands closest (log-scale) to
// TARGET_HEAD_SESSIONS. So the first divider shows up after roughly the most
// recent five sessions — but only ever at a genuine pause, never mid-burst: a
// truly unbroken run stays whole, and an isolated newest session stands alone.
// Runs chain naturally across midnight.
//
// Fuzzy-merge rule: when the cut falls at the run's end and the sessions just
// below it share the head's calendar bucket, the head adds nothing — it's just
// the top of that group. Dissolve it (the first-group rule keeps the top
// unlabelled anyway) so a divider never strands near-identical neighbours,
// e.g. a lone 6-day-old session above a "Last week" label.
//
// Returns the oldest timestamp (ms) still inside the head; -Infinity means the
// whole list is one run, +Infinity means no head (calendar groups own it all).
function headRunCutoffMs(entries: readonly SidebarSessionEntry[], nowMs: number, weekStartsOn: number): number {
const times = entries
.filter(entry => !entry.branchStem)
.map(recencyMs)
.sort((a, b) => b - a)
let bestIdx = -1
let bestScore = Number.POSITIVE_INFINITY
let runEnded = false
for (let i = 1; i < times.length; i++) {
const gap = times[i - 1] - times[i]
const endsRun = gap > MAX_RUN_GAP_MS
if (gap >= MIN_RUN_BREAK_MS || endsRun) {
// `i` sessions would sit above a cut at this gap.
const score = Math.abs(Math.log(i / TARGET_HEAD_SESSIONS))
if (score < bestScore) {
bestScore = score
bestIdx = i
runEnded = endsRun
}
}
if (endsRun) {
break
}
}
if (bestIdx === -1) {
return Number.NEGATIVE_INFINITY
}
if (runEnded) {
const headBucket = calendarBucket(times[0] / SECOND, nowMs, weekStartsOn)
const belowBucket = calendarBucket(times[bestIdx] / SECOND, nowMs, weekStartsOn)
if (headBucket.key === belowBucket.key) {
return Number.POSITIVE_INFINITY
}
}
return times[bestIdx - 1]
}
// Insert a date divider before each labelled group. The unlabelled head is the
// newest run of sessions (see headRunCutoffMs); below it, groups are coarse
// calendar ranges — earlier today → yesterday → earlier this week → last week
// → earlier this month → month → month + year — one divider per range, never
// one per day. Whatever group happens to render first is also never labelled.
// Branch children inherit their parent cluster's group and never trigger a
// divider, so a parent→branches block never splits.
export function groupEntriesByRecency(
entries: readonly SidebarSessionEntry[],
nowMs = Date.now(),
weekStartsOn = localeWeekStartDay()
): SidebarListRow[] {
const rows: SidebarListRow[] = []
const emitted = new Set<string>()
const cutoff = headRunCutoffMs(entries, nowMs, weekStartsOn)
let lastKey: null | string = null
for (const entry of entries) {
// Nested branch rows travel with their parent cluster; they never open a new
// bucket or move the divider cursor.
if (entry.branchStem) {
rows.push({ entry, kind: 'session' })
continue
}
const ms = recencyMs(entry)
// Head-run sessions are never labelled.
if (ms >= cutoff) {
rows.push({ entry, kind: 'session' })
lastKey = '__recent__'
continue
}
const bucket = calendarBucket(ms / SECOND, nowMs, weekStartsOn)
if (bucket.key !== lastKey) {
lastKey = bucket.key
const alreadyEmitted = emitted.has(bucket.key)
// Mark it emitted even when skipped so a non-monotonic order (possible
// inside a project lane) can't later re-label it or collide React keys.
emitted.add(bucket.key)
// A divider only ever separates two groups — never label the very first
// rendered row, whatever group it belongs to.
if (rows.length > 0 && !alreadyEmitted) {
rows.push({ bucket, key: bucket.key, kind: 'divider' })
}
}
rows.push({ entry, kind: 'session' })
}
return rows
}
// Wrap entries as plain session rows (no dividers) so the ungrouped path shares
// the same `SidebarListRow[]` shape as the grouped one.
export function toSessionRows(entries: readonly SidebarSessionEntry[]): SidebarListRow[] {
return entries.map(entry => ({ entry, kind: 'session' }))
}
+1 -93
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { calendarBucket, DAY, formatAgo, HOUR, MINUTE, nominalDayStart, SECOND, sessionBucketLabel } from './time'
import { DAY, formatAgo, HOUR, MINUTE, SECOND } from './time'
const labels = {
ageNow: 'now',
@@ -30,95 +30,3 @@ describe('formatAgo', () => {
expect(ago(-HOUR)).toBe('now')
})
})
// Thursday 18 Jun 2026, local noon (15 Jun 2026 is a Monday).
const THU_NOON = new Date(2026, 5, 18, 12, 0, 0).getTime()
const secondsAt = (year: number, month: number, day: number, hour = 10) =>
Math.floor(new Date(year, month, day, hour, 0, 0).getTime() / 1000)
describe('nominalDayStart', () => {
it('rolls the day boundary at 4 AM, not midnight', () => {
// 1 AM Saturday still belongs to Friday's run.
expect(nominalDayStart(new Date(2026, 5, 20, 1, 30).getTime())).toBe(new Date(2026, 5, 19).getTime())
expect(nominalDayStart(new Date(2026, 5, 20, 4, 30).getTime())).toBe(new Date(2026, 5, 20).getTime())
})
})
describe('calendarBucket', () => {
// Monday week start: the current week began Mon 15 Jun, last week is Jun 8-14.
const MONDAY = 1
const kindAt = (year: number, month: number, day: number, hour = 10) =>
calendarBucket(secondsAt(year, month, day, hour), THU_NOON, MONDAY).kind
it('buckets the current day (and, defensively, the future) as today', () => {
// The head run normally absorbs these; "Earlier today" covers the rest.
expect(kindAt(2026, 5, 18, 5)).toBe('today')
expect(kindAt(2026, 5, 18, 23)).toBe('today')
expect(kindAt(2026, 5, 19)).toBe('today')
})
it('assigns the small hours to the previous evening', () => {
// 1 AM today (before the 4 AM rollover) is part of yesterday's run.
expect(kindAt(2026, 5, 18, 1)).toBe('yesterday')
// And viewed at 00:58, last evening's sessions are still the current day.
const smallHours = new Date(2026, 5, 19, 0, 58).getTime()
expect(calendarBucket(secondsAt(2026, 5, 18, 23), smallHours, MONDAY).kind).toBe('today')
expect(calendarBucket(secondsAt(2026, 5, 18, 10), smallHours, MONDAY).kind).toBe('today')
expect(calendarBucket(secondsAt(2026, 5, 17, 15), smallHours, MONDAY).kind).toBe('yesterday')
})
it('uses coarse, non-overlapping ranges that coarsen with age', () => {
expect(kindAt(2026, 5, 17)).toBe('yesterday')
expect(kindAt(2026, 5, 16)).toBe('thisWeek') // Tue this week
expect(kindAt(2026, 5, 15)).toBe('thisWeek') // Mon this week
expect(kindAt(2026, 5, 14)).toBe('lastWeek') // Sun last week
expect(kindAt(2026, 5, 8)).toBe('lastWeek') // Mon last week
expect(kindAt(2026, 5, 7)).toBe('thisMonth') // earlier in June
expect(kindAt(2026, 5, 1)).toBe('thisMonth')
expect(kindAt(2026, 4, 28)).toBe('month') // May, same year
expect(kindAt(2025, 11, 3)).toBe('monthYear') // December, prior year
})
it('respects a Sunday week start', () => {
// With the week starting Sun 14 Jun, that Sunday is this week, not last.
expect(calendarBucket(secondsAt(2026, 5, 14), THU_NOON, 0).kind).toBe('thisWeek')
expect(calendarBucket(secondsAt(2026, 5, 13), THU_NOON, 0).kind).toBe('lastWeek')
})
it('keys same-month sessions together and disambiguates across years', () => {
expect(calendarBucket(secondsAt(2026, 2, 3), THU_NOON, MONDAY).key).toBe('m-2026-2')
expect(calendarBucket(secondsAt(2026, 2, 20), THU_NOON, MONDAY).key).toBe('m-2026-2')
expect(calendarBucket(secondsAt(2025, 2, 3), THU_NOON, MONDAY).key).toBe('my-2025-2')
})
})
describe('sessionBucketLabel', () => {
const labels = {
lastWeek: 'Last week',
thisMonth: 'Earlier this month',
thisWeek: 'Earlier this week',
today: 'Earlier today',
yesterday: 'Yesterday'
}
const labelAt = (year: number, month: number, day: number) =>
sessionBucketLabel(calendarBucket(secondsAt(year, month, day), THU_NOON, 1), labels)
it('uses fixed labels for the relative buckets', () => {
expect(labelAt(2026, 5, 18)).toBe('Earlier today')
expect(labelAt(2026, 5, 17)).toBe('Yesterday')
expect(labelAt(2026, 5, 16)).toBe('Earlier this week')
expect(labelAt(2026, 5, 10)).toBe('Last week')
expect(labelAt(2026, 5, 2)).toBe('Earlier this month')
})
it('formats month (same year) and month + year (prior year) via Intl', () => {
// en-US default in the test env: month name, plus year for the prior year.
expect(labelAt(2026, 2, 3)).toBe('March')
expect(labelAt(2025, 11, 3)).toBe('December 2025')
})
})
-141
View File
@@ -25,11 +25,6 @@ export const fmtDateTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'medi
// Date only, "5 Jun 2026" (starmap tooltip).
export const fmtDate = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
// Month name alone / with year — session-list date-bucket dividers ("September",
// "September 2025").
export const fmtMonth = new Intl.DateTimeFormat(undefined, { month: 'long' })
export const fmtMonthYear = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' })
// ── Relative time ──────────────────────────────────────────────────────────
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' })
@@ -55,142 +50,6 @@ export function relativeTime(targetMs: number, nowMs = Date.now()): string {
return rtf.format(sign * Math.round(abs / DAY), 'day')
}
// A dated divider bucket below the sidebar's unlabelled "recent" head cluster
// (see session-date-groups.ts for the clustering). Buckets are coarse,
// non-overlapping calendar ranges — one divider per *cluster* of activity,
// never one per day, and never a rolling window like "previous 7 days" that
// semantically overlaps the groups above it. `kind` drives the label; `at` is
// the session's nominal day start (ms) for month formatting.
export type SessionBucketKind = 'lastWeek' | 'month' | 'monthYear' | 'thisMonth' | 'thisWeek' | 'today' | 'yesterday'
export interface SessionBucket {
at: number
key: string
kind: SessionBucketKind
}
// Fixed divider labels, resolved from i18n (month labels come from Intl).
export interface SessionBucketLabels {
lastWeek: string
thisMonth: string
thisWeek: string
today: string
yesterday: string
}
export const startOfLocalDay = (ms: number): number => {
const d = new Date(ms)
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
}
// The human day doesn't end at midnight — it ends when you sleep. Sessions
// from the small hours belong to the previous evening's run, so the day
// boundary sits at 4 AM local (same trick activity/sleep trackers use).
// A 12:30 AM session groups with 11:50 PM instead of splitting off.
export const DAY_ROLLOVER_HOUR = 4
// Start of the *nominal* local day a timestamp belongs to, honoring the 4 AM
// rollover: Saturday 1 AM → start of Friday.
export const nominalDayStart = (ms: number): number => startOfLocalDay(ms - DAY_ROLLOVER_HOUR * HOUR)
// Locale-aware first day of week in JS getDay() convention (0=Sun … 6=Sat).
// Intl.Locale weekInfo reports 1=Mon … 7=Sun; unsupported → Monday.
export function localeWeekStartDay(): number {
try {
const locale = new Intl.Locale(new Intl.DateTimeFormat().resolvedOptions().locale)
const withWeekInfo = locale as { getWeekInfo?: () => { firstDay?: number }; weekInfo?: { firstDay?: number } }
const firstDay = (withWeekInfo.getWeekInfo?.() ?? withWeekInfo.weekInfo)?.firstDay
return typeof firstDay === 'number' ? firstDay % 7 : 1
} catch {
return 1
}
}
// Start of the local calendar week containing `ms` (DST-safe Date field math).
export function startOfLocalWeek(ms: number, weekStartsOn: number): number {
const d = new Date(startOfLocalDay(ms))
const back = (d.getDay() - weekStartsOn + 7) % 7
return new Date(d.getFullYear(), d.getMonth(), d.getDate() - back).getTime()
}
// Coarse calendar bucket for a Unix-seconds timestamp. Granularity coarsens
// with age: earlier today → yesterday → earlier this week → last week →
// earlier this month → month → month + year. Empty ranges simply never emit a
// bucket, so a sparse tail jumps straight to its month or month-year. The
// newest run of sessions never reaches here (it is the unlabelled head — see
// session-date-groups.ts), which is what makes "Earlier today" truthful.
export function calendarBucket(
seconds: number,
nowMs = Date.now(),
weekStartsOn = localeWeekStartDay()
): SessionBucket {
const nominal = nominalDayStart(seconds * SECOND)
const todayNominal = nominalDayStart(nowMs)
const dayDiff = Math.round((todayNominal - nominal) / DAY)
if (dayDiff <= 0) {
return { at: nominal, key: 'today', kind: 'today' }
}
if (dayDiff === 1) {
return { at: nominal, key: 'yesterday', kind: 'yesterday' }
}
const weekStart = startOfLocalWeek(todayNominal, weekStartsOn)
if (nominal >= weekStart) {
return { at: nominal, key: 'this-week', kind: 'thisWeek' }
}
const ws = new Date(weekStart)
if (nominal >= new Date(ws.getFullYear(), ws.getMonth(), ws.getDate() - 7).getTime()) {
return { at: nominal, key: 'last-week', kind: 'lastWeek' }
}
const d = new Date(nominal)
const now = new Date(todayNominal)
const sameYear = d.getFullYear() === now.getFullYear()
if (sameYear && d.getMonth() === now.getMonth()) {
return { at: nominal, key: 'this-month', kind: 'thisMonth' }
}
const ym = `${d.getFullYear()}-${d.getMonth()}`
return sameYear ? { at: nominal, key: `m-${ym}`, kind: 'month' } : { at: nominal, key: `my-${ym}`, kind: 'monthYear' }
}
// Localized divider label for a bucket: fixed relative strings from i18n,
// Intl-formatted month / month-year for the rest.
export function sessionBucketLabel(bucket: SessionBucket, labels: SessionBucketLabels): string {
switch (bucket.kind) {
case 'today':
return labels.today
case 'yesterday':
return labels.yesterday
case 'thisWeek':
return labels.thisWeek
case 'lastWeek':
return labels.lastWeek
case 'thisMonth':
return labels.thisMonth
case 'month':
return fmtMonth.format(bucket.at)
case 'monthYear':
return fmtMonthYear.format(bucket.at)
}
}
export type ElapsedUnit = 'day' | 'hour' | 'minute' | 'second'
// Coarsest elapsed bucket for a (clamped-nonnegative) duration, floored. The
@@ -1,95 +0,0 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SessionInfo } from '@/types/hermes'
const patch = vi.fn<(id: string, pinned: boolean, profile?: null | string) => Promise<{ ok: boolean }>>(() =>
Promise.resolve({ ok: true })
)
vi.mock('@/hermes', () => ({
setSessionPinnedRemote: (id: string, pinned: boolean, profile?: null | string) => patch(id, pinned, profile)
}))
import { $pinnedSessionIds } from '@/store/layout'
import { $sessions } from '@/store/session'
import { watchSessionPins } from './session-pin-sync'
const row = (id: string, extra: Partial<SessionInfo> = {}): SessionInfo =>
({ id, message_count: 1, source: 'cli', started_at: 0, title: id, ...extra }) as SessionInfo
const flush = () => Promise.resolve()
beforeAll(() => {
;(globalThis as { window?: unknown }).window ??= {}
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {}
// Attach the listeners once — module state is process-global.
watchSessionPins()
})
beforeEach(() => {
$sessions.set([])
$pinnedSessionIds.set([])
patch.mockClear()
})
afterEach(() => {
$sessions.set([])
$pinnedSessionIds.set([])
})
describe('watchSessionPins', () => {
it('mirrors a new pin as pinned=true with the row profile', async () => {
$sessions.set([row('a', { profile: 'work' })])
$pinnedSessionIds.set(['a'])
await flush()
expect(patch).toHaveBeenCalledWith('a', true, 'work')
})
it('mirrors an unpin as pinned=false', async () => {
$sessions.set([row('b')])
$pinnedSessionIds.set(['b'])
await flush()
patch.mockClear()
$pinnedSessionIds.set([])
await flush()
expect(patch).toHaveBeenCalledWith('b', false, undefined)
})
it('defers a pin whose row is not loaded, then flushes once it appears', async () => {
$pinnedSessionIds.set(['c'])
await flush()
// No row yet -> nothing sent.
expect(patch).not.toHaveBeenCalled()
$sessions.set([row('c', { profile: 'p2' })])
await flush()
expect(patch).toHaveBeenCalledWith('c', true, 'p2')
})
it('matches a pin id against the lineage root', async () => {
// pin id is the lineage root; the live row carries it as _lineage_root_id.
$sessions.set([row('tip', { _lineage_root_id: 'root' })])
$pinnedSessionIds.set(['root'])
await flush()
expect(patch).toHaveBeenCalledWith('root', true, undefined)
})
it('does not re-PATCH an already-mirrored pin on unrelated session updates', async () => {
$sessions.set([row('d')])
$pinnedSessionIds.set(['d'])
await flush()
patch.mockClear()
// A session-list refresh that doesn't change the pinned set.
$sessions.set([row('d'), row('e')])
await flush()
expect(patch).not.toHaveBeenCalled()
})
})
@@ -1,75 +0,0 @@
/**
* Mirror the sidebar's localStorage pins into the backend "keep" flag.
*
* Pins live in `$pinnedSessionIds` (localStorage) and drive the sidebar UI.
* The `sessions.auto_archive` sweep, however, runs backend-side and is blind to
* localStorage — so without this bridge it could hide a pinned chat. This
* watcher PATCHes `pinned` on the session REST endpoint whenever the pinned set
* changes, and re-asserts the whole current set at boot, which transparently
* migrates pre-existing pins (no flag, no user action — the sweep just starts
* honouring them). It never touches the sidebar's own display; localStorage
* stays the source of truth there.
*/
import { setSessionPinnedRemote } from '@/hermes'
import { $pinnedSessionIds } from '@/store/layout'
import { $sessions, sessionMatchesStoredId } from '@/store/session'
// pin ids we've successfully PATCHed pinned=true this session.
const mirrored = new Set<string>()
// pin ids awaiting their row so we can resolve the owning profile before PATCH.
const pending = new Set<string>()
function profileFor(pinId: string): null | string | undefined {
return $sessions.get().find(row => sessionMatchesStoredId(row, pinId))?.profile
}
function reconcile(): void {
// Config/session REST is only reachable through the Electron bridge.
if (!window.hermesDesktop) {
return
}
const current = new Set($pinnedSessionIds.get())
// Unpinned: anything we were tracking that's no longer in the set.
for (const id of [...mirrored, ...pending]) {
if (!current.has(id)) {
mirrored.delete(id)
pending.delete(id)
void setSessionPinnedRemote(id, false, profileFor(id)).catch(() => {})
}
}
// Newly pinned: hold until we can resolve the row (for its profile).
for (const id of current) {
if (!mirrored.has(id)) {
pending.add(id)
}
}
// Flush whatever we can resolve now; unresolved ids (row not loaded yet)
// retry on the next $sessions change.
for (const id of [...pending]) {
const row = $sessions.get().find(entry => sessionMatchesStoredId(entry, id))
if (!row) {
continue
}
pending.delete(id)
mirrored.add(id)
void setSessionPinnedRemote(id, true, row.profile).catch(() => {
// Let a later reconcile retry the mirror.
mirrored.delete(id)
pending.add(id)
})
}
}
// Sync once, then re-sync on pin-set and session-list changes. Call once per app.
export function watchSessionPins(): void {
reconcile()
$pinnedSessionIds.listen(reconcile)
$sessions.listen(reconcile)
}
+1 -92
View File
@@ -243,58 +243,6 @@ export interface MessagingPlatformTestResponse {
state?: null | string
}
// -- Webhooks (subscription CRUD) --------------------------------------------
// Incoming HTTP event routes served by the webhook gateway platform. Backed by
// the same JSON store the CLI/dashboard use; per-route HMAC secrets are
// redacted on read and surfaced exactly once on create.
export interface WebhookRoute {
created_at: null | string
deliver: string
deliver_only: boolean
description: string
enabled: boolean
events: string[]
name: string
prompt: string
secret_set: boolean
skills: string[]
url: string
}
export interface WebhooksResponse {
base_url: string
enabled: boolean
subscriptions: WebhookRoute[]
}
export interface WebhookCreatePayload {
deliver?: string
deliver_chat_id?: string
deliver_only?: boolean
description?: string
events?: string[]
name: string
prompt?: string
skills?: string[]
}
// Create echoes the route summary plus the one-time secret.
export interface WebhookCreateResponse extends WebhookRoute {
secret: string
}
export interface WebhookEnableResponse {
enabled: true
needs_restart: boolean
ok: boolean
platform: 'webhook'
restart_action?: string
restart_error?: string
restart_pid?: null | number
restart_started?: boolean
}
export interface GatewayReadyPayload {
skin?: unknown
}
@@ -728,46 +676,6 @@ export interface CronJobUpdates {
schedule?: string
}
// A cron delivery target from GET /api/cron/delivery-targets — the single
// source of truth (cron.scheduler.cron_delivery_targets) for where a cron job
// can auto-deliver. Only 'local' plus configured gateway platforms appear; a
// configured platform without a cron home channel comes back with
// home_target_set=false so the UI can flag it.
export interface CronDeliveryTarget {
home_env_var: null | string
home_target_set: boolean
id: string
name: string
}
// Automation Blueprints — parameterized cron templates with typed slots. The
// backend (cron/blueprint_catalog.py) is the single source of truth; the
// desktop renders each slot as a form field, then instantiates a real cron job
// via the same create_job path as everything else. Shapes mirror the JSON from
// GET /api/cron/blueprints (blueprint_catalog_entry).
export interface AutomationBlueprintField {
name: string
type: 'enum' | 'text' | 'time' | 'weekdays'
label: string
default: null | string
options: string[]
optional: boolean
/** When false, options are suggestions — any value is accepted. */
strict?: boolean
help: string
}
export interface AutomationBlueprint {
key: string
title: string
description: string
category: string
tags: string[]
fields: AutomationBlueprintField[]
command: string
appUrl: string
}
export interface ProfileCreatePayload {
clone_all?: boolean
clone_from?: null | string
@@ -1035,6 +943,7 @@ export interface StatusResponse {
gateway_updated_at: string | null
hermes_home: string
latest_config_version: number
release_date: string
version: string
}
-65
View File
@@ -1,65 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createWebhook, deleteWebhook, enableWebhooks, getWebhooks, setWebhookEnabled } from './hermes'
describe('Webhook REST parity helpers', () => {
let api: ReturnType<typeof vi.fn>
beforeEach(() => {
api = vi.fn().mockResolvedValue({})
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { api }
})
})
afterEach(() => {
vi.restoreAllMocks()
Reflect.deleteProperty(window, 'hermesDesktop')
})
it('lists webhooks from the admin endpoint', async () => {
await getWebhooks()
expect(api).toHaveBeenCalledWith(expect.objectContaining({ path: '/api/webhooks' }))
})
it('enables the webhook platform with POST', async () => {
await enableWebhooks()
expect(api).toHaveBeenCalledWith(expect.objectContaining({ method: 'POST', path: '/api/webhooks/enable' }))
})
it('creates a subscription with the full payload', async () => {
const body = {
deliver: 'telegram',
deliver_only: true,
description: 'push events',
events: ['push'],
name: 'github-push',
prompt: 'summarize the push'
}
await createWebhook(body)
expect(api).toHaveBeenCalledWith(expect.objectContaining({ body, method: 'POST', path: '/api/webhooks' }))
})
it('encodes the name when deleting a subscription', async () => {
await deleteWebhook('my hook')
expect(api).toHaveBeenCalledWith(expect.objectContaining({ method: 'DELETE', path: '/api/webhooks/my%20hook' }))
})
it('toggles a subscription enabled state via PUT', async () => {
await setWebhookEnabled('github-push', false)
expect(api).toHaveBeenCalledWith(
expect.objectContaining({
body: { enabled: false },
method: 'PUT',
path: '/api/webhooks/github-push/enabled'
})
)
})
})
+1 -1
View File
@@ -282,7 +282,7 @@ def _process_single_prompt(
print(f" Prompt {prompt_index}: Pulling docker image {container_image}...", flush=True)
pull = _sp.run(
["docker", "pull", container_image],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=600,
capture_output=True, text=True, timeout=600,
)
if pull.returncode != 0:
return {
+26 -39
View File
@@ -1432,7 +1432,7 @@ def _git_repo_root() -> Optional[str]:
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5,
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
return _normalize_git_bash_path(result.stdout.strip())
@@ -1479,7 +1479,7 @@ def _resolve_worktree_base(repo_root: str) -> tuple:
def _git(args, timeout=20):
return subprocess.run(
["git", *args],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, cwd=repo_root,
capture_output=True, text=True, timeout=timeout, cwd=repo_root,
)
# 1. Current branch's upstream, if it tracks one.
@@ -1578,7 +1578,7 @@ def _setup_worktree(repo_root: str = None, sync_base: bool = True) -> Optional[D
try:
result = subprocess.run(
["git", "worktree", "add", str(wt_path), "-b", branch_name, base_ref],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, cwd=repo_root,
capture_output=True, text=True, timeout=30, cwd=repo_root,
)
if result.returncode != 0:
# If branching from the resolved remote ref failed for any reason
@@ -1592,7 +1592,7 @@ def _setup_worktree(repo_root: str = None, sync_base: bool = True) -> Optional[D
base_ref, base_label = "HEAD", "HEAD (fallback — remote base failed)"
result = subprocess.run(
["git", "worktree", "add", str(wt_path), "-b", branch_name, base_ref],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, cwd=repo_root,
capture_output=True, text=True, timeout=30, cwd=repo_root,
)
if result.returncode != 0:
print(f"\033[31m✗ Failed to create worktree: {result.stderr.strip()}\033[0m")
@@ -1673,7 +1673,7 @@ def _setup_worktree(repo_root: str = None, sync_base: bool = True) -> Optional[D
try:
subprocess.run(
["git", "worktree", "lock", "--reason", f"hermes pid={os.getpid()}", str(wt_path)],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, cwd=repo_root,
capture_output=True, text=True, timeout=10, cwd=repo_root,
)
logger.debug("Worktree locked: %s (pid=%s)", wt_path, os.getpid())
except Exception as e:
@@ -1706,7 +1706,7 @@ def _worktree_has_unpushed_commits(worktree_path: str, timeout: int = 10) -> boo
try:
remote_refs = subprocess.run(
["git", "for-each-ref", "--format=%(refname)", "refs/remotes"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, cwd=worktree_path,
capture_output=True, text=True, timeout=timeout, cwd=worktree_path,
)
if remote_refs.returncode != 0:
return True
@@ -1715,7 +1715,7 @@ def _worktree_has_unpushed_commits(worktree_path: str, timeout: int = 10) -> boo
result = subprocess.run(
["git", "log", "--oneline", "HEAD", "--not", "--remotes"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, cwd=worktree_path,
capture_output=True, text=True, timeout=timeout, cwd=worktree_path,
)
if result.returncode != 0:
return True
@@ -1736,7 +1736,7 @@ def _worktree_is_dirty(worktree_path: str, timeout: int = 10) -> bool:
try:
result = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, cwd=worktree_path,
capture_output=True, text=True, timeout=timeout, cwd=worktree_path,
)
if result.returncode != 0:
return True
@@ -1769,7 +1769,7 @@ def _worktree_commits_all_merged_upstream(
try:
probe = subprocess.run(
["git", "rev-parse", "--verify", "--quiet", candidate],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, cwd=worktree_path,
capture_output=True, text=True, timeout=timeout, cwd=worktree_path,
)
if probe.returncode == 0 and probe.stdout.strip():
base = candidate
@@ -1782,7 +1782,7 @@ def _worktree_commits_all_merged_upstream(
try:
ahead = subprocess.run(
["git", "rev-list", "--count", f"{base}..HEAD"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, cwd=worktree_path,
capture_output=True, text=True, timeout=timeout, cwd=worktree_path,
)
if ahead.returncode != 0:
return False
@@ -1794,7 +1794,7 @@ def _worktree_commits_all_merged_upstream(
cherry = subprocess.run(
["git", "cherry", base, "HEAD"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, cwd=worktree_path,
capture_output=True, text=True, timeout=timeout, cwd=worktree_path,
)
if cherry.returncode != 0:
return False
@@ -1829,7 +1829,7 @@ def _worktree_lock_is_live(repo_root: str, worktree_path: str, timeout: int = 10
try:
result = subprocess.run(
["git", "worktree", "list", "--porcelain"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, cwd=repo_root,
capture_output=True, text=True, timeout=timeout, cwd=repo_root,
)
if result.returncode != 0:
return "live"
@@ -1904,7 +1904,7 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None:
try:
subprocess.run(
["git", "worktree", "unlock", wt_path],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, cwd=repo_root,
capture_output=True, text=True, timeout=10, cwd=repo_root,
)
except Exception as e:
logger.debug("git worktree unlock failed (non-fatal): %s", e)
@@ -1912,7 +1912,7 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None:
try:
subprocess.run(
["git", "worktree", "remove", wt_path, "--force"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, cwd=repo_root,
capture_output=True, text=True, timeout=15, cwd=repo_root,
)
except Exception as e:
logger.debug("Failed to remove worktree: %s", e)
@@ -1921,7 +1921,7 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None:
try:
subprocess.run(
["git", "branch", "-D", branch],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, cwd=repo_root,
capture_output=True, text=True, timeout=10, cwd=repo_root,
)
except Exception as e:
logger.debug("Failed to delete branch %s: %s", branch, e)
@@ -1972,16 +1972,6 @@ def _run_state_db_auto_maintenance(session_db) -> None:
logger.debug("Orphan compression finalize skipped: %s", _finalize_exc)
cfg = (_load_full_config().get("sessions") or {})
# Auto-archive (soft-hide stale sessions) is independent of the
# destructive auto_prune sweep — run it first, before prune's early
# return, so enabling one doesn't require the other.
if cfg.get("auto_archive", False):
session_db.maybe_auto_archive(
idle_days=float(cfg.get("auto_archive_days", 3)),
min_interval_hours=int(cfg.get("min_interval_hours", 24)),
)
if not cfg.get("auto_prune", False):
return
session_db.maybe_auto_prune_and_vacuum(
@@ -2122,7 +2112,7 @@ def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
try:
subprocess.run(
["git", "worktree", "unlock", str(entry)],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, cwd=repo_root,
capture_output=True, text=True, timeout=10, cwd=repo_root,
)
except Exception as e:
logger.debug("Failed to unlock dead worktree %s: %s", entry.name, e)
@@ -2131,13 +2121,13 @@ def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
try:
branch_result = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, cwd=str(entry),
capture_output=True, text=True, timeout=5, cwd=str(entry),
)
branch = branch_result.stdout.strip()
remove_result = subprocess.run(
["git", "worktree", "remove", str(entry), "--force"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, cwd=repo_root,
capture_output=True, text=True, timeout=15, cwd=repo_root,
)
if remove_result.returncode != 0:
# Removal failed — keep the branch so any commits stay
@@ -2150,7 +2140,7 @@ def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
if branch:
subprocess.run(
["git", "branch", "-D", branch],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, cwd=repo_root,
capture_output=True, text=True, timeout=10, cwd=repo_root,
)
logger.debug("Pruned stale worktree: %s (force=%s)", entry.name, force)
except Exception as e:
@@ -2178,7 +2168,7 @@ def _prune_orphaned_branches(repo_root: str) -> None:
try:
result = subprocess.run(
["git", "branch", "--format=%(refname:short)"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, cwd=repo_root,
capture_output=True, text=True, timeout=10, cwd=repo_root,
)
if result.returncode != 0:
return
@@ -2191,7 +2181,7 @@ def _prune_orphaned_branches(repo_root: str) -> None:
try:
wt_result = subprocess.run(
["git", "worktree", "list", "--porcelain"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, cwd=repo_root,
capture_output=True, text=True, timeout=10, cwd=repo_root,
)
for line in wt_result.stdout.split("\n"):
if line.startswith("branch refs/heads/"):
@@ -2203,7 +2193,7 @@ def _prune_orphaned_branches(repo_root: str) -> None:
try:
head_result = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, cwd=repo_root,
capture_output=True, text=True, timeout=5, cwd=repo_root,
)
current = head_result.stdout.strip()
if current:
@@ -2227,7 +2217,7 @@ def _prune_orphaned_branches(repo_root: str) -> None:
try:
subprocess.run(
["git", "branch", "-D"] + batch,
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, cwd=repo_root,
capture_output=True, text=True, timeout=30, cwd=repo_root,
)
except Exception as e:
logger.debug("Failed to prune orphaned branches: %s", e)
@@ -3687,9 +3677,10 @@ def _build_compact_banner() -> str:
tiny_line = agent_name
if os.environ.get("HERMES_FAST_STARTUP_BANNER") == "1":
from hermes_cli import __release_date__ as _release_date
from hermes_cli import __version__ as _version
version_line = f"Hermes Agent v{_version}"
version_line = f"Hermes Agent v{_version} ({_release_date})"
else:
version_line = format_banner_version_label()
@@ -9283,10 +9274,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._show_gateway_status()
elif canonical == "status":
self._show_session_status()
elif canonical == "egress":
from hermes_cli.proxy_cli import format_status_text
self._console_print(format_status_text(), highlight=False, markup=False)
elif canonical == "statusbar":
self._status_bar_visible = not self._status_bar_visible
state = "visible" if self._status_bar_visible else "hidden"
@@ -9527,7 +9514,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
from hermes_cli._subprocess_compat import windows_hide_flags
result = subprocess.run(
exec_cmd, shell=True, capture_output=True,
text=True, encoding="utf-8", errors="replace", timeout=30, env=sanitized_env,
text=True, timeout=30, env=sanitized_env,
# No console flash on Windows (#56747).
creationflags=windows_hide_flags(),
)
-1
View File
@@ -1 +0,0 @@
webtecnica
@@ -1 +0,0 @@
bedirhancode

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