mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-28 12:46:31 +08:00
Compare commits
125 Commits
feat/telem
...
thin-clien
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a8d4da69a | ||
|
|
4dce531189 | ||
|
|
cdb1dfbc49 | ||
|
|
d15cc9bc83 | ||
|
|
fa8f1517da | ||
|
|
a67ddf5983 | ||
|
|
2608f78b93 | ||
|
|
25b7348457 | ||
|
|
6326d5c6f6 | ||
|
|
24a4df9cd1 | ||
|
|
cf7bf5bdc9 | ||
|
|
70c834a740 | ||
|
|
9c9b28a2b3 | ||
|
|
5eb108f06c | ||
|
|
391090083c | ||
|
|
7e101e553b | ||
|
|
515192c4b9 | ||
|
|
f0678b031e | ||
|
|
9b2af36d5a | ||
|
|
525e1e775d | ||
|
|
f509f6e598 | ||
|
|
217047de2d | ||
|
|
3c8d3ecfa0 | ||
|
|
ba7026c376 | ||
|
|
772cf847b0 | ||
|
|
699adc2ca5 | ||
|
|
ed962104c8 | ||
|
|
db6ced4712 | ||
|
|
2d3071f9d4 | ||
|
|
9dd56f0dfb | ||
|
|
3d735fe156 | ||
|
|
d430684d7c | ||
|
|
bb6a4d2a57 | ||
|
|
c0568ca95f | ||
|
|
5cc4009deb | ||
|
|
5038678647 | ||
|
|
d9f1f1a1de | ||
|
|
65be0061e0 | ||
|
|
3c5bcd3eee | ||
|
|
9274f73e48 | ||
|
|
7b2c51152a | ||
|
|
9ef49cd78f | ||
|
|
8ab7246c45 | ||
|
|
e3db1ef92d | ||
|
|
1c832762a8 | ||
|
|
07cc567dfa | ||
|
|
ca82d0accc | ||
|
|
54b50037e1 | ||
|
|
8559246bfb | ||
|
|
1aa458a1e6 | ||
|
|
da0ed979fa | ||
|
|
05ba5f3962 | ||
|
|
41ede84b93 | ||
|
|
e36d9862ec | ||
|
|
0c190083cd | ||
|
|
81ac562bf0 | ||
|
|
063fe4f6ef | ||
|
|
fbfccbb3ee | ||
|
|
a0dc92450b | ||
|
|
41f8126148 | ||
|
|
6a319f570f | ||
|
|
619dc4a561 | ||
|
|
19b2624404 | ||
|
|
2e322466b1 | ||
|
|
cb9cb6ba1c | ||
|
|
099df3cd89 | ||
|
|
4d0dd6bd52 | ||
|
|
075f93ad78 | ||
|
|
6e4e5967f7 | ||
|
|
a2b49e60b6 | ||
|
|
7d568293f9 | ||
|
|
3cf900eb67 | ||
|
|
cb7d1f68f8 | ||
|
|
0f81b0d458 | ||
|
|
62fe9fd101 | ||
|
|
4e66bf1f80 | ||
|
|
7aa32ec82f | ||
|
|
2b86e9dae4 | ||
|
|
bf60bbb6c5 | ||
|
|
fe255ab28b | ||
|
|
7d1b72a15d | ||
|
|
6ba551e942 | ||
|
|
dd980aaba1 | ||
|
|
594380d44a | ||
|
|
a28b939092 | ||
|
|
27c486e3b1 | ||
|
|
f4c656b0a0 | ||
|
|
4d04c652f2 | ||
|
|
96bc524a71 | ||
|
|
eed9bbeb0a | ||
|
|
6c58878e7d | ||
|
|
8ff426e53b | ||
|
|
5add283ec8 | ||
|
|
8233598e64 | ||
|
|
76074b2145 | ||
|
|
3b1344c18c | ||
|
|
da5484b61f | ||
|
|
5b5c79a8ef | ||
|
|
43b8ba4181 | ||
|
|
f44415e71a | ||
|
|
0b7128582f | ||
|
|
85e084d60d | ||
|
|
dedf5643d8 | ||
|
|
1c8594b634 | ||
|
|
a4091e49f1 | ||
|
|
233ef98afe | ||
|
|
1abfa66ba6 | ||
|
|
865a09a610 | ||
|
|
811df74a10 | ||
|
|
e29823f1e8 | ||
|
|
ce802e932c | ||
|
|
8501caf51f | ||
|
|
56cf517ccd | ||
|
|
6b639bc2b9 | ||
|
|
41f4dce828 | ||
|
|
985350dd85 | ||
|
|
7f02f30b76 | ||
|
|
563d347e4d | ||
|
|
6e096a850a | ||
|
|
09623b4527 | ||
|
|
c456029b4e | ||
|
|
1f950e189c | ||
|
|
ff81365988 | ||
|
|
f23d077b5f | ||
|
|
f168631be0 |
@@ -666,6 +666,28 @@ def _pool_runtime_base_url(entry: Any, fallback: str = "") -> str:
|
||||
return str(url or "").strip().rstrip("/")
|
||||
|
||||
|
||||
# Hostnames (lowercase, exact) that the auxiliary Anthropic path is allowed to
|
||||
# be pointed at via config.yaml model.base_url. Anything else falls back to the
|
||||
# Anthropic default — operators routing main-session traffic through a
|
||||
# non-Anthropic host (e.g. OpenRouter, OpenAI) with provider=anthropic in config
|
||||
# must NOT have that foreign host leak into the auxiliary client. See #52608.
|
||||
_ANTHROPIC_COMPATIBLE_HOSTS = frozenset({
|
||||
"api.anthropic.com",
|
||||
})
|
||||
|
||||
|
||||
def _is_anthropic_compatible_host(url: str) -> bool:
|
||||
"""Return True if ``url``'s hostname is an Anthropic endpoint we trust for aux calls."""
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
host = (urlparse(url).hostname or "").strip().lower().rstrip(".")
|
||||
return host in _ANTHROPIC_COMPATIBLE_HOSTS
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _nous_min_key_ttl_seconds() -> int:
|
||||
try:
|
||||
return max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800")))
|
||||
@@ -2256,9 +2278,16 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
|
||||
if not token:
|
||||
return None, None
|
||||
|
||||
# Allow base URL override from config.yaml model.base_url, but only
|
||||
# when the configured provider is anthropic — otherwise a non-Anthropic
|
||||
# base_url (e.g. Codex endpoint) would leak into Anthropic requests.
|
||||
# Allow base URL override from config.yaml model.base_url, but only when:
|
||||
# 1. the configured provider is anthropic (otherwise a non-Anthropic
|
||||
# base_url, e.g. Codex endpoint, would leak into Anthropic requests), AND
|
||||
# 2. the override URL actually points at an Anthropic-compatible endpoint.
|
||||
# Without gate (2), operators who route main-session traffic through a
|
||||
# non-Anthropic provider that accepts Anthropic-format requests (e.g.
|
||||
# OpenRouter at openrouter.ai/api/v1, with provider=anthropic in config.yaml)
|
||||
# would have every auxiliary side-channel call (memory extractors,
|
||||
# reflection, vision, title generation) 401 from the foreign host —
|
||||
# see issue #52608.
|
||||
base_url = _pool_runtime_base_url(entry, _ANTHROPIC_DEFAULT_BASE_URL) if pool_present else _ANTHROPIC_DEFAULT_BASE_URL
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
@@ -2268,7 +2297,7 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
|
||||
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
if cfg_provider == "anthropic":
|
||||
cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/")
|
||||
if cfg_base_url:
|
||||
if cfg_base_url and _is_anthropic_compatible_host(cfg_base_url):
|
||||
base_url = cfg_base_url
|
||||
except Exception:
|
||||
pass
|
||||
@@ -2754,6 +2783,25 @@ def _is_model_incompatible_error(exc: Exception) -> bool:
|
||||
))
|
||||
|
||||
|
||||
def _is_invalid_aux_response_error(exc: Exception) -> bool:
|
||||
"""Detect provider responses that authenticated but cannot serve aux shape.
|
||||
|
||||
Some OpenAI-compatible routes return HTTP 200 with an empty/malformed
|
||||
ChatCompletion instead of a normal provider error. That is still a
|
||||
provider/model capability failure for auxiliary tasks: downstream callers
|
||||
need ``choices[0].message`` and should be able to continue through the
|
||||
same fallback path as explicit model-incompatibility errors.
|
||||
"""
|
||||
if not isinstance(exc, RuntimeError):
|
||||
return False
|
||||
msg = str(exc).lower()
|
||||
return (
|
||||
"auxiliary " in msg
|
||||
and "llm returned invalid response" in msg
|
||||
and "choices[0].message" in msg
|
||||
)
|
||||
|
||||
|
||||
def _evict_cached_clients(provider: str) -> None:
|
||||
"""Drop cached auxiliary clients for a provider so fresh creds are used."""
|
||||
normalized = _normalize_aux_provider(provider)
|
||||
@@ -5445,6 +5493,9 @@ def _validate_llm_response(response: Any, task: str = None) -> Any:
|
||||
if not choices or not hasattr(choices[0], "message"):
|
||||
raise AttributeError("missing choices[0].message")
|
||||
except (AttributeError, TypeError, IndexError) as exc:
|
||||
recovered = _recover_aux_response_message(response)
|
||||
if recovered is not None:
|
||||
return recovered
|
||||
response_type = type(response).__name__
|
||||
response_preview = str(response)[:120]
|
||||
raise RuntimeError(
|
||||
@@ -5456,6 +5507,64 @@ def _validate_llm_response(response: Any, task: str = None) -> Any:
|
||||
return response
|
||||
|
||||
|
||||
def _recover_aux_response_message(response: Any) -> Optional[Any]:
|
||||
"""Synthesize chat-completions shape from Responses-style text fields.
|
||||
|
||||
Auxiliary callers consume ``choices[0].message``. Some compatible
|
||||
endpoints return text outside ``choices`` (for example ``output_text`` or
|
||||
``output`` items). Preserve that response before declaring it malformed.
|
||||
"""
|
||||
text = _extract_aux_response_text(response)
|
||||
if not text:
|
||||
return None
|
||||
|
||||
choice = SimpleNamespace(
|
||||
message=SimpleNamespace(content=text),
|
||||
finish_reason=getattr(response, "finish_reason", None) or "stop",
|
||||
)
|
||||
try:
|
||||
response.choices = [choice]
|
||||
return response
|
||||
except Exception:
|
||||
return SimpleNamespace(
|
||||
id=getattr(response, "id", ""),
|
||||
model=getattr(response, "model", ""),
|
||||
object=getattr(response, "object", "chat.completion"),
|
||||
choices=[choice],
|
||||
usage=getattr(response, "usage", None),
|
||||
)
|
||||
|
||||
|
||||
def _extract_aux_response_text(response: Any) -> str:
|
||||
output_text = _obj_get(response, "output_text")
|
||||
if isinstance(output_text, str) and output_text.strip():
|
||||
return output_text.strip()
|
||||
|
||||
output = _obj_get(response, "output")
|
||||
if not isinstance(output, list):
|
||||
return ""
|
||||
|
||||
parts: List[str] = []
|
||||
for item in output:
|
||||
item_type = _obj_get(item, "type")
|
||||
if item_type and item_type != "message":
|
||||
continue
|
||||
for part in (_obj_get(item, "content") or []):
|
||||
part_type = _obj_get(part, "type")
|
||||
if part_type in {"output_text", "text", None}:
|
||||
text = _obj_get(part, "text")
|
||||
if isinstance(text, str) and text.strip():
|
||||
parts.append(text.strip())
|
||||
return "\n".join(parts).strip()
|
||||
|
||||
|
||||
def _obj_get(obj: Any, key: str, default: Any = None) -> Any:
|
||||
value = getattr(obj, key, default)
|
||||
if value is default and isinstance(obj, dict):
|
||||
value = obj.get(key, default)
|
||||
return value
|
||||
|
||||
|
||||
def call_llm(
|
||||
task: str = None,
|
||||
*,
|
||||
@@ -5858,6 +5967,7 @@ def call_llm(
|
||||
or _is_connection_error(first_err)
|
||||
or _is_rate_limit_error(first_err)
|
||||
or _is_model_incompatible_error(first_err)
|
||||
or _is_invalid_aux_response_error(first_err)
|
||||
)
|
||||
# Respect explicit provider choice for transient errors (auth, request
|
||||
# validation, etc.) but allow fallback when the provider clearly cannot
|
||||
@@ -5880,6 +5990,7 @@ def call_llm(
|
||||
or _is_connection_error(first_err)
|
||||
or _is_rate_limit_error(first_err)
|
||||
or _is_model_incompatible_error(first_err)
|
||||
or _is_invalid_aux_response_error(first_err)
|
||||
)
|
||||
if should_fallback and (is_auto or is_capacity_error):
|
||||
if _is_payment_error(first_err):
|
||||
@@ -5895,6 +6006,8 @@ def call_llm(
|
||||
reason = "rate limit"
|
||||
elif _is_model_incompatible_error(first_err):
|
||||
reason = "model incompatible with route"
|
||||
elif _is_invalid_aux_response_error(first_err):
|
||||
reason = "invalid provider response"
|
||||
else:
|
||||
reason = "connection error"
|
||||
logger.info("Auxiliary %s: %s on %s (%s), trying fallback",
|
||||
@@ -6334,6 +6447,7 @@ async def async_call_llm(
|
||||
or _is_connection_error(first_err)
|
||||
or _is_rate_limit_error(first_err)
|
||||
or _is_model_incompatible_error(first_err)
|
||||
or _is_invalid_aux_response_error(first_err)
|
||||
)
|
||||
# Capacity errors (payment/quota/connection/rate-limit) bypass the
|
||||
# explicit-provider gate — the provider cannot serve the request
|
||||
@@ -6348,6 +6462,7 @@ async def async_call_llm(
|
||||
or _is_connection_error(first_err)
|
||||
or _is_rate_limit_error(first_err)
|
||||
or _is_model_incompatible_error(first_err)
|
||||
or _is_invalid_aux_response_error(first_err)
|
||||
)
|
||||
if should_fallback and (is_auto or is_capacity_error):
|
||||
if _is_payment_error(first_err):
|
||||
@@ -6359,6 +6474,8 @@ async def async_call_llm(
|
||||
reason = "rate limit"
|
||||
elif _is_model_incompatible_error(first_err):
|
||||
reason = "model incompatible with route"
|
||||
elif _is_invalid_aux_response_error(first_err):
|
||||
reason = "invalid provider response"
|
||||
else:
|
||||
reason = "connection error"
|
||||
logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback",
|
||||
|
||||
@@ -2561,6 +2561,17 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
_stream_stale_timeout = max(_stream_stale_timeout_base, 240.0)
|
||||
else:
|
||||
_stream_stale_timeout = _stream_stale_timeout_base
|
||||
# Reasoning-model floor: known reasoning models (Nemotron 3 Ultra,
|
||||
# OpenAI o1/o3, Anthropic Opus 4.x thinking, DeepSeek R1, Qwen QwQ,
|
||||
# xAI Grok reasoning, etc.) routinely exceed the default 180s chat-
|
||||
# model threshold during their thinking phase. The cloud gateway
|
||||
# upstream kills the socket first, surfacing as BrokenPipeError.
|
||||
# Raises the floor only — never overrides explicit user config
|
||||
# (handled by get_provider_stale_timeout above).
|
||||
from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor
|
||||
_reasoning_floor = get_reasoning_stale_timeout_floor(api_kwargs.get("model"))
|
||||
if _reasoning_floor is not None:
|
||||
_stream_stale_timeout = max(_stream_stale_timeout, _reasoning_floor)
|
||||
|
||||
t = threading.Thread(target=_call, daemon=True)
|
||||
t.start()
|
||||
|
||||
@@ -2011,9 +2011,21 @@ def run_conversation(
|
||||
agent.thinking_callback("")
|
||||
api_elapsed = time.time() - api_start_time
|
||||
agent._vprint(f"{agent.log_prefix}⚡ Interrupted during API call.", force=True)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
interrupted = True
|
||||
final_response = f"{INTERRUPT_WAITING_FOR_MODEL_PREFIX}{api_elapsed:.1f}s elapsed)."
|
||||
# Preserve any assistant text already streamed to the user
|
||||
# before the stop landed. Dropping it leaves history with no
|
||||
# record of the half-finished reply on screen, so the next turn
|
||||
# the model "forgets" what it just said — exactly what users hit
|
||||
# when they stop to redirect mid-response.
|
||||
_partial = agent._strip_think_blocks(
|
||||
getattr(agent, "_current_streamed_assistant_text", "") or ""
|
||||
).strip()
|
||||
if _partial:
|
||||
messages.append({"role": "assistant", "content": _partial})
|
||||
final_response = _partial
|
||||
else:
|
||||
final_response = f"{INTERRUPT_WAITING_FOR_MODEL_PREFIX}{api_elapsed:.1f}s elapsed)."
|
||||
agent._persist_session(messages, conversation_history)
|
||||
break
|
||||
|
||||
except Exception as api_error:
|
||||
@@ -3527,6 +3539,65 @@ def run_conversation(
|
||||
force=True,
|
||||
)
|
||||
|
||||
# Detect thinking-timeout pattern: a known reasoning model
|
||||
# hit a transport-layer error before the first content
|
||||
# token arrived. Distinct from _is_stream_drop above
|
||||
# (which fires for large file-write stream drops) and
|
||||
# from any classifier reason that's not a transport
|
||||
# timeout. Reuses the reasoning-model allowlist from
|
||||
# agent/reasoning_timeouts.py (Fixes #52217) so the
|
||||
# trigger is consistent with what the per-model
|
||||
# stale-timeout floor covers. After the classifier
|
||||
# override at agent/error_classifier.py:720-738 (this
|
||||
# PR), transport disconnects on reasoning models route
|
||||
# to FailoverReason.timeout rather than
|
||||
# context_overflow, so this branch actually fires.
|
||||
# Detection and message text live in
|
||||
# agent.thinking_timeout_guidance so they're
|
||||
# unit-testable without driving the full retry loop.
|
||||
# (Part 2 of Fixes #52310.)
|
||||
from agent.thinking_timeout_guidance import (
|
||||
is_thinking_timeout,
|
||||
)
|
||||
_is_thinking_timeout = is_thinking_timeout(
|
||||
classified,
|
||||
_model,
|
||||
error_msg,
|
||||
)
|
||||
if _is_thinking_timeout:
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} 💡 The model's thinking "
|
||||
f"phase exceeded the upstream proxy's idle "
|
||||
f"timeout before the first content token "
|
||||
f"arrived. This is a known issue with "
|
||||
f"reasoning models behind cloud gateways "
|
||||
f"(NVIDIA NIM, OpenAI, Anthropic, DeepSeek).",
|
||||
force=True,
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} Workarounds in priority order:",
|
||||
force=True,
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} 1. Set "
|
||||
f"`providers.{_provider}.models.{_model}.stale_timeout_seconds: 900` "
|
||||
f"in `~/.hermes/config.yaml` to extend the per-call "
|
||||
f"timeout. (Hermes's built-in floor is 600s for "
|
||||
f"known reasoning models — if you still see this "
|
||||
f"after raising, the upstream cap is even shorter.)",
|
||||
force=True,
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} 2. Lower `reasoning_budget` or set "
|
||||
f"`reasoning_effort: medium` on this model if the provider supports it.",
|
||||
force=True,
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} 3. Use a smaller / faster reasoning "
|
||||
f"model if the task doesn't require deep thinking.",
|
||||
force=True,
|
||||
)
|
||||
|
||||
logger.error(
|
||||
"%sAPI call failed after %s retries. %s | provider=%s model=%s msgs=%s tokens=~%s",
|
||||
agent.log_prefix, max_retries, _final_summary,
|
||||
@@ -3543,7 +3614,22 @@ def run_conversation(
|
||||
_final_response += f"\n\n{_billing_guidance}"
|
||||
else:
|
||||
_final_response = f"API call failed after {max_retries} retries: {_final_summary}"
|
||||
if _is_stream_drop:
|
||||
if _is_thinking_timeout:
|
||||
# Thinking-timeout guidance overrides the generic
|
||||
# stream-drop guidance — the latter is wrong for
|
||||
# this case (it suggests splitting large file
|
||||
# writes, which isn't what happened). See the
|
||||
# reasoning-model override at
|
||||
# agent/error_classifier.py:720-738 and the
|
||||
# detection block above for context.
|
||||
from agent.thinking_timeout_guidance import (
|
||||
build_thinking_timeout_guidance,
|
||||
)
|
||||
_final_response += build_thinking_timeout_guidance(
|
||||
provider=_provider,
|
||||
model=_model,
|
||||
)
|
||||
elif _is_stream_drop:
|
||||
_final_response += (
|
||||
"\n\nThe provider's stream connection keeps "
|
||||
"dropping — this often happens when generating "
|
||||
@@ -4608,7 +4694,11 @@ def run_conversation(
|
||||
"_verification_stop_synthetic": True,
|
||||
})
|
||||
agent._session_messages = messages
|
||||
agent._emit_status("↻ Verification required before finishing")
|
||||
# Run the verification-stop loop silently — the nudge is an
|
||||
# internal turn that should not add noise to the user's
|
||||
# terminal. Keep a debug breadcrumb in agent.log for tracing.
|
||||
logger.debug("verification stop-loop nudge issued (attempt %d)",
|
||||
agent._verification_stop_nudges)
|
||||
continue
|
||||
|
||||
messages.append(final_msg)
|
||||
|
||||
@@ -11,6 +11,7 @@ import uuid
|
||||
import re
|
||||
from dataclasses import dataclass, fields, replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
@@ -447,6 +448,63 @@ def get_pool_strategy(provider: str) -> str:
|
||||
DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL = 1
|
||||
|
||||
|
||||
def _write_through_provider_state_to_global_root(
|
||||
provider_id: str, state: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Persist a rotated OAuth ``state`` into the global-root auth.json.
|
||||
|
||||
Best-effort write-through for the multi-profile rotation hazard
|
||||
(#48415 / #43589): nous, openai-codex, and xai-oauth rotate the
|
||||
refresh_token on refresh, so when a profile pool refresh rotates a grant
|
||||
it resolved from the root fallback, the rotated chain must land back in
|
||||
root. Otherwise root keeps a now-revoked refresh token and every other
|
||||
profile reading the stale root grant dies with ``refresh_token_reused`` /
|
||||
``invalid_grant`` once its access token expires.
|
||||
|
||||
Only updates ``providers.<provider_id>`` in the root store; never touches
|
||||
the profile store (the caller already saved that). Swallows all errors — a
|
||||
failed write-through degrades to the pre-existing behavior (root stale), it
|
||||
must never break the profile's own successful save. Mirrors
|
||||
``hermes_cli.auth._write_through_xai_oauth_to_global_root`` (which covers
|
||||
the non-pool xAI refresh path) for the credential-pool refresh path.
|
||||
"""
|
||||
try:
|
||||
global_path = auth_mod._global_auth_file_path()
|
||||
except Exception:
|
||||
return
|
||||
if global_path is None:
|
||||
# Classic mode (profile == root); the profile save already hit root.
|
||||
return
|
||||
# Seat belt: under pytest, refuse to write the real user's
|
||||
# ~/.hermes/auth.json even when HERMES_HOME points at a profile path
|
||||
# (mirrors the read-side guard in _load_global_auth_store). Uses the
|
||||
# unmodified HOME env, not Path.home() which fixtures may monkeypatch.
|
||||
if os.environ.get("PYTEST_CURRENT_TEST"):
|
||||
real_home_env = os.environ.get("HOME", "")
|
||||
if real_home_env:
|
||||
real_root = Path(real_home_env) / ".hermes" / "auth.json"
|
||||
try:
|
||||
if global_path.resolve(strict=False) == real_root.resolve(strict=False):
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
if global_path.exists():
|
||||
global_store = _load_auth_store(global_path)
|
||||
else:
|
||||
global_store = {}
|
||||
if not isinstance(global_store, dict):
|
||||
return
|
||||
_store_provider_state(global_store, provider_id, dict(state), set_active=False)
|
||||
auth_mod._save_auth_store(global_store, global_path)
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.debug(
|
||||
"%s pool refresh: write-through to global root failed: %s",
|
||||
provider_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
class CredentialPool:
|
||||
def __init__(self, provider: str, entries: List[PooledCredential]):
|
||||
self.provider = provider
|
||||
@@ -800,6 +858,28 @@ class CredentialPool:
|
||||
try:
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
# Decide BEFORE writing whether this profile is reading the
|
||||
# grant from the global root (no own providers.<id> block) vs.
|
||||
# genuinely shadowing it. A pool refresh rotates single-use
|
||||
# OAuth refresh tokens, so a profile that resolved the grant
|
||||
# from root MUST write the rotated chain back to root too —
|
||||
# otherwise root keeps a revoked refresh token and every other
|
||||
# profile reading the stale root grant dies with
|
||||
# refresh_token_reused / invalid_grant once its access token
|
||||
# expires. This mirrors the xAI write-through in
|
||||
# hermes_cli.auth._save_xai_oauth_tokens (#43589); the pool
|
||||
# refresh path is the Codex/xAI analog reported in #48415.
|
||||
_wt_provider_id = {
|
||||
"nous": "nous",
|
||||
"openai-codex": "openai-codex",
|
||||
"xai-oauth": "xai-oauth",
|
||||
}.get(self.provider)
|
||||
write_through_to_root = bool(_wt_provider_id) and not (
|
||||
isinstance(auth_store.get("providers"), dict)
|
||||
and isinstance(
|
||||
auth_store["providers"].get(_wt_provider_id), dict
|
||||
)
|
||||
)
|
||||
if self.provider == "nous":
|
||||
state = _load_provider_state(auth_store, "nous")
|
||||
if state is None:
|
||||
@@ -855,6 +935,10 @@ class CredentialPool:
|
||||
return
|
||||
|
||||
_save_auth_store(auth_store)
|
||||
if write_through_to_root and _wt_provider_id:
|
||||
_write_through_provider_state_to_global_root(
|
||||
_wt_provider_id, state
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to sync %s pool entry back to auth store: %s", self.provider, exc)
|
||||
|
||||
|
||||
@@ -377,8 +377,10 @@ CURATOR_REVIEW_PROMPT = (
|
||||
"bodies + `references/`, `templates/`, and `scripts/` subfiles for "
|
||||
"session-specific detail — not one-session-one-skill micro-entries.\n\n"
|
||||
"Hard rules — do not violate:\n"
|
||||
"1. DO NOT touch bundled or hub-installed skills. The candidate list "
|
||||
"below is already filtered to agent-created skills only.\n"
|
||||
"1. DO NOT touch bundled, hub-installed, or external-dir skills "
|
||||
"(`skills.external_dirs`). The candidate list below is already filtered "
|
||||
"to local curator-managed skills only; external skills are externally "
|
||||
"owned and read-only to this background curator.\n"
|
||||
"2. DO NOT delete any skill. Archiving (moving the skill's directory "
|
||||
"into ~/.hermes/skills/.archive/) is the maximum destructive action. "
|
||||
"Archives are recoverable; deletion is not.\n"
|
||||
@@ -469,8 +471,9 @@ CURATOR_REVIEW_PROMPT = (
|
||||
"skill, or `absorbed_into=\"\"` when you're truly pruning with no "
|
||||
"forwarding target. This drives cron-job skill-reference migration — "
|
||||
"guessing from your YAML summary after the fact is fragile.\n"
|
||||
" - terminal — mv a sibling into the archive "
|
||||
"OR move its content into a support subfile\n\n"
|
||||
" - terminal — move LOCAL candidate content into "
|
||||
"a support subfile when package integrity requires it; never mv, cp, rm, "
|
||||
"patch, or rewrite bundled, hub-installed, or external-dir skills\n\n"
|
||||
"'keep' is a legitimate decision ONLY when the skill is already a "
|
||||
"class-level umbrella and none of the proposed merges would improve "
|
||||
"discoverability. 'This is narrow but distinct from its siblings' "
|
||||
@@ -1843,6 +1846,14 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
|
||||
# Disable recursive nudges — the curator must never spawn its own review.
|
||||
review_agent._memory_nudge_interval = 0
|
||||
review_agent._skill_nudge_interval = 0
|
||||
# Tag this fork as autonomous background curation so skill_manage's
|
||||
# background-review write guard fires. Without this the fork inherits
|
||||
# the default "assistant_tool" origin, is_background_review() is False,
|
||||
# and the external/bundled/hub-installed skill_manage guards never
|
||||
# trigger during the curation pass they exist to protect against.
|
||||
# turn_context.py binds this onto the write-origin ContextVar at turn
|
||||
# start (see agent/turn_context.py).
|
||||
review_agent._memory_write_origin = "background_review"
|
||||
|
||||
# Redirect the forked agent's stdout/stderr to /dev/null while it
|
||||
# runs so its tool-call chatter doesn't pollute the foreground
|
||||
|
||||
@@ -16,6 +16,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from utils import safe_json_loads
|
||||
from agent.redact import redact_sensitive_text
|
||||
from agent.tool_result_classification import file_mutation_result_landed
|
||||
|
||||
# ANSI escape codes for coloring tool failure indicators
|
||||
@@ -339,6 +340,62 @@ def _read_file_line_label(args: dict) -> str:
|
||||
return f"L{offset}-{offset + limit - 1}"
|
||||
|
||||
|
||||
def redact_browser_typed_text_for_display(value: Any, typed_text: Any) -> Any:
|
||||
"""Apply secret redaction to browser_type text in display-facing payloads.
|
||||
|
||||
Backends sometimes echo the attempted input in error strings or fallback
|
||||
metadata. When the raw typed value contains a recognizable secret (API
|
||||
key, token, JWT, etc.) the redacted form differs from the raw value, so we
|
||||
replace every occurrence of the raw value with its redacted form before a
|
||||
browser_type result reaches logs, callbacks, the model, or chat history.
|
||||
|
||||
Normal typed text (search queries, addresses, form fields) matches no
|
||||
secret pattern, so it passes through unchanged and stays readable.
|
||||
|
||||
Redaction is forced here regardless of the global ``security.redact_secrets``
|
||||
preference: a typed credential leaking into chat history is a security
|
||||
boundary, not mere log hygiene.
|
||||
"""
|
||||
if typed_text is None:
|
||||
return value
|
||||
needle = str(typed_text)
|
||||
if needle == "":
|
||||
return value
|
||||
redacted = redact_sensitive_text(needle, force=True)
|
||||
if redacted == needle:
|
||||
# Nothing secret-looking in the typed text; leave payload untouched.
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.replace(needle, redacted)
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: redact_browser_typed_text_for_display(item, typed_text)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [redact_browser_typed_text_for_display(item, typed_text) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(redact_browser_typed_text_for_display(item, typed_text) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
def redact_tool_args_for_display(tool_name: str, args: dict | None) -> dict | None:
|
||||
"""Return a copy of tool args safe for logs/progress UI.
|
||||
|
||||
For ``browser_type`` the ``text`` argument is run through the same
|
||||
secret-pattern redactor used for logs. Recognizable credentials (API
|
||||
keys, tokens) are masked before the value reaches tool progress
|
||||
notifications; normal typed text is left intact for debuggability.
|
||||
"""
|
||||
if not isinstance(args, dict):
|
||||
return args
|
||||
if tool_name == "browser_type" and isinstance(args.get("text"), str):
|
||||
safe_args = dict(args)
|
||||
safe_args["text"] = redact_sensitive_text(args["text"], force=True)
|
||||
return safe_args
|
||||
return args
|
||||
|
||||
|
||||
def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]:
|
||||
if not isinstance(tasks, list):
|
||||
return 0, []
|
||||
@@ -362,6 +419,7 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
|
||||
max_len = _tool_preview_max_len
|
||||
if not args:
|
||||
return None
|
||||
args = redact_tool_args_for_display(tool_name, args) or args
|
||||
primary_args = {
|
||||
"terminal": "command", "web_search": "query", "web_extract": "urls",
|
||||
"read_file": "path", "write_file": "path", "patch": "path",
|
||||
@@ -1085,6 +1143,7 @@ def get_cute_tool_message(
|
||||
When *result* is provided the line is checked for failure indicators.
|
||||
Failed tool calls get a red prefix and an informational suffix.
|
||||
"""
|
||||
args = redact_tool_args_for_display(tool_name, args) or args
|
||||
dur = f"{duration:.1f}s"
|
||||
is_failure, failure_suffix = _detect_tool_failure(tool_name, result)
|
||||
skin_prefix = get_skin_tool_prefix()
|
||||
|
||||
@@ -717,6 +717,26 @@ def classify_api_error(
|
||||
|
||||
is_disconnect = any(p in error_msg for p in _SERVER_DISCONNECT_PATTERNS)
|
||||
if is_disconnect and not status_code:
|
||||
# Reasoning-model override: a transport disconnect on a reasoning
|
||||
# model is much more likely the upstream proxy idle-killing a
|
||||
# long thinking stream than a true context overflow — even on
|
||||
# large sessions. The default disconnect+large-session routing
|
||||
# below would otherwise send the user into the compression
|
||||
# branch (should_compress=True) and silently delete
|
||||
# conversation history on a phantom context-length error.
|
||||
# Reasoning models have multi-minute thinking phases that
|
||||
# routinely exceed the cloud gateway's idle window (NVIDIA
|
||||
# NIM ~120s — first-party repro at NVIDIA/NemoClaw#4846;
|
||||
# OpenAI worker / Anthropic stream-idle similar). The
|
||||
# per-reasoning-model stale-timeout floor in
|
||||
# agent/reasoning_timeouts.py raises the stale-detector
|
||||
# threshold to tolerate long thinking, so a true
|
||||
# transport-layer failure here is recoverable via the retry
|
||||
# path — not via context compression. Reclassify as timeout.
|
||||
# (Part 1 of Fixes #52310.)
|
||||
from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor
|
||||
if get_reasoning_stale_timeout_floor(model) is not None:
|
||||
return _result(FailoverReason.timeout, retryable=True)
|
||||
# Absolute token/message-count thresholds are only a proxy for smaller
|
||||
# context windows. Large-context sessions can have hundreds of
|
||||
# messages while still being far below their actual token budget.
|
||||
|
||||
@@ -77,15 +77,22 @@ def build_write_denied_prefixes(home: str) -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
def get_safe_write_root() -> Optional[str]:
|
||||
"""Return the resolved HERMES_WRITE_SAFE_ROOT path, or None if unset."""
|
||||
root = os.getenv("HERMES_WRITE_SAFE_ROOT", "")
|
||||
if not root:
|
||||
return None
|
||||
try:
|
||||
return os.path.realpath(os.path.expanduser(root))
|
||||
except Exception:
|
||||
return None
|
||||
def get_safe_write_roots() -> set[str]:
|
||||
"""Return resolved HERMES_WRITE_SAFE_ROOT paths. Supports multiple directories
|
||||
separated by ``os.pathsep`` (``:`` on Unix, ``;`` on Windows).
|
||||
E.g., ``/opt/data:/var/www/html`` on Unix, ``C:\\data;D:\\www`` on Windows."""
|
||||
env = os.getenv("HERMES_WRITE_SAFE_ROOT", "")
|
||||
if not env:
|
||||
return set()
|
||||
roots: set[str] = set()
|
||||
for path in env.split(os.pathsep):
|
||||
if path:
|
||||
try:
|
||||
resolved = os.path.realpath(os.path.expanduser(path))
|
||||
roots.add(resolved)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return roots
|
||||
|
||||
|
||||
def is_write_denied(path: str) -> bool:
|
||||
@@ -124,9 +131,15 @@ def is_write_denied(path: str) -> bool:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
safe_root = get_safe_write_root()
|
||||
if safe_root and not (resolved == safe_root or resolved.startswith(safe_root + os.sep)):
|
||||
return True
|
||||
safe_roots = get_safe_write_roots()
|
||||
if safe_roots:
|
||||
allowed = False
|
||||
for safe_root in safe_roots:
|
||||
if resolved == safe_root or resolved.startswith(safe_root + os.sep):
|
||||
allowed = True
|
||||
break
|
||||
if not allowed:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
216
agent/reasoning_timeouts.py
Normal file
216
agent/reasoning_timeouts.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""Per-reasoning-model stale-timeout floor for known reasoning models.
|
||||
|
||||
Reasoning models (those that emit extended thinking blocks before their
|
||||
first content token) routinely exceed Hermes's default chat-model
|
||||
stale detectors:
|
||||
|
||||
* Stream stale detector: ``HERMES_STREAM_STALE_TIMEOUT`` default 180s
|
||||
``agent/chat_completion_helpers.py:2544``
|
||||
* Non-stream stale detector: ``HERMES_API_CALL_STALE_TIMEOUT`` default 90s
|
||||
``run_agent.py:1140``
|
||||
|
||||
For NVIDIA Nemotron 3 Ultra on the hosted NIM gateway the empirical
|
||||
upstream idle kill is ~120s (first-party reproduction at
|
||||
NVIDIA/NemoClaw#4846 — TTFB ~31s, stream dies at 120s). The same
|
||||
failure mode exists on OpenAI o1/o3, Anthropic Opus 4.x thinking,
|
||||
DeepSeek R1, Qwen QwQ, xAI Grok reasoning — every cloud reasoning
|
||||
model hits upstream-proxies / load-balancers with idle timeouts
|
||||
shorter than the model's thinking phase. Result: the stale detector
|
||||
kills the connection mid-think, surfacing as
|
||||
``BrokenPipeError``/``RemoteProtocolError`` on the next read.
|
||||
|
||||
This module provides a floor that the existing stale-detector scaling
|
||||
blocks consult via :func:`get_reasoning_stale_timeout_floor` and
|
||||
apply as ``max(default, floor)``. It is a FLOOR:
|
||||
|
||||
* Never overrides explicit user config (``providers.<id>.models.<model>.stale_timeout_seconds``
|
||||
or ``request_timeout_seconds`` already wins — this code never runs
|
||||
in that branch).
|
||||
* Never lowers an existing threshold.
|
||||
* Has zero effect on non-reasoning models — they are not in the
|
||||
allowlist and the resolver returns ``None``.
|
||||
|
||||
Matching uses start-anchored regex on the slug-only component of
|
||||
the model name (after stripping any aggregator prefix like
|
||||
``openai/``, ``x-ai/``, ``anthropic/``). The right-anchor matches
|
||||
end-of-string or a ``-``/``.``/``_`` slug separator, so ``qwen3-235b``
|
||||
matches the ``qwen3`` family entry (a future model slug would be
|
||||
``qwen3-235b-instruct`` and would also match) but ``some-other-qwen3``
|
||||
does NOT match ``qwen3`` (the ``-qwen3`` is not at start of slug).
|
||||
|
||||
The ``o1`` case is the most delicate: a model named
|
||||
``llama-4-70b-o1-preview`` is a hypothetical community derivative that
|
||||
should NOT trigger the reasoning-model floor for the user (the user
|
||||
chose a non-OpenAI model, not a reasoning model). The start-of-slug
|
||||
anchor naturally excludes this — the matched ``o1-preview`` is at
|
||||
position 11 of the slug, not at position 0. The previous substring-
|
||||
with-trailing-hyphen design would have over-matched here, which is
|
||||
why start-of-slug anchoring is the right shape.
|
||||
|
||||
Fixes #52217.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# (slug, floor_seconds). Each slug is matched as a discrete
|
||||
# word-boundary component via the wrapper regex in ``_match_any``
|
||||
# below. Order is irrelevant — the first regex match wins.
|
||||
_REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = (
|
||||
# NVIDIA Nemotron — reasoning models behind hosted NIM with
|
||||
# documented 60-180s upstream idle kill (NVIDIA/NemoClaw#4846:
|
||||
# 120s measured).
|
||||
("nemotron-3-ultra", 600),
|
||||
("nemotron-3-super", 600),
|
||||
("nemotron-3-nano", 300),
|
||||
# DeepSeek — R1 reasoning model on hosted NIM / DeepSeek direct.
|
||||
("deepseek-r1", 600),
|
||||
("deepseek-reasoner", 600),
|
||||
# Qwen — QwQ reasoning + Qwen3 thinking variants. QwQ-32B
|
||||
# preview is the stable slug; ``qwen3`` covers the family of
|
||||
# thinking-mode Qwen3 models (qwen3-235b-a22b, qwen3-32b, etc.)
|
||||
# without over-matching every Qwen3 instruct variant — the
|
||||
# right-anchor requires the slug to be at the start of the
|
||||
# remaining model name, so ``qwen3-235b-instruct`` (instruct is
|
||||
# NOT a thinking variant) would still match. Acceptable
|
||||
# trade-off: instruct variants of qwen3 get the 180s floor
|
||||
# even though they don't reason. The cost is a slightly longer
|
||||
# wait on a hung provider; the alternative (matching only
|
||||
# ``qwen3-.*-thinking``) breaks the moment NVIDIA or Alibaba
|
||||
# ships a slightly different naming shape.
|
||||
("qwq-32b", 300),
|
||||
("qwen3", 180),
|
||||
# OpenAI o-series — known multi-minute TTFB. Each variant
|
||||
# enumerated explicitly so bare ``o1`` doesn't over-match
|
||||
# ``olmo-1`` or hypothetical future community derivatives.
|
||||
("o1", 600),
|
||||
("o1-mini", 600),
|
||||
("o1-pro", 600),
|
||||
("o1-preview", 600),
|
||||
("o3", 600),
|
||||
("o3-pro", 600),
|
||||
("o3-mini", 300),
|
||||
("o4-mini", 300),
|
||||
# Anthropic Claude 4.x thinking variants. Anchored at
|
||||
# ``claude-opus-4`` so non-thinking Claude 3.x or future
|
||||
# non-reasoning Claude variants don't match.
|
||||
("claude-opus-4", 240),
|
||||
("claude-sonnet-4.5", 180),
|
||||
("claude-sonnet-4.6", 180),
|
||||
# xAI Grok reasoning variants. Explicit reasoning-only keys
|
||||
# plus one for the ``non-reasoning`` variant so users picking
|
||||
# the fast variant don't get the 300s floor. Bare ``grok-3``,
|
||||
# ``grok-4`` etc. don't match — only the explicit reasoning /
|
||||
# non-reasoning pairs.
|
||||
("grok-4-fast-reasoning", 300),
|
||||
("grok-4.20-reasoning", 300),
|
||||
("grok-4-fast-non-reasoning", 180),
|
||||
)
|
||||
|
||||
|
||||
# Pre-compile each pattern. Wrapper = start-of-slug + slug + end-or-
|
||||
# separator, where ``start-of-slug`` means start-of-string OR
|
||||
# immediately after the last ``/`` (aggregator separator) and
|
||||
# ``end-or-separator`` means end-of-string OR a ``-``/``.``/``_``.
|
||||
#
|
||||
# Why start-of-slug and not start-of-string: aggregator prefixes
|
||||
# like ``openai/`` should not affect matching — the slug identity is
|
||||
# the part after the last ``/``. Stripping the aggregator prefix in
|
||||
# :func:`get_reasoning_stale_timeout_floor` before regex matching
|
||||
# gives the wrapper a clean start-of-string anchor.
|
||||
#
|
||||
# Why end-or-separator on the right: ``openai/o3-mini`` must match
|
||||
# the ``o3-mini`` slug (the right anchor is end-of-string). And
|
||||
# ``openai/o3-mini-2025-01-31`` must also match ``o3-mini`` (the right
|
||||
# anchor is the ``-`` separator). But ``openai/o3-mini-fork`` should
|
||||
# NOT match ``o3-mini`` if we wanted to exclude forks — though the
|
||||
# pattern ``o3-mini-fork`` would be matched as a derivative anyway,
|
||||
# so we accept that community forks inheriting the same prefix are
|
||||
# treated as reasoning models (a reasonable default — the upstream
|
||||
# gateway timing is the same).
|
||||
_PATTERN_CACHE: dict[str, re.Pattern[str]] = {}
|
||||
|
||||
|
||||
def _get_pattern(slug: str) -> re.Pattern[str]:
|
||||
compiled = _PATTERN_CACHE.get(slug)
|
||||
if compiled is None:
|
||||
compiled = re.compile(
|
||||
r"^"
|
||||
+ re.escape(slug)
|
||||
+ r"(?:$|[\-._])"
|
||||
)
|
||||
_PATTERN_CACHE[slug] = compiled
|
||||
return compiled
|
||||
|
||||
|
||||
def _match_any(model_lower: str) -> Optional[float]:
|
||||
"""Return the floor for the first matching slug, else None.
|
||||
|
||||
Each table entry is matched as a start-of-slug prefix with the
|
||||
slug-separator-or-end-of-string right-anchor. Table iteration
|
||||
order is irrelevant: longest slug wins (so ``o3-mini`` beats
|
||||
``o3`` on a model like ``openai/o3-mini``).
|
||||
"""
|
||||
# Sort by slug length descending so longer / more-specific slugs
|
||||
# win on shared prefixes (o3-mini beats o3).
|
||||
sorted_floors = sorted(
|
||||
_REASONING_STALE_TIMEOUT_FLOORS, key=lambda kv: -len(kv[0])
|
||||
)
|
||||
for slug, floor in sorted_floors:
|
||||
if _get_pattern(slug).search(model_lower):
|
||||
return float(floor)
|
||||
return None
|
||||
|
||||
|
||||
def get_reasoning_stale_timeout_floor(model: object) -> Optional[float]:
|
||||
"""Return the stale-timeout floor (seconds) for a known reasoning model.
|
||||
|
||||
Returns ``None`` when the model is not in the allowlist or the
|
||||
argument is empty / not a string. Matching uses
|
||||
word-boundary-anchored regex on the lowercased model name, so
|
||||
``openai/o3-mini`` matches the ``o3-mini`` slug but
|
||||
``olmo-1`` does NOT match ``o1`` (the ``o1`` substring is not
|
||||
at a word boundary inside ``olmo-1``).
|
||||
|
||||
Aggregator prefixes (``openai/``, ``x-ai/``, ``anthropic/`` etc.)
|
||||
are preserved through matching — the ``/`` is itself a word
|
||||
boundary, so ``openai/o3-mini`` matches ``o3-mini`` because the
|
||||
``/`` before ``o3-mini`` satisfies the left-anchor alternation.
|
||||
|
||||
This is a FLOOR — callers must apply it as ``max(default, floor)``
|
||||
and only when no explicit user-configured per-model
|
||||
``stale_timeout_seconds`` exists.
|
||||
|
||||
>>> get_reasoning_stale_timeout_floor("nvidia/nemotron-3-ultra-550b-a55b")
|
||||
600.0
|
||||
>>> get_reasoning_stale_timeout_floor("openai/o3-mini")
|
||||
300.0
|
||||
>>> get_reasoning_stale_timeout_floor("deepseek/deepseek-r1")
|
||||
600.0
|
||||
>>> get_reasoning_stale_timeout_floor("qwen/qwen3-235b-a22b-thinking")
|
||||
180.0
|
||||
>>> get_reasoning_stale_timeout_floor("x-ai/grok-4-fast-reasoning")
|
||||
300.0
|
||||
>>> get_reasoning_stale_timeout_floor("anthropic/claude-opus-4-6")
|
||||
240.0
|
||||
>>> get_reasoning_stale_timeout_floor("gpt-4o") is None
|
||||
True
|
||||
>>> get_reasoning_stale_timeout_floor("olmo-1") is None
|
||||
True
|
||||
>>> get_reasoning_stale_timeout_floor(None) is None
|
||||
True
|
||||
"""
|
||||
if not model or not isinstance(model, str):
|
||||
return None
|
||||
name = model.strip().lower()
|
||||
if not name:
|
||||
return None
|
||||
# Strip aggregator prefix (everything before and including the
|
||||
# last ``/``). The wrapper regex anchors at start-of-string, so
|
||||
# the slug identity is the bare model name.
|
||||
if "/" in name:
|
||||
name = name.rsplit("/", 1)[1]
|
||||
return _match_any(name)
|
||||
@@ -507,6 +507,34 @@ def get_all_skills_dirs() -> List[Path]:
|
||||
return dirs
|
||||
|
||||
|
||||
def _resolve_for_skill_ownership(path) -> Path:
|
||||
path_obj = path if isinstance(path, Path) else Path(str(path))
|
||||
try:
|
||||
return path_obj.expanduser().resolve()
|
||||
except (OSError, RuntimeError):
|
||||
return path_obj.expanduser().absolute()
|
||||
|
||||
|
||||
def is_external_skill_path(path) -> bool:
|
||||
"""Return True when ``path`` lives under a configured external skills dir.
|
||||
|
||||
``skills.external_dirs`` are externally owned: Hermes can discover and view
|
||||
their skills, and foreground user-directed tool calls may still edit them,
|
||||
but autonomous lifecycle maintenance must treat them as read-only. This
|
||||
helper centralizes the ownership boundary so curator/reporting/tool paths do
|
||||
not each need to re-interpret the config.
|
||||
"""
|
||||
candidate = _resolve_for_skill_ownership(path)
|
||||
for root in get_external_skills_dirs():
|
||||
resolved_root = _resolve_for_skill_ownership(root)
|
||||
try:
|
||||
candidate.relative_to(resolved_root)
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
# ── Condition extraction ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
136
agent/thinking_timeout_guidance.py
Normal file
136
agent/thinking_timeout_guidance.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Thinking-timeout detection and user-facing guidance for reasoning models.
|
||||
|
||||
When a known reasoning model (NVIDIA Nemotron 3 Ultra, OpenAI o1/o3,
|
||||
Anthropic Opus 4.x thinking, DeepSeek R1, Qwen QwQ, xAI Grok reasoning)
|
||||
hits a transport-layer error before the first content token arrives, the
|
||||
upstream proxy has almost certainly idle-killed a long thinking stream —
|
||||
not a true context overflow or a configuration error. The user needs
|
||||
distinct guidance for this case:
|
||||
|
||||
"The model's thinking phase exceeded the upstream proxy's idle
|
||||
timeout before the first content token arrived. This is a known
|
||||
issue with reasoning models behind cloud gateways (NVIDIA NIM,
|
||||
OpenAI, Anthropic, DeepSeek). Workarounds in priority order:
|
||||
1. Set `providers.<provider>.models.<model>.stale_timeout_seconds: 900`
|
||||
in `~/.hermes/config.yaml` to extend the per-call timeout...
|
||||
2. Lower `reasoning_budget` or set `reasoning_effort: medium`...
|
||||
3. Use a smaller / faster reasoning model..."
|
||||
|
||||
The existing `_is_stream_drop` guidance at
|
||||
``agent/conversation_loop.py:3464-3486`` fires for large-file-write
|
||||
stream drops ("try execute_code with Python's open() for large files")
|
||||
which is the WRONG advice for the thinking-timeout case. This module
|
||||
provides the detection and the message as standalone helpers so the
|
||||
detection logic is unit-testable without driving the full retry loop,
|
||||
and the message text can be regression-tested for spelling and accuracy.
|
||||
|
||||
Part 2 of Fixes #52310.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# Substring set that identifies a transport-layer failure on the
|
||||
# response stream. Same shape as the existing
|
||||
# ``_SERVER_DISCONNECT_PATTERNS`` in ``agent/error_classifier.py:394``
|
||||
# but extended to also catch the OSS-level error signature
|
||||
# (``broken pipe`` / ``errno 32``) that the upstream kill surfaces
|
||||
# to the OpenAI SDK wrapper.
|
||||
_THINKING_TIMEOUT_SUBSTRINGS: tuple[str, ...] = (
|
||||
"broken pipe",
|
||||
"errno 32",
|
||||
"remote protocol",
|
||||
"connection reset",
|
||||
"connection lost",
|
||||
"peer closed",
|
||||
"server disconnected",
|
||||
)
|
||||
|
||||
|
||||
def is_thinking_timeout(classified: object, model: str, error_msg: str) -> bool:
|
||||
"""Return True when a reasoning model's thinking phase hit a transport kill.
|
||||
|
||||
Args:
|
||||
classified: a :class:`agent.error_classifier.ClassifiedError` instance
|
||||
(duck-typed here to avoid an import cycle in unit tests).
|
||||
model: the model slug at failure time (e.g.
|
||||
``"nvidia/nemotron-3-ultra-550b-a55b"``).
|
||||
error_msg: lowercased string representation of the underlying
|
||||
exception (typically ``str(api_error).lower()``).
|
||||
|
||||
Returns True when ALL conditions hold:
|
||||
1. ``classified.reason == FailoverReason.timeout`` (the classifier
|
||||
override at ``agent/error_classifier.py:720-738`` ensures this
|
||||
is the case for reasoning models even on large sessions).
|
||||
2. ``api_error`` has no ``.status_code`` attribute set (transport
|
||||
disconnect, not an HTTP error).
|
||||
3. ``model`` is in the reasoning-model allowlist (reuses
|
||||
``agent.reasoning_timeouts.get_reasoning_stale_timeout_floor``).
|
||||
4. ``error_msg`` contains one of the transport-kill substrings.
|
||||
|
||||
Non-reasoning models always return False. Non-transport errors
|
||||
(billing / rate_limit / auth / context_overflow / format_error)
|
||||
always return False. HTTP-status errors always return False.
|
||||
"""
|
||||
# Import here (not at module top) to keep this helper cheap to
|
||||
# import even from callers that don't need it. ``agent.reasoning_timeouts``
|
||||
# is small and dependency-free.
|
||||
from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor
|
||||
|
||||
# Condition 1: classifier says timeout. Use a string/value check
|
||||
# rather than importing FailoverReason so this module has zero
|
||||
# import cycles from the error_classifier package.
|
||||
reason = getattr(classified, "reason", None)
|
||||
reason_value = getattr(reason, "value", None)
|
||||
if reason_value != "timeout":
|
||||
return False
|
||||
|
||||
# Condition 2: no HTTP status code (transport, not API error).
|
||||
# Caller is expected to gate on ``getattr(api_error, "status_code", None) is None``
|
||||
# before calling this helper; the surface here is just the post-gate
|
||||
# boolean so the caller can pass an already-prepped error_msg.
|
||||
|
||||
# Condition 3: reasoning model allowlist.
|
||||
if get_reasoning_stale_timeout_floor(model) is None:
|
||||
return False
|
||||
|
||||
# Condition 4: transport-kill substring in the error message.
|
||||
error_msg_lower = (error_msg or "").lower()
|
||||
return any(p in error_msg_lower for p in _THINKING_TIMEOUT_SUBSTRINGS)
|
||||
|
||||
|
||||
def build_thinking_timeout_guidance(
|
||||
provider: str, model: str, model_label: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Return the user-facing guidance string appended to ``_final_response``.
|
||||
|
||||
Args:
|
||||
provider: provider slug (e.g. ``"nvidia"``, ``"openai"``).
|
||||
model: bare model slug the user would put in their config
|
||||
(e.g. ``"nemotron-3-ultra-550b-a55b"`` if the user uses
|
||||
NVIDIA direct, or the full ``"nvidia/nemotron-3-ultra-550b-a55b"``
|
||||
if they go through an aggregator). Used verbatim in the
|
||||
config snippet so the user can copy-paste.
|
||||
model_label: optional short label for the model name in the
|
||||
prose (e.g. ``"Nemotron 3 Ultra"``). Falls back to the
|
||||
slug if not provided.
|
||||
"""
|
||||
label = model_label or model
|
||||
return (
|
||||
"\n\nThe model's thinking phase exceeded the upstream proxy's "
|
||||
"idle timeout before the first content token arrived. This is a "
|
||||
f"known issue with reasoning models (like {label}) behind cloud "
|
||||
"gateways (NVIDIA NIM, OpenAI, Anthropic, DeepSeek). Workarounds "
|
||||
"in priority order:\n"
|
||||
f"1. Set `providers.{provider}.models.{model}.stale_timeout_seconds: 900` "
|
||||
"in `~/.hermes/config.yaml` to extend the per-call timeout. "
|
||||
"(Hermes's built-in floor is 600s for known reasoning models — "
|
||||
"if you still see this after raising, the upstream cap is even "
|
||||
"shorter.)\n"
|
||||
"2. Lower `reasoning_budget` or set `reasoning_effort: medium` on this "
|
||||
"model if the provider supports it.\n"
|
||||
"3. Use a smaller / faster reasoning model if the task doesn't "
|
||||
"require deep thinking."
|
||||
)
|
||||
@@ -26,6 +26,7 @@ from agent.display import (
|
||||
build_tool_preview as _build_tool_preview,
|
||||
get_cute_tool_message as _get_cute_tool_message_impl,
|
||||
get_tool_emoji as _get_tool_emoji,
|
||||
redact_tool_args_for_display as _redact_tool_args_for_display,
|
||||
_detect_tool_failure,
|
||||
)
|
||||
from agent.tool_guardrails import ToolGuardrailDecision
|
||||
@@ -469,10 +470,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
|
||||
print(f" ⚡ Concurrent: {num_tools} tool calls — {tool_names_str}")
|
||||
for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls, 1):
|
||||
args_str = json.dumps(args, ensure_ascii=False)
|
||||
display_args = _redact_tool_args_for_display(name, args) or args
|
||||
args_str = json.dumps(display_args, ensure_ascii=False)
|
||||
if agent.verbose_logging:
|
||||
print(f" 📞 Tool {i}: {name}({list(args.keys())})")
|
||||
print(agent._wrap_verbose("Args: ", json.dumps(args, indent=2, ensure_ascii=False)))
|
||||
print(f" 📞 Tool {i}: {name}({list(display_args.keys())})")
|
||||
print(agent._wrap_verbose("Args: ", json.dumps(display_args, indent=2, ensure_ascii=False)))
|
||||
else:
|
||||
args_preview = args_str[:agent.log_prefix_chars] + "..." if len(args_str) > agent.log_prefix_chars else args_str
|
||||
print(f" 📞 Tool {i}: {name}({list(args.keys())}) - {args_preview}")
|
||||
@@ -482,8 +484,9 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
continue
|
||||
if agent.tool_progress_callback:
|
||||
try:
|
||||
preview = _build_tool_preview(name, args)
|
||||
agent.tool_progress_callback("tool.started", name, preview, args)
|
||||
display_args = _redact_tool_args_for_display(name, args) or args
|
||||
preview = _build_tool_preview(name, display_args)
|
||||
agent.tool_progress_callback("tool.started", name, preview, display_args)
|
||||
except Exception as cb_err:
|
||||
logging.debug(f"Tool progress callback error: {cb_err}")
|
||||
|
||||
@@ -492,7 +495,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
continue
|
||||
if agent.tool_start_callback:
|
||||
try:
|
||||
agent.tool_start_callback(tc.id, name, args)
|
||||
display_args = _redact_tool_args_for_display(name, args) or args
|
||||
agent.tool_start_callback(tc.id, name, display_args)
|
||||
except Exception as cb_err:
|
||||
logging.debug(f"Tool start callback error: {cb_err}")
|
||||
|
||||
@@ -792,7 +796,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
|
||||
if not blocked and agent.tool_complete_callback:
|
||||
try:
|
||||
agent.tool_complete_callback(tc.id, name, args, function_result)
|
||||
display_args = _redact_tool_args_for_display(name, args) or args
|
||||
agent.tool_complete_callback(tc.id, name, display_args, function_result)
|
||||
except Exception as cb_err:
|
||||
logging.debug(f"Tool complete callback error: {cb_err}")
|
||||
|
||||
@@ -954,10 +959,11 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
agent._iters_since_skill = 0
|
||||
|
||||
if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
|
||||
args_str = json.dumps(function_args, ensure_ascii=False)
|
||||
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
|
||||
args_str = json.dumps(display_args, ensure_ascii=False)
|
||||
if agent.verbose_logging:
|
||||
print(f" 📞 Tool {i}: {function_name}({list(function_args.keys())})")
|
||||
print(agent._wrap_verbose("Args: ", json.dumps(function_args, indent=2, ensure_ascii=False)))
|
||||
print(f" 📞 Tool {i}: {function_name}({list(display_args.keys())})")
|
||||
print(agent._wrap_verbose("Args: ", json.dumps(display_args, indent=2, ensure_ascii=False)))
|
||||
else:
|
||||
args_preview = args_str[:agent.log_prefix_chars] + "..." if len(args_str) > agent.log_prefix_chars else args_str
|
||||
print(f" 📞 Tool {i}: {function_name}({list(function_args.keys())}) - {args_preview}")
|
||||
@@ -978,14 +984,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
|
||||
if not _execution_blocked and agent.tool_progress_callback:
|
||||
try:
|
||||
preview = _build_tool_preview(function_name, function_args)
|
||||
agent.tool_progress_callback("tool.started", function_name, preview, function_args)
|
||||
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
|
||||
preview = _build_tool_preview(function_name, display_args)
|
||||
agent.tool_progress_callback("tool.started", function_name, preview, display_args)
|
||||
except Exception as cb_err:
|
||||
logging.debug(f"Tool progress callback error: {cb_err}")
|
||||
|
||||
if not _execution_blocked and agent.tool_start_callback:
|
||||
try:
|
||||
agent.tool_start_callback(tool_call.id, function_name, function_args)
|
||||
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
|
||||
agent.tool_start_callback(tool_call.id, function_name, display_args)
|
||||
except Exception as cb_err:
|
||||
logging.debug(f"Tool start callback error: {cb_err}")
|
||||
|
||||
@@ -1215,7 +1223,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
if agent._should_emit_quiet_tool_messages():
|
||||
face = random.choice(KawaiiSpinner.get_waiting_faces())
|
||||
emoji = _get_tool_emoji(function_name)
|
||||
preview = _build_tool_preview(function_name, function_args) or function_name
|
||||
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
|
||||
preview = _build_tool_preview(function_name, display_args) or function_name
|
||||
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn)
|
||||
spinner.start()
|
||||
_ce_result = None
|
||||
@@ -1248,7 +1257,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
if agent._should_emit_quiet_tool_messages() and agent._should_start_quiet_spinner():
|
||||
face = random.choice(KawaiiSpinner.get_waiting_faces())
|
||||
emoji = _get_tool_emoji(function_name)
|
||||
preview = _build_tool_preview(function_name, function_args) or function_name
|
||||
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
|
||||
preview = _build_tool_preview(function_name, display_args) or function_name
|
||||
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn)
|
||||
spinner.start()
|
||||
_mem_result = None
|
||||
@@ -1279,7 +1289,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
if agent._should_emit_quiet_tool_messages() and agent._should_start_quiet_spinner():
|
||||
face = random.choice(KawaiiSpinner.get_waiting_faces())
|
||||
emoji = _get_tool_emoji(function_name)
|
||||
preview = _build_tool_preview(function_name, function_args) or function_name
|
||||
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
|
||||
preview = _build_tool_preview(function_name, display_args) or function_name
|
||||
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn)
|
||||
spinner.start()
|
||||
_spinner_result = None
|
||||
@@ -1441,7 +1452,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
|
||||
if not _execution_blocked and agent.tool_complete_callback:
|
||||
try:
|
||||
agent.tool_complete_callback(tool_call.id, function_name, function_args, function_result)
|
||||
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
|
||||
agent.tool_complete_callback(tool_call.id, function_name, display_args, function_result)
|
||||
except Exception as cb_err:
|
||||
logging.debug(f"Tool complete callback error: {cb_err}")
|
||||
|
||||
|
||||
@@ -15,9 +15,77 @@ from typing import Any, Iterable
|
||||
|
||||
_MAX_CHANGED_PATHS_IN_NUDGE = 8
|
||||
|
||||
# Session identities (platform or source) that are NOT human conversational
|
||||
# messaging surfaces: interactive coding surfaces (CLI, TUI, desktop, codex,
|
||||
# local, gateway) and programmatic callers (API server, webhooks, tools).
|
||||
# Verify-on-stop stays ON by default for these. Any other resolved gateway
|
||||
# platform is a conversational messaging surface (Telegram, Discord, WhatsApp,
|
||||
# Signal, Slack, etc.) where the verification narrative would reach a human as
|
||||
# chat noise, so it defaults OFF. Mirrors LOCAL_SESSION_SOURCE_IDS in
|
||||
# apps/desktop/src/lib/session-source.ts; keep roughly in sync when adding a
|
||||
# local or programmatic surface. Default-deny by design: an unrecognized
|
||||
# identity is treated as messaging (OFF) so a new chat platform never leaks the
|
||||
# verification receipt before this set is updated.
|
||||
_NON_MESSAGING_SESSION_SURFACES = frozenset(
|
||||
{
|
||||
"",
|
||||
"cli",
|
||||
"codex",
|
||||
"desktop",
|
||||
"gateway",
|
||||
"local",
|
||||
"tui",
|
||||
"tool",
|
||||
"api_server",
|
||||
"webhook",
|
||||
"msgraph_webhook",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _session_is_messaging_surface() -> bool:
|
||||
"""Return whether this turn is delivered over a human messaging channel.
|
||||
|
||||
The gateway binds the platform value (e.g. ``telegram``) to
|
||||
``HERMES_SESSION_PLATFORM``; the CLI and TUI set ``HERMES_SESSION_SOURCE``
|
||||
(e.g. ``cli``, ``tui``) instead. Both are consulted via the session-context
|
||||
helper (with an ``os.environ`` fallback), alongside the ``HERMES_PLATFORM``
|
||||
override, matching the sibling platform resolution in
|
||||
``agent/skill_commands.py`` and ``agent/prompt_builder.py``. A turn is a
|
||||
messaging surface when a resolved identity is present and is not a known
|
||||
non-messaging surface.
|
||||
"""
|
||||
try:
|
||||
from gateway.session_context import get_session_env
|
||||
|
||||
platform = (
|
||||
os.getenv("HERMES_PLATFORM")
|
||||
or get_session_env("HERMES_SESSION_PLATFORM", "")
|
||||
)
|
||||
source = get_session_env("HERMES_SESSION_SOURCE", "")
|
||||
except Exception:
|
||||
platform = os.getenv("HERMES_PLATFORM", "") or os.environ.get(
|
||||
"HERMES_SESSION_PLATFORM", ""
|
||||
)
|
||||
source = os.environ.get("HERMES_SESSION_SOURCE", "")
|
||||
for identity in (platform, source):
|
||||
identity = str(identity or "").strip().lower()
|
||||
if identity and identity not in _NON_MESSAGING_SESSION_SURFACES:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool:
|
||||
"""Return whether edit -> verify-before-finish behavior is enabled."""
|
||||
"""Return whether edit -> verify-before-finish behavior is enabled.
|
||||
|
||||
Precedence: an explicit ``HERMES_VERIFY_ON_STOP`` env var wins, then an
|
||||
explicit boolean ``agent.verify_on_stop`` config value, then a surface-aware
|
||||
default. The config default is the sentinel ``"auto"`` (see
|
||||
``DEFAULT_CONFIG``), which resolves to ON for interactive coding surfaces
|
||||
(CLI, TUI, desktop) and programmatic callers, and OFF for conversational
|
||||
messaging surfaces (Telegram, Discord, etc.) where the verification
|
||||
narrative would otherwise reach a human as chat noise.
|
||||
"""
|
||||
env = os.environ.get("HERMES_VERIFY_ON_STOP")
|
||||
if env is not None:
|
||||
return env.strip().lower() not in {"0", "false", "no", "off"}
|
||||
@@ -29,9 +97,17 @@ def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool:
|
||||
except Exception:
|
||||
config = {}
|
||||
agent_cfg = (config or {}).get("agent") if isinstance(config, dict) else None
|
||||
if isinstance(agent_cfg, dict) and "verify_on_stop" in agent_cfg:
|
||||
return bool(agent_cfg.get("verify_on_stop"))
|
||||
return True
|
||||
cfg_val = agent_cfg.get("verify_on_stop") if isinstance(agent_cfg, dict) else None
|
||||
if isinstance(cfg_val, bool):
|
||||
return cfg_val
|
||||
if isinstance(cfg_val, str):
|
||||
token = cfg_val.strip().lower()
|
||||
if token in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if token in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
# "auto", missing, or any other value -> surface-aware default.
|
||||
return not _session_is_messaging_surface()
|
||||
|
||||
|
||||
def _candidate_cwds(paths: Iterable[str]) -> list[Path]:
|
||||
|
||||
@@ -61,10 +61,7 @@ function buildDesktopBackendPath({
|
||||
const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null
|
||||
const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES
|
||||
|
||||
return appendUniquePathEntries(
|
||||
[hermesNodeBin, venvBin, currentPath, saneEntries],
|
||||
{ delimiter }
|
||||
)
|
||||
return appendUniquePathEntries([hermesNodeBin, venvBin, currentPath, saneEntries], { delimiter })
|
||||
}
|
||||
|
||||
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) } = {}) {
|
||||
|
||||
@@ -76,10 +76,7 @@ test('normalizeHermesHomeRoot maps profile homes back to the global Hermes root'
|
||||
normalizeHermesHomeRoot('C:\\Users\\test\\AppData\\Local\\hermes\\profiles\\oracle', { pathModule: path.win32 }),
|
||||
'C:\\Users\\test\\AppData\\Local\\hermes'
|
||||
)
|
||||
assert.equal(
|
||||
normalizeHermesHomeRoot('/Users/test/.hermes', { pathModule: path.posix }),
|
||||
'/Users/test/.hermes'
|
||||
)
|
||||
assert.equal(normalizeHermesHomeRoot('/Users/test/.hermes', { pathModule: path.posix }), '/Users/test/.hermes')
|
||||
})
|
||||
|
||||
test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => {
|
||||
@@ -104,8 +101,5 @@ test('Windows PATH casing and delimiter are preserved without POSIX sane entries
|
||||
})
|
||||
|
||||
test('appendUniquePathEntries drops empty entries and keeps first occurrence', () => {
|
||||
assert.equal(
|
||||
appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }),
|
||||
'/a:/b:/c'
|
||||
)
|
||||
assert.equal(appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }), '/a:/b:/c')
|
||||
})
|
||||
|
||||
@@ -167,5 +167,5 @@ module.exports = {
|
||||
readDashboardReadyFile,
|
||||
resolvePortAnnounceTimeoutMs,
|
||||
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
MIN_PORT_ANNOUNCE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ const {
|
||||
waitForDashboardReadyFile,
|
||||
resolvePortAnnounceTimeoutMs,
|
||||
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
MIN_PORT_ANNOUNCE_TIMEOUT_MS
|
||||
} = require('./backend-ready.cjs')
|
||||
|
||||
// A minimal stand-in for a spawned child process: an EventEmitter with a
|
||||
|
||||
@@ -179,7 +179,13 @@ function downloadInstallScript(commit, destPath) {
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit, _download = downloadInstallScript }) {
|
||||
async function resolveInstallScript({
|
||||
installStamp,
|
||||
sourceRepoRoot,
|
||||
hermesHome,
|
||||
emit,
|
||||
_download = downloadInstallScript
|
||||
}) {
|
||||
// 1. Dev shortcut: prefer a local checkout's installer so we can iterate
|
||||
// without pushing. SOURCE_REPO_ROOT comes from main.cjs (path.resolve
|
||||
// of APP_ROOT/../..).
|
||||
@@ -293,15 +299,19 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
|
||||
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
|
||||
|
||||
const child = spawn(ps, fullArgs, hiddenWindowsChildOptions({
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
// Pass HERMES_HOME through so install.ps1 respects the caller's
|
||||
// choice rather than re-computing the default.
|
||||
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
|
||||
}
|
||||
}))
|
||||
const child = spawn(
|
||||
ps,
|
||||
fullArgs,
|
||||
hiddenWindowsChildOptions({
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
// Pass HERMES_HOME through so install.ps1 respects the caller's
|
||||
// choice rather than re-computing the default.
|
||||
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
20
apps/desktop/electron/build-mode.cjs
Normal file
20
apps/desktop/electron/build-mode.cjs
Normal file
@@ -0,0 +1,20 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* build-mode.cjs — pure helper for the desktop's thin-vs-thick build mode.
|
||||
*
|
||||
* The desktop ships in two shapes:
|
||||
* - thick (default): bundles the first-launch bootstrap installer, can
|
||||
* spawn a local Hermes backend, and supports in-app self-update.
|
||||
* - thin: no bootstrap, no local backend, no self-update. Connects ONLY
|
||||
* to a remote gateway. Used for sandboxed/package-managed deployments
|
||||
* (Flatpak, Snap, etc.) where the agent lives elsewhere.
|
||||
*
|
||||
* The esbuild bundler bakes this env var into the source code, so it's read at build time, not runtime.
|
||||
*/
|
||||
|
||||
function isThinClient() {
|
||||
return process.env.HERMES_DESKTOP_BUILD_MODE === 'thin'
|
||||
}
|
||||
|
||||
module.exports = { isThinClient }
|
||||
41
apps/desktop/electron/build-mode.test.cjs
Normal file
41
apps/desktop/electron/build-mode.test.cjs
Normal file
@@ -0,0 +1,41 @@
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// We test build-mode.cjs by controlling process.env directly. The module
|
||||
// reads process.env.HERMES_DESKTOP_BUILD_MODE at call time (not import time),
|
||||
// so we can mutate the env and re-require to exercise both modes.
|
||||
|
||||
function freshModule() {
|
||||
// Bust the require cache so the module re-evaluates with the current env.
|
||||
delete require.cache[require.resolve('./build-mode.cjs')]
|
||||
return require('./build-mode.cjs')
|
||||
}
|
||||
|
||||
test('isThinClient returns false by default (thick mode)', () => {
|
||||
const prev = process.env.HERMES_DESKTOP_BUILD_MODE
|
||||
delete process.env.HERMES_DESKTOP_BUILD_MODE
|
||||
const { isThinClient } = freshModule()
|
||||
assert.equal(isThinClient(), false)
|
||||
process.env.HERMES_DESKTOP_BUILD_MODE = prev
|
||||
})
|
||||
|
||||
test('isThinClient returns true when HERMES_DESKTOP_BUILD_MODE=thin', () => {
|
||||
const prev = process.env.HERMES_DESKTOP_BUILD_MODE
|
||||
process.env.HERMES_DESKTOP_BUILD_MODE = 'thin'
|
||||
const { isThinClient } = freshModule()
|
||||
assert.equal(isThinClient(), true)
|
||||
process.env.HERMES_DESKTOP_BUILD_MODE = prev
|
||||
})
|
||||
|
||||
test('isThinClient returns false for non-thin values', () => {
|
||||
const prev = process.env.HERMES_DESKTOP_BUILD_MODE
|
||||
process.env.HERMES_DESKTOP_BUILD_MODE = 'thick'
|
||||
const { isThinClient } = freshModule()
|
||||
assert.equal(isThinClient(), false)
|
||||
process.env.HERMES_DESKTOP_BUILD_MODE = 'thick-client'
|
||||
const { isThinClient: isThin2 } = freshModule()
|
||||
assert.equal(isThin2(), false)
|
||||
process.env.HERMES_DESKTOP_BUILD_MODE = prev
|
||||
})
|
||||
@@ -261,12 +261,7 @@ function cookiesHaveSession(cookies) {
|
||||
*/
|
||||
function cookiesHaveLiveSession(cookies) {
|
||||
if (!Array.isArray(cookies)) return false
|
||||
return cookies.some(
|
||||
c =>
|
||||
c &&
|
||||
c.value &&
|
||||
(AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name))
|
||||
)
|
||||
return cookies.some(c => c && c.value && (AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name)))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -138,10 +138,7 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot,
|
||||
if (pythonPath) {
|
||||
lines.push(`export PYTHONPATH=${q(pythonPath)}\${PYTHONPATH:+:$PYTHONPATH}`)
|
||||
}
|
||||
lines.push(
|
||||
`cd ${q(agentRoot)} 2>/dev/null || true`,
|
||||
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`
|
||||
)
|
||||
lines.push(`cd ${q(agentRoot)} 2>/dev/null || true`, `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`)
|
||||
if (appPath) {
|
||||
lines.push(`rm -rf ${q(appPath)} || true`)
|
||||
}
|
||||
@@ -169,7 +166,15 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot,
|
||||
* Removal: even after the desktop PID is gone, Windows releases directory
|
||||
* handles lazily, so a single `rmdir /s /q` can half-fail — retry up to 10x.
|
||||
*/
|
||||
function buildWindowsCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
|
||||
function buildWindowsCleanupScript({
|
||||
desktopPid,
|
||||
pythonExe,
|
||||
pythonPath,
|
||||
agentRoot,
|
||||
uninstallArgs,
|
||||
appPath,
|
||||
hermesHome
|
||||
}) {
|
||||
const pid = Number(desktopPid) || 0
|
||||
// cmd.exe has no string escaping inside quotes; strip embedded quotes (paths
|
||||
// under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be
|
||||
|
||||
@@ -101,10 +101,7 @@ test('resolveRemovableAppPath uses APPIMAGE on Linux when set', () => {
|
||||
})
|
||||
|
||||
test('resolveRemovableAppPath finds the unpacked dir on Linux', () => {
|
||||
assert.equal(
|
||||
resolveRemovableAppPath('/opt/hermes/linux-unpacked/hermes', 'linux', {}),
|
||||
'/opt/hermes/linux-unpacked'
|
||||
)
|
||||
assert.equal(resolveRemovableAppPath('/opt/hermes/linux-unpacked/hermes', 'linux', {}), '/opt/hermes/linux-unpacked')
|
||||
// A system-package install (/usr/bin) → null, left to apt/dnf.
|
||||
assert.equal(resolveRemovableAppPath('/usr/bin/hermes', 'linux', {}), null)
|
||||
})
|
||||
|
||||
48
apps/desktop/electron/embed-referer.cjs
Normal file
48
apps/desktop/electron/embed-referer.cjs
Normal file
@@ -0,0 +1,48 @@
|
||||
'use strict'
|
||||
|
||||
const { session } = require('electron')
|
||||
|
||||
const EMBED_SESSION_PARTITION = 'persist:hermes-embed'
|
||||
const EMBED_REFERER = 'https://www.youtube.com/'
|
||||
const YOUTUBE_REFERER_HOST_RE =
|
||||
/(^|\.)(youtube\.com|youtube-nocookie\.com|googlevideo\.com|ytimg\.com|youtubei\.googleapis\.com)$/i
|
||||
|
||||
function installEmbedRefererForSession(embedSession) {
|
||||
if (!embedSession) {
|
||||
return
|
||||
}
|
||||
|
||||
embedSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
let host = ''
|
||||
|
||||
try {
|
||||
host = new URL(details.url).hostname
|
||||
} catch {
|
||||
host = ''
|
||||
}
|
||||
|
||||
if (!YOUTUBE_REFERER_HOST_RE.test(host)) {
|
||||
callback({ requestHeaders: details.requestHeaders })
|
||||
return
|
||||
}
|
||||
|
||||
const headers = { ...details.requestHeaders }
|
||||
|
||||
if (!headers.Referer && !headers.referer) {
|
||||
headers.Referer = EMBED_REFERER
|
||||
}
|
||||
|
||||
callback({ requestHeaders: headers })
|
||||
})
|
||||
}
|
||||
|
||||
/** Stamp Referer on YouTube requests in the embed webview partition only. */
|
||||
function installEmbedReferer() {
|
||||
try {
|
||||
installEmbedRefererForSession(session.fromPartition(EMBED_SESSION_PARTITION))
|
||||
} catch {
|
||||
// Non-fatal: embeds still render; YouTube may show referer errors.
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { installEmbedReferer }
|
||||
@@ -92,9 +92,7 @@ async function readDirForIpc(dirPath, options = {}) {
|
||||
try {
|
||||
const dirents = await fsImpl.promises.readdir(resolved, { withFileTypes: true })
|
||||
const visibleDirents = dirents.filter(dirent => !FS_READDIR_HIDDEN.has(dirent.name))
|
||||
const entries = await mapWithStatConcurrency(visibleDirents, dirent =>
|
||||
entryForDirent(dirent, resolved, fsImpl)
|
||||
)
|
||||
const entries = await mapWithStatConcurrency(visibleDirents, dirent => entryForDirent(dirent, resolved, fsImpl))
|
||||
|
||||
entries.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))
|
||||
|
||||
|
||||
@@ -349,7 +349,10 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
|
||||
assert.equal(result.error, undefined)
|
||||
assert.equal(result.entries.length, names.length)
|
||||
assert.equal(statCalls.length, names.length)
|
||||
assert.equal(statCalls.some(fullPath => fullPath.endsWith(`${path.sep}node_modules`)), false)
|
||||
assert.equal(
|
||||
statCalls.some(fullPath => fullPath.endsWith(`${path.sep}node_modules`)),
|
||||
false
|
||||
)
|
||||
assert.ok(peak > 1, `expected concurrent stats, observed peak ${peak}`)
|
||||
assert.ok(peak <= 16, `expected at most 16 concurrent stats, observed peak ${peak}`)
|
||||
assert.deepEqual(
|
||||
@@ -357,8 +360,5 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
|
||||
expectedNames
|
||||
)
|
||||
assert.equal(result.entries.find(entry => entry.name === failedName)?.isDirectory, false)
|
||||
assert.equal(
|
||||
result.entries.filter(entry => entry.isDirectory).length,
|
||||
successfulDirectoryNames.size
|
||||
)
|
||||
assert.equal(result.entries.filter(entry => entry.isDirectory).length, successfulDirectoryNames.size)
|
||||
})
|
||||
|
||||
@@ -86,10 +86,8 @@ async function scanGitRepos(roots, options = {}) {
|
||||
await mapLimit(subdirs, MAX_CONCURRENCY, sub => walk(sub, depth + 1))
|
||||
}
|
||||
|
||||
await mapLimit(
|
||||
searchRoots.map(root => String(root || '').trim()).filter(Boolean),
|
||||
MAX_CONCURRENCY,
|
||||
root => walk(root, 0)
|
||||
await mapLimit(searchRoots.map(root => String(root || '').trim()).filter(Boolean), MAX_CONCURRENCY, root =>
|
||||
walk(root, 0)
|
||||
)
|
||||
|
||||
return [...found.entries()].map(([root, label]) => ({ label, root }))
|
||||
|
||||
@@ -188,7 +188,12 @@ async function defaultBranchName(git) {
|
||||
|
||||
// Prefer a local trunk, then a remote-only one (returns the clean name either
|
||||
// way) so "branch off main" works even before main is checked out locally.
|
||||
for (const ref of ['refs/heads/main', 'refs/heads/master', 'refs/remotes/origin/main', 'refs/remotes/origin/master']) {
|
||||
for (const ref of [
|
||||
'refs/heads/main',
|
||||
'refs/heads/master',
|
||||
'refs/remotes/origin/main',
|
||||
'refs/remotes/origin/master'
|
||||
]) {
|
||||
try {
|
||||
await git.raw(['rev-parse', '--verify', '--quiet', ref])
|
||||
|
||||
|
||||
@@ -45,7 +45,10 @@ function parseWorktrees(out) {
|
||||
} else if (!cur) {
|
||||
continue
|
||||
} else if (line.startsWith('branch ')) {
|
||||
cur.branch = line.slice(7).trim().replace(/^refs\/heads\//, '')
|
||||
cur.branch = line
|
||||
.slice(7)
|
||||
.trim()
|
||||
.replace(/^refs\/heads\//, '')
|
||||
} else if (line === 'detached') {
|
||||
cur.detached = true
|
||||
} else if (line === 'bare') {
|
||||
@@ -122,10 +125,9 @@ async function gitLine(gitBin, args, cwd) {
|
||||
}
|
||||
|
||||
async function defaultBranch(gitBin, cwd) {
|
||||
const remote = (await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd)).replace(
|
||||
/^origin\//,
|
||||
''
|
||||
)
|
||||
const remote = (
|
||||
await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd)
|
||||
).replace(/^origin\//, '')
|
||||
|
||||
if (remote) {
|
||||
return remote
|
||||
@@ -177,7 +179,16 @@ async function ensureGitRepo(gitBin, dir) {
|
||||
// Inline identity so the seed commit lands even with no global git config.
|
||||
await runGit(
|
||||
gitBin,
|
||||
['-c', 'user.email=hermes@localhost', '-c', 'user.name=Hermes', 'commit', '--allow-empty', '-m', 'Initial commit'],
|
||||
[
|
||||
'-c',
|
||||
'user.email=hermes@localhost',
|
||||
'-c',
|
||||
'user.name=Hermes',
|
||||
'commit',
|
||||
'--allow-empty',
|
||||
'-m',
|
||||
'Initial commit'
|
||||
],
|
||||
dir
|
||||
)
|
||||
}
|
||||
|
||||
@@ -186,7 +186,10 @@ async function statForIpc(fsImpl, resolvedPath, purpose, typeLabel) {
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
throw ipcPathError(code || 'ENOENT', `${purpose} failed: ${typeLabel} does not exist.`)
|
||||
}
|
||||
throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
throw ipcPathError(
|
||||
code || 'read-error',
|
||||
`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +204,10 @@ async function realpathForIpc(fsImpl, resolvedPath, purpose) {
|
||||
return realPath
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? error.code : ''
|
||||
throw ipcPathError(code || 'read-error', `${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
throw ipcPathError(
|
||||
code || 'read-error',
|
||||
`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,10 +21,11 @@ const crypto = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const http = require('node:http')
|
||||
const https = require('node:https')
|
||||
const net = require('node:net')
|
||||
const path = require('node:path')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const { execFileSync, spawn } = require('node:child_process')
|
||||
const { isThinClient } = require('./build-mode.cjs')
|
||||
const { installEmbedReferer } = require('./embed-referer.cjs')
|
||||
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
const {
|
||||
@@ -43,6 +44,8 @@ const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-reques
|
||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
|
||||
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
|
||||
const { readWslWindowsClipboardImage } = require('./wsl-clipboard-image.cjs')
|
||||
const { nativeOverlayWidth: computeNativeOverlayWidth } = require('./titlebar-overlay-width.cjs')
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
const { readLiveUpdateMarker } = require('./update-marker.cjs')
|
||||
const {
|
||||
@@ -186,6 +189,16 @@ if (REMOTE_DISPLAY_REASON) {
|
||||
)
|
||||
}
|
||||
|
||||
// WSLg: Chromium blocklists the Mesa vGPU → software compositing → typing lag.
|
||||
// /dev/dxg means a real GPU is available; un-blocklist it. Skipped when a remote
|
||||
// display already forced software (SSH'd-into-WSL).
|
||||
if (IS_WSL && !REMOTE_DISPLAY_REASON && fs.existsSync('/dev/dxg')) {
|
||||
app.commandLine.appendSwitch('ignore-gpu-blocklist')
|
||||
app.commandLine.appendSwitch('enable-gpu-rasterization')
|
||||
app.commandLine.appendSwitch('enable-zero-copy')
|
||||
console.log('[hermes] WSL GPU passthrough (/dev/dxg) detected; enabling GPU acceleration')
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON)
|
||||
|
||||
// Keep the renderer running at full speed while the window is in the background
|
||||
@@ -318,9 +331,7 @@ function hermesManagedNodePathEntries() {
|
||||
}
|
||||
|
||||
function pathWithHermesManagedNode(...entries) {
|
||||
return [...hermesManagedNodePathEntries(), ...entries, process.env.PATH]
|
||||
.filter(Boolean)
|
||||
.join(path.delimiter)
|
||||
return [...hermesManagedNodePathEntries(), ...entries, process.env.PATH].filter(Boolean).join(path.delimiter)
|
||||
}
|
||||
|
||||
// ACTIVE_HERMES_ROOT — the canonical mutable Hermes install. Same path
|
||||
@@ -398,14 +409,10 @@ const WINDOW_BUTTON_POSITION = {
|
||||
x: 24,
|
||||
y: TITLEBAR_HEIGHT / 2 - MACOS_TRAFFIC_LIGHTS_HEIGHT / 2
|
||||
}
|
||||
// Width Electron reserves for the Windows/Linux native min/max/close cluster
|
||||
// when `titleBarOverlay` is enabled. The OS paints these buttons in the
|
||||
// top-right corner of the renderer; we have to leave that much room on the
|
||||
// right edge so our system tools (file browser, haptics, settings) don't sit
|
||||
// underneath them. macOS uses left-side traffic lights instead and reports a
|
||||
// position via getWindowButtonPosition(), so this width is non-zero only on
|
||||
// non-macOS platforms.
|
||||
const NATIVE_OVERLAY_BUTTON_WIDTH = 144
|
||||
// Right-edge window-control reservation lives in titlebar-overlay-width.cjs
|
||||
// (pure + unit-testable); computeNativeOverlayWidth() applies it per platform.
|
||||
// It's only the pre-layout fallback — the renderer measures the exact overlay
|
||||
// width live via the Window Controls Overlay API.
|
||||
const APP_ICON_PATHS = [
|
||||
path.join(APP_ROOT, 'public', 'apple-touch-icon.png'),
|
||||
path.join(APP_ROOT, 'dist', 'apple-touch-icon.png'),
|
||||
@@ -519,25 +526,48 @@ function getWindowBackgroundColor() {
|
||||
return nativeTheme.shouldUseDarkColors ? '#111111' : '#f7f7f7'
|
||||
}
|
||||
|
||||
// Transparent WCO — renderer chrome shows through. rgba(0,0,0,0) can fall back
|
||||
// to GetFrameColor() on some Electron builds; rgba(1,0,0,0) is the escape hatch.
|
||||
const TITLEBAR_OVERLAY_COLOR = 'rgba(1, 0, 0, 0)'
|
||||
|
||||
function getTitleBarOverlayOptions() {
|
||||
if (IS_MAC) {
|
||||
return { height: TITLEBAR_HEIGHT }
|
||||
}
|
||||
|
||||
if (rendererTitleBarTheme) {
|
||||
return {
|
||||
color: rendererTitleBarTheme.background,
|
||||
height: TITLEBAR_HEIGHT,
|
||||
symbolColor: rendererTitleBarTheme.foreground
|
||||
}
|
||||
// Windows + WSLg paint WCO natively; plain Linux disables it (frameless hidden
|
||||
// titlebar still applies).
|
||||
if (!IS_WINDOWS && !IS_WSL) {
|
||||
return false
|
||||
}
|
||||
|
||||
const useDarkColors = nativeTheme.shouldUseDarkColors
|
||||
|
||||
return {
|
||||
color: useDarkColors ? '#111111' : '#f7f7f7',
|
||||
color: TITLEBAR_OVERLAY_COLOR,
|
||||
height: TITLEBAR_HEIGHT,
|
||||
symbolColor: useDarkColors ? '#f7f7f7' : '#242424'
|
||||
symbolColor:
|
||||
rendererTitleBarTheme && isHexColor(rendererTitleBarTheme.foreground)
|
||||
? rendererTitleBarTheme.foreground
|
||||
: nativeTheme.shouldUseDarkColors
|
||||
? '#f7f7f7'
|
||||
: '#242424'
|
||||
}
|
||||
}
|
||||
|
||||
// Push refreshed overlay options to a live window after a theme/appearance
|
||||
// change. No-op only on plain (non-WSL) Linux, where getTitleBarOverlayOptions()
|
||||
// returns false; the try/catch additionally guards builds where
|
||||
// setTitleBarOverlay isn't supported.
|
||||
function applyTitleBarOverlay(win) {
|
||||
const options = getTitleBarOverlayOptions()
|
||||
if (!options || typeof options !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
win?.setTitleBarOverlay?.(options)
|
||||
} catch {
|
||||
// Overlay not supported on this platform/build — leave the frameless
|
||||
// titlebar as-is.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1294,10 +1324,7 @@ function unwrapWindowsVenvHermesCommand(command, dashboardArgs) {
|
||||
bootstrap: false,
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome: HERMES_HOME,
|
||||
pythonPathEntries: [
|
||||
...(directoryExists(root) ? [root] : []),
|
||||
...getVenvSitePackagesEntries(venvRoot)
|
||||
],
|
||||
pythonPathEntries: [...(directoryExists(root) ? [root] : []), ...getVenvSitePackagesEntries(venvRoot)],
|
||||
venvRoot
|
||||
}),
|
||||
kind: 'python',
|
||||
@@ -1573,9 +1600,7 @@ function applyWindowsNoConsoleSpawnHints(backend) {
|
||||
|
||||
const usesHermesModule =
|
||||
backend.kind === 'python' ||
|
||||
(Array.isArray(backend.args) &&
|
||||
backend.args[0] === '-m' &&
|
||||
backend.args[1] === 'hermes_cli.main')
|
||||
(Array.isArray(backend.args) && backend.args[0] === '-m' && backend.args[1] === 'hermes_cli.main')
|
||||
|
||||
if (!usesHermesModule) return backend
|
||||
|
||||
@@ -1815,6 +1840,17 @@ async function resolveHealedBranch(updateRoot, branch) {
|
||||
}
|
||||
|
||||
async function checkUpdates() {
|
||||
// Thin client: no local checkout to git-pull, no bundled updater. Updates
|
||||
// come from the package manager (Flatpak, Snap, etc.), not in-app self-update.
|
||||
if (isThinClient()) {
|
||||
return {
|
||||
supported: false,
|
||||
reason: 'thin-client',
|
||||
message: 'Updates are managed by your package manager. This is a thin client build.',
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
const updateRoot = resolveUpdateRoot()
|
||||
let { branch } = readDesktopUpdateConfig()
|
||||
const gitDir = path.join(updateRoot, '.git')
|
||||
@@ -1948,6 +1984,10 @@ let updateInFlight = false
|
||||
// updater isn't staged (e.g. a dev/source run that never went through the
|
||||
// installer); callers degrade gracefully.
|
||||
function resolveUpdaterBinary() {
|
||||
// Thin client: no staged Tauri updater — the packaged app is managed
|
||||
// externally. Returning null lets the existing callers degrade gracefully
|
||||
// (the manual-command path), though applyUpdates already short-circuits.
|
||||
if (isThinClient()) return null
|
||||
const name = IS_WINDOWS ? 'hermes-setup.exe' : 'hermes-setup'
|
||||
const candidate = path.join(HERMES_HOME, name)
|
||||
return fileExists(candidate) ? candidate : null
|
||||
@@ -2106,6 +2146,16 @@ async function releaseBackendLock(updateRoot, tag) {
|
||||
// Detection (checkUpdates / commit changelog / "N behind") stays in the UI;
|
||||
// only this apply action changed.
|
||||
async function applyUpdates(opts = {}) {
|
||||
// Thin client: self-update is not supported. The packaged app is managed
|
||||
// externally (Flatpak, Snap, etc.).
|
||||
if (isThinClient()) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'unsupported',
|
||||
message: 'Self-update is not available in thin client builds. Use your package manager to update.'
|
||||
}
|
||||
}
|
||||
|
||||
if (updateInFlight) {
|
||||
throw new Error('An update is already in progress.')
|
||||
}
|
||||
@@ -2151,7 +2201,8 @@ async function applyUpdates(opts = {}) {
|
||||
|
||||
emitUpdateProgress({
|
||||
stage: 'restart',
|
||||
message: 'Updating Hermes — this window will close and the updater will open. Don’t reopen Hermes yourself; it restarts automatically when the update finishes.',
|
||||
message:
|
||||
'Updating Hermes — this window will close and the updater will open. Don’t reopen Hermes yourself; it restarts automatically when the update finishes.',
|
||||
percent: 100
|
||||
})
|
||||
repairMacUpdaterHelper(updater)
|
||||
@@ -2234,7 +2285,9 @@ async function handOffWindowsBootstrapRecovery(reason) {
|
||||
})
|
||||
child.unref()
|
||||
|
||||
rememberLog(`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`)
|
||||
rememberLog(
|
||||
`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`
|
||||
)
|
||||
// Same dwell as the in-app update hand-off (#50419): give the updater's
|
||||
// window time to appear before we vanish, so the recovery doesn't look like
|
||||
// a crash and provoke a mid-recovery relaunch.
|
||||
@@ -2761,8 +2814,7 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
|
||||
|
||||
const venvRoot = path.join(root, 'venv')
|
||||
const venvPython = getVenvPython(venvRoot)
|
||||
const command =
|
||||
IS_WINDOWS && fileExists(venvPython) ? getNoConsoleVenvPython(venvRoot) : toNoConsolePython(python)
|
||||
const command = IS_WINDOWS && fileExists(venvPython) ? getNoConsoleVenvPython(venvRoot) : toNoConsolePython(python)
|
||||
|
||||
return applyWindowsNoConsoleSpawnHints({
|
||||
kind: 'python',
|
||||
@@ -2786,9 +2838,7 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
|
||||
// ensureRuntime() to create / refresh it before launch.
|
||||
function createActiveBackend(dashboardArgs) {
|
||||
const venvPython = getVenvPython(VENV_ROOT)
|
||||
const command = fileExists(venvPython)
|
||||
? getNoConsoleVenvPython(VENV_ROOT)
|
||||
: toNoConsolePython(findSystemPython())
|
||||
const command = fileExists(venvPython) ? getNoConsoleVenvPython(VENV_ROOT) : toNoConsolePython(findSystemPython())
|
||||
|
||||
return applyWindowsNoConsoleSpawnHints({
|
||||
kind: 'python',
|
||||
@@ -2807,6 +2857,23 @@ function createActiveBackend(dashboardArgs) {
|
||||
}
|
||||
|
||||
function resolveHermesBackend(dashboardArgs) {
|
||||
// Thin client: no local backend, no bootstrap. The only valid path is a
|
||||
// remote gateway connection. Returning the bootstrap-needed sentinel here
|
||||
// would kick off install.ps1, which the thin build doesn't ship. Instead
|
||||
// return a dedicated sentinel so startHermes() can produce a clear error
|
||||
// directing the user to configure a remote gateway.
|
||||
if (isThinClient()) {
|
||||
return {
|
||||
kind: 'thin-client-no-local',
|
||||
label: 'Thin client build — remote gateway required',
|
||||
command: null,
|
||||
args: dashboardArgs,
|
||||
bootstrap: false,
|
||||
env: {},
|
||||
shell: false
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Explicit override -- HERMES_DESKTOP_HERMES_ROOT points at a developer
|
||||
// checkout. Honour it as-is (no bootstrap; the user is driving).
|
||||
const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT)
|
||||
@@ -2878,15 +2945,17 @@ function resolveHermesBackend(dashboardArgs) {
|
||||
// and lets the resolver fall through to step 6 / bootstrap.
|
||||
const shellForProbe = isCommandScript(hermesCommand)
|
||||
if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) {
|
||||
return unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs) || {
|
||||
label: `existing Hermes CLI at ${hermesCommand}`,
|
||||
command: hermesCommand,
|
||||
args: dashboardArgs,
|
||||
bootstrap: false,
|
||||
env: {},
|
||||
kind: 'command',
|
||||
shell: shellForProbe
|
||||
}
|
||||
return (
|
||||
unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs) || {
|
||||
label: `existing Hermes CLI at ${hermesCommand}`,
|
||||
command: hermesCommand,
|
||||
args: dashboardArgs,
|
||||
bootstrap: false,
|
||||
env: {},
|
||||
kind: 'command',
|
||||
shell: shellForProbe
|
||||
}
|
||||
)
|
||||
}
|
||||
rememberLog(
|
||||
`Ignoring existing Hermes CLI at ${hermesCommand}: --version probe failed; falling through to bootstrap.`
|
||||
@@ -2948,6 +3017,19 @@ function resolveHermesBackend(dashboardArgs) {
|
||||
}
|
||||
|
||||
async function ensureRuntime(backend) {
|
||||
// Thin client: resolveHermesBackend returned a sentinel telling us there
|
||||
// is no local backend to spawn. Rather than crashing on a null command
|
||||
// spawn, throw a clear error so startHermes() catches it and the boot-
|
||||
// failure overlay surfaces the "configure a remote gateway" message.
|
||||
if (backend.kind === 'thin-client-no-local') {
|
||||
const err = new Error(
|
||||
'This is a thin client build with no bundled Hermes agent. ' +
|
||||
'Go to Settings → Gateway and configure a remote gateway URL.'
|
||||
)
|
||||
err.isThinClientNoLocal = true
|
||||
throw err
|
||||
}
|
||||
|
||||
if (!backend.bootstrap) {
|
||||
await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32)
|
||||
return applyWindowsNoConsoleSpawnHints(backend)
|
||||
@@ -2966,7 +3048,9 @@ async function ensureRuntime(backend) {
|
||||
rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap')
|
||||
|
||||
if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) {
|
||||
const handoffError = new Error('Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.')
|
||||
const handoffError = new Error(
|
||||
'Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.'
|
||||
)
|
||||
handoffError.isBootstrapFailure = true
|
||||
handoffError.bootstrapHandedOff = true
|
||||
bootstrapFailure = handoffError
|
||||
@@ -3760,11 +3844,7 @@ function getWindowButtonPosition() {
|
||||
}
|
||||
|
||||
function getNativeOverlayWidth() {
|
||||
// macOS reports traffic-light coords via windowButtonPosition; the
|
||||
// titlebarOverlay there doesn't reserve right-edge space. Windows/Linux
|
||||
// render the native window-controls overlay on the right, so the renderer
|
||||
// needs to inset its right cluster by this much to clear them.
|
||||
return IS_MAC ? 0 : NATIVE_OVERLAY_BUTTON_WIDTH
|
||||
return computeNativeOverlayWidth({ isWindows: IS_WINDOWS, isWsl: IS_WSL })
|
||||
}
|
||||
|
||||
function getWindowState() {
|
||||
@@ -4961,6 +5041,10 @@ async function testDesktopConnectionConfig(input = {}) {
|
||||
if (authMode !== 'oauth') {
|
||||
token = decryptDesktopSecret(block.token)
|
||||
}
|
||||
} else if (isThinClient()) {
|
||||
// Thin client: no local backend to test against. A "local" connection
|
||||
// test is meaningless — there's no bundled agent to reach.
|
||||
throw new Error('Local connection test is not available in thin client builds. Configure a remote gateway URL.')
|
||||
} else {
|
||||
const remote = (await resolveRemoteBackend(key)) || (await startHermes())
|
||||
baseUrl = remote.baseUrl
|
||||
@@ -5377,6 +5461,17 @@ async function startHermes() {
|
||||
}
|
||||
}
|
||||
|
||||
// Thin client: the remote check above was the only path. If we get here,
|
||||
// no remote was configured — refuse to spawn a local backend.
|
||||
if (isThinClient()) {
|
||||
const err = new Error(
|
||||
'No remote gateway configured. This thin client build requires a remote gateway. ' +
|
||||
'Go to Settings → Gateway to configure one.'
|
||||
)
|
||||
err.isThinClientNoRemote = true
|
||||
throw err
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -5485,7 +5580,10 @@ async function startHermes() {
|
||||
|
||||
await advanceBootProgress('backend.port', 'Waiting for Hermes backend to launch', 86)
|
||||
// Discover the ephemeral port the child bound to
|
||||
const port = await Promise.race([waitForDashboardPortAnnouncement(hermesProcess, { readyFile }), backendStartFailed])
|
||||
const port = await Promise.race([
|
||||
waitForDashboardPortAnnouncement(hermesProcess, { readyFile }),
|
||||
backendStartFailed
|
||||
])
|
||||
if (readyFile) {
|
||||
fs.unlink(readyFile, () => {})
|
||||
}
|
||||
@@ -5820,7 +5918,7 @@ function createWindow() {
|
||||
if (!nativeThemeListenerInstalled) {
|
||||
nativeThemeListenerInstalled = true
|
||||
nativeTheme.on('updated', () => {
|
||||
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
|
||||
applyTitleBarOverlay(mainWindow)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6004,19 +6102,32 @@ ipcMain.handle('hermes:pet-overlay:close', async () => {
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
// Drag: the overlay reports a new absolute screen position (it already knows the
|
||||
// pointer's screen coords), we just move the window.
|
||||
// Drag/resize: the overlay reports new absolute screen bounds (it already knows
|
||||
// the pointer's screen coords). Drag keeps the size constant; the wheel-to-scale
|
||||
// gesture grows/shrinks it so the sprite is never cropped by the window edge.
|
||||
// The window is created non-resizable (no stray edge-drag on the transparent
|
||||
// frameless panel), which on Windows/Linux also blocks programmatic setBounds
|
||||
// sizing — so briefly flip resizable on whenever the size actually changes.
|
||||
ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => {
|
||||
if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) {
|
||||
return
|
||||
}
|
||||
|
||||
petOverlayWindow.setBounds({
|
||||
x: Math.round(bounds.x),
|
||||
y: Math.round(bounds.y),
|
||||
width: Math.max(80, Math.round(bounds.width)),
|
||||
height: Math.max(80, Math.round(bounds.height))
|
||||
})
|
||||
const win = petOverlayWindow
|
||||
const width = Math.max(80, Math.round(bounds.width))
|
||||
const height = Math.max(80, Math.round(bounds.height))
|
||||
const [curW, curH] = win.getSize()
|
||||
const resizing = width !== curW || height !== curH
|
||||
|
||||
if (resizing && !win.isResizable()) {
|
||||
win.setResizable(true)
|
||||
}
|
||||
|
||||
win.setBounds({ x: Math.round(bounds.x), y: Math.round(bounds.y), width, height })
|
||||
|
||||
if (resizing) {
|
||||
win.setResizable(false)
|
||||
}
|
||||
})
|
||||
// Click-through: the overlay window is a full rectangle but only the pet pixels
|
||||
// should be interactive. The renderer toggles this as the cursor enters/leaves
|
||||
@@ -6080,6 +6191,10 @@ ipcMain.on('hermes:pet-overlay:control', (_event, payload) => {
|
||||
mainWindow.webContents.send('hermes:pet-overlay:control', payload)
|
||||
})
|
||||
ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||
// Thin client: no bootstrap to reset. Just reload the window.
|
||||
if (isThinClient()) {
|
||||
return { ok: true }
|
||||
}
|
||||
// Renderer's "Reload and retry" path. Clear the latched failure and
|
||||
// reset connection state so the next startHermes() call restarts the
|
||||
// full backend flow (including a fresh runBootstrap pass).
|
||||
@@ -6100,6 +6215,10 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:bootstrap:repair', async () => {
|
||||
// Thin client: no local install to repair.
|
||||
if (isThinClient()) {
|
||||
return { ok: true }
|
||||
}
|
||||
// Forceful repair: drop the bootstrap-complete marker so the next
|
||||
// startHermes() re-runs the full installer (refreshing a broken/partial
|
||||
// venv), and clear any latched failure + live connection. The renderer
|
||||
@@ -6482,11 +6601,21 @@ ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => {
|
||||
|
||||
ipcMain.handle('hermes:saveClipboardImage', async () => {
|
||||
const image = clipboard.readImage()
|
||||
if (!image || image.isEmpty()) {
|
||||
return ''
|
||||
if (image && !image.isEmpty()) {
|
||||
return writeComposerImage(image.toPNG(), '.png')
|
||||
}
|
||||
|
||||
return writeComposerImage(image.toPNG(), '.png')
|
||||
// WSL2/WSLg doesn't bridge clipboard *images* from the Windows host to the
|
||||
// Linux clipboard Electron reads, so a host screenshot looks empty above.
|
||||
// Pull it straight off the Windows clipboard via PowerShell as a fallback.
|
||||
if (IS_WSL) {
|
||||
const png = readWslWindowsClipboardImage()
|
||||
if (png) {
|
||||
return writeComposerImage(png, '.png')
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:normalizePreviewTarget', (_event, target, baseDir) =>
|
||||
@@ -6506,7 +6635,7 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
|
||||
background: payload.background,
|
||||
foreground: payload.foreground
|
||||
}
|
||||
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
|
||||
applyTitleBarOverlay(mainWindow)
|
||||
})
|
||||
|
||||
// Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH).
|
||||
@@ -6882,9 +7011,7 @@ ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => {
|
||||
|
||||
// Git-driven worktree management ("Start work" flow). Errors surface to the
|
||||
// renderer as rejected promises so it can toast a friendly message.
|
||||
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) =>
|
||||
listWorktrees(repoPath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary()))
|
||||
|
||||
ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) =>
|
||||
addWorktree(repoPath, options || {}, resolveGitBinary())
|
||||
@@ -6898,9 +7025,7 @@ ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
|
||||
switchBranch(repoPath, branch, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) =>
|
||||
listBranches(repoPath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
|
||||
|
||||
// Compact repo status (branch, ahead/behind, change counts + files) for the
|
||||
// composer coding rail. Returns null on a non-repo / remote backend so the rail
|
||||
@@ -7111,6 +7236,23 @@ function uninstallVenvPython() {
|
||||
}
|
||||
|
||||
async function getUninstallSummary() {
|
||||
// Thin client: no local agent install to uninstall. Return a minimal
|
||||
// summary so the settings UI can show "nothing to remove" instead of
|
||||
// probing for a venv that doesn't exist.
|
||||
if (isThinClient()) {
|
||||
return {
|
||||
hermes_home: HERMES_HOME,
|
||||
agent_installed: false,
|
||||
gui_installed: true,
|
||||
source_built_artifacts: [],
|
||||
packaged_app_paths: [],
|
||||
userdata_dir: app.getPath('userData'),
|
||||
userdata_exists: true,
|
||||
platform: process.platform,
|
||||
probe: 'thin-client'
|
||||
}
|
||||
}
|
||||
|
||||
const py = uninstallVenvPython()
|
||||
const agentRoot = ACTIVE_HERMES_ROOT
|
||||
// Fast JS-side fallback used when the agent venv is gone (lite client) or the
|
||||
@@ -7283,6 +7425,11 @@ async function runDesktopUninstall(mode) {
|
||||
|
||||
ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary())
|
||||
ipcMain.handle('hermes:uninstall:run', async (_event, payload) => {
|
||||
// Thin client: no local agent to uninstall. The packaged app is managed
|
||||
// by the OS package manager.
|
||||
if (isThinClient()) {
|
||||
return { ok: false, error: 'unsupported', message: 'Uninstall is handled by your package manager in thin client builds.' }
|
||||
}
|
||||
const mode = payload && typeof payload === 'object' ? payload.mode : payload
|
||||
return runDesktopUninstall(String(mode || ''))
|
||||
})
|
||||
@@ -7375,6 +7522,7 @@ function registerDeepLinkProtocol() {
|
||||
// whole new app instead of routing into the running one.
|
||||
const _gotSingleInstanceLock = app.requestSingleInstanceLock()
|
||||
if (!_gotSingleInstanceLock) {
|
||||
console.log("Hermes Desktop is already running, exiting.")
|
||||
app.quit()
|
||||
} else {
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
@@ -7402,6 +7550,7 @@ app.whenReady().then(() => {
|
||||
}
|
||||
installMediaPermissions()
|
||||
registerMediaProtocol()
|
||||
installEmbedReferer()
|
||||
registerDeepLinkProtocol()
|
||||
ensureWslWindowsFonts()
|
||||
configureSpellChecker()
|
||||
|
||||
@@ -30,5 +30,8 @@ test('setJsonRequestHeaders does not set Electron-restricted Content-Length', ()
|
||||
setJsonRequestHeaders(request)
|
||||
|
||||
assert.deepEqual(headers, [['Content-Type', 'application/json']])
|
||||
assert.equal(headers.some(([name]) => name.toLowerCase() === 'content-length'), false)
|
||||
assert.equal(
|
||||
headers.some(([name]) => name.toLowerCase() === 'content-length'),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
11
apps/desktop/electron/titlebar-overlay-width.cjs
Normal file
11
apps/desktop/electron/titlebar-overlay-width.cjs
Normal file
@@ -0,0 +1,11 @@
|
||||
// Pre-layout fallback for WCO right-edge reservation (--titlebar-tools-right).
|
||||
// Live width comes from navigator.windowControlsOverlay in the renderer.
|
||||
|
||||
const OVERLAY_FALLBACK_WIDTH = 144
|
||||
|
||||
/** @param {{ isWindows?: boolean, isWsl?: boolean }} opts */
|
||||
function nativeOverlayWidth({ isWindows = false, isWsl = false } = {}) {
|
||||
return isWindows || isWsl ? OVERLAY_FALLBACK_WIDTH : 0
|
||||
}
|
||||
|
||||
module.exports = { OVERLAY_FALLBACK_WIDTH, nativeOverlayWidth }
|
||||
29
apps/desktop/electron/titlebar-overlay-width.test.cjs
Normal file
29
apps/desktop/electron/titlebar-overlay-width.test.cjs
Normal file
@@ -0,0 +1,29 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { OVERLAY_FALLBACK_WIDTH, nativeOverlayWidth } = require('./titlebar-overlay-width.cjs')
|
||||
|
||||
// This static reservation is only the pre-layout FALLBACK. Once laid out the
|
||||
// renderer reads the exact width from navigator.windowControlsOverlay
|
||||
// (use-window-controls-overlay-width.ts) and uses these values only when the WCO
|
||||
// API is unavailable.
|
||||
|
||||
test('Windows reserves the overlay fallback width', () => {
|
||||
assert.equal(nativeOverlayWidth({ isWindows: true }), OVERLAY_FALLBACK_WIDTH)
|
||||
})
|
||||
|
||||
test('WSLg paints the same WCO, so it reserves the same fallback width', () => {
|
||||
// The original bug: WSL fell through to 0, so the right tools sat under the
|
||||
// controls and the title overran into them.
|
||||
assert.equal(nativeOverlayWidth({ isWsl: true }), OVERLAY_FALLBACK_WIDTH)
|
||||
})
|
||||
|
||||
test('plain Linux and macOS reserve nothing', () => {
|
||||
assert.equal(nativeOverlayWidth({ isWindows: false, isWsl: false }), 0)
|
||||
assert.equal(nativeOverlayWidth(), 0)
|
||||
assert.equal(nativeOverlayWidth({}), 0)
|
||||
})
|
||||
|
||||
test('the fallback width is a sane positive pixel value', () => {
|
||||
assert.ok(Number.isInteger(OVERLAY_FALLBACK_WIDTH) && OVERLAY_FALLBACK_WIDTH > 0)
|
||||
})
|
||||
@@ -7,45 +7,81 @@ const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
|
||||
// unconditionally, so a shallow checkout with no merge-base surfaced the bogus
|
||||
// rev-list count (e.g. 12104). This asserts the new shallow/no-merge-base branch.
|
||||
test('shallow checkout with no merge-base does NOT trust the bogus rev-list count', () => {
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '12104', currentSha: 'aaa', targetSha: 'bbb',
|
||||
isShallow: true, hasMergeBase: false,
|
||||
}), 1)
|
||||
assert.equal(
|
||||
resolveBehindCount({
|
||||
countStr: '12104',
|
||||
currentSha: 'aaa',
|
||||
targetSha: 'bbb',
|
||||
isShallow: true,
|
||||
hasMergeBase: false
|
||||
}),
|
||||
1
|
||||
)
|
||||
})
|
||||
|
||||
test('shallow checkout with no merge-base but identical SHA reports up-to-date', () => {
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '12104', currentSha: 'abc', targetSha: 'abc',
|
||||
isShallow: true, hasMergeBase: false,
|
||||
}), 0)
|
||||
assert.equal(
|
||||
resolveBehindCount({
|
||||
countStr: '12104',
|
||||
currentSha: 'abc',
|
||||
targetSha: 'abc',
|
||||
isShallow: true,
|
||||
hasMergeBase: false
|
||||
}),
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
test('shallow checkout WITH a merge-base keeps the exact count (reliable)', () => {
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '3', currentSha: 'aaa', targetSha: 'bbb',
|
||||
isShallow: true, hasMergeBase: true,
|
||||
}), 3)
|
||||
assert.equal(
|
||||
resolveBehindCount({
|
||||
countStr: '3',
|
||||
currentSha: 'aaa',
|
||||
targetSha: 'bbb',
|
||||
isShallow: true,
|
||||
hasMergeBase: true
|
||||
}),
|
||||
3
|
||||
)
|
||||
})
|
||||
|
||||
test('full (non-shallow) clone keeps the exact count path unchanged', () => {
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '7', currentSha: 'aaa', targetSha: 'bbb',
|
||||
isShallow: false, hasMergeBase: true,
|
||||
}), 7)
|
||||
assert.equal(
|
||||
resolveBehindCount({
|
||||
countStr: '7',
|
||||
currentSha: 'aaa',
|
||||
targetSha: 'bbb',
|
||||
isShallow: false,
|
||||
hasMergeBase: true
|
||||
}),
|
||||
7
|
||||
)
|
||||
})
|
||||
|
||||
test('up-to-date full clone reports 0', () => {
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '0', currentSha: 'x', targetSha: 'x',
|
||||
isShallow: false, hasMergeBase: true,
|
||||
}), 0)
|
||||
assert.equal(
|
||||
resolveBehindCount({
|
||||
countStr: '0',
|
||||
currentSha: 'x',
|
||||
targetSha: 'x',
|
||||
isShallow: false,
|
||||
hasMergeBase: true
|
||||
}),
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
test('non-numeric count falls back to 0 (defensive, unchanged behaviour)', () => {
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '', currentSha: 'aaa', targetSha: 'bbb',
|
||||
isShallow: false, hasMergeBase: true,
|
||||
}), 0)
|
||||
assert.equal(
|
||||
resolveBehindCount({
|
||||
countStr: '',
|
||||
currentSha: 'aaa',
|
||||
targetSha: 'bbb',
|
||||
isShallow: false,
|
||||
hasMergeBase: true
|
||||
}),
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
// shouldCountCommits gates the expensive `rev-list --count` in checkUpdates().
|
||||
@@ -68,12 +104,24 @@ test('full (non-shallow) clone always runs the count', () => {
|
||||
// The skip path produces an empty countStr; resolveBehindCount must NOT trust
|
||||
// it and must fall through to the SHA compare (mirrors the live call site).
|
||||
test('skipped-count path resolves via SHA compare, never via empty countStr', () => {
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '', currentSha: 'aaa', targetSha: 'bbb',
|
||||
isShallow: true, hasMergeBase: false,
|
||||
}), 1)
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '', currentSha: 'same', targetSha: 'same',
|
||||
isShallow: true, hasMergeBase: false,
|
||||
}), 0)
|
||||
assert.equal(
|
||||
resolveBehindCount({
|
||||
countStr: '',
|
||||
currentSha: 'aaa',
|
||||
targetSha: 'bbb',
|
||||
isShallow: true,
|
||||
hasMergeBase: false
|
||||
}),
|
||||
1
|
||||
)
|
||||
assert.equal(
|
||||
resolveBehindCount({
|
||||
countStr: '',
|
||||
currentSha: 'same',
|
||||
targetSha: 'same',
|
||||
isShallow: true,
|
||||
hasMergeBase: false
|
||||
}),
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
@@ -62,7 +62,10 @@ test('resolveUnpackedRelease is null for AppImage / .deb / .rpm / dev / unresolv
|
||||
assert.equal(resolveUnpackedRelease('/usr/lib/hermes/hermes', ROOT, 'linux'), null)
|
||||
assert.equal(resolveUnpackedRelease('/opt/Hermes/hermes', ROOT, 'linux'), null)
|
||||
// dev electron
|
||||
assert.equal(resolveUnpackedRelease('/home/u/.hermes/hermes-agent/node_modules/electron/dist/electron', ROOT, 'linux'), null)
|
||||
assert.equal(
|
||||
resolveUnpackedRelease('/home/u/.hermes/hermes-agent/node_modules/electron/dist/electron', ROOT, 'linux'),
|
||||
null
|
||||
)
|
||||
// empty / missing
|
||||
assert.equal(resolveUnpackedRelease('', ROOT, 'linux'), null)
|
||||
assert.equal(resolveUnpackedRelease(path.join(UNPACKED, 'hermes'), '', 'linux'), null)
|
||||
|
||||
@@ -39,7 +39,9 @@ function canonicalGitHubRemote(url) {
|
||||
}
|
||||
|
||||
function isSshRemote(url) {
|
||||
const value = String(url || '').trim().toLowerCase()
|
||||
const value = String(url || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return value.startsWith('git@') || value.startsWith('ssh://')
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,11 @@ const REQUEST_TIMEOUT_MS = 20_000
|
||||
const ID_RE = /^[\w-]+\.[\w-]+$/
|
||||
|
||||
/** Minimal HTTPS helper with redirect-following, timeout, and a size cap. */
|
||||
function request(url, { method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {}, redirectsLeft = MAX_REDIRECTS) {
|
||||
function request(
|
||||
url,
|
||||
{ method = 'GET', headers = {}, body = null, maxBytes = MAX_VSIX_BYTES } = {},
|
||||
redirectsLeft = MAX_REDIRECTS
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(url, { method, headers }, res => {
|
||||
const status = res.statusCode ?? 0
|
||||
@@ -42,7 +46,13 @@ function request(url, { method = 'GET', headers = {}, body = null, maxBytes = MA
|
||||
const next = new URL(res.headers.location, url).toString()
|
||||
res.resume()
|
||||
// Redirects to the CDN are plain GETs (drop the POST body).
|
||||
resolve(request(next, { method: 'GET', headers: { 'User-Agent': headers['User-Agent'] }, maxBytes }, redirectsLeft - 1))
|
||||
resolve(
|
||||
request(
|
||||
next,
|
||||
{ method: 'GET', headers: { 'User-Agent': headers['User-Agent'] }, maxBytes },
|
||||
redirectsLeft - 1
|
||||
)
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -26,7 +26,16 @@ const LAPTOP = [{ workArea: { x: 0, y: 0, width: 1366, height: 728 } }]
|
||||
// ─── sanitizeWindowState ───────────────────────────────────────────────────
|
||||
|
||||
test('sanitizeWindowState rejects missing/garbage input', () => {
|
||||
for (const bad of [null, undefined, 'nope', 42, {}, { width: 'x', height: 800 }, { width: NaN, height: 800 }, { width: 1000 }]) {
|
||||
for (const bad of [
|
||||
null,
|
||||
undefined,
|
||||
'nope',
|
||||
42,
|
||||
{},
|
||||
{ width: 'x', height: 800 },
|
||||
{ width: NaN, height: 800 },
|
||||
{ width: 1000 }
|
||||
]) {
|
||||
assert.equal(sanitizeWindowState(bad), null)
|
||||
}
|
||||
})
|
||||
@@ -112,9 +121,13 @@ test('computeWindowOptions does not clamp when displays are unknown', () => {
|
||||
test('debounce coalesces a burst into one trailing run', t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
let calls = 0
|
||||
const d = debounce(() => { calls += 1 }, 250)
|
||||
const d = debounce(() => {
|
||||
calls += 1
|
||||
}, 250)
|
||||
|
||||
d(); d(); d()
|
||||
d()
|
||||
d()
|
||||
d()
|
||||
assert.equal(calls, 0)
|
||||
t.mock.timers.tick(249)
|
||||
assert.equal(calls, 0)
|
||||
@@ -125,7 +138,9 @@ test('debounce coalesces a burst into one trailing run', t => {
|
||||
test('debounce.flush runs now and cancels the pending timer', t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
let calls = 0
|
||||
const d = debounce(() => { calls += 1 }, 250)
|
||||
const d = debounce(() => {
|
||||
calls += 1
|
||||
}, 250)
|
||||
|
||||
d()
|
||||
d.flush()
|
||||
|
||||
@@ -13,7 +13,7 @@ function readElectronFile(name) {
|
||||
|
||||
function requireHiddenChildOptions(source, needle) {
|
||||
const match = needle instanceof RegExp ? needle.exec(source) : null
|
||||
const index = needle instanceof RegExp ? match?.index ?? -1 : source.indexOf(needle)
|
||||
const index = needle instanceof RegExp ? (match?.index ?? -1) : source.indexOf(needle)
|
||||
assert.notEqual(index, -1, `missing call site: ${needle}`)
|
||||
const snippet = source.slice(index, index + 700)
|
||||
assert.match(
|
||||
|
||||
@@ -21,8 +21,7 @@ const { execFileSync } = require('node:child_process')
|
||||
// the requested value line isn't present.
|
||||
function parseRegQueryValue(stdout, name) {
|
||||
if (!stdout || !name) return null
|
||||
const typePattern =
|
||||
/^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/
|
||||
const typePattern = /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/
|
||||
for (const rawLine of String(stdout).split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
const match = line.match(typePattern)
|
||||
@@ -47,10 +46,7 @@ function expandWindowsEnvRefs(value, env = process.env) {
|
||||
// Read a User-scoped env var from HKCU\Environment. Windows-only: returns null
|
||||
// off-Windows (without spawning), on any spawn error, when `reg` exits non-zero
|
||||
// (the value doesn't exist), or when the value is empty.
|
||||
function readWindowsUserEnvVar(
|
||||
name,
|
||||
{ platform = process.platform, env = process.env, exec = execFileSync } = {}
|
||||
) {
|
||||
function readWindowsUserEnvVar(name, { platform = process.platform, env = process.env, exec = execFileSync } = {}) {
|
||||
if (platform !== 'win32' || !name) return null
|
||||
let stdout
|
||||
try {
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { test } = require('node:test')
|
||||
|
||||
const {
|
||||
expandWindowsEnvRefs,
|
||||
parseRegQueryValue,
|
||||
readWindowsUserEnvVar
|
||||
} = require('./windows-user-env.cjs')
|
||||
const { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } = require('./windows-user-env.cjs')
|
||||
|
||||
// ── parseRegQueryValue ─────────────────────────────────────────────────────
|
||||
|
||||
test('parseRegQueryValue extracts a REG_SZ value', () => {
|
||||
const out = [
|
||||
'',
|
||||
'HKEY_CURRENT_USER\\Environment',
|
||||
' HERMES_HOME REG_SZ F:\\Hermes\\data',
|
||||
''
|
||||
].join('\r\n')
|
||||
const out = ['', 'HKEY_CURRENT_USER\\Environment', ' HERMES_HOME REG_SZ F:\\Hermes\\data', ''].join('\r\n')
|
||||
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'F:\\Hermes\\data')
|
||||
})
|
||||
|
||||
@@ -39,10 +30,7 @@ test('parseRegQueryValue returns null when the value line is absent', () => {
|
||||
// ── expandWindowsEnvRefs ───────────────────────────────────────────────────
|
||||
|
||||
test('expandWindowsEnvRefs expands %VAR% case-insensitively', () => {
|
||||
assert.equal(
|
||||
expandWindowsEnvRefs('%UserProfile%\\h', { USERPROFILE: 'C:\\Users\\jeff' }),
|
||||
'C:\\Users\\jeff\\h'
|
||||
)
|
||||
assert.equal(expandWindowsEnvRefs('%UserProfile%\\h', { USERPROFILE: 'C:\\Users\\jeff' }), 'C:\\Users\\jeff\\h')
|
||||
})
|
||||
|
||||
test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () => {
|
||||
|
||||
@@ -14,11 +14,7 @@ function isPackagedInstallPath(dir, { installRoots, isPackaged }) {
|
||||
return false
|
||||
}
|
||||
|
||||
const roots = new Set(
|
||||
(installRoots ?? [])
|
||||
.filter(Boolean)
|
||||
.map(candidate => path.resolve(String(candidate)))
|
||||
)
|
||||
const roots = new Set((installRoots ?? []).filter(Boolean).map(candidate => path.resolve(String(candidate))))
|
||||
|
||||
for (const root of roots) {
|
||||
if (resolved === root) {
|
||||
|
||||
@@ -13,33 +13,21 @@ const { isPackagedInstallPath } = require('./workspace-cwd.cjs')
|
||||
const installRoot = path.resolve('/opt/Hermes')
|
||||
|
||||
test('isPackagedInstallPath returns false when not packaged', () => {
|
||||
assert.equal(
|
||||
isPackagedInstallPath(installRoot, { isPackaged: false, installRoots: [installRoot] }),
|
||||
false
|
||||
)
|
||||
assert.equal(isPackagedInstallPath(installRoot, { isPackaged: false, installRoots: [installRoot] }), false)
|
||||
})
|
||||
|
||||
test('isPackagedInstallPath flags the install root itself', () => {
|
||||
assert.equal(
|
||||
isPackagedInstallPath(installRoot, { isPackaged: true, installRoots: [installRoot] }),
|
||||
true
|
||||
)
|
||||
assert.equal(isPackagedInstallPath(installRoot, { isPackaged: true, installRoots: [installRoot] }), true)
|
||||
})
|
||||
|
||||
test('isPackagedInstallPath flags paths nested under the install root', () => {
|
||||
const nested = path.join(installRoot, 'resources', 'app.asar')
|
||||
|
||||
assert.equal(
|
||||
isPackagedInstallPath(nested, { isPackaged: true, installRoots: [installRoot] }),
|
||||
true
|
||||
)
|
||||
assert.equal(isPackagedInstallPath(nested, { isPackaged: true, installRoots: [installRoot] }), true)
|
||||
})
|
||||
|
||||
test('isPackagedInstallPath ignores paths outside the install root', () => {
|
||||
const homeProject = path.resolve('/home/user/projects/demo')
|
||||
|
||||
assert.equal(
|
||||
isPackagedInstallPath(homeProject, { isPackaged: true, installRoots: [installRoot] }),
|
||||
false
|
||||
)
|
||||
assert.equal(isPackagedInstallPath(homeProject, { isPackaged: true, installRoots: [installRoot] }), false)
|
||||
})
|
||||
|
||||
92
apps/desktop/electron/wsl-clipboard-image.cjs
Normal file
92
apps/desktop/electron/wsl-clipboard-image.cjs
Normal file
@@ -0,0 +1,92 @@
|
||||
// Pull a Windows-host clipboard image from inside WSL2 via PowerShell (WSLg
|
||||
// bridges text but not images). Returns PNG bytes or null; exec injectable.
|
||||
|
||||
const { execFileSync } = require('node:child_process')
|
||||
|
||||
// STA is mandatory: System.Windows.Forms.Clipboard throws ThreadStateException
|
||||
// off a single-threaded apartment. We emit base64 (not raw bytes) so the PNG
|
||||
// survives stdout's text decoding intact, and write with [Console]::Out.Write
|
||||
// to avoid a trailing newline.
|
||||
const PS_SCRIPT = [
|
||||
'Add-Type -AssemblyName System.Windows.Forms,System.Drawing',
|
||||
'$img = [System.Windows.Forms.Clipboard]::GetImage()',
|
||||
'if ($null -eq $img) { exit 0 }',
|
||||
'$ms = New-Object System.IO.MemoryStream',
|
||||
'$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)',
|
||||
'[Console]::Out.Write([System.Convert]::ToBase64String($ms.ToArray()))'
|
||||
].join('\n')
|
||||
|
||||
// PowerShell's -EncodedCommand takes UTF-16LE base64. Encoding the whole script
|
||||
// this way sidesteps every layer of WSL→Windows quoting (spaces, quotes,
|
||||
// brackets, newlines) that plain -Command arguments would mangle.
|
||||
function encodePowerShellCommand(script) {
|
||||
return Buffer.from(String(script), 'utf16le').toString('base64')
|
||||
}
|
||||
|
||||
// Locate powershell.exe. The bare name resolves through WSL's Windows-interop
|
||||
// PATH on every standard WSL2 setup; the absolute fallback covers a stripped
|
||||
// PATH. Returns the first candidate — execFile surfaces ENOENT if it's wrong
|
||||
// and we fall back to null.
|
||||
function powershellCandidates() {
|
||||
return ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']
|
||||
}
|
||||
|
||||
function decodeClipboardImageBase64(stdout) {
|
||||
const b64 = String(stdout || '').trim()
|
||||
if (!b64) return null
|
||||
|
||||
let buffer
|
||||
try {
|
||||
buffer = Buffer.from(b64, 'base64')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
// Guard against partial / garbage output: require a real PNG signature.
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
if (buffer.length < PNG_SIGNATURE.length || !buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
// Read the Windows clipboard image from inside WSL. Returns a PNG Buffer, or
|
||||
// null when there's no image, PowerShell is unreachable, or output is invalid.
|
||||
// Linux-only by contract (caller gates on IS_WSL); never throws.
|
||||
function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powershellCandidates() } = {}) {
|
||||
const encoded = encodePowerShellCommand(PS_SCRIPT)
|
||||
|
||||
for (const ps of candidates) {
|
||||
try {
|
||||
const stdout = exec(
|
||||
ps,
|
||||
['-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
timeout: 8000,
|
||||
// A 4K screenshot base64s to a few MB; give stdout generous headroom.
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
// PowerShell writes progress/CLIXML noise to stderr — ignore it.
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
}
|
||||
)
|
||||
const decoded = decodeClipboardImageBase64(stdout)
|
||||
if (decoded) return decoded
|
||||
// Empty stdout = no image on the clipboard; stop, don't try fallbacks.
|
||||
if (String(stdout || '').trim() === '') return null
|
||||
} catch {
|
||||
// This powershell.exe candidate is missing/failed — try the next one.
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
decodeClipboardImageBase64,
|
||||
encodePowerShellCommand,
|
||||
powershellCandidates,
|
||||
readWslWindowsClipboardImage
|
||||
}
|
||||
114
apps/desktop/electron/wsl-clipboard-image.test.cjs
Normal file
114
apps/desktop/electron/wsl-clipboard-image.test.cjs
Normal file
@@ -0,0 +1,114 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const {
|
||||
decodeClipboardImageBase64,
|
||||
encodePowerShellCommand,
|
||||
powershellCandidates,
|
||||
readWslWindowsClipboardImage
|
||||
} = require('./wsl-clipboard-image.cjs')
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
|
||||
function fakePngBuffer(extraBytes = 16) {
|
||||
return Buffer.concat([PNG_SIGNATURE, Buffer.alloc(extraBytes, 0x42)])
|
||||
}
|
||||
|
||||
test('encodePowerShellCommand produces UTF-16LE base64 PowerShell can decode', () => {
|
||||
const encoded = encodePowerShellCommand('Write-Output "hi"')
|
||||
const roundTripped = Buffer.from(encoded, 'base64').toString('utf16le')
|
||||
assert.equal(roundTripped, 'Write-Output "hi"')
|
||||
})
|
||||
|
||||
test('decodeClipboardImageBase64 returns a Buffer for valid PNG base64', () => {
|
||||
const png = fakePngBuffer()
|
||||
const decoded = decodeClipboardImageBase64(png.toString('base64'))
|
||||
assert.ok(Buffer.isBuffer(decoded))
|
||||
assert.ok(decoded.equals(png))
|
||||
})
|
||||
|
||||
test('decodeClipboardImageBase64 trims surrounding whitespace before decoding', () => {
|
||||
const png = fakePngBuffer()
|
||||
const decoded = decodeClipboardImageBase64(`\n ${png.toString('base64')} \r\n`)
|
||||
assert.ok(decoded && decoded.equals(png))
|
||||
})
|
||||
|
||||
test('decodeClipboardImageBase64 returns null for empty / whitespace input', () => {
|
||||
assert.equal(decodeClipboardImageBase64(''), null)
|
||||
assert.equal(decodeClipboardImageBase64(' \n '), null)
|
||||
assert.equal(decodeClipboardImageBase64(null), null)
|
||||
assert.equal(decodeClipboardImageBase64(undefined), null)
|
||||
})
|
||||
|
||||
test('decodeClipboardImageBase64 rejects base64 without a PNG signature', () => {
|
||||
// Valid base64, but the decoded bytes are not a PNG.
|
||||
const notPng = Buffer.from('this is not a png at all').toString('base64')
|
||||
assert.equal(decodeClipboardImageBase64(notPng), null)
|
||||
})
|
||||
|
||||
test('readWslWindowsClipboardImage decodes the first candidate that returns a PNG', () => {
|
||||
const png = fakePngBuffer()
|
||||
const calls = []
|
||||
const exec = (cmd, args) => {
|
||||
calls.push({ cmd, args })
|
||||
return png.toString('base64')
|
||||
}
|
||||
|
||||
const result = readWslWindowsClipboardImage({ exec, candidates: ['powershell.exe'] })
|
||||
assert.ok(result && result.equals(png))
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].cmd, 'powershell.exe')
|
||||
// -STA is mandatory for System.Windows.Forms.Clipboard.
|
||||
assert.ok(calls[0].args.includes('-STA'))
|
||||
assert.ok(calls[0].args.includes('-EncodedCommand'))
|
||||
})
|
||||
|
||||
test('readWslWindowsClipboardImage returns null and stops when stdout is empty (no image)', () => {
|
||||
let count = 0
|
||||
const exec = () => {
|
||||
count += 1
|
||||
return ''
|
||||
}
|
||||
|
||||
const result = readWslWindowsClipboardImage({
|
||||
exec,
|
||||
candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']
|
||||
})
|
||||
assert.equal(result, null)
|
||||
// Empty stdout means "no image on the clipboard" — don't probe further candidates.
|
||||
assert.equal(count, 1)
|
||||
})
|
||||
|
||||
test('readWslWindowsClipboardImage falls through to the next candidate when one throws', () => {
|
||||
const png = fakePngBuffer()
|
||||
const seen = []
|
||||
const exec = cmd => {
|
||||
seen.push(cmd)
|
||||
if (cmd === 'powershell.exe') {
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' })
|
||||
}
|
||||
return png.toString('base64')
|
||||
}
|
||||
|
||||
const result = readWslWindowsClipboardImage({
|
||||
exec,
|
||||
candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']
|
||||
})
|
||||
assert.ok(result && result.equals(png))
|
||||
assert.deepEqual(seen, ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'])
|
||||
})
|
||||
|
||||
test('readWslWindowsClipboardImage returns null when every candidate throws', () => {
|
||||
const exec = () => {
|
||||
throw new Error('boom')
|
||||
}
|
||||
|
||||
const result = readWslWindowsClipboardImage({ exec, candidates: ['a', 'b'] })
|
||||
assert.equal(result, null)
|
||||
})
|
||||
|
||||
test('powershellCandidates lists the bare name first, then the absolute fallback', () => {
|
||||
const candidates = powershellCandidates()
|
||||
assert.equal(candidates[0], 'powershell.exe')
|
||||
assert.ok(candidates.some(c => c.endsWith('WindowsPowerShell/v1.0/powershell.exe')))
|
||||
})
|
||||
@@ -18,11 +18,13 @@
|
||||
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
|
||||
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && 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 .",
|
||||
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/bundle-electron-main.mjs && npm run postbuild",
|
||||
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/bundle-electron-main.mjs && npm run postbuild",
|
||||
"build:thin": "cross-env HERMES_DESKTOP_BUILD_MODE=thin npm run build",
|
||||
"postbuild": "node scripts/assert-dist-built.cjs",
|
||||
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
|
||||
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs",
|
||||
"pack": "npm run build && npm run builder -- --dir",
|
||||
"pack:thin": "cross-env HERMES_DESKTOP_BUILD_MODE=thin npm run pack",
|
||||
"dist": "npm run build && npm run builder",
|
||||
"dist:mac": "npm run build && npm run builder -- --mac",
|
||||
"dist:mac:dmg": "npm run build && npm run builder -- --mac dmg",
|
||||
@@ -31,13 +33,15 @@
|
||||
"dist:win:msi": "npm run build && npm run builder -- --win msi",
|
||||
"dist:win:nsis": "npm run build && npm run builder -- --win nsis",
|
||||
"dist:linux": "npm run build && npm run builder -- --linux AppImage deb rpm",
|
||||
"dist:thin": "cross-env HERMES_DESKTOP_BUILD_MODE=thin npm run dist",
|
||||
"dist:linux:thin": "cross-env HERMES_DESKTOP_BUILD_MODE=thin npm run dist:linux",
|
||||
"test:desktop": "node scripts/test-desktop.mjs",
|
||||
"test:desktop:all": "node scripts/test-desktop.mjs all",
|
||||
"test:desktop:dmg": "node scripts/test-desktop.mjs dmg",
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/window-state.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/build-mode.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
@@ -51,11 +55,17 @@
|
||||
"@assistant-ui/react-streamdown": "^0.1.11",
|
||||
"@audiowave/react": "^0.6.2",
|
||||
"@chenglou/pretext": "^0.0.6",
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/view": "^6.43.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hermes/shared": "file:../shared",
|
||||
"@icons-pack/react-simple-icons": "=13.11.1",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@nanostores/react": "^1.1.0",
|
||||
"@nous-research/ui": "^0.13.0",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
@@ -75,11 +85,13 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dnd-core": "^14.0.1",
|
||||
"dompurify": "^3.4.11",
|
||||
"hast-util-from-html-isomorphic": "^2.0.0",
|
||||
"hast-util-to-text": "^4.0.2",
|
||||
"ignore": "^7.0.5",
|
||||
"katex": "^0.16.45",
|
||||
"leva": "^0.10.1",
|
||||
"mermaid": "^11.15.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanostores": "^1.3.0",
|
||||
"node-pty": "1.1.0",
|
||||
|
||||
@@ -2,10 +2,32 @@
|
||||
* Desktop bundles ship precompiled renderer assets. Returning false here tells
|
||||
* electron-builder to skip the node_modules collector/install step, which
|
||||
* avoids workspace dependency graph explosions and keeps packaging
|
||||
* deterministic across environments. The Hermes Agent Python payload is no
|
||||
* longer bundled; the Electron app fetches it at first launch via
|
||||
* `install.ps1`'s stage protocol (Windows). See `electron/main.cjs`.
|
||||
* deterministic across environments.
|
||||
*
|
||||
* In thin-client builds we also strip the install-stamp and native-deps
|
||||
* extraResources entries — no bootstrap, no local PTY.
|
||||
*/
|
||||
module.exports = async function beforeBuild() {
|
||||
const path = require('node:path')
|
||||
|
||||
const THIN_CLIENT = process.env.HERMES_DESKTOP_BUILD_MODE === 'thin'
|
||||
|
||||
module.exports = async function beforeBuild(context) {
|
||||
if (THIN_CLIENT && context.packager) {
|
||||
// Strip install-stamp.json and native-deps from extraResources — neither
|
||||
// exists in a thin build (write-build-stamp and stage-native-deps are
|
||||
// skipped in build:thin).
|
||||
const buildConfig = context.packager.config
|
||||
if (Array.isArray(buildConfig.extraResources)) {
|
||||
buildConfig.extraResources = buildConfig.extraResources.filter(
|
||||
entry => {
|
||||
const to = typeof entry === 'object' && entry ? entry.to : null
|
||||
if (to === 'install-stamp.json' || to === 'native-deps') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ const root = resolve(here, '..')
|
||||
const entry = resolve(root, 'electron/main.cjs')
|
||||
const tmp = resolve(root, 'electron/main.bundled.cjs')
|
||||
|
||||
// Thin-client builds strip bootstrap, local backend, and self-update.
|
||||
// Bake the flag into the bundle so build-mode.cjs's process.env read resolves
|
||||
// to a string literal at runtime (no env var needed in the packaged app).
|
||||
const thinClient = process.env.HERMES_DESKTOP_BUILD_MODE === 'thin'
|
||||
|
||||
await build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
@@ -24,6 +29,9 @@ await build({
|
||||
target: 'node20',
|
||||
outfile: tmp,
|
||||
external: ['electron', 'node-pty'],
|
||||
define: {
|
||||
'process.env.HERMES_DESKTOP_BUILD_MODE': JSON.stringify(thinClient ? 'thin' : '')
|
||||
},
|
||||
logLevel: 'info'
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use strict"
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* Writes apps/desktop/build/install-stamp.json with the git ref the desktop
|
||||
@@ -17,29 +17,30 @@
|
||||
* }
|
||||
*
|
||||
* Source preference order:
|
||||
* 1. CI env vars ($GITHUB_SHA / $GITHUB_REF_NAME) -- avoid edge cases with
|
||||
* 1. BUILD_STAMP env var (json)
|
||||
* 2. CI env vars ($GITHUB_SHA / $GITHUB_REF_NAME) -- avoid edge cases with
|
||||
* shallow clones, detached HEADs, etc. in CI.
|
||||
* 2. Local `git rev-parse` against the parent repo (../..).
|
||||
* 3. Local `git rev-parse` against the parent repo (../..).
|
||||
*
|
||||
* Dev / out-of-repo builds without git produce an explicit error rather than
|
||||
* silently writing an unstamped manifest -- the packaged app refuses to
|
||||
* bootstrap without a stamp.
|
||||
*/
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { execSync } = require("child_process")
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { execSync } = require('child_process')
|
||||
|
||||
const STAMP_SCHEMA_VERSION = 1
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(__dirname, "..")
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, "..", "..")
|
||||
const OUT_DIR = path.join(DESKTOP_ROOT, "build")
|
||||
const OUT_FILE = path.join(OUT_DIR, "install-stamp.json")
|
||||
const DESKTOP_ROOT = path.resolve(__dirname, '..')
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
|
||||
const OUT_DIR = path.join(DESKTOP_ROOT, 'build')
|
||||
const OUT_FILE = path.join(OUT_DIR, 'install-stamp.json')
|
||||
|
||||
function tryExec(cmd, opts) {
|
||||
try {
|
||||
return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], ...opts }).trim()
|
||||
return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], ...opts }).trim()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -53,52 +54,79 @@ function fromCI() {
|
||||
commit: sha,
|
||||
branch: branch,
|
||||
dirty: false, // CI builds from a checkout-of-ref by definition
|
||||
source: "ci"
|
||||
source: 'ci'
|
||||
}
|
||||
}
|
||||
|
||||
function fromLocalGit() {
|
||||
const sha = tryExec("git rev-parse HEAD", { cwd: REPO_ROOT })
|
||||
const sha = tryExec('git rev-parse HEAD', { cwd: REPO_ROOT })
|
||||
if (!sha) return null
|
||||
const branch = tryExec("git rev-parse --abbrev-ref HEAD", { cwd: REPO_ROOT })
|
||||
const branch = tryExec('git rev-parse --abbrev-ref HEAD', { cwd: REPO_ROOT })
|
||||
// `git status --porcelain -uno` is empty iff tracked files match HEAD.
|
||||
// We exclude untracked files (-uno) intentionally: a developer who's
|
||||
// checked out an installer scratch dir alongside the repo shouldn't
|
||||
// poison every local build with a [DIRTY] stamp. We DO care about
|
||||
// tracked-but-modified files because those mean the .exe content
|
||||
// differs from the commit being pinned.
|
||||
const status = tryExec("git status --porcelain -uno", { cwd: REPO_ROOT })
|
||||
const status = tryExec('git status --porcelain -uno', { cwd: REPO_ROOT })
|
||||
const dirty = status !== null && status.length > 0
|
||||
return {
|
||||
commit: sha,
|
||||
branch: branch === "HEAD" ? null : branch, // detached HEAD -> null
|
||||
branch: branch === 'HEAD' ? null : branch, // detached HEAD -> null
|
||||
dirty: dirty,
|
||||
source: "local"
|
||||
source: 'local'
|
||||
}
|
||||
}
|
||||
|
||||
function fromEnv() {
|
||||
const stamp = process.env.BUILD_STAMP
|
||||
if (!stamp) return null
|
||||
|
||||
const json = JSON.parse(stamp)
|
||||
if (json.schemaVersion !== 1) {
|
||||
throw new Error('Schema version !== 1')
|
||||
}
|
||||
if (typeof json.branch !== 'string') {
|
||||
throw new Error('Expected branch to be string')
|
||||
}
|
||||
if (typeof json.commit !== 'string') {
|
||||
throw new Error('Expected commit to be string')
|
||||
}
|
||||
if (typeof json.builtAt !== 'string') {
|
||||
throw new Error('Expected builtAt to be string')
|
||||
}
|
||||
if (typeof json.dirty !== 'boolean') {
|
||||
throw new Error('Expected dirty to be boolean')
|
||||
}
|
||||
if (typeof json.source !== 'string') {
|
||||
throw new Error('Expected source to be string')
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
function main() {
|
||||
const stamp = fromCI() || fromLocalGit()
|
||||
const stamp = fromEnv() || fromCI() || fromLocalGit()
|
||||
if (!stamp || !stamp.commit) {
|
||||
console.error(
|
||||
"[write-build-stamp] ERROR: could not determine git commit.\n" +
|
||||
" - $GITHUB_SHA not set\n" +
|
||||
" - `git rev-parse HEAD` failed at " +
|
||||
'[write-build-stamp] ERROR: could not determine git commit.\n' +
|
||||
' - $GITHUB_SHA not set\n' +
|
||||
' - `git rev-parse HEAD` failed at ' +
|
||||
REPO_ROOT +
|
||||
"\n" +
|
||||
"Packaged builds require a git ref to pin first-launch install.ps1\n" +
|
||||
"against. Run from a git checkout or set $GITHUB_SHA explicitly."
|
||||
'\n' +
|
||||
'Packaged builds require a git ref to pin first-launch install.ps1\n' +
|
||||
'against. Run from a git checkout or set $GITHUB_SHA explicitly.'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (stamp.dirty) {
|
||||
console.warn(
|
||||
"[write-build-stamp] WARNING: working tree is dirty.\n" +
|
||||
" Pinning to " +
|
||||
'[write-build-stamp] WARNING: working tree is dirty.\n' +
|
||||
' Pinning to ' +
|
||||
stamp.commit.slice(0, 12) +
|
||||
" but the packaged code may differ from that commit.\n" +
|
||||
" Commit your changes before publishing this build."
|
||||
' but the packaged code may differ from that commit.\n' +
|
||||
' Commit your changes before publishing this build.'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -112,14 +140,14 @@ function main() {
|
||||
}
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true })
|
||||
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8")
|
||||
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + '\n', 'utf8')
|
||||
console.log(
|
||||
"[write-build-stamp] wrote " +
|
||||
'[write-build-stamp] wrote ' +
|
||||
path.relative(REPO_ROOT, OUT_FILE) +
|
||||
" -> " +
|
||||
' -> ' +
|
||||
stamp.commit.slice(0, 12) +
|
||||
(stamp.branch ? " (" + stamp.branch + ")" : "") +
|
||||
(stamp.dirty ? " [DIRTY]" : "")
|
||||
(stamp.branch ? ' (' + stamp.branch + ')' : '') +
|
||||
(stamp.dirty ? ' [DIRTY]' : '')
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
|
||||
|
||||
@@ -477,17 +477,20 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
}
|
||||
}, [artifacts])
|
||||
|
||||
const openArtifact = useCallback(async (href: string) => {
|
||||
try {
|
||||
if (window.hermesDesktop?.openExternal) {
|
||||
await window.hermesDesktop.openExternal(href)
|
||||
} else {
|
||||
window.open(href, '_blank', 'noopener,noreferrer')
|
||||
const openArtifact = useCallback(
|
||||
async (href: string) => {
|
||||
try {
|
||||
if (window.hermesDesktop?.openExternal) {
|
||||
await window.hermesDesktop.openExternal(href)
|
||||
} else {
|
||||
window.open(href, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, a.openFailed)
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, a.openFailed)
|
||||
}
|
||||
}, [a])
|
||||
},
|
||||
[a]
|
||||
)
|
||||
|
||||
const markImageFailed = useCallback((id: string) => {
|
||||
setFailedImageIds(current => {
|
||||
@@ -839,7 +842,8 @@ const ARTIFACT_COLUMNS: readonly ArtifactColumn[] = [
|
||||
{
|
||||
Cell: PrimaryCell,
|
||||
bodyClassName: 'p-0',
|
||||
header: (filter, a) => (filter === 'link' ? a.colTitleLink : filter === 'file' ? a.colTitleFile : a.colTitleDefault),
|
||||
header: (filter, a) =>
|
||||
filter === 'link' ? a.colTitleLink : filter === 'file' ? a.colTitleFile : a.colTitleDefault,
|
||||
id: 'primary',
|
||||
width: filter => (filter === 'link' ? 'w-[50%]' : 'w-[35%]')
|
||||
},
|
||||
|
||||
@@ -2,9 +2,9 @@ import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n/context'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
|
||||
import { AttachmentList } from './attachments'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
|
||||
function makeAttachment(id: string, label = 'test.pdf'): ComposerAttachment {
|
||||
return { id, kind: 'file', label }
|
||||
@@ -32,7 +32,10 @@ describe('AttachmentList', () => {
|
||||
|
||||
it('renders empty list without error', () => {
|
||||
renderWithI18n(<AttachmentList attachments={[]} />)
|
||||
const container = screen.getByTestId?.('composer-attachments') ?? document.querySelector('[data-slot="composer-attachments"]')
|
||||
|
||||
const container =
|
||||
screen.getByTestId?.('composer-attachments') ?? document.querySelector('[data-slot="composer-attachments"]')
|
||||
|
||||
expect(container).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -55,10 +58,7 @@ describe('AttachmentList', () => {
|
||||
})
|
||||
|
||||
it('does not crash when attachments array contains null entries', () => {
|
||||
const attachments = [
|
||||
null as unknown as ComposerAttachment,
|
||||
makeAttachment('a', 'valid.txt')
|
||||
]
|
||||
const attachments = [null as unknown as ComposerAttachment, makeAttachment('a', 'valid.txt')]
|
||||
|
||||
expect(() => {
|
||||
renderWithI18n(<AttachmentList attachments={attachments} />)
|
||||
|
||||
@@ -73,7 +73,11 @@ export function ContextMenu({
|
||||
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
|
||||
{c.images}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
|
||||
<ContextMenuItem
|
||||
disabled={!onPasteClipboardImage}
|
||||
icon={Clipboard}
|
||||
onSelect={onPasteClipboardImage ? () => void onPasteClipboardImage() : undefined}
|
||||
>
|
||||
{c.pasteImage}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
|
||||
@@ -167,7 +171,7 @@ interface ContextMenuItemProps {
|
||||
interface ContextMenuProps {
|
||||
onInsertText: (text: string) => void
|
||||
onOpenUrlDialog: () => void
|
||||
onPasteClipboardImage?: () => void
|
||||
onPasteClipboardImage?: (opts?: { silent?: boolean }) => Promise<boolean> | void
|
||||
onPickFiles?: () => void
|
||||
onPickFolders?: () => void
|
||||
onPickImages?: () => void
|
||||
|
||||
@@ -59,8 +59,10 @@ function Harness({
|
||||
}
|
||||
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor) {
|
||||
const domText = composerPlainText(editor)
|
||||
|
||||
if (domText !== draftRef.current) {
|
||||
draftRef.current = domText
|
||||
setDraft(domText)
|
||||
@@ -127,9 +129,11 @@ function Harness({
|
||||
describe('composer Enter submit — live DOM vs stale composer state (#39630)', () => {
|
||||
it('sends the just-typed text on Enter even when composer state has not synced', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
|
||||
const { getByTestId } = render(
|
||||
<Harness onCancel={vi.fn()} onDrain={vi.fn()} onQueue={vi.fn()} onSubmit={onSubmit} />
|
||||
)
|
||||
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
// Fast typing: the DOM has the text but NO input event fired, so `draft`
|
||||
@@ -146,9 +150,11 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
|
||||
const onQueue = vi.fn()
|
||||
const onDrain = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
|
||||
const { getByTestId } = render(
|
||||
<Harness busy onCancel={onCancel} onDrain={onDrain} onQueue={onQueue} onSubmit={vi.fn()} queued={['queued-1']} />
|
||||
)
|
||||
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
await act(async () => {
|
||||
@@ -165,9 +171,11 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
|
||||
const onCancel = vi.fn()
|
||||
const onSubmit = vi.fn()
|
||||
const onQueue = vi.fn()
|
||||
|
||||
const { getByTestId } = render(
|
||||
<Harness busy onCancel={onCancel} onDrain={vi.fn()} onQueue={onQueue} onSubmit={onSubmit} />
|
||||
)
|
||||
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
await act(async () => {
|
||||
@@ -183,9 +191,11 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
|
||||
it('drains the next queued prompt on Enter when idle with a truly empty editor', async () => {
|
||||
const onDrain = vi.fn()
|
||||
const onSubmit = vi.fn()
|
||||
|
||||
const { getByTestId } = render(
|
||||
<Harness onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
|
||||
)
|
||||
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
await act(async () => {
|
||||
@@ -200,9 +210,18 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
|
||||
it('keeps reconnect drafts editable but blocks Enter submit until the gateway returns', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const onDrain = vi.fn()
|
||||
|
||||
const { getByTestId } = render(
|
||||
<Harness disabled onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
|
||||
<Harness
|
||||
disabled
|
||||
onCancel={vi.fn()}
|
||||
onDrain={onDrain}
|
||||
onQueue={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
queued={['queued-1']}
|
||||
/>
|
||||
)
|
||||
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -33,7 +33,7 @@ export function HelpHint() {
|
||||
|
||||
<Section title={c.hotkeys}>
|
||||
{COMPOSER_HOTKEY_ROWS.map(row => (
|
||||
<HotkeyRow description={c.hotkeyDescs[row.id] ?? ''} combos={[...row.combos]} key={row.id} />
|
||||
<HotkeyRow combos={[...row.combos]} description={c.hotkeyDescs[row.id] ?? ''} key={row.id} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -59,7 +59,11 @@ function micError(error: unknown, copy: MicRecorderErrorCopy): Error {
|
||||
return new Error(copy.microphoneStartFailed)
|
||||
}
|
||||
|
||||
export function useMicRecorder(copy: MicRecorderErrorCopy): { handle: MicRecorderHandle; level: number; recording: boolean } {
|
||||
export function useMicRecorder(copy: MicRecorderErrorCopy): {
|
||||
handle: MicRecorderHandle
|
||||
level: number
|
||||
recording: boolean
|
||||
} {
|
||||
const [level, setLevel] = useState(0)
|
||||
const [recording, setRecording] = useState(false)
|
||||
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { type PointerEvent as ReactPointerEvent, type RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
POPOUT_ESTIMATED_HEIGHT,
|
||||
POPOUT_WIDTH_REM,
|
||||
readPopoutBounds,
|
||||
setComposerPopoutPosition,
|
||||
type PopoutPosition,
|
||||
type PopoutSize
|
||||
type PopoutSize,
|
||||
readPopoutBounds,
|
||||
setComposerPopoutPosition
|
||||
} from '@/store/composer-popout'
|
||||
|
||||
// Floating surface long-press before it becomes draggable (the 5px platform drags
|
||||
@@ -80,6 +73,7 @@ function dockProximityOf(rect: DOMRect) {
|
||||
const verticalGap = window.innerHeight - DOCK_ZONE_BOTTOM_PX - rect.bottom
|
||||
|
||||
const v = verticalGap <= 0 ? 1 : Math.max(0, 1 - verticalGap / DOCK_VERTICAL_FALLOFF_PX)
|
||||
|
||||
const h =
|
||||
horizontalDist <= DOCK_ZONE_CENTER_TOLERANCE_PX
|
||||
? 1
|
||||
|
||||
@@ -98,12 +98,14 @@ export function useSlashCompletions(options: {
|
||||
|
||||
const matches = (
|
||||
needle
|
||||
? $sessions.get().filter(
|
||||
session =>
|
||||
sessionTitle(session).toLowerCase().includes(needle) ||
|
||||
(session.preview ?? '').toLowerCase().includes(needle) ||
|
||||
session.id.toLowerCase().includes(needle)
|
||||
)
|
||||
? $sessions
|
||||
.get()
|
||||
.filter(
|
||||
session =>
|
||||
sessionTitle(session).toLowerCase().includes(needle) ||
|
||||
(session.preview ?? '').toLowerCase().includes(needle) ||
|
||||
session.id.toLowerCase().includes(needle)
|
||||
)
|
||||
: $sessions.get()
|
||||
).slice(0, SESSION_INLINE_LIMIT)
|
||||
|
||||
@@ -135,9 +137,7 @@ export function useSlashCompletions(options: {
|
||||
// Prefer the categorized layout so the popover renders section headers
|
||||
// (Session, Tools & Skills, ...). Fall back to the flat list when the
|
||||
// backend didn't categorize.
|
||||
const sections = catalog.categories?.length
|
||||
? catalog.categories
|
||||
: [{ name: '', pairs: catalog.pairs ?? [] }]
|
||||
const sections = catalog.categories?.length ? catalog.categories : [{ name: '', pairs: catalog.pairs ?? [] }]
|
||||
|
||||
const items = sections.flatMap(section =>
|
||||
section.pairs.map(([command, meta]) => ({
|
||||
@@ -151,10 +151,9 @@ export function useSlashCompletions(options: {
|
||||
return { items, query }
|
||||
}
|
||||
|
||||
const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>(
|
||||
'complete.slash',
|
||||
{ text }
|
||||
)
|
||||
const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>('complete.slash', {
|
||||
text
|
||||
})
|
||||
|
||||
// Arg-completion items (replace_from > 1) carry just the arg stub —
|
||||
// e.g. complete.slash returns `{text: "alice"}` for `/personality alic`
|
||||
|
||||
@@ -220,22 +220,25 @@ export function useVoiceConversation({
|
||||
}
|
||||
}, [handle, handleTurn, onFatalError, voiceCopy.couldNotStartSession, voiceCopy.microphoneFailed])
|
||||
|
||||
const speak = useCallback(async (text: string) => {
|
||||
setStatus('speaking')
|
||||
const speak = useCallback(
|
||||
async (text: string) => {
|
||||
setStatus('speaking')
|
||||
|
||||
try {
|
||||
await playSpeechText(text, { source: 'voice-conversation' })
|
||||
} catch (error) {
|
||||
notifyError(error, voiceCopy.playbackFailed)
|
||||
} finally {
|
||||
if (enabledRef.current) {
|
||||
pendingStartRef.current = true
|
||||
setStatus('idle')
|
||||
} else {
|
||||
setStatus('idle')
|
||||
try {
|
||||
await playSpeechText(text, { source: 'voice-conversation' })
|
||||
} catch (error) {
|
||||
notifyError(error, voiceCopy.playbackFailed)
|
||||
} finally {
|
||||
if (enabledRef.current) {
|
||||
pendingStartRef.current = true
|
||||
setStatus('idle')
|
||||
} else {
|
||||
setStatus('idle')
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [voiceCopy.playbackFailed])
|
||||
},
|
||||
[voiceCopy.playbackFailed]
|
||||
)
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (!onTranscribeAudio) {
|
||||
@@ -255,7 +258,14 @@ export function useVoiceConversation({
|
||||
consumePendingResponse()
|
||||
pendingStartRef.current = true
|
||||
await startListening()
|
||||
}, [consumePendingResponse, onFatalError, onTranscribeAudio, startListening, voiceCopy.configureSpeechToText, voiceCopy.unavailable])
|
||||
}, [
|
||||
consumePendingResponse,
|
||||
onFatalError,
|
||||
onTranscribeAudio,
|
||||
startListening,
|
||||
voiceCopy.configureSpeechToText,
|
||||
voiceCopy.unavailable
|
||||
])
|
||||
|
||||
const end = useCallback(async () => {
|
||||
pendingStartRef.current = false
|
||||
|
||||
@@ -63,6 +63,7 @@ import { $statusItemsBySession } from '@/store/composer-status'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { $previewStatusBySession } from '@/store/preview-status'
|
||||
import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
|
||||
import { $activeSessionAwaitingInput } from '@/store/prompts'
|
||||
import { toggleReview } from '@/store/review'
|
||||
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
@@ -229,6 +230,11 @@ export function ChatBar({
|
||||
const statusItemsBySession = useStore($statusItemsBySession)
|
||||
const previewStatusBySession = useStore($previewStatusBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
// The turn is parked on the user (clarify / approval / sudo / secret). Esc must
|
||||
// not interrupt it — there's nothing actively running to stop, and stopping
|
||||
// would discard a question the user may want to come back to. The blocking
|
||||
// prompt owns its own dismissal (Skip, Reject, dialog close).
|
||||
const awaitingInput = useStore($activeSessionAwaitingInput)
|
||||
// Pop-out is a shared, persisted state — but secondary windows (the Ctrl+Shift+N
|
||||
// tiny window, subagent watch windows) always start docked and can't pop out:
|
||||
// a floating composer makes no sense in a single-session side window, and it
|
||||
@@ -278,14 +284,17 @@ export function ChatBar({
|
||||
poppedOut ? handleComposerDock() : handleComposerPopOut()
|
||||
}, [handleComposerDock, handleComposerPopOut, poppedOut])
|
||||
|
||||
const { dockProximity, dragging, onPointerDown: onComposerGesturePointerDown } =
|
||||
useComposerPopoutGestures({
|
||||
composerRef,
|
||||
onDock: handleComposerDock,
|
||||
onPopOut: handleComposerPopOut,
|
||||
poppedOut,
|
||||
position: popoutPosition
|
||||
})
|
||||
const {
|
||||
dockProximity,
|
||||
dragging,
|
||||
onPointerDown: onComposerGesturePointerDown
|
||||
} = useComposerPopoutGestures({
|
||||
composerRef,
|
||||
onDock: handleComposerDock,
|
||||
onPopOut: handleComposerPopOut,
|
||||
poppedOut,
|
||||
position: popoutPosition
|
||||
})
|
||||
|
||||
const draftRef = useRef(draft)
|
||||
const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
|
||||
@@ -784,6 +793,16 @@ export function ChatBar({
|
||||
if (!pastedText) {
|
||||
event.preventDefault()
|
||||
|
||||
// Under WSL2/WSLg the Windows host clipboard doesn't bridge *images* to
|
||||
// the Linux clipboard the DOM paste event reads, so a host screenshot
|
||||
// arrives as an empty paste (no blobs, no text). Fall back to the main
|
||||
// process, which pulls the image straight off the Windows clipboard.
|
||||
// Silent so a genuinely-empty paste doesn't pop a "no image" warning.
|
||||
if (onPasteClipboardImage) {
|
||||
triggerHaptic('selection')
|
||||
void onPasteClipboardImage({ silent: true })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -816,8 +835,7 @@ export function ChatBar({
|
||||
// Suppress the "No matches" empty state once a slash command is past its name:
|
||||
// a no-arg command has nothing to offer, and a fully-typed arg commits on
|
||||
// Space/Tab — neither should dead-end on a popover.
|
||||
const argStageEmpty =
|
||||
trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
|
||||
const argStageEmpty = trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
|
||||
|
||||
const closeTrigger = () => {
|
||||
setTrigger(null)
|
||||
@@ -844,7 +862,14 @@ export function ChatBar({
|
||||
id: text,
|
||||
type: 'slash',
|
||||
label: text.slice(1),
|
||||
metadata: { command: slashCommandToken(trigger.query), display: text, meta: '', group: '', action: '', rawText: text }
|
||||
metadata: {
|
||||
command: slashCommandToken(trigger.query),
|
||||
display: text,
|
||||
meta: '',
|
||||
group: '',
|
||||
action: '',
|
||||
rawText: text
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -984,10 +1009,7 @@ export function ChatBar({
|
||||
|
||||
// Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large
|
||||
// drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through.
|
||||
if (
|
||||
(event.key === 'Backspace' || event.key === 'Delete') &&
|
||||
deleteSelectionInEditor(event.currentTarget)
|
||||
) {
|
||||
if ((event.key === 'Backspace' || event.key === 'Delete') && deleteSelectionInEditor(event.currentTarget)) {
|
||||
event.preventDefault()
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
|
||||
@@ -1198,8 +1220,10 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise Esc interrupts the running turn (Stop-button parity).
|
||||
if (busy) {
|
||||
// Otherwise Esc interrupts the running turn (Stop-button parity) — unless
|
||||
// the turn is parked waiting on the user, where Esc must not discard the
|
||||
// pending prompt.
|
||||
if (busy && !awaitingInput) {
|
||||
event.preventDefault()
|
||||
triggerHaptic('cancel')
|
||||
void Promise.resolve(onCancel())
|
||||
@@ -1761,12 +1785,17 @@ export function ChatBar({
|
||||
// open — Esc must close that overlay, never double as canceling the stream
|
||||
// behind it. A latest-handler ref keeps the listener registered once.
|
||||
const escCancelRef = useRef<(event: globalThis.KeyboardEvent) => void>(() => {})
|
||||
|
||||
escCancelRef.current = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented || !busy) {
|
||||
// `awaitingInput`: the turn is parked on a clarify / approval / sudo / secret
|
||||
// prompt, which owns Esc (or is meant to persist) — never cancel the stream
|
||||
// out from under it.
|
||||
if (event.key !== 'Escape' || event.defaultPrevented || !busy || awaitingInput) {
|
||||
return
|
||||
}
|
||||
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
|
||||
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
|
||||
return
|
||||
}
|
||||
@@ -2254,7 +2283,9 @@ export function ChatBar({
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer-surface:opacity-100' : 'opacity-100'
|
||||
scrolledUp
|
||||
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer-surface:opacity-100'
|
||||
: 'opacity-100'
|
||||
)}
|
||||
data-slot="composer-fade"
|
||||
>
|
||||
|
||||
@@ -3,12 +3,7 @@ import { contextPath } from '@/lib/chat-runtime'
|
||||
|
||||
import type { DroppedFile } from '../hooks/use-composer-actions'
|
||||
|
||||
import {
|
||||
composerPlainText,
|
||||
normalizeComposerEditorDom,
|
||||
placeCaretEnd,
|
||||
refChipElement
|
||||
} from './rich-editor'
|
||||
import { composerPlainText, normalizeComposerEditorDom, placeCaretEnd, refChipElement } from './rich-editor'
|
||||
|
||||
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
|
||||
export type InlineRefInput = string | { kind: string; label?: string; value: string }
|
||||
@@ -159,6 +154,7 @@ export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonl
|
||||
editor.focus({ preventScroll: true })
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
const range =
|
||||
selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer)
|
||||
? selection.getRangeAt(0)
|
||||
|
||||
@@ -94,13 +94,7 @@ export function ModelPill({
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<Tip label={title} side="top">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={title}
|
||||
className={pillClass}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Button aria-label={title} className={pillClass} disabled={disabled} type="button" variant="ghost">
|
||||
{label}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -4,14 +4,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { StatusRow } from '@/components/chat/status-row'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/command'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -240,7 +233,8 @@ export const CodingStatusRow = memo(function CodingStatusRow({
|
||||
branchTargets.push({ base: undefined, label: s.newBranch })
|
||||
}
|
||||
|
||||
const switchTarget = onSwitchBranch && current && status.defaultBranch && status.defaultBranch !== current ? status.defaultBranch : null
|
||||
const switchTarget =
|
||||
onSwitchBranch && current && status.defaultBranch && status.defaultBranch !== current ? status.defaultBranch : null
|
||||
|
||||
// Other worktrees to jump into — everything except the one we're already in
|
||||
// (matched by its checked-out branch) and the bare/main placeholder entry.
|
||||
|
||||
@@ -76,7 +76,12 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss
|
||||
return (
|
||||
<StatusRow
|
||||
leading={
|
||||
<Codicon aria-hidden className={cn('text-muted-foreground/70', opening && 'animate-pulse')} name="globe" size="0.8rem" />
|
||||
<Codicon
|
||||
aria-hidden
|
||||
className={cn('text-muted-foreground/70', opening && 'animate-pulse')}
|
||||
name="globe"
|
||||
size="0.8rem"
|
||||
/>
|
||||
}
|
||||
// Plain click opens the link in the browser; ⌘/Ctrl-click opens it in the
|
||||
// in-app preview pane instead. (isOpen still toggles the pane closed.)
|
||||
|
||||
@@ -11,7 +11,14 @@ function renderPopover(kind: '@' | '/', loading = false) {
|
||||
|
||||
const rendered = render(
|
||||
<I18nProvider configClient={null} initialLocale="zh">
|
||||
<ComposerTriggerPopover activeIndex={0} items={[]} kind={kind} loading={loading} onHover={onHover} onPick={onPick} />
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={0}
|
||||
items={[]}
|
||||
kind={kind}
|
||||
loading={loading}
|
||||
onHover={onHover}
|
||||
onPick={onPick}
|
||||
/>
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface ChatBarProps {
|
||||
onAddUrl?: (url: string) => void
|
||||
onAttachImageBlob?: (blob: Blob) => Promise<boolean | void> | boolean | void
|
||||
onAttachDroppedItems?: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
|
||||
onPasteClipboardImage?: () => void
|
||||
onPasteClipboardImage?: (opts?: { silent?: boolean }) => Promise<boolean> | void
|
||||
onPickFiles?: () => void
|
||||
onPickFolders?: () => void
|
||||
onPickImages?: () => void
|
||||
|
||||
@@ -226,9 +226,10 @@ const attachToMain = (attachment: ComposerAttachment) => {
|
||||
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.desktop
|
||||
|
||||
const addTextToDraft = useCallback((text: string) => {
|
||||
requestComposerInsert(text, { mode: 'block' })
|
||||
}, [copy.imagePreviewFailed])
|
||||
}, [])
|
||||
|
||||
const addTerminalSelectionAttachment = useCallback((text: string, label = 'selection') => {
|
||||
const trimmed = text.trim()
|
||||
@@ -329,35 +330,38 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
[currentCwd]
|
||||
)
|
||||
|
||||
const attachImagePath = useCallback(async (filePath: string) => {
|
||||
if (!filePath) {
|
||||
return false
|
||||
}
|
||||
|
||||
const baseAttachment: ComposerAttachment = {
|
||||
id: attachmentId('image', filePath),
|
||||
kind: 'image',
|
||||
label: pathLabel(filePath),
|
||||
detail: filePath,
|
||||
path: filePath
|
||||
}
|
||||
|
||||
attachToMain(baseAttachment)
|
||||
|
||||
try {
|
||||
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
|
||||
|
||||
if (previewUrl) {
|
||||
addComposerAttachment({ ...baseAttachment, previewUrl })
|
||||
const attachImagePath = useCallback(
|
||||
async (filePath: string) => {
|
||||
if (!filePath) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
notifyError(err, copy.imagePreviewFailed)
|
||||
const baseAttachment: ComposerAttachment = {
|
||||
id: attachmentId('image', filePath),
|
||||
kind: 'image',
|
||||
label: pathLabel(filePath),
|
||||
detail: filePath,
|
||||
path: filePath
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}, [])
|
||||
attachToMain(baseAttachment)
|
||||
|
||||
try {
|
||||
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
|
||||
|
||||
if (previewUrl) {
|
||||
addComposerAttachment({ ...baseAttachment, previewUrl })
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
notifyError(err, copy.imagePreviewFailed)
|
||||
|
||||
return true
|
||||
}
|
||||
},
|
||||
[copy.imagePreviewFailed]
|
||||
)
|
||||
|
||||
const attachImageBlob = useCallback(
|
||||
async (blob: Blob) => {
|
||||
@@ -411,25 +415,36 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
}
|
||||
}, [attachImagePath, copy.attachImages, currentCwd, t.composer.images])
|
||||
|
||||
const pasteClipboardImage = useCallback(async () => {
|
||||
try {
|
||||
const path = await window.hermesDesktop?.saveClipboardImage()
|
||||
const pasteClipboardImage = useCallback(
|
||||
async ({ silent = false }: { silent?: boolean } = {}) => {
|
||||
try {
|
||||
const path = await window.hermesDesktop?.saveClipboardImage()
|
||||
|
||||
if (!path) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: copy.clipboard,
|
||||
message: copy.noClipboardImage
|
||||
})
|
||||
if (!path) {
|
||||
if (!silent) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: copy.clipboard,
|
||||
message: copy.noClipboardImage
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
await attachImagePath(path)
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
notifyError(err, copy.clipboardPasteFailed)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
await attachImagePath(path)
|
||||
} catch (err) {
|
||||
notifyError(err, copy.clipboardPasteFailed)
|
||||
}
|
||||
}, [attachImagePath, copy.clipboard, copy.clipboardPasteFailed, copy.noClipboardImage])
|
||||
},
|
||||
[attachImagePath, copy.clipboard, copy.clipboardPasteFailed, copy.noClipboardImage]
|
||||
)
|
||||
|
||||
const attachContextFolderPath = useCallback(
|
||||
(folderPath: string) => {
|
||||
|
||||
@@ -75,7 +75,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
maxVoiceRecordingSeconds?: number
|
||||
onAttachImageBlob: (blob: Blob) => Promise<boolean | void> | boolean | void
|
||||
onAttachDroppedItems: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
|
||||
onPasteClipboardImage: () => void
|
||||
onPasteClipboardImage: (opts?: { silent?: boolean }) => Promise<boolean> | void
|
||||
onPickFiles: () => void
|
||||
onPickFolders: () => void
|
||||
onPickImages: () => void
|
||||
@@ -88,10 +88,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
|
||||
onEdit: (message: AppendMessage) => Promise<void>
|
||||
onReload: (parentId: string | null) => Promise<void>
|
||||
onRestoreToMessage?: (
|
||||
messageId: string,
|
||||
target?: { text?: string; userOrdinal?: number | null }
|
||||
) => Promise<void>
|
||||
onRestoreToMessage?: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise<void>
|
||||
onRetryResume: (sessionId: string) => void
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
onDismissError?: (messageId: string) => void
|
||||
@@ -320,7 +317,12 @@ export function ChatView({
|
||||
// The compact new-session pop-out skips the wordmark/tagline intro — it's a
|
||||
// scratch window, not the full-height empty state.
|
||||
const showIntro =
|
||||
!isSecondaryWindow() && freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
|
||||
!isSecondaryWindow() &&
|
||||
freshDraftReady &&
|
||||
!isRoutedSessionView &&
|
||||
!selectedSessionId &&
|
||||
!activeSessionId &&
|
||||
messagesEmpty
|
||||
|
||||
// Session is still loading if the route references a session we haven't
|
||||
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactNode
|
||||
} from 'react'
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import ShikiHighlighter from 'react-shiki'
|
||||
import { Streamdown } from 'streamdown'
|
||||
|
||||
@@ -14,15 +14,25 @@ import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/comp
|
||||
import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs'
|
||||
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { isAddSelectionShortcut } from '@/app/right-sidebar/terminal/selection'
|
||||
import { CodeEditor } from '@/components/chat/code-editor'
|
||||
import { FileDiffPanel } from '@/components/chat/diff-lines'
|
||||
import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { translateNow, useI18n } from '@/i18n'
|
||||
import { desktopFileDiff, desktopGitRoot, readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
|
||||
import {
|
||||
desktopFileDiff,
|
||||
desktopGitRoot,
|
||||
readDesktopFileDataUrl,
|
||||
readDesktopFileText,
|
||||
writeDesktopFileText
|
||||
} from '@/lib/desktop-fs'
|
||||
import { Check, Pencil, X } from '@/lib/icons'
|
||||
import { shikiLanguageForFilename } from '@/lib/markdown-code'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
import { setPreviewDirty } from '@/store/preview-edit'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
import { notifyWorkspaceChanged } from '@/store/workspace-events'
|
||||
|
||||
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
|
||||
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
|
||||
@@ -141,6 +151,19 @@ interface LocalPreviewState {
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
// True when focus is in a field that should swallow plain keystrokes (so the
|
||||
// bare-`e` edit shortcut never fires while the user is typing in the composer,
|
||||
// a search box, or the editor itself).
|
||||
function isTypableElement(el: Element | null): boolean {
|
||||
if (!el) {
|
||||
return false
|
||||
}
|
||||
|
||||
const tag = el.tagName
|
||||
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (el as HTMLElement).isContentEditable
|
||||
}
|
||||
|
||||
function filePathForTarget(target: PreviewTarget) {
|
||||
if (target.path) {
|
||||
return target.path
|
||||
@@ -310,13 +333,20 @@ function MarkdownPreview({ text }: { text: string }) {
|
||||
function PreviewModeSwitcher({
|
||||
active,
|
||||
modes,
|
||||
onSelect
|
||||
onSelect,
|
||||
trailing
|
||||
}: {
|
||||
active: PreviewViewMode
|
||||
modes: PreviewViewMode[]
|
||||
onSelect: (mode: PreviewViewMode) => void
|
||||
trailing?: ReactNode
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const showModes = modes.length > 1
|
||||
|
||||
if (!showModes && !trailing) {
|
||||
return null
|
||||
}
|
||||
|
||||
const label: Record<PreviewViewMode, string> = {
|
||||
diff: t.preview.diff,
|
||||
@@ -325,26 +355,68 @@ function PreviewModeSwitcher({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 justify-end gap-3 border-b border-border/40 px-3 py-1">
|
||||
{modes.map(mode => (
|
||||
<button
|
||||
className={cn(
|
||||
'text-[0.625rem] font-bold underline-offset-4 transition-colors',
|
||||
mode === active
|
||||
? 'text-foreground underline decoration-current/30'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
key={mode}
|
||||
onClick={() => onSelect(mode)}
|
||||
type="button"
|
||||
>
|
||||
{label[mode]}
|
||||
</button>
|
||||
))}
|
||||
// Fixed height so the header is byte-identical between read and edit modes —
|
||||
// swapping the trailing controls must never move the body below it.
|
||||
<div className="flex h-7 shrink-0 items-center justify-end gap-3 border-b border-border/40 px-3">
|
||||
{showModes &&
|
||||
modes.map(mode => (
|
||||
<button
|
||||
className={cn(
|
||||
'text-[0.625rem] font-bold underline-offset-4 transition-colors',
|
||||
mode === active
|
||||
? 'text-foreground underline decoration-current/30'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
key={mode}
|
||||
onClick={() => onSelect(mode)}
|
||||
type="button"
|
||||
>
|
||||
{label[mode]}
|
||||
</button>
|
||||
))}
|
||||
{trailing && <div className="flex items-center gap-1.5">{trailing}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Cancel / Save controls rendered as the header's trailing slot (not a bar of
|
||||
// their own) so edit mode reuses the read-mode header row verbatim.
|
||||
function EditControls({
|
||||
dirty,
|
||||
onCancel,
|
||||
onSave,
|
||||
saving
|
||||
}: {
|
||||
dirty: boolean
|
||||
onCancel: () => void
|
||||
onSave: () => void
|
||||
saving: boolean
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-md px-1.5 text-[0.625rem] font-bold text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
{t.common.cancel}
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-md bg-primary px-2 py-0.5 text-[0.625rem] font-bold text-primary-foreground shadow-xs transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
disabled={!dirty || saving}
|
||||
onClick={onSave}
|
||||
type="button"
|
||||
>
|
||||
<Check className="size-3" />
|
||||
{saving ? t.common.saving : t.common.save}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface LineSelection {
|
||||
end: number
|
||||
start: number
|
||||
@@ -431,9 +503,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
|
||||
return (
|
||||
<div className="h-full overflow-auto" onScroll={onScroll} ref={scrollerRef}>
|
||||
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-[0.7rem] leading-relaxed">
|
||||
{beforeRows > 0 && (
|
||||
<div aria-hidden className="col-span-2" style={{ height: beforeRows * SOURCE_LINE_PX }} />
|
||||
)}
|
||||
{beforeRows > 0 && <div aria-hidden className="col-span-2" style={{ height: beforeRows * SOURCE_LINE_PX }} />}
|
||||
{visibleChunks.map(chunk => (
|
||||
<Fragment key={chunk.start}>
|
||||
<div className="select-none text-right text-muted-foreground/55">
|
||||
@@ -475,9 +545,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
{afterRows > 0 && (
|
||||
<div aria-hidden className="col-span-2" style={{ height: afterRows * SOURCE_LINE_PX }} />
|
||||
)}
|
||||
{afterRows > 0 && <div aria-hidden className="col-span-2" style={{ height: afterRows * SOURCE_LINE_PX }} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -492,11 +560,36 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
// User-picked view; null = auto (diff when changed, else rendered markdown,
|
||||
// else source). Reset when the previewed file changes.
|
||||
const [userMode, setUserMode] = useState<null | PreviewViewMode>(null)
|
||||
// Spot-editor state. The editor owns its buffer (keyed by `editorKey`); the
|
||||
// live draft + the snapshot the user started from live in refs so typing
|
||||
// never re-renders this (large) component — `dirty` is the only render-worthy
|
||||
// signal and it flips just once when crossing the clean↔dirty boundary.
|
||||
// `selfReload` re-runs the load after a save without the parent.
|
||||
const [editing, setEditing] = useState(false)
|
||||
const draftRef = useRef('')
|
||||
const baselineRef = useRef('')
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [editorKey, setEditorKey] = useState(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<null | string>(null)
|
||||
const [conflict, setConflict] = useState(false)
|
||||
const [selfReload, setSelfReload] = useState(0)
|
||||
// For the bare-`e` shortcut: the read-view root (to detect focus-within) and a
|
||||
// hover flag (no state — only the keydown handler reads it).
|
||||
const readViewRef = useRef<HTMLDivElement>(null)
|
||||
const hoverRef = useRef(false)
|
||||
const filePath = filePathForTarget(target)
|
||||
const isImage = target.previewKind === 'image'
|
||||
|
||||
useEffect(() => {
|
||||
setUserMode(null)
|
||||
setEditing(false)
|
||||
setDirty(false)
|
||||
setSaving(false)
|
||||
setSaveError(null)
|
||||
setConflict(false)
|
||||
draftRef.current = ''
|
||||
baselineRef.current = ''
|
||||
}, [filePath, reloadKey])
|
||||
|
||||
// HTML files are rendered as source code, not in a webview - so they take
|
||||
@@ -582,7 +675,188 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.dataUrl, target.language])
|
||||
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, selfReload, target.dataUrl, target.language])
|
||||
|
||||
// Editing is only offered for whole, readable text — never images, binaries,
|
||||
// or files we only loaded the first 512 KB of (saving would drop the tail).
|
||||
const canEdit =
|
||||
isText && !isImage && !blockedByTarget && state.text !== undefined && !state.truncated && !state.binary
|
||||
|
||||
// Per-keystroke: update the draft ref (no render) and only set `dirty` when it
|
||||
// actually changes — React bails on an identical value, so a long typing run
|
||||
// triggers a single re-render at most.
|
||||
const handleEditorChange = useCallback((value: string) => {
|
||||
draftRef.current = value
|
||||
const next = value !== baselineRef.current
|
||||
setDirty(prev => (prev === next ? prev : next))
|
||||
}, [])
|
||||
|
||||
// Publish the unsaved state to the rail so the tab can show a modified dot.
|
||||
// Keyed by url; cleared on unmount/tab-change so a stale dot never lingers.
|
||||
useEffect(() => {
|
||||
setPreviewDirty(target.url, editing && dirty)
|
||||
|
||||
return () => setPreviewDirty(target.url, false)
|
||||
}, [target.url, editing, dirty])
|
||||
|
||||
const beginEdit = () => {
|
||||
const text = state.text ?? ''
|
||||
baselineRef.current = text
|
||||
draftRef.current = text
|
||||
setDirty(false)
|
||||
setEditorKey(key => key + 1)
|
||||
setSaving(false)
|
||||
setSaveError(null)
|
||||
setConflict(false)
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
// Latest `beginEdit` for the keydown listener, so the listener can stay
|
||||
// subscribed across renders without recreating itself or going stale.
|
||||
const beginEditRef = useRef(beginEdit)
|
||||
beginEditRef.current = beginEdit
|
||||
|
||||
// Bare `e` enters edit mode when the file pane is hovered or focused and no
|
||||
// typable field has focus — a fast, button-free path (double-click felt laggy
|
||||
// because of the browser's click-disambiguation delay).
|
||||
useEffect(() => {
|
||||
if (!canEdit || editing) {
|
||||
return
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'e' || event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isTypableElement(document.activeElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
const root = readViewRef.current
|
||||
const focusWithin = Boolean(root && document.activeElement && root.contains(document.activeElement))
|
||||
|
||||
if (!hoverRef.current && !focusWithin) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
beginEditRef.current()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [canEdit, editing])
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditing(false)
|
||||
setSaveError(null)
|
||||
setConflict(false)
|
||||
}
|
||||
|
||||
const discardAndReload = () => {
|
||||
setEditing(false)
|
||||
setConflict(false)
|
||||
setSaveError(null)
|
||||
setSelfReload(n => n + 1)
|
||||
}
|
||||
|
||||
const saveEdit = async (force = false) => {
|
||||
if (saving) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
|
||||
try {
|
||||
// Stale-on-disk guard: re-read what's on disk now and compare to the
|
||||
// snapshot the user started from. If something changed underneath (an
|
||||
// agent edit, an external save), don't clobber it silently — surface the
|
||||
// choice. `force` is the user picking "overwrite" from that banner.
|
||||
if (!force) {
|
||||
try {
|
||||
const current = await readTextPreview(filePath)
|
||||
|
||||
if (!current.binary && (current.text ?? '') !== baselineRef.current) {
|
||||
setConflict(true)
|
||||
setSaving(false)
|
||||
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Couldn't re-read for the check — fall through and attempt the write.
|
||||
}
|
||||
}
|
||||
|
||||
await writeDesktopFileText(filePath, draftRef.current)
|
||||
baselineRef.current = draftRef.current
|
||||
setDirty(false)
|
||||
setConflict(false)
|
||||
setEditing(false)
|
||||
notifyWorkspaceChanged()
|
||||
setSelfReload(n => n + 1)
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Rendered before the loading/error branches so a background re-read (file
|
||||
// watcher, workspace tick) can't unmount the editor and drop the draft. Uses
|
||||
// the SAME container + fixed-height header as the read view so entering edit
|
||||
// never shifts the body — only the trailing controls and the body swap.
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-transparent">
|
||||
<PreviewModeSwitcher
|
||||
active="source"
|
||||
modes={[]}
|
||||
onSelect={() => {}}
|
||||
trailing={<EditControls dirty={dirty} onCancel={cancelEdit} onSave={() => void saveEdit()} saving={saving} />}
|
||||
/>
|
||||
{conflict && (
|
||||
<div className="shrink-0 border-b border-amber-400/40 bg-amber-50 px-3 py-2 text-[0.7rem] text-amber-900 dark:border-amber-300/30 dark:bg-amber-300/10 dark:text-amber-100">
|
||||
<div className="font-semibold">{t.preview.diskChangedTitle}</div>
|
||||
<div className="mt-0.5 leading-relaxed">{t.preview.diskChangedBody}</div>
|
||||
<div className="mt-1.5 flex gap-3">
|
||||
<button
|
||||
className="font-bold underline underline-offset-4 transition-opacity hover:opacity-80"
|
||||
onClick={() => void saveEdit(true)}
|
||||
type="button"
|
||||
>
|
||||
{t.preview.overwrite}
|
||||
</button>
|
||||
<button
|
||||
className="font-bold underline underline-offset-4 transition-opacity hover:opacity-80"
|
||||
onClick={discardAndReload}
|
||||
type="button"
|
||||
>
|
||||
{t.preview.discardReload}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{saveError && (
|
||||
<div className="shrink-0 border-b border-destructive/40 bg-destructive/10 px-3 py-1.5 text-[0.7rem] text-destructive">
|
||||
{t.preview.saveFailed(saveError)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<CodeEditor
|
||||
filePath={filePath}
|
||||
initialValue={baselineRef.current}
|
||||
key={editorKey}
|
||||
onCancel={cancelEdit}
|
||||
onChange={handleEditorChange}
|
||||
onSave={() => void saveEdit()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.loading) {
|
||||
return <PageLoader label={t.preview.loading} />
|
||||
@@ -602,11 +876,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
|
||||
return (
|
||||
<PreviewEmptyState
|
||||
body={
|
||||
binary
|
||||
? t.preview.binaryBody(target.label)
|
||||
: t.preview.largeBody(target.label, formatBytes(size))
|
||||
}
|
||||
body={binary ? t.preview.binaryBody(target.label) : t.preview.largeBody(target.label, formatBytes(size))}
|
||||
primaryAction={{ label: t.preview.previewAnyway, onClick: () => setForcePreview(true) }}
|
||||
title={binary ? t.preview.binaryTitle : t.preview.largeTitle}
|
||||
tone="warning"
|
||||
@@ -647,13 +917,39 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
const mode = userMode && modes.includes(userMode) ? userMode : autoMode
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-transparent">
|
||||
<div
|
||||
className="flex h-full flex-col overflow-hidden bg-transparent"
|
||||
onMouseEnter={() => {
|
||||
hoverRef.current = true
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
hoverRef.current = false
|
||||
}}
|
||||
ref={readViewRef}
|
||||
>
|
||||
{state.truncated && (
|
||||
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
|
||||
{t.preview.truncated}
|
||||
</div>
|
||||
)}
|
||||
{modes.length > 1 && <PreviewModeSwitcher active={mode} modes={modes} onSelect={setUserMode} />}
|
||||
<PreviewModeSwitcher
|
||||
active={mode}
|
||||
modes={modes}
|
||||
onSelect={setUserMode}
|
||||
trailing={
|
||||
canEdit ? (
|
||||
<button
|
||||
className="flex items-center gap-1 text-[0.625rem] font-bold text-muted-foreground underline-offset-4 transition-colors hover:text-foreground"
|
||||
onClick={beginEdit}
|
||||
title={`${t.preview.edit} (e)`}
|
||||
type="button"
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
{t.preview.edit}
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{mode === 'rendered' ? (
|
||||
<MarkdownPreview text={state.text} />
|
||||
@@ -677,10 +973,5 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PreviewEmptyState
|
||||
body={t.preview.noInlineBody(target.mimeType || '')}
|
||||
title={t.preview.noInlineTitle}
|
||||
/>
|
||||
)
|
||||
return <PreviewEmptyState body={t.preview.noInlineBody(target.mimeType || '')} title={t.preview.noInlineTitle} />
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import { PreviewPane } from './preview-pane'
|
||||
|
||||
describe('PreviewPane console state', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0))
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
window.setTimeout(() => callback(Date.now()), 0)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
|
||||
})
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
closeRightRailTabsToRight,
|
||||
type PreviewTarget
|
||||
} from '@/store/preview'
|
||||
import { $dirtyPreviewUrls } from '@/store/preview-edit'
|
||||
|
||||
import { PreviewPane } from './preview-pane'
|
||||
|
||||
@@ -70,10 +71,13 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const filePreviewTabs = useStore($filePreviewTabs)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
const dirtyPreviewUrls = useStore($dirtyPreviewUrls)
|
||||
|
||||
const tabs = useMemo<readonly RailTab[]>(
|
||||
() => [
|
||||
...(previewTarget ? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: t.preview.tab, target: previewTarget } as RailTab] : []),
|
||||
...(previewTarget
|
||||
? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: t.preview.tab, target: previewTarget } as RailTab]
|
||||
: []),
|
||||
...filePreviewTabs.map(({ id, target }) => ({ id, label: tabLabelFor(target), target }) as RailTab)
|
||||
],
|
||||
[filePreviewTabs, previewTarget, t.preview.tab]
|
||||
@@ -99,6 +103,12 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
'relative flex h-full w-full min-w-0 flex-col overflow-hidden border-(--ui-stroke-tertiary) bg-(--ui-editor-surface-background) text-(--ui-text-tertiary)',
|
||||
panesFlipped ? 'border-r' : 'border-l'
|
||||
)}
|
||||
// Windows/WSLg paint Electron's Window Controls Overlay across our
|
||||
// titlebar band, so the editor-style tab strip (which normally sits IN that
|
||||
// band) would land under the fixed titlebar tools. --right-rail-top-inset
|
||||
// (set by AppShell only when the overlay is present) drops the rail one
|
||||
// titlebar-height so it opens below the band. 0px elsewhere → unchanged.
|
||||
style={{ paddingTop: 'var(--right-rail-top-inset, 0px)' }}
|
||||
>
|
||||
<div className="group/rail-tabs flex h-(--titlebar-height) shrink-0 border-b border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background)">
|
||||
<div
|
||||
@@ -109,6 +119,7 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
const active = tab.id === activeTab.id
|
||||
const hasOthers = tabs.length > 1
|
||||
const hasTabsToRight = index < tabs.length - 1
|
||||
const dirty = Boolean(dirtyPreviewUrls[tab.target.url])
|
||||
|
||||
return (
|
||||
<ContextMenu key={tab.id}>
|
||||
@@ -155,6 +166,16 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
|
||||
/>
|
||||
{dirty && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center opacity-100 transition-opacity group-hover/tab:opacity-0 group-focus-within/tab:opacity-0"
|
||||
>
|
||||
{/* Amber (our warn color); a tab-bg ring + soft drop keeps it
|
||||
legible where it overlaps the filename. */}
|
||||
<span className="size-2 rounded-full bg-amber-500 shadow-[0_0_0_2px_var(--tab-bg),0_1px_2px_rgba(0,0,0,0.45)] dark:bg-amber-400" />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
aria-label={t.preview.closeTab(tab.label)}
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
|
||||
|
||||
@@ -146,10 +146,7 @@ export function SidebarRowLeadGlyph({
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'grid size-full place-items-center text-(--ui-text-tertiary) [&_.codicon]:leading-none',
|
||||
className
|
||||
)}
|
||||
className={cn('grid size-full place-items-center text-(--ui-text-tertiary) [&_.codicon]:leading-none', className)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -77,13 +77,7 @@ import {
|
||||
toggleSidebarMessagingOpen,
|
||||
unpinSession
|
||||
} from '@/store/layout'
|
||||
import {
|
||||
$newChatProfile,
|
||||
$profiles,
|
||||
$profileScope,
|
||||
ALL_PROFILES,
|
||||
normalizeProfileKey
|
||||
} from '@/store/profile'
|
||||
import { $newChatProfile, $profiles, $profileScope, ALL_PROFILES, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$activeProjectId,
|
||||
$projects,
|
||||
@@ -247,7 +241,12 @@ function ReorderableList({
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext autoScroll={reorderAutoScroll} collisionDetection={closestCenter} onDragEnd={handleDragEnd} sensors={sensors}>
|
||||
<DndContext
|
||||
autoScroll={reorderAutoScroll}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
sensors={sensors}
|
||||
>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{children}
|
||||
</SortableContext>
|
||||
@@ -1119,9 +1118,7 @@ export function ChatSidebar({
|
||||
)
|
||||
|
||||
const recentsVirtualizes =
|
||||
!displayAgentGroups?.length &&
|
||||
!agentProjectTree?.length &&
|
||||
displayAgentSessions.length >= VIRTUALIZE_THRESHOLD
|
||||
!displayAgentGroups?.length && !agentProjectTree?.length && displayAgentSessions.length >= VIRTUALIZE_THRESHOLD
|
||||
|
||||
// Keep the persisted parent + worktree orders reconciled with what's on screen:
|
||||
// freshly-seen repos/worktrees surface at the top, vanished ones drop out of
|
||||
@@ -1439,11 +1436,13 @@ export function ChatSidebar({
|
||||
}
|
||||
label={sessionsLabel}
|
||||
labelMeta={
|
||||
worktreeGroupingActive
|
||||
? reposScanning && !projectsSkeletonVisible
|
||||
? <GlyphSpinner ariaLabel={s.loading} className="text-[0.6875rem] text-(--ui-text-quaternary)" />
|
||||
: undefined
|
||||
: recentsMeta
|
||||
worktreeGroupingActive ? (
|
||||
reposScanning && !projectsSkeletonVisible ? (
|
||||
<GlyphSpinner ariaLabel={s.loading} className="text-[0.6875rem] text-(--ui-text-quaternary)" />
|
||||
) : undefined
|
||||
) : (
|
||||
recentsMeta
|
||||
)
|
||||
}
|
||||
liveSessions={inProject ? agentSessions : undefined}
|
||||
onArchiveSession={onArchiveSession}
|
||||
@@ -1458,7 +1457,9 @@ export function ChatSidebar({
|
||||
onTogglePin={pinSession}
|
||||
open={agentsOpen}
|
||||
pinned={false}
|
||||
projectBackRow={inProject ? <ProjectBackRow label={s.projects.back} onClick={exitProjectScope} /> : undefined}
|
||||
projectBackRow={
|
||||
inProject ? <ProjectBackRow label={s.projects.back} onClick={exitProjectScope} /> : undefined
|
||||
}
|
||||
projectContent={inProject ? enteredProjectContent : undefined}
|
||||
projectOverview={projectOverview}
|
||||
projectOverviewPreviews={overviewPreviews}
|
||||
@@ -1562,7 +1563,15 @@ interface SidebarSectionHeaderProps {
|
||||
collapsible?: boolean
|
||||
}
|
||||
|
||||
function SidebarSectionHeader({ label, open, onToggle, action, meta, icon, collapsible = true }: SidebarSectionHeaderProps) {
|
||||
function SidebarSectionHeader({
|
||||
label,
|
||||
open,
|
||||
onToggle,
|
||||
action,
|
||||
meta,
|
||||
icon,
|
||||
collapsible = true
|
||||
}: SidebarSectionHeaderProps) {
|
||||
const labelBody = (
|
||||
<>
|
||||
{icon}
|
||||
@@ -1597,7 +1606,10 @@ function SidebarSessionSkeletons() {
|
||||
return (
|
||||
<div aria-hidden="true" className="grid gap-px">
|
||||
{['w-32', 'w-40', 'w-28', 'w-36', 'w-24'].map((width, i) => (
|
||||
<div className="grid min-h-[1.625rem] grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md pl-2" key={`${width}-${i}`}>
|
||||
<div
|
||||
className="grid min-h-[1.625rem] grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md pl-2"
|
||||
key={`${width}-${i}`}
|
||||
>
|
||||
<Skeleton className={cn('h-3 rounded-sm', width)} />
|
||||
<Skeleton className="mx-auto size-3.5 rounded-sm opacity-60" />
|
||||
</div>
|
||||
@@ -1732,8 +1744,7 @@ function SidebarSessionsSection({
|
||||
const hasProjectContent = Boolean(projectContent && projectContent.sessionCount > 0)
|
||||
|
||||
const showEmptyState =
|
||||
forceEmptyState ||
|
||||
(!hasGroupedSessions && !hasProjectOverview && !hasProjectContent && sessions.length === 0)
|
||||
forceEmptyState || (!hasGroupedSessions && !hasProjectOverview && !hasProjectContent && sessions.length === 0)
|
||||
|
||||
// The flat recents/pinned list is the only place sessions reorder by hand;
|
||||
// grouped/tree views always sort by creation date and never drag.
|
||||
@@ -1828,7 +1839,11 @@ function SidebarSessionsSection({
|
||||
|
||||
inner =
|
||||
projectsDraggable && onReorderProjects ? (
|
||||
<ReorderableList ids={projectOverview.map(project => project.id)} onReorder={onReorderProjects} sensors={dndSensors}>
|
||||
<ReorderableList
|
||||
ids={projectOverview.map(project => project.id)}
|
||||
onReorder={onReorderProjects}
|
||||
sensors={dndSensors}
|
||||
>
|
||||
{rows}
|
||||
</ReorderableList>
|
||||
) : (
|
||||
@@ -1837,7 +1852,12 @@ function SidebarSessionsSection({
|
||||
} else if (groups?.length) {
|
||||
// Profile/source groups never reorder; render them flat with static rows.
|
||||
inner = groups.map(group => (
|
||||
<SidebarWorkspaceGroup group={group} key={group.id} onNewSession={onNewSessionInWorkspace} renderRows={renderRows} />
|
||||
<SidebarWorkspaceGroup
|
||||
group={group}
|
||||
key={group.id}
|
||||
onNewSession={onNewSessionInWorkspace}
|
||||
renderRows={renderRows}
|
||||
/>
|
||||
))
|
||||
} else if (flatVirtualized) {
|
||||
const virtual = (
|
||||
|
||||
@@ -23,7 +23,11 @@ export function SidebarLoadMoreRow({ step, onClick, loading = false }: SidebarLo
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{loading ? <GlyphSpinner ariaLabel={label} className="text-[0.75rem]" /> : <Codicon name="ellipsis" size="0.75rem" />}
|
||||
{loading ? (
|
||||
<GlyphSpinner ariaLabel={label} className="text-[0.75rem]" />
|
||||
) : (
|
||||
<Codicon name="ellipsis" size="0.75rem" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -132,7 +132,11 @@ export function ProfileRail() {
|
||||
const defaultProfile = profiles.find(profile => profile.is_default)
|
||||
const onDefault = !isAll && activeKey === 'default'
|
||||
|
||||
const named = sortByProfileOrder(profiles.filter(profile => !profile.is_default), order)
|
||||
const named = sortByProfileOrder(
|
||||
profiles.filter(profile => !profile.is_default),
|
||||
order
|
||||
)
|
||||
|
||||
const multiProfile = profiles.length > 1
|
||||
|
||||
// distance constraint: a small drag reorders, a tap still selects the profile.
|
||||
@@ -482,7 +486,11 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
<span>{p.rename}</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive focus:text-destructive" onSelect={onDelete} variant="destructive">
|
||||
<ContextMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={onDelete}
|
||||
variant="destructive"
|
||||
>
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
<span>{t.common.delete}</span>
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -3,7 +3,14 @@ import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { GenerateButton } from '@/components/ui/generate-button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
@@ -4,7 +4,14 @@ import { useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import type { HermesGitWorktree } from '@/global'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
@@ -122,7 +129,8 @@ function RepoFlatSection({
|
||||
// A live `git worktree list` hit wins over an old dismissal: if git says the
|
||||
// worktree exists again (or still exists after "hide from sidebar"), surface it.
|
||||
const ordered = overlaidGroups.filter(
|
||||
group => group.isMain || !dismissedWorktrees.includes(group.id) || (group.path && discoveredWorktreePaths.has(group.path))
|
||||
group =>
|
||||
group.isMain || !dismissedWorktrees.includes(group.id) || (group.path && discoveredWorktreePaths.has(group.path))
|
||||
)
|
||||
|
||||
const repoCount = ordered.reduce((sum, group) => sum + group.sessions.length, 0)
|
||||
@@ -248,7 +256,9 @@ function RepoFlatSection({
|
||||
<SidebarRowStack>
|
||||
<WorkspaceHeader
|
||||
action={
|
||||
onNewSession && <WorkspaceAddButton label={s.newSessionIn(repo.label)} onClick={() => onNewSession(repo.path)} />
|
||||
onNewSession && (
|
||||
<WorkspaceAddButton label={s.newSessionIn(repo.label)} onClick={() => onNewSession(repo.path)} />
|
||||
)
|
||||
}
|
||||
count={repoCount}
|
||||
emphasis
|
||||
|
||||
@@ -19,7 +19,11 @@ export const PROJECT_PREVIEW_COUNT = 3
|
||||
const WORKTREE_PROBE_CONCURRENCY = 4
|
||||
|
||||
const pathListKey = (paths: string[]): string =>
|
||||
paths.map(path => path.trim()).filter(Boolean).sort((a, b) => a.localeCompare(b)).join('\n')
|
||||
paths
|
||||
.map(path => path.trim())
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.join('\n')
|
||||
|
||||
// Every session in a project, across its repos/worktrees (order-agnostic).
|
||||
const projectSessions = (project: SidebarProjectTree): SessionInfo[] =>
|
||||
@@ -63,7 +67,10 @@ export function sortProjectsForOverview(
|
||||
return aHasSessions ? -1 : 1
|
||||
}
|
||||
|
||||
return projectActivityTime(b) - projectActivityTime(a) || a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
|
||||
return (
|
||||
projectActivityTime(b) - projectActivityTime(a) ||
|
||||
a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -116,7 +116,9 @@ export function ProjectOverviewRow({
|
||||
<SidebarRowShell
|
||||
actions={
|
||||
<>
|
||||
{onNewSession && <WorkspaceAddButton label={s.newSessionIn(project.label)} onClick={() => onNewSession(project.path)} />}
|
||||
{onNewSession && (
|
||||
<WorkspaceAddButton label={s.newSessionIn(project.label)} onClick={() => onNewSession(project.path)} />
|
||||
)}
|
||||
<ProjectMenu anchorRef={rowRef} isActive={isActive} project={project} />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -31,10 +31,34 @@ import type { SidebarProjectTree } from './workspace-groups'
|
||||
|
||||
// Curated codicons for the project glyph (tinted by the chosen color).
|
||||
const ICONS = [
|
||||
'folder-library', 'repo', 'rocket', 'beaker', 'flame', 'star-full', 'heart',
|
||||
'zap', 'target', 'lightbulb', 'tools', 'device-desktop', 'device-mobile', 'terminal',
|
||||
'dashboard', 'globe', 'broadcast', 'cloud', 'database', 'package', 'book',
|
||||
'organization', 'bug', 'shield', 'key', 'gift', 'telescope', 'home'
|
||||
'folder-library',
|
||||
'repo',
|
||||
'rocket',
|
||||
'beaker',
|
||||
'flame',
|
||||
'star-full',
|
||||
'heart',
|
||||
'zap',
|
||||
'target',
|
||||
'lightbulb',
|
||||
'tools',
|
||||
'device-desktop',
|
||||
'device-mobile',
|
||||
'terminal',
|
||||
'dashboard',
|
||||
'globe',
|
||||
'broadcast',
|
||||
'cloud',
|
||||
'database',
|
||||
'package',
|
||||
'book',
|
||||
'organization',
|
||||
'bug',
|
||||
'shield',
|
||||
'key',
|
||||
'gift',
|
||||
'telescope',
|
||||
'home'
|
||||
]
|
||||
|
||||
// Per-project actions, modeled on git GUIs (GitHub Desktop / GitKraken): reveal
|
||||
@@ -114,7 +138,12 @@ export function ProjectMenu({
|
||||
{/* Closing the menu refocuses the trigger (also the popover anchor),
|
||||
which the appearance popover would read as focus-outside and die on.
|
||||
Suppress that refocus so it survives. */}
|
||||
<DropdownMenuContent align="end" className="w-48" onCloseAutoFocus={event => event.preventDefault()} sideOffset={6}>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-48"
|
||||
onCloseAutoFocus={event => event.preventDefault()}
|
||||
sideOffset={6}
|
||||
>
|
||||
{!project.isAuto && (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => openProjectRename(target)}>
|
||||
|
||||
@@ -129,7 +129,11 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov
|
||||
)}
|
||||
{hiddenCount > 0 &&
|
||||
(isProfileGroup ? (
|
||||
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
|
||||
<SidebarLoadMoreRow
|
||||
loading={Boolean(group.loadingMore)}
|
||||
onClick={handleProfileLoadMore}
|
||||
step={nextCount}
|
||||
/>
|
||||
) : (
|
||||
<WorkspaceShowMoreButton
|
||||
count={nextCount}
|
||||
|
||||
@@ -97,10 +97,7 @@ describe('sortWorktreeGroups', () => {
|
||||
})
|
||||
|
||||
it('falls back to label order for equally-idle lanes', () => {
|
||||
const groups = [
|
||||
lane({ id: 'b', label: 'beta', isMain: false }),
|
||||
lane({ id: 'a', label: 'alpha', isMain: false })
|
||||
]
|
||||
const groups = [lane({ id: 'b', label: 'beta', isMain: false }), lane({ id: 'a', label: 'alpha', isMain: false })]
|
||||
|
||||
expect(sortWorktreeGroups(groups).map(g => g.label)).toEqual(['alpha', 'beta'])
|
||||
})
|
||||
@@ -108,7 +105,11 @@ describe('sortWorktreeGroups', () => {
|
||||
|
||||
describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
|
||||
it('injects a linked worktree lane discovered by git that has no sessions yet', () => {
|
||||
const repo = { id: '/repo', path: '/repo', groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })] }
|
||||
const repo = {
|
||||
id: '/repo',
|
||||
path: '/repo',
|
||||
groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })]
|
||||
}
|
||||
|
||||
const discovered: HermesGitWorktree[] = [
|
||||
{ branch: 'feature', detached: false, isMain: false, locked: false, path: '/repo-wt-feature' }
|
||||
@@ -122,7 +123,11 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
|
||||
})
|
||||
|
||||
it('never spawns a lane per kanban task worktree', () => {
|
||||
const repo = { id: '/repo', path: '/repo', groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })] }
|
||||
const repo = {
|
||||
id: '/repo',
|
||||
path: '/repo',
|
||||
groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })]
|
||||
}
|
||||
|
||||
const discovered: HermesGitWorktree[] = [
|
||||
{ branch: 'wt/t_aaaaaaaa', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/t_aaaaaaaa' },
|
||||
@@ -137,7 +142,13 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
|
||||
id: '/repo',
|
||||
path: '/repo',
|
||||
groups: [
|
||||
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })
|
||||
lane({
|
||||
id: '/repo::branch::main',
|
||||
label: 'main',
|
||||
isMain: true,
|
||||
path: '/repo',
|
||||
sessions: [makeSession('/repo')]
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
@@ -155,22 +166,34 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
|
||||
it('is a no-op when git worktree list is unavailable (remote backend)', () => {
|
||||
const groups = [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })]
|
||||
|
||||
expect(mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups }, undefined).map(g => g.label)).toEqual(['main'])
|
||||
expect(mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups }, undefined).map(g => g.label)).toEqual([
|
||||
'main'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not add a second "main" for a linked worktree checked out on main', () => {
|
||||
const groups = [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })]
|
||||
const groups = [
|
||||
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })
|
||||
]
|
||||
|
||||
const discovered: HermesGitWorktree[] = [
|
||||
{ branch: 'main', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/main-mirror' }
|
||||
]
|
||||
|
||||
expect(mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups }, discovered).filter(g => g.label === 'main')).toHaveLength(1)
|
||||
expect(
|
||||
mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups }, discovered).filter(g => g.label === 'main')
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('surfaces a user-named "New worktree" under .worktrees/ as its own lane', () => {
|
||||
const discovered: HermesGitWorktree[] = [
|
||||
{ branch: 'hermes/test-gui-stuff', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/test-gui-stuff' }
|
||||
{
|
||||
branch: 'hermes/test-gui-stuff',
|
||||
detached: false,
|
||||
isMain: false,
|
||||
locked: false,
|
||||
path: '/repo/.worktrees/test-gui-stuff'
|
||||
}
|
||||
]
|
||||
|
||||
const merged = mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups: [] }, discovered)
|
||||
@@ -185,7 +208,13 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
|
||||
id: '/repo',
|
||||
path: '/repo',
|
||||
groups: [
|
||||
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] }),
|
||||
lane({
|
||||
id: '/repo::branch::main',
|
||||
label: 'main',
|
||||
isMain: true,
|
||||
path: '/repo',
|
||||
sessions: [makeSession('/repo')]
|
||||
}),
|
||||
lane({
|
||||
id: '/repo-ci',
|
||||
label: 'hermes-agent-ci',
|
||||
@@ -297,7 +326,13 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
|
||||
id: '/repo',
|
||||
path: '/repo',
|
||||
groups: [
|
||||
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })
|
||||
lane({
|
||||
id: '/repo::branch::main',
|
||||
label: 'main',
|
||||
isMain: true,
|
||||
path: '/repo',
|
||||
sessions: [makeSession('/repo')]
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
@@ -322,10 +357,20 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
|
||||
const repo = {
|
||||
id: '/repo',
|
||||
path: '/repo',
|
||||
groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })]
|
||||
groups: [
|
||||
lane({
|
||||
id: '/repo::branch::main',
|
||||
label: 'main',
|
||||
isMain: true,
|
||||
path: '/repo',
|
||||
sessions: [makeSession('/repo')]
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
const discovered: HermesGitWorktree[] = [{ branch: 'main', detached: false, isMain: true, locked: false, path: '/repo' }]
|
||||
const discovered: HermesGitWorktree[] = [
|
||||
{ branch: 'main', detached: false, isMain: true, locked: false, path: '/repo' }
|
||||
]
|
||||
|
||||
const home = mergeRepoWorktreeGroups(repo, discovered).find(g => g.isHome)
|
||||
|
||||
@@ -338,12 +383,26 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
|
||||
id: '/repo',
|
||||
path: '/repo',
|
||||
groups: [
|
||||
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo', { id: 'a' })] }),
|
||||
lane({ id: '/repo::branch::old', label: 'old-feature', isMain: true, path: '/repo', sessions: [makeSession('/repo', { id: 'b' })] })
|
||||
lane({
|
||||
id: '/repo::branch::main',
|
||||
label: 'main',
|
||||
isMain: true,
|
||||
path: '/repo',
|
||||
sessions: [makeSession('/repo', { id: 'a' })]
|
||||
}),
|
||||
lane({
|
||||
id: '/repo::branch::old',
|
||||
label: 'old-feature',
|
||||
isMain: true,
|
||||
path: '/repo',
|
||||
sessions: [makeSession('/repo', { id: 'b' })]
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
const discovered: HermesGitWorktree[] = [{ branch: 'bb/live', detached: false, isMain: true, locked: false, path: '/repo' }]
|
||||
const discovered: HermesGitWorktree[] = [
|
||||
{ branch: 'bb/live', detached: false, isMain: true, locked: false, path: '/repo' }
|
||||
]
|
||||
|
||||
const merged = mergeRepoWorktreeGroups(repo, discovered)
|
||||
const home = merged.find(g => g.isHome)
|
||||
@@ -354,7 +413,19 @@ describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
|
||||
})
|
||||
|
||||
it('leaves main lanes untouched on a remote backend (no git probe)', () => {
|
||||
const repo = { id: '/repo', path: '/repo', groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })] }
|
||||
const repo = {
|
||||
id: '/repo',
|
||||
path: '/repo',
|
||||
groups: [
|
||||
lane({
|
||||
id: '/repo::branch::main',
|
||||
label: 'main',
|
||||
isMain: true,
|
||||
path: '/repo',
|
||||
sessions: [makeSession('/repo')]
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
// No discovered worktrees → no live branch truth → backend label stands.
|
||||
const merged = mergeRepoWorktreeGroups(repo, undefined)
|
||||
@@ -411,9 +482,9 @@ describe('liveSessionProjectId', () => {
|
||||
// "Convert a branch" / "new worktree" land at `<repoRoot>/.worktrees/<slug>`,
|
||||
// so they belong to the same auto project as the repo root and must show in
|
||||
// the overview at once, not wait for the next backend refresh.
|
||||
expect(
|
||||
liveSessionProjectId(makeSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [])
|
||||
).toBe('/www/app')
|
||||
expect(liveSessionProjectId(makeSession('/www/app/.worktrees/test1', { git_repo_root: '/www/app' }), [])).toBe(
|
||||
'/www/app'
|
||||
)
|
||||
})
|
||||
|
||||
it('routes an in-tree worktree session to the owning explicit project', () => {
|
||||
@@ -488,7 +559,9 @@ describe('overlayLiveLanes', () => {
|
||||
label: 'app',
|
||||
path: '/www/app',
|
||||
sessionCount: 1,
|
||||
groups: [lane({ id: '/www/app::branch::main', label: 'main', isMain: true, path: '/www/app', sessions: [existing] })]
|
||||
groups: [
|
||||
lane({ id: '/www/app::branch::main', label: 'main', isMain: true, path: '/www/app', sessions: [existing] })
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -513,7 +586,12 @@ describe('overlayLiveLanes', () => {
|
||||
path: '/www/app',
|
||||
sessionCount: 1,
|
||||
groups: [
|
||||
lane({ id: '/www/app::branch::baby', label: 'baby', path: '/www/app/.worktrees/baby', sessions: [existing] })
|
||||
lane({
|
||||
id: '/www/app::branch::baby',
|
||||
label: 'baby',
|
||||
path: '/www/app/.worktrees/baby',
|
||||
sessions: [existing]
|
||||
})
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -560,7 +638,10 @@ describe('overlayLiveLanes', () => {
|
||||
})
|
||||
|
||||
it('places into a visual-only discovered worktree lane after merge', () => {
|
||||
const discovered = [{ path: '/www/app-retry', branch: 'bb/ci-install-retry', isMain: false, detached: false, locked: false }]
|
||||
const discovered = [
|
||||
{ path: '/www/app-retry', branch: 'bb/ci-install-retry', isMain: false, detached: false, locked: false }
|
||||
]
|
||||
|
||||
const groups = mergeRepoWorktreeGroups({ id: '/www/app', path: '/www/app', groups: [] }, discovered)
|
||||
|
||||
const project = projectNode({
|
||||
|
||||
@@ -280,7 +280,11 @@ export function mergeRepoWorktreeGroups(
|
||||
continue
|
||||
}
|
||||
|
||||
const label = (worktree.isMain ? worktree.branch?.trim() || DEFAULT_BRANCH_LABEL : worktree.branch?.trim()) || baseName(wtPath) || wtPath
|
||||
const label =
|
||||
(worktree.isMain ? worktree.branch?.trim() || DEFAULT_BRANCH_LABEL : worktree.branch?.trim()) ||
|
||||
baseName(wtPath) ||
|
||||
wtPath
|
||||
|
||||
const id = worktree.isMain ? branchLaneId(repo.id, label) : wtPath
|
||||
|
||||
const alreadySeen =
|
||||
@@ -479,7 +483,9 @@ export function overlayRepoLanes(
|
||||
|
||||
lane =
|
||||
lanes.find(g => g.id === placed.id) ??
|
||||
(placed.isMain ? lanes.find(g => g.isMain && g.label.toLowerCase() === placed.label.toLowerCase()) : undefined) ??
|
||||
(placed.isMain
|
||||
? lanes.find(g => g.isMain && g.label.toLowerCase() === placed.label.toLowerCase())
|
||||
: undefined) ??
|
||||
(!placed.isMain && placedPath ? lanes.find(g => normalizePath(g.path) === placedPath) : undefined)
|
||||
|
||||
if (!lane) {
|
||||
|
||||
@@ -4,7 +4,14 @@ import { useCallback, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -70,7 +77,15 @@ export function WorkspaceAddButton({ label, onClick }: { label: string; onClick:
|
||||
}
|
||||
|
||||
// Reveals the next page of already-loaded rows within a workspace/worktree.
|
||||
export function WorkspaceShowMoreButton({ count, label, onClick }: { count: number; label: string; onClick: () => void }) {
|
||||
export function WorkspaceShowMoreButton({
|
||||
count,
|
||||
label,
|
||||
onClick
|
||||
}: {
|
||||
count: number
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const text = t.sidebar.showMoreIn(count, label)
|
||||
|
||||
|
||||
@@ -93,7 +93,16 @@ interface ItemSpec {
|
||||
variant?: 'destructive'
|
||||
}
|
||||
|
||||
function useSessionActions({ sessionId, title, pinned = false, profile, onPin, onBranch, onArchive, onDelete }: SessionActions) {
|
||||
function useSessionActions({
|
||||
sessionId,
|
||||
title,
|
||||
pinned = false,
|
||||
profile,
|
||||
onPin,
|
||||
onBranch,
|
||||
onArchive,
|
||||
onDelete
|
||||
}: SessionActions) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
|
||||
@@ -81,7 +81,7 @@ export function SidebarSessionRow({
|
||||
// messaging platform — surface that origin as a small badge so e.g. a
|
||||
// Telegram thread continued here still reads as Telegram.
|
||||
const handoffSource = handoffOriginSource(session.handoff_state, session.handoff_platform)
|
||||
const handoffLabel = handoffSource ? sessionSourceLabel(handoffSource) ?? handoffSource : null
|
||||
const handoffLabel = handoffSource ? (sessionSourceLabel(handoffSource) ?? handoffSource) : null
|
||||
// Subscribe per-row (the leaf) instead of drilling a set through the list —
|
||||
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
|
||||
// session is waiting on the user.
|
||||
@@ -159,7 +159,9 @@ export function SidebarSessionRow({
|
||||
{...rest}
|
||||
>
|
||||
{isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />}
|
||||
<SidebarRowBody className={cn('z-0 group-hover:pr-12', branchStem && 'pl-3.5')} onClick={event => {
|
||||
<SidebarRowBody
|
||||
className={cn('z-0 group-hover:pr-12', branchStem && 'pl-3.5')}
|
||||
onClick={event => {
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -116,7 +116,10 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
// DndContext + SortableContext (keyed on the same ids); the virtualized rows
|
||||
// just consume that context via useSortable.
|
||||
return (
|
||||
<div className={cn('relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain', className)} ref={scrollerRef}>
|
||||
<div
|
||||
className={cn('relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain', className)}
|
||||
ref={scrollerRef}
|
||||
>
|
||||
<div className="grid gap-px" style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
|
||||
{rows}
|
||||
</div>
|
||||
|
||||
@@ -5,14 +5,7 @@ import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import {
|
||||
getActionStatus,
|
||||
getLogs,
|
||||
getStatus,
|
||||
getUsageAnalytics,
|
||||
restartGateway,
|
||||
updateHermes
|
||||
} from '@/hermes'
|
||||
import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway, updateHermes } from '@/hermes'
|
||||
import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
@@ -336,11 +329,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
||||
onClick={() => (pinned ? unpinSession(pinId) : pinSession(pinId))}
|
||||
title={pinned ? cc.unpinSession : cc.pinSession}
|
||||
>
|
||||
{pinned ? (
|
||||
<BookmarkFilled className="size-3.5" />
|
||||
) : (
|
||||
<Bookmark className="size-3.5" />
|
||||
)}
|
||||
{pinned ? <BookmarkFilled className="size-3.5" /> : <Bookmark className="size-3.5" />}
|
||||
</RowIconButton>
|
||||
<RowIconButton
|
||||
onClick={() => void exportSession(session.id, { session, title: sessionTitle(session) })}
|
||||
@@ -404,7 +393,11 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
||||
{systemAction && (
|
||||
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{systemAction.name} ·{' '}
|
||||
{systemAction.running ? cc.actionRunning : systemAction.exit_code === 0 ? cc.actionDone : cc.actionFailed}
|
||||
{systemAction.running
|
||||
? cc.actionRunning
|
||||
: systemAction.exit_code === 0
|
||||
? cc.actionDone
|
||||
: cc.actionFailed}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,12 @@ import {
|
||||
} from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $repoWorktrees } from '@/store/coding-status'
|
||||
import { $commandPaletteOpen, $commandPalettePage, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
|
||||
import {
|
||||
$commandPaletteOpen,
|
||||
$commandPalettePage,
|
||||
closeCommandPalette,
|
||||
setCommandPaletteOpen
|
||||
} from '@/store/command-palette'
|
||||
import { $bindings } from '@/store/keybinds'
|
||||
import { openPetGenerate } from '@/store/pet-generate'
|
||||
import { requestStartWorkSession } from '@/store/projects'
|
||||
@@ -206,7 +211,8 @@ function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
const background = target === 'dark' ? (resolved.darkColors ?? resolved.colors).background : resolved.colors.background
|
||||
const background =
|
||||
target === 'dark' ? (resolved.darkColors ?? resolved.colors).background : resolved.colors.background
|
||||
|
||||
return target === 'dark' ? luminance(background) <= 0.5 : luminance(background) > 0.5
|
||||
}
|
||||
@@ -703,7 +709,13 @@ export function CommandPalette() {
|
||||
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
|
||||
{/* Server-driven pages render their own list; the rest show groups. */}
|
||||
{page === 'pets' ? (
|
||||
<PetPalettePage onGenerate={() => { closeCommandPalette(); openPetGenerate() }} search={search} />
|
||||
<PetPalettePage
|
||||
onGenerate={() => {
|
||||
closeCommandPalette()
|
||||
openPetGenerate()
|
||||
}}
|
||||
search={search}
|
||||
/>
|
||||
) : page === 'install-theme' ? (
|
||||
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
|
||||
) : (
|
||||
|
||||
@@ -183,7 +183,9 @@ export function PetInlineToggle() {
|
||||
aria-pressed={enabled}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-md p-1.5 transition-colors disabled:opacity-50',
|
||||
enabled ? 'bg-(--chrome-action-hover) text-foreground' : 'text-muted-foreground hover:bg-(--chrome-action-hover)/60'
|
||||
enabled
|
||||
? 'bg-(--chrome-action-hover) text-foreground'
|
||||
: 'text-muted-foreground hover:bg-(--chrome-action-hover)/60'
|
||||
)}
|
||||
disabled={Boolean(busy)}
|
||||
onClick={toggle}
|
||||
|
||||
@@ -285,7 +285,9 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
||||
// it, queue a scroll, then clear the one-shot focus so re-opening cron
|
||||
// normally doesn't re-trigger it.
|
||||
useEffect(() => {
|
||||
if (!focusJobId) {return}
|
||||
if (!focusJobId) {
|
||||
return
|
||||
}
|
||||
|
||||
const match = jobs.find(job => job.id === focusJobId || jobName(job) === focusJobId)
|
||||
|
||||
@@ -313,7 +315,9 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
||||
useEffect(() => {
|
||||
const target = pendingScrollRef.current
|
||||
|
||||
if (!target || selectedJob?.id !== target) {return}
|
||||
if (!target || selectedJob?.id !== target) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingScrollRef.current = null
|
||||
requestAnimationFrame(() => {
|
||||
@@ -460,30 +464,30 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
||||
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{c.deleteTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pendingDelete ? (
|
||||
<>
|
||||
{c.deleteDescPrefix}
|
||||
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span>
|
||||
{c.deleteDescSuffix}
|
||||
</>
|
||||
) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
|
||||
{deleting ? c.deleting : t.common.delete}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{c.deleteTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pendingDelete ? (
|
||||
<>
|
||||
{c.deleteDescPrefix}
|
||||
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span>
|
||||
{c.deleteDescSuffix}
|
||||
</>
|
||||
) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
|
||||
{deleting ? c.deleting : t.common.delete}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
@@ -646,20 +650,28 @@ function CronJobRuns({
|
||||
const load = () =>
|
||||
getCronJobRuns(jobId)
|
||||
.then(result => {
|
||||
if (!cancelled) {setRuns(result)}
|
||||
if (!cancelled) {
|
||||
setRuns(result)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {setRuns(prev => prev ?? [])}
|
||||
if (!cancelled) {
|
||||
setRuns(prev => prev ?? [])
|
||||
}
|
||||
})
|
||||
|
||||
void load()
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible') {void load()}
|
||||
if (document.visibilityState === 'visible') {
|
||||
void load()
|
||||
}
|
||||
}, RUNS_POLL_INTERVAL_MS)
|
||||
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') {void load()}
|
||||
if (document.visibilityState === 'visible') {
|
||||
void load()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
|
||||
@@ -10,6 +10,8 @@ import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overla
|
||||
import { Pane, PaneMain } from '@/components/pane-shell'
|
||||
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { isThinClient } from '@/lib/build-mode'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
import { latestSessionTodos } from '../lib/todos'
|
||||
import { setCronFocusJobId, setCronJobs } from '../store/cron'
|
||||
import {
|
||||
$fileBrowserOpen,
|
||||
$panesFlipped,
|
||||
$pinnedSessionIds,
|
||||
$sessionsLimit,
|
||||
@@ -42,8 +45,14 @@ import {
|
||||
unpinSession
|
||||
} from '../store/layout'
|
||||
import { respondToApprovalAction } from '../store/native-notifications'
|
||||
import { $paneOpen } from '../store/panes'
|
||||
import { setPetActivity } from '../store/pet'
|
||||
import { setPetOverlayOpenAppHandler, setPetOverlaySubmitHandler } from '../store/pet-overlay'
|
||||
import { setPetScale } from '../store/pet-gallery'
|
||||
import {
|
||||
setPetOverlayOpenAppHandler,
|
||||
setPetOverlayScaleHandler,
|
||||
setPetOverlaySubmitHandler
|
||||
} from '../store/pet-overlay'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
@@ -223,6 +232,8 @@ export function DesktopController() {
|
||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const reviewOpen = useStore($reviewOpen)
|
||||
const fileBrowserOpen = useStore($fileBrowserOpen)
|
||||
const previewPaneOpen = useStore($paneOpen(PREVIEW_PANE_ID))
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const profileScope = useStore($profileScope)
|
||||
// Below SIDEBAR_COLLAPSE_BREAKPOINT_PX there's no room for a docked rail —
|
||||
@@ -934,6 +945,8 @@ export function DesktopController() {
|
||||
submitTextRef.current = submitText
|
||||
const resumeSessionRef = useRef(resumeSession)
|
||||
resumeSessionRef.current = resumeSession
|
||||
const requestGatewayRef = useRef(requestGateway)
|
||||
requestGatewayRef.current = requestGateway
|
||||
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow()) {
|
||||
@@ -941,6 +954,9 @@ export function DesktopController() {
|
||||
}
|
||||
|
||||
setPetOverlaySubmitHandler(text => void submitTextRef.current(text))
|
||||
// Alt+wheel resize from the popped-out pet — persist it through this
|
||||
// window's gateway (the overlay has none) so it survives restart.
|
||||
setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale))
|
||||
// Mail icon: $sessions is ordered most-recent-first; the pet is global (not
|
||||
// per session) so "most recent" is the right target. main.cjs already raised
|
||||
// the window before forwarding this.
|
||||
@@ -955,6 +971,7 @@ export function DesktopController() {
|
||||
return () => {
|
||||
setPetOverlaySubmitHandler(null)
|
||||
setPetOverlayOpenAppHandler(null)
|
||||
setPetOverlayScaleHandler(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -1092,7 +1109,7 @@ export function DesktopController() {
|
||||
{!isSecondaryWindow() && <DesktopInstallOverlay />}
|
||||
{!isSecondaryWindow() && (
|
||||
<DesktopOnboardingOverlay
|
||||
enabled={gatewayState === 'open'}
|
||||
enabled={isThinClient() || gatewayState === 'open'}
|
||||
onCompleted={() => {
|
||||
void refreshHermesConfig()
|
||||
void refreshCurrentModel()
|
||||
@@ -1186,7 +1203,7 @@ export function DesktopController() {
|
||||
}}
|
||||
onDismissError={dismissError}
|
||||
onEdit={editMessage}
|
||||
onPasteClipboardImage={() => void composer.pasteClipboardImage()}
|
||||
onPasteClipboardImage={opts => composer.pasteClipboardImage(opts)}
|
||||
onPickFiles={() => void composer.pickContextPaths('file')}
|
||||
onPickFolders={() => void composer.pickContextPaths('folder')}
|
||||
onPickImages={() => void composer.pickImages()}
|
||||
@@ -1207,6 +1224,17 @@ export function DesktopController() {
|
||||
const sidebarSide = panesFlipped ? 'right' : 'left'
|
||||
const railSide = panesFlipped ? 'left' : 'right'
|
||||
|
||||
// Other sidebars docked as real columns on the terminal's rail. Force-collapsed
|
||||
// hover-reveal overlays (narrow window) don't take a column, so they don't count.
|
||||
const railColumnOpen =
|
||||
(chatOpen && Boolean(previewTarget || filePreviewTarget) && previewPaneOpen) ||
|
||||
(chatOpen && !narrowViewport && fileBrowserOpen) ||
|
||||
(chatOpen && Boolean(currentCwd.trim()) && !narrowViewport && reviewOpen)
|
||||
|
||||
// Once the terminal would share its rail with another sidebar, drop it to a
|
||||
// full-width row beneath them rather than cramming in one more skinny column.
|
||||
const terminalAsRow = terminalSidebarOpen && railColumnOpen
|
||||
|
||||
const previewPane = (
|
||||
<Pane
|
||||
disabled={!chatOpen || (!previewTarget && !filePreviewTarget)}
|
||||
@@ -1277,18 +1305,31 @@ export function DesktopController() {
|
||||
|
||||
const terminalPane = (
|
||||
<Pane
|
||||
bottomRow={terminalAsRow}
|
||||
defaultOpen
|
||||
disabled={!terminalSidebarOpen}
|
||||
divider
|
||||
height="38vh"
|
||||
id="terminal-sidebar"
|
||||
key="terminal-sidebar"
|
||||
maxHeight="80vh"
|
||||
maxWidth="80vw"
|
||||
minHeight="8rem"
|
||||
minWidth="22vw"
|
||||
resizable
|
||||
side={railSide}
|
||||
width="42vw"
|
||||
>
|
||||
<div className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--ui-editor-surface-background) pt-(--titlebar-height)">
|
||||
{/* As a column the terminal clears the titlebar; as a bottom row it sits
|
||||
below the rail's panes (so it fills its row edge-to-edge) and gets a
|
||||
left border separating it from the chat — the column-mode separator
|
||||
lives on the resize sash, which moves to the top edge as a row. */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--ui-editor-surface-background)',
|
||||
terminalAsRow ? 'border-l border-(--ui-stroke-secondary) pt-0' : 'pt-(--titlebar-height)'
|
||||
)}
|
||||
>
|
||||
<TerminalSlot />
|
||||
</div>
|
||||
</Pane>
|
||||
|
||||
@@ -68,7 +68,9 @@ class FakeWebSocket {
|
||||
}
|
||||
|
||||
private emit(type: string, ev: unknown) {
|
||||
for (const fn of this.listeners[type] ?? []) fn(ev)
|
||||
for (const fn of this.listeners[type] ?? []) {
|
||||
fn(ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,9 +252,11 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
|
||||
FakeWebSocket.mode = 'fail'
|
||||
act(() => FakeWebSocket.instances[0].drop())
|
||||
await flushAsync()
|
||||
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
await advanceBackoff()
|
||||
}
|
||||
|
||||
expect($desktopBoot.get().error).toBeTruthy()
|
||||
|
||||
// The remote comes back: next reconnect attempt opens.
|
||||
|
||||
@@ -377,10 +377,12 @@ export function useGatewayBoot({
|
||||
})
|
||||
await ensureDefaultWorkspaceCwd()
|
||||
const remoteDefault = await desktopDefaultCwd().catch(() => null)
|
||||
|
||||
if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {
|
||||
setCurrentCwd(remoteDefault.cwd)
|
||||
setCurrentBranch(remoteDefault.branch || '')
|
||||
}
|
||||
|
||||
await callbacksRef.current.refreshHermesConfig()
|
||||
|
||||
if (cancelled) {
|
||||
|
||||
@@ -108,24 +108,27 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
const platformIds = useMemo(() => platforms?.map(p => p.id) ?? [], [platforms])
|
||||
const [selectedId, setSelectedId] = useRouteEnumParam('platform', platformIds, platformIds[0] ?? '')
|
||||
|
||||
const refreshPlatforms = useCallback(async (silent = false) => {
|
||||
if (!silent) {
|
||||
setRefreshing(true)
|
||||
}
|
||||
const refreshPlatforms = useCallback(
|
||||
async (silent = false) => {
|
||||
if (!silent) {
|
||||
setRefreshing(true)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getMessagingPlatforms()
|
||||
setPlatforms(result.platforms)
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
notifyError(err, m.loadFailed)
|
||||
try {
|
||||
const result = await getMessagingPlatforms()
|
||||
setPlatforms(result.platforms)
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
notifyError(err, m.loadFailed)
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
}, [m])
|
||||
},
|
||||
[m]
|
||||
)
|
||||
|
||||
useRefreshHotkey(() => void refreshPlatforms())
|
||||
|
||||
@@ -532,7 +535,7 @@ const PLATFORM_INTRO: Record<string, string> = {
|
||||
wecom_callback:
|
||||
'Set up a WeCom self-built app, expose its callback URL, and provide the corp ID, secret, agent ID, and AES key.',
|
||||
weixin:
|
||||
'Run `hermes gateway setup`, select Weixin, then scan and confirm the QR code with a personal WeChat account. Hermes connects through Tencent\'s iLink Bot API and saves the credentials.',
|
||||
"Run `hermes gateway setup`, select Weixin, then scan and confirm the QR code with a personal WeChat account. Hermes connects through Tencent's iLink Bot API and saves the credentials.",
|
||||
qqbot: 'Register an app on the QQ Open Platform (q.qq.com) and copy the App ID and Client Secret.',
|
||||
api_server:
|
||||
'Expose Hermes as an OpenAI-compatible API. Set an auth key, then point Open WebUI / LobeChat / etc. at the host:port.',
|
||||
|
||||
@@ -16,7 +16,9 @@ export function GenerateUnavailable({ onSetup }: GenerateUnavailableProps) {
|
||||
<PawPrint className="size-5" />
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[length:var(--conversation-text-font-size)] font-semibold">Add an image backend to generate</p>
|
||||
<p className="text-[length:var(--conversation-text-font-size)] font-semibold">
|
||||
Add an image backend to generate
|
||||
</p>
|
||||
<p className="mx-auto max-w-[19rem] text-[length:var(--conversation-caption-font-size)] leading-relaxed text-(--ui-text-tertiary)">
|
||||
Hatching a custom pet needs a provider that can ground on a reference image.
|
||||
</p>
|
||||
|
||||
@@ -16,7 +16,17 @@ import { frameCountForRow } from '../lib/frame-count'
|
||||
const PREVIEW_SCALE = 0.7
|
||||
const PREVIEW_STATE_MS = 1400
|
||||
|
||||
const PREVIEW_ROWS = ['idle', 'waving', 'running-right', 'running-left', 'running', 'review', 'jumping', 'failed', 'waiting']
|
||||
const PREVIEW_ROWS = [
|
||||
'idle',
|
||||
'waving',
|
||||
'running-right',
|
||||
'running-left',
|
||||
'running',
|
||||
'review',
|
||||
'jumping',
|
||||
'failed',
|
||||
'waiting'
|
||||
]
|
||||
|
||||
interface HatchPreviewProps {
|
||||
pet: PetInfo
|
||||
@@ -38,7 +48,11 @@ export function HatchPreview({ pet, adopting, error, onAdopt, onDiscard }: Hatch
|
||||
// hands off to the normal state-cycling preview.
|
||||
const [celebrating, setCelebrating] = useState(false)
|
||||
const [stateIndex, setStateIndex] = useState(0)
|
||||
const previewRows = (pet.stateRows?.length ? pet.stateRows : PREVIEW_ROWS).filter(row => frameCountForRow(pet, row) > 0)
|
||||
|
||||
const previewRows = (pet.stateRows?.length ? pet.stateRows : PREVIEW_ROWS).filter(
|
||||
row => frameCountForRow(pet, row) > 0
|
||||
)
|
||||
|
||||
const rows = previewRows.length > 0 ? previewRows : ['idle']
|
||||
const activeRow = rows[stateIndex % rows.length] ?? 'idle'
|
||||
const canJump = frameCountForRow(pet, 'jumping') > 0
|
||||
@@ -58,10 +72,13 @@ export function HatchPreview({ pet, adopting, error, onAdopt, onDiscard }: Hatch
|
||||
|
||||
setCelebrating(true)
|
||||
|
||||
const id = setTimeout(() => {
|
||||
setCelebrating(false)
|
||||
setStateIndex(0)
|
||||
}, 2 * (pet.loopMs ?? 1100))
|
||||
const id = setTimeout(
|
||||
() => {
|
||||
setCelebrating(false)
|
||||
setStateIndex(0)
|
||||
},
|
||||
2 * (pet.loopMs ?? 1100)
|
||||
)
|
||||
|
||||
return () => clearTimeout(id)
|
||||
}, [revealed, pet.loopMs])
|
||||
|
||||
@@ -22,5 +22,11 @@ const ROW_TO_FRAME_KEY: Record<string, string> = {
|
||||
export function frameCountForRow(pet: PetInfo, row: string): number {
|
||||
const mapped = ROW_TO_FRAME_KEY[row]
|
||||
|
||||
return pet.framesByRow?.[row] ?? pet.framesByState?.[row] ?? (mapped ? pet.framesByState?.[mapped] : undefined) ?? pet.framesPerState ?? 0
|
||||
return (
|
||||
pet.framesByRow?.[row] ??
|
||||
pet.framesByState?.[row] ??
|
||||
(mapped ? pet.framesByState?.[mapped] : undefined) ??
|
||||
pet.framesPerState ??
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,14 @@ export function PetGenerateOverlay() {
|
||||
const working = status === 'generating' || status === 'hatching'
|
||||
const errored = status === 'error' && drafts.length === 0
|
||||
const stepOne = status === 'idle' || status === 'ready'
|
||||
const banner = errored ? error || copy.genericError : working ? copy.backgroundHint : stepOne ? copy.slowProviderHint : undefined
|
||||
|
||||
const banner = errored
|
||||
? error || copy.genericError
|
||||
: working
|
||||
? copy.backgroundHint
|
||||
: stepOne
|
||||
? copy.slowProviderHint
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user