Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73dccbe9c1 | ||
|
|
ceed22d2af |
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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]] = {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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" />
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
-21
@@ -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
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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' } }),
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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: '仕上げ中',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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,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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
webtecnica
|
||||
@@ -1 +0,0 @@
|
||||
bedirhancode
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user