mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-02 08:17:42 +08:00
Compare commits
95 Commits
bb/ableton
...
bb/agent-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17df5ea573 | ||
|
|
0db227a6d8 | ||
|
|
49f7a0b456 | ||
|
|
74ab798b49 | ||
|
|
0f81b0d458 | ||
|
|
62fe9fd101 | ||
|
|
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 | ||
|
|
a4091e49f1 | ||
|
|
233ef98afe | ||
|
|
1abfa66ba6 | ||
|
|
865a09a610 | ||
|
|
811df74a10 | ||
|
|
e29823f1e8 | ||
|
|
ce802e932c | ||
|
|
8501caf51f | ||
|
|
56cf517ccd | ||
|
|
6b639bc2b9 | ||
|
|
41f4dce828 | ||
|
|
985350dd85 | ||
|
|
7f02f30b76 | ||
|
|
563d347e4d | ||
|
|
6e096a850a | ||
|
|
09623b4527 | ||
|
|
c456029b4e | ||
|
|
1f950e189c | ||
|
|
ff81365988 | ||
|
|
b8fc8c908b | ||
|
|
7cd5eaa646 | ||
|
|
df514654ba | ||
|
|
55af6c447a | ||
|
|
6dfb8326f5 | ||
|
|
6d9ca04574 | ||
|
|
263f6b03eb | ||
|
|
abd6b85200 | ||
|
|
208f0d7c3b | ||
|
|
e4ff494860 | ||
|
|
ffa3d3c811 | ||
|
|
fd2a35b169 | ||
|
|
19ca295a84 | ||
|
|
3e99ec0ff9 | ||
|
|
c7e934a5b4 | ||
|
|
bf0513bca0 | ||
|
|
e7d2f0b93c | ||
|
|
9f3aa1685c | ||
|
|
890e890281 | ||
|
|
a391523bcc | ||
|
|
b8d220f268 | ||
|
|
62af32efe7 | ||
|
|
68680db10d | ||
|
|
7a7f9a5b3d | ||
|
|
488ae376db | ||
|
|
74352a1e61 | ||
|
|
344415892f | ||
|
|
e2b8018729 | ||
|
|
86e748df13 | ||
|
|
cb3f8ec03d | ||
|
|
4ffdedd369 | ||
|
|
4e023f5bc9 | ||
|
|
e7811345c1 | ||
|
|
8a45ce2dd4 | ||
|
|
4cdd1a3230 | ||
|
|
c4ba4770eb | ||
|
|
43f9d24513 | ||
|
|
2e3efce66e | ||
|
|
f7bf740640 | ||
|
|
c6575df927 | ||
|
|
f284d85efa | ||
|
|
f23d077b5f | ||
|
|
00779800f6 |
@@ -74,7 +74,7 @@ _POLISHED_TOOLS = {
|
||||
"kanban_create", "kanban_show", "kanban_comment", "kanban_complete",
|
||||
"kanban_block", "kanban_link", "kanban_heartbeat",
|
||||
"yb_query_group_info", "yb_query_group_members", "yb_search_sticker",
|
||||
"yb_send_dm", "yb_send_sticker", "mixture_of_agents",
|
||||
"yb_send_dm", "yb_send_sticker",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -719,6 +719,15 @@ def init_agent(
|
||||
print("🔑 Using credentials: Microsoft Entra ID")
|
||||
elif isinstance(effective_key, str) and len(effective_key) > 12:
|
||||
print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}")
|
||||
elif agent.provider == "moa":
|
||||
from agent.moa_loop import MoAClient
|
||||
agent.api_mode = "chat_completions"
|
||||
agent.client = MoAClient(agent.model or "default")
|
||||
agent._client_kwargs = {}
|
||||
agent.api_key = api_key or "moa-virtual-provider"
|
||||
agent.base_url = base_url or "moa://local"
|
||||
if not agent.quiet_mode:
|
||||
print(f"🤖 AI Agent initialized with MoA preset: {agent.model}")
|
||||
elif agent.api_mode == "bedrock_converse":
|
||||
# AWS Bedrock — uses boto3 directly, no OpenAI client needed.
|
||||
# Region is extracted from the base_url or defaults to us-east-1.
|
||||
|
||||
@@ -1697,6 +1697,27 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
old_model, old_provider, new_model, new_provider,
|
||||
)
|
||||
|
||||
# ── Persist billing route to session DB ──
|
||||
# The agent's _session_db / session_id may not be set in all contexts
|
||||
# (tests, bare agents without a session DB, etc.). This ensures the
|
||||
# dashboard Model cards show the actual provider after a mid-session
|
||||
# /model switch instead of the stale session-creation provider.
|
||||
# See #48248 for the full bug description.
|
||||
_session_db = getattr(agent, "_session_db", None)
|
||||
_session_id = getattr(agent, "session_id", None)
|
||||
if _session_db is not None and _session_id:
|
||||
try:
|
||||
_session_db.update_session_billing_route(
|
||||
_session_id,
|
||||
provider=agent.provider,
|
||||
base_url=agent.base_url,
|
||||
billing_mode=getattr(agent, "api_mode", None),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist billing route after model switch",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def invoke_tool(agent, function_name: str, function_args: dict, effective_task_id: str,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -83,6 +83,59 @@ _PROJECT_MARKERS = (
|
||||
# Agent-instruction files surfaced separately from manifests in the snapshot.
|
||||
_CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules")
|
||||
|
||||
# Source-file extensions that make a git repo a *code* workspace even with no
|
||||
# manifest. Without this, `git init` on a notes/writing/research folder (a huge
|
||||
# non-coding use case) would flip the whole session into the coding posture just
|
||||
# for having a `.git`. A manifest still wins on its own (see `_PROJECT_MARKERS`).
|
||||
_CODE_EXTENSIONS = frozenset({
|
||||
".py", ".pyi", ".ipynb", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
|
||||
".go", ".rs", ".java", ".kt", ".kts", ".scala", ".rb", ".php", ".c", ".h",
|
||||
".cc", ".cpp", ".hpp", ".cs", ".swift", ".m", ".mm", ".dart", ".ex", ".exs",
|
||||
".lua", ".sh", ".bash", ".zsh", ".sql", ".vue", ".svelte", ".r", ".jl",
|
||||
".hs", ".clj", ".erl", ".pl",
|
||||
})
|
||||
|
||||
# Dirs never worth scanning for the code check (deps/build/vcs/venv noise).
|
||||
_CODE_SCAN_SKIP_DIRS = frozenset({
|
||||
".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build",
|
||||
"target", ".next", ".turbo", "vendor",
|
||||
})
|
||||
|
||||
# Bounded sweep: a code workspace reveals itself in the first handful of entries.
|
||||
_CODE_SCAN_MAX_ENTRIES = 500
|
||||
|
||||
|
||||
def _has_code_files(root: Path) -> bool:
|
||||
"""Cheap, bounded check for source files in a repo's top two levels.
|
||||
|
||||
Lets a git repo of loose scripts (no manifest) still read as a code
|
||||
workspace while a bare notes/writing repo does not. Scans the root and its
|
||||
immediate subdirectories only, capped at ``_CODE_SCAN_MAX_ENTRIES`` stats —
|
||||
a handful of readdirs at session start, not a full walk.
|
||||
"""
|
||||
seen = 0
|
||||
stack = [(root, True)]
|
||||
while stack:
|
||||
directory, is_root = stack.pop()
|
||||
try:
|
||||
with os.scandir(directory) as entries:
|
||||
for entry in entries:
|
||||
seen += 1
|
||||
if seen > _CODE_SCAN_MAX_ENTRIES:
|
||||
return False
|
||||
name = entry.name
|
||||
try:
|
||||
if entry.is_file():
|
||||
if os.path.splitext(name)[1].lower() in _CODE_EXTENSIONS:
|
||||
return True
|
||||
elif is_root and entry.is_dir() and name not in _CODE_SCAN_SKIP_DIRS and not name.startswith("."):
|
||||
stack.append((Path(entry.path), False))
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
return False
|
||||
|
||||
# Lockfile → package manager, checked in priority order.
|
||||
_PY_LOCKFILES = (("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile.lock", "pipenv"))
|
||||
_JS_LOCKFILES = (
|
||||
@@ -368,10 +421,16 @@ def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str:
|
||||
if platform and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS:
|
||||
return GENERAL_PROFILE.name
|
||||
cwd = Path(cwd_str)
|
||||
# A recognized project root (manifest / AGENTS.md / .cursorrules) is a code
|
||||
# workspace on its own — cheap stat checks, no scan.
|
||||
if _marker_root(cwd) is not None:
|
||||
return CODING_PROFILE.name
|
||||
git_root = _git_root(cwd)
|
||||
if git_root is not None and git_root == _home():
|
||||
git_root = None # dotfiles repo at $HOME — not a code workspace
|
||||
if git_root is not None or _marker_root(cwd) is not None:
|
||||
# A bare git repo only counts when it actually holds code, so `git init` on a
|
||||
# notes/writing/research folder stays in the general posture.
|
||||
if git_root is not None and _has_code_files(git_root):
|
||||
return CODING_PROFILE.name
|
||||
return GENERAL_PROFILE.name
|
||||
|
||||
|
||||
@@ -502,6 +502,7 @@ def run_conversation(
|
||||
stream_callback: Optional[callable] = None,
|
||||
persist_user_message: Optional[str] = None,
|
||||
persist_user_timestamp: Optional[float] = None,
|
||||
moa_config: Optional[dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Run a complete conversation with tool calling until completion.
|
||||
@@ -524,6 +525,19 @@ def run_conversation(
|
||||
Returns:
|
||||
Dict: Complete conversation result with final response and message history
|
||||
"""
|
||||
if moa_config is None:
|
||||
try:
|
||||
from hermes_cli.moa_config import decode_moa_turn
|
||||
|
||||
_decoded_message, _decoded_moa_config = decode_moa_turn(user_message)
|
||||
if _decoded_moa_config is not None:
|
||||
user_message = _decoded_message
|
||||
moa_config = _decoded_moa_config
|
||||
if persist_user_message is None:
|
||||
persist_user_message = _decoded_message
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Per-turn setup (the prologue) ──
|
||||
# All once-per-turn setup — stdio guarding, retry-counter resets, user
|
||||
# message sanitization, todo/nudge hydration, system-prompt restore-or-
|
||||
@@ -802,6 +816,29 @@ def run_conversation(
|
||||
if effective_system:
|
||||
api_messages = [{"role": "system", "content": effective_system}] + api_messages
|
||||
|
||||
if moa_config:
|
||||
try:
|
||||
from agent.moa_loop import aggregate_moa_context
|
||||
|
||||
_moa_context = aggregate_moa_context(
|
||||
user_prompt=original_user_message if isinstance(original_user_message, str) else str(original_user_message),
|
||||
api_messages=api_messages,
|
||||
reference_models=moa_config.get("reference_models") or [],
|
||||
aggregator=moa_config.get("aggregator") or {},
|
||||
temperature=float(moa_config.get("reference_temperature", 0.6) or 0.6),
|
||||
aggregator_temperature=float(moa_config.get("aggregator_temperature", 0.4) or 0.4),
|
||||
max_tokens=int(moa_config.get("max_tokens", 4096) or 4096),
|
||||
)
|
||||
if _moa_context:
|
||||
for _msg in reversed(api_messages):
|
||||
if _msg.get("role") == "user":
|
||||
_base = _msg.get("content", "")
|
||||
if isinstance(_base, str):
|
||||
_msg["content"] = _base + "\n\n" + _moa_context
|
||||
break
|
||||
except Exception as _moa_exc:
|
||||
logger.warning("MoA context aggregation failed: %s", _moa_exc)
|
||||
|
||||
# Inject ephemeral prefill messages right after the system prompt
|
||||
# but before conversation history. Same API-call-time-only pattern.
|
||||
if agent.prefill_messages:
|
||||
@@ -1123,7 +1160,7 @@ def run_conversation(
|
||||
# stream. Mirror the ACP exclusion used for Responses
|
||||
# API upgrade (lines ~1083-1085).
|
||||
elif (
|
||||
agent.provider == "copilot-acp"
|
||||
agent.provider in {"copilot-acp", "moa"}
|
||||
or str(agent.base_url or "").lower().startswith("acp://copilot")
|
||||
or str(agent.base_url or "").lower().startswith("acp+tcp://")
|
||||
):
|
||||
@@ -1974,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:
|
||||
@@ -3490,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,
|
||||
@@ -3506,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 "
|
||||
|
||||
@@ -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,13 +419,14 @@ 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",
|
||||
"search_files": "pattern", "browser_navigate": "url",
|
||||
"browser_click": "ref", "browser_type": "text",
|
||||
"image_generate": "prompt", "text_to_speech": "text",
|
||||
"vision_analyze": "question", "mixture_of_agents": "user_prompt",
|
||||
"vision_analyze": "question",
|
||||
"skill_view": "name", "skills_list": "category",
|
||||
"cronjob": "action",
|
||||
"execute_code": "code", "delegate_task": "goal",
|
||||
@@ -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()
|
||||
@@ -1216,8 +1275,6 @@ def get_cute_tool_message(
|
||||
return _wrap(f"┊ 🔊 speak {_trunc(args.get('text', ''), 30)} {dur}")
|
||||
if tool_name == "vision_analyze":
|
||||
return _wrap(f"┊ 👁️ vision {_trunc(args.get('question', ''), 30)} {dur}")
|
||||
if tool_name == "mixture_of_agents":
|
||||
return _wrap(f"┊ 🧠 reason {_trunc(args.get('user_prompt', ''), 30)} {dur}")
|
||||
if tool_name == "send_message":
|
||||
return _wrap(f"┊ 📨 send {args.get('target', '?')}: \"{_trunc(args.get('message', ''), 25)}\" {dur}")
|
||||
if tool_name == "cronjob":
|
||||
|
||||
@@ -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.
|
||||
|
||||
306
agent/moa_loop.py
Normal file
306
agent/moa_loop.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""Mixture-of-Agents runtime helpers for /moa turns.
|
||||
|
||||
The slash command is deliberately not a model tool. It marks one user turn as
|
||||
MoA-enabled; the normal Hermes agent loop still owns tool calling and turn
|
||||
termination, while this module gathers reference-model context before each model
|
||||
iteration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
|
||||
from agent.auxiliary_client import call_llm
|
||||
from agent.transports import get_transport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Upper bound on concurrent reference-model calls. References are independent
|
||||
# advisory calls (no tools, no inter-dependence), so we fan them out the same
|
||||
# way delegate_task runs a batch: all in flight at once, results collected when
|
||||
# every reference finishes. Presets rarely list more than a handful of
|
||||
# references; this cap just protects against a pathologically large preset
|
||||
# opening dozens of sockets at once.
|
||||
_MAX_REFERENCE_WORKERS = 8
|
||||
|
||||
|
||||
def _slot_label(slot: dict[str, str]) -> str:
|
||||
return f"{slot.get('provider', '').strip()}:{slot.get('model', '').strip()}"
|
||||
|
||||
|
||||
def _run_reference(
|
||||
slot: dict[str, str],
|
||||
ref_messages: list[dict[str, Any]],
|
||||
*,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
) -> tuple[str, str]:
|
||||
"""Call one reference model and return ``(label, text)``.
|
||||
|
||||
Never raises: a failed reference becomes a labelled note so the aggregator
|
||||
can still act with partial context. Designed to run inside a thread pool —
|
||||
``call_llm`` is synchronous/blocking, so threads (not asyncio) are the right
|
||||
concurrency primitive, mirroring ``delegate_task``'s batch fan-out.
|
||||
"""
|
||||
label = _slot_label(slot)
|
||||
try:
|
||||
response = call_llm(
|
||||
task="moa_reference",
|
||||
provider=slot["provider"],
|
||||
model=slot["model"],
|
||||
messages=ref_messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
return label, _extract_text(response) or "(empty response)"
|
||||
except Exception as exc:
|
||||
logger.warning("MoA reference model %s failed: %s", label, exc)
|
||||
return label, f"[failed: {exc}]"
|
||||
|
||||
|
||||
def _run_references_parallel(
|
||||
reference_models: list[dict[str, str]],
|
||||
ref_messages: list[dict[str, Any]],
|
||||
*,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Fan out all reference models in parallel, returning outputs in order.
|
||||
|
||||
Like ``delegate_task``'s batch mode, every reference is dispatched at once
|
||||
and we block until all of them finish before handing the joined results to
|
||||
the aggregator. Output order matches ``reference_models`` so the
|
||||
``Reference {idx}`` labelling stays stable. MoA presets that reference
|
||||
another MoA preset are skipped here (recursion guard) with a labelled note.
|
||||
"""
|
||||
if not reference_models:
|
||||
return []
|
||||
|
||||
results: list[tuple[str, str] | None] = [None] * len(reference_models)
|
||||
futures = {}
|
||||
workers = min(_MAX_REFERENCE_WORKERS, len(reference_models))
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
for idx, slot in enumerate(reference_models):
|
||||
if slot.get("provider") == "moa":
|
||||
results[idx] = (
|
||||
_slot_label(slot),
|
||||
"[skipped: MoA presets cannot recursively reference MoA]",
|
||||
)
|
||||
continue
|
||||
futures[
|
||||
executor.submit(
|
||||
_run_reference,
|
||||
slot,
|
||||
ref_messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
] = idx
|
||||
# Collect every reference before returning — the aggregator needs the
|
||||
# complete set, so there is no early-exit / first-completed path here.
|
||||
for future, idx in futures.items():
|
||||
results[idx] = future.result()
|
||||
|
||||
return [r for r in results if r is not None]
|
||||
|
||||
|
||||
def _reference_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Build an advisory-safe view of the conversation for reference models.
|
||||
|
||||
Reference calls are advisory: they never call tools and never emit the
|
||||
``tool_calls`` the main model did. Replaying the full transcript verbatim
|
||||
(a) re-bills the ~8K-token Hermes system prompt per reference per
|
||||
iteration and (b) risks 400s from strict providers (Mistral, Fireworks)
|
||||
that reject orphan ``tool`` messages or ``tool_calls`` the reference never
|
||||
produced. We keep only the user/assistant *text* turns, dropping the
|
||||
system prompt, any ``tool``-role messages, and any ``tool_calls`` payloads.
|
||||
"""
|
||||
trimmed: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
role = msg.get("role")
|
||||
if role not in ("user", "assistant"):
|
||||
# Drop system prompt and tool-result messages.
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, str):
|
||||
# Skip non-text (multimodal/tool-call-only) assistant turns.
|
||||
if not content:
|
||||
continue
|
||||
text = content if isinstance(content, str) else ""
|
||||
if role == "assistant" and not text.strip():
|
||||
# Assistant turn that was purely tool calls — nothing advisory.
|
||||
continue
|
||||
trimmed.append({"role": role, "content": text})
|
||||
if not trimmed:
|
||||
# Degenerate case (e.g. first turn was stripped): fall back to a
|
||||
# minimal user turn so the reference still has something to answer.
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user" and isinstance(msg.get("content"), str):
|
||||
return [{"role": "user", "content": msg["content"]}]
|
||||
return trimmed
|
||||
|
||||
|
||||
|
||||
def _extract_text(response: Any) -> str:
|
||||
try:
|
||||
transport = get_transport("chat_completions")
|
||||
if transport is None:
|
||||
raise RuntimeError("chat_completions transport unavailable")
|
||||
normalized = transport.normalize_response(response)
|
||||
text = (normalized.content or "").strip()
|
||||
if text:
|
||||
return text
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
content = response.choices[0].message.content
|
||||
return (content or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def aggregate_moa_context(
|
||||
*,
|
||||
user_prompt: str,
|
||||
api_messages: list[dict[str, Any]],
|
||||
reference_models: list[dict[str, str]],
|
||||
aggregator: dict[str, str],
|
||||
temperature: float = 0.6,
|
||||
aggregator_temperature: float = 0.4,
|
||||
max_tokens: int = 4096,
|
||||
) -> str:
|
||||
"""Run configured reference models and synthesize their advice.
|
||||
|
||||
Failures are returned as model-specific notes instead of aborting the normal
|
||||
agent loop; the main model can still act with partial context.
|
||||
"""
|
||||
reference_outputs: list[tuple[str, str]] = []
|
||||
ref_messages = _reference_messages(api_messages)
|
||||
reference_outputs = _run_references_parallel(
|
||||
reference_models,
|
||||
ref_messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
|
||||
joined = "\n\n".join(
|
||||
f"Reference {idx} — {label}:\n{text}"
|
||||
for idx, (label, text) in enumerate(reference_outputs, start=1)
|
||||
)
|
||||
synth_prompt = (
|
||||
"You are the aggregator in a Mixture of Agents process. Synthesize the "
|
||||
"reference responses into concise, actionable guidance for the main "
|
||||
"Hermes agent. Focus on next steps, tool-use strategy, risks, and any "
|
||||
"disagreements. Do not answer the user directly unless that is all that "
|
||||
"is needed; produce context the main agent should use in its normal loop.\n\n"
|
||||
f"Original user prompt:\n{user_prompt}\n\n"
|
||||
f"Reference responses:\n{joined}"
|
||||
)
|
||||
|
||||
agg_label = _slot_label(aggregator)
|
||||
try:
|
||||
response = call_llm(
|
||||
task="moa_aggregator",
|
||||
provider=aggregator["provider"],
|
||||
model=aggregator["model"],
|
||||
messages=[{"role": "user", "content": synth_prompt}],
|
||||
temperature=aggregator_temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
synthesis = _extract_text(response)
|
||||
except Exception as exc:
|
||||
logger.warning("MoA aggregator model %s failed: %s", agg_label, exc)
|
||||
synthesis = ""
|
||||
|
||||
if not synthesis:
|
||||
synthesis = joined
|
||||
|
||||
return (
|
||||
"[Mixture of Agents context — use this as private guidance for the "
|
||||
"normal Hermes agent loop. You may call tools, continue reasoning, or "
|
||||
"finish normally.]\n"
|
||||
f"Aggregator: {agg_label}\n"
|
||||
f"References: {', '.join(_slot_label(slot) for slot in reference_models)}\n\n"
|
||||
f"{synthesis.strip()}"
|
||||
)
|
||||
|
||||
|
||||
class MoAChatCompletions:
|
||||
"""OpenAI-chat-compatible facade where the aggregator is the acting model."""
|
||||
|
||||
def __init__(self, preset_name: str):
|
||||
self.preset_name = preset_name or "default"
|
||||
|
||||
def create(self, **api_kwargs: Any) -> Any:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.moa_config import resolve_moa_preset
|
||||
|
||||
preset = resolve_moa_preset(load_config().get("moa") or {}, self.preset_name)
|
||||
messages = list(api_kwargs.get("messages") or [])
|
||||
reference_models = preset.get("reference_models") or []
|
||||
aggregator = preset.get("aggregator") or {}
|
||||
max_tokens = int(preset.get("max_tokens", api_kwargs.get("max_tokens") or 4096) or 4096)
|
||||
temperature = float(preset.get("reference_temperature", 0.6) or 0.6)
|
||||
aggregator_temperature = float(preset.get("aggregator_temperature", api_kwargs.get("temperature") or 0.4) or 0.4)
|
||||
|
||||
# When the preset is disabled, skip the reference fan-out and let the
|
||||
# configured aggregator act alone — it is the preset's acting model, so
|
||||
# a disabled MoA preset is simply "use the aggregator directly."
|
||||
if not preset.get("enabled", True):
|
||||
reference_models = []
|
||||
|
||||
reference_outputs: list[tuple[str, str]] = []
|
||||
ref_messages = _reference_messages(messages)
|
||||
reference_outputs = _run_references_parallel(
|
||||
reference_models,
|
||||
ref_messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
|
||||
agg_messages = [dict(m) for m in messages]
|
||||
if reference_outputs:
|
||||
joined = "\n\n".join(
|
||||
f"Reference {idx} — {label}:\n{text}"
|
||||
for idx, (label, text) in enumerate(reference_outputs, start=1)
|
||||
)
|
||||
guidance = (
|
||||
"[Mixture of Agents reference context]\n"
|
||||
f"Preset: {self.preset_name}\n"
|
||||
f"Aggregator/acting model: {_slot_label(aggregator)}\n"
|
||||
f"References: {', '.join(label for label, _ in reference_outputs)}\n\n"
|
||||
"Use the reference responses below as private context. You are the aggregator and acting model: "
|
||||
"answer the user directly or call tools as needed.\n\n"
|
||||
f"{joined}"
|
||||
)
|
||||
for msg in reversed(agg_messages):
|
||||
if msg.get("role") == "user" and isinstance(msg.get("content"), str):
|
||||
msg["content"] = msg["content"] + "\n\n" + guidance
|
||||
break
|
||||
else:
|
||||
agg_messages.append({"role": "user", "content": guidance})
|
||||
|
||||
if aggregator.get("provider") == "moa":
|
||||
raise RuntimeError("MoA aggregator cannot be another MoA preset")
|
||||
agg_kwargs = dict(api_kwargs)
|
||||
agg_kwargs["messages"] = agg_messages
|
||||
agg_kwargs["model"] = aggregator.get("model")
|
||||
agg_kwargs["temperature"] = aggregator_temperature
|
||||
return call_llm(
|
||||
task="moa_aggregator",
|
||||
provider=aggregator.get("provider"),
|
||||
model=aggregator.get("model"),
|
||||
messages=agg_messages,
|
||||
temperature=aggregator_temperature,
|
||||
max_tokens=agg_kwargs.get("max_tokens"),
|
||||
tools=agg_kwargs.get("tools"),
|
||||
extra_body=agg_kwargs.get("extra_body"),
|
||||
)
|
||||
|
||||
|
||||
class MoAClient:
|
||||
def __init__(self, preset_name: str):
|
||||
self.chat = type("_MoAChat", (), {})()
|
||||
self.chat.completions = MoAChatCompletions(preset_name)
|
||||
@@ -243,7 +243,10 @@ KANBAN_GUIDANCE = (
|
||||
"- **Workspace.** `cd $HERMES_KANBAN_WORKSPACE` first. For a `worktree` kind "
|
||||
"with no `.git`, `git worktree add <path> "
|
||||
"${HERMES_KANBAN_BRANCH:-wt/$HERMES_KANBAN_TASK}` from the main repo, then "
|
||||
"cd there.\n"
|
||||
"cd there. For a project-linked task the workspace is a fresh "
|
||||
"`<repo>/.worktrees/<task-id>` and `$HERMES_KANBAN_BRANCH` a deterministic "
|
||||
"`<project-slug>/<task-id>` — the main repo is two levels up, so run "
|
||||
"`git worktree add` from there.\n"
|
||||
"- **Deliverables.** Files a human wants go in "
|
||||
"`kanban_complete(artifacts=[<absolute paths>])` (top-level param; paths in "
|
||||
"`metadata` are NOT uploaded). Files must exist at completion.\n"
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
610
agent/trace_builder.py
Normal file
610
agent/trace_builder.py
Normal file
@@ -0,0 +1,610 @@
|
||||
"""Derive OpenTelemetry-style traces from the Hermes session store.
|
||||
|
||||
Hermes already persists everything a trace needs: ``sessions`` rows carry
|
||||
server-side ``started_at`` / ``ended_at`` and full token accounting, and
|
||||
``messages`` rows carry a server-side ``timestamp`` plus ``tool_calls`` (the
|
||||
OpenAI tool-call JSON) and ``tool_call_id`` so a tool call can be paired with
|
||||
its result. Every subagent is itself a session linked by
|
||||
``parent_session_id``. That means a complete, accurately-timed span tree can be
|
||||
reconstructed for *any* session — historical or live — with zero extra
|
||||
instrumentation.
|
||||
|
||||
This module is the read-side "derive-on-read" trace builder. It turns a session
|
||||
(and its subagent descendants) into a provider-neutral :class:`Trace` of
|
||||
:class:`Span` objects. ``agent/trace_export.py`` renders that into OTLP/JSON
|
||||
(OpenInference conventions, ingestible by Arize Phoenix / any OTel backend) or
|
||||
the Chrome Trace Event format (viewable in https://ui.perfetto.dev).
|
||||
|
||||
Accuracy note: the Hermes agent loop runs tool calls sequentially, so inferring
|
||||
span durations from consecutive message timestamps matches real execution. The
|
||||
only inferred link is a ``delegate_task`` tool call → its child session, matched
|
||||
by start-time proximity; a future precision pass can persist the spawning
|
||||
``tool_call_id`` to make that exact.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Protocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# OpenInference span kinds (what Phoenix and other OTel-GenAI viewers expect).
|
||||
KIND_AGENT = "AGENT"
|
||||
KIND_LLM = "LLM"
|
||||
KIND_TOOL = "TOOL"
|
||||
KIND_CHAIN = "CHAIN"
|
||||
|
||||
# Status codes, mirroring OTLP (1 = OK, 2 = ERROR).
|
||||
STATUS_OK = "ok"
|
||||
STATUS_ERROR = "error"
|
||||
STATUS_UNSET = "unset"
|
||||
|
||||
# Tool names that spawn subagent sessions. A span with one of these names gets
|
||||
# its matched child session's subtree nested underneath it.
|
||||
_DELEGATE_TOOL_NAMES = frozenset({"delegate_task"})
|
||||
|
||||
# How long after the last message a session's ``ended_at`` may sit and still be
|
||||
# trusted as real activity (vs a cleanup/orphan reaper firing much later).
|
||||
_END_GRACE_SECONDS = 300.0
|
||||
|
||||
|
||||
class _SessionStore(Protocol):
|
||||
"""The slice of ``SessionDB`` the builder depends on (keeps it testable)."""
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]: ...
|
||||
|
||||
def get_messages(
|
||||
self, session_id: str, include_inactive: bool = False
|
||||
) -> List[Dict[str, Any]]: ...
|
||||
|
||||
def get_child_session_ids(self, parent_session_id: str) -> List[str]: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class Span:
|
||||
"""A single unit of work on the trace timeline.
|
||||
|
||||
Times are epoch seconds (float) to match ``messages.timestamp``. Exporters
|
||||
convert to their own units (OTLP nanoseconds, Chrome microseconds).
|
||||
"""
|
||||
|
||||
span_id: str
|
||||
parent_id: Optional[str]
|
||||
name: str
|
||||
kind: str
|
||||
start: float
|
||||
end: float
|
||||
status: str = STATUS_UNSET
|
||||
session_id: Optional[str] = None
|
||||
attributes: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return max(0.0, self.end - self.start)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"span_id": self.span_id,
|
||||
"parent_id": self.parent_id,
|
||||
"name": self.name,
|
||||
"kind": self.kind,
|
||||
"start": self.start,
|
||||
"end": self.end,
|
||||
"duration": self.duration,
|
||||
"status": self.status,
|
||||
"session_id": self.session_id,
|
||||
"attributes": {k: v for k, v in self.attributes.items() if v is not None},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trace:
|
||||
"""A full span tree rooted at one session (plus its subagent descendants)."""
|
||||
|
||||
trace_id: str
|
||||
root_session_id: str
|
||||
spans: List[Span] = field(default_factory=list)
|
||||
root_span_id: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def start(self) -> float:
|
||||
return min((s.start for s in self.spans), default=0.0)
|
||||
|
||||
@property
|
||||
def end(self) -> float:
|
||||
return max((s.end for s in self.spans), default=0.0)
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return max(0.0, self.end - self.start)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"trace_id": self.trace_id,
|
||||
"root_session_id": self.root_session_id,
|
||||
"root_span_id": self.root_span_id,
|
||||
"start": self.start,
|
||||
"end": self.end,
|
||||
"duration": self.duration,
|
||||
"metadata": {k: v for k, v in self.metadata.items() if v is not None},
|
||||
"spans": [s.to_dict() for s in self.spans],
|
||||
}
|
||||
|
||||
|
||||
# ── tool-call shape helpers ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _tool_call_id(call: Dict[str, Any]) -> str:
|
||||
return str(call.get("id") or call.get("tool_call_id") or "")
|
||||
|
||||
|
||||
def _tool_call_name(call: Dict[str, Any]) -> str:
|
||||
fn = call.get("function")
|
||||
if isinstance(fn, dict) and fn.get("name"):
|
||||
return str(fn["name"])
|
||||
return str(call.get("name") or "tool")
|
||||
|
||||
|
||||
def _tool_call_args(call: Dict[str, Any]) -> Any:
|
||||
fn = call.get("function")
|
||||
raw = fn.get("arguments") if isinstance(fn, dict) else call.get("arguments")
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return raw
|
||||
return raw
|
||||
|
||||
|
||||
def _as_text(content: Any) -> str:
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
try:
|
||||
return json.dumps(content, ensure_ascii=False, default=str)
|
||||
except (TypeError, ValueError):
|
||||
return str(content)
|
||||
|
||||
|
||||
def _looks_like_error(message: Dict[str, Any]) -> bool:
|
||||
"""Best-effort error detection on a tool-result message."""
|
||||
text = _as_text(message.get("content")).lstrip()
|
||||
if not text:
|
||||
return False
|
||||
head = text[:400].lower()
|
||||
if text.startswith("{"):
|
||||
try:
|
||||
obj = json.loads(text)
|
||||
if isinstance(obj, dict):
|
||||
if obj.get("error") or obj.get("success") is False:
|
||||
return True
|
||||
status = str(obj.get("status", "")).lower()
|
||||
if status in {"error", "failed", "failure"}:
|
||||
return True
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return any(
|
||||
marker in head
|
||||
for marker in ("traceback (most recent call last)", "error:", "exception:")
|
||||
)
|
||||
|
||||
|
||||
# ── builder ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _short(value: str, limit: int = 120) -> str:
|
||||
flat = " ".join(value.split())
|
||||
return flat if len(flat) <= limit else flat[: limit - 1] + "…"
|
||||
|
||||
|
||||
def _llm_span_name() -> str:
|
||||
"""Label for an LLM (assistant-turn) span. A plain structural "llm" (matching
|
||||
the OTel/Langfuse convention of short, uniform LLM labels) — the model and
|
||||
the response text live in the span's attributes / detail panel, not the row.
|
||||
"""
|
||||
return "llm"
|
||||
|
||||
|
||||
def _mk_span_id(prefix: str, session_id: str, key: Any) -> str:
|
||||
return f"{prefix}:{session_id}:{key}"
|
||||
|
||||
|
||||
def _trace_id(session_id: str) -> str:
|
||||
return f"trace:{session_id}"
|
||||
|
||||
|
||||
def build_trace(
|
||||
store: _SessionStore,
|
||||
session_id: str,
|
||||
*,
|
||||
include_subagents: bool = True,
|
||||
_depth: int = 0,
|
||||
_max_depth: int = 8,
|
||||
) -> Optional[Trace]:
|
||||
"""Reconstruct a :class:`Trace` for ``session_id`` from the session store.
|
||||
|
||||
Returns ``None`` when the session does not exist. Walks delegate subagent
|
||||
descendants (``parent_session_id``) and nests each under the
|
||||
``delegate_task`` tool span that spawned it.
|
||||
"""
|
||||
session = store.get_session(session_id)
|
||||
if not session:
|
||||
return None
|
||||
|
||||
trace = Trace(
|
||||
trace_id=_trace_id(session_id),
|
||||
root_session_id=session_id,
|
||||
metadata={
|
||||
"source": session.get("source"),
|
||||
"model": session.get("model"),
|
||||
"cwd": session.get("cwd"),
|
||||
"git_branch": session.get("git_branch"),
|
||||
},
|
||||
)
|
||||
|
||||
root_span = _build_session_spans(store, session, parent_span_id=None, trace=trace)
|
||||
trace.root_span_id = root_span.span_id if root_span else None
|
||||
|
||||
if include_subagents and root_span and _depth < _max_depth:
|
||||
_attach_subagents(store, session_id, trace, _depth=_depth, _max_depth=_max_depth)
|
||||
|
||||
trace.spans.sort(key=lambda s: (s.start, s.span_id))
|
||||
return trace
|
||||
|
||||
|
||||
# Synthetic re-injections that re-enter the conversation as ``user`` messages
|
||||
# but are CONTINUATIONS of earlier work, not a fresh prompt: async-delegation
|
||||
# completions (`[ASYNC DELEGATION …]`) and background-process notifications
|
||||
# (`[IMPORTANT: …]`). They must not open a new turn — otherwise a background
|
||||
# subagent dispatched in turn N shows up as its own orphan "[ASYNC DELEGATION]"
|
||||
# turn when it finishes, instead of folding into the group that spawned it. This
|
||||
# mirrors the desktop live view, which only resets the live turn on a real
|
||||
# ``prompt.submit``.
|
||||
_CONTINUATION_PREFIXES = (
|
||||
"[ASYNC DELEGATION",
|
||||
"[IMPORTANT:",
|
||||
)
|
||||
|
||||
|
||||
def _is_continuation(message: Dict[str, Any]) -> bool:
|
||||
"""True for a synthetic re-injection that should merge into the current turn."""
|
||||
if message.get("role") != "user":
|
||||
return False
|
||||
return _as_text(message.get("content")).lstrip().startswith(_CONTINUATION_PREFIXES)
|
||||
|
||||
|
||||
def _split_turns(messages: List[Dict[str, Any]]) -> List[tuple]:
|
||||
"""Split messages into turns. A turn begins at each *real* ``user`` message and
|
||||
runs until the next one. Synthetic continuations (async-delegation /
|
||||
background-process re-injections) do NOT start a turn — they merge into the
|
||||
one that spawned the work. Leading non-user messages (e.g. system) join the
|
||||
first turn. Returns ``[(start_idx, end_idx), ...]`` index ranges.
|
||||
"""
|
||||
bounds: List[tuple] = []
|
||||
start = 0
|
||||
for i, m in enumerate(messages):
|
||||
if i > 0 and m.get("role") == "user" and not _is_continuation(m):
|
||||
bounds.append((start, i))
|
||||
start = i
|
||||
bounds.append((start, len(messages)))
|
||||
return bounds
|
||||
|
||||
|
||||
def build_session_turns(
|
||||
store: _SessionStore,
|
||||
session_id: str,
|
||||
*,
|
||||
include_subagents: bool = True,
|
||||
) -> List[Trace]:
|
||||
"""Build one :class:`Trace` per turn for a session.
|
||||
|
||||
A turn (one user prompt → the agent's full response, subagents included) is
|
||||
the natural trace unit — it has no inter-turn idle gaps, so each renders as a
|
||||
tight waterfall. Returns traces in chronological order.
|
||||
"""
|
||||
session = store.get_session(session_id)
|
||||
if not session:
|
||||
return []
|
||||
messages = store.get_messages(session_id)
|
||||
if not messages:
|
||||
return []
|
||||
|
||||
meta = {
|
||||
"source": session.get("source"),
|
||||
"model": session.get("model"),
|
||||
"cwd": session.get("cwd"),
|
||||
"git_branch": session.get("git_branch"),
|
||||
}
|
||||
out: List[Trace] = []
|
||||
for ti, (a, b) in enumerate(_split_turns(messages)):
|
||||
slice_msgs = messages[a:b]
|
||||
if not slice_msgs:
|
||||
continue
|
||||
trace = Trace(
|
||||
trace_id=f"{_trace_id(session_id)}:t{ti}",
|
||||
root_session_id=session_id,
|
||||
metadata={**meta, "turn": ti},
|
||||
)
|
||||
root = _build_session_spans(
|
||||
store,
|
||||
session,
|
||||
parent_span_id=None,
|
||||
trace=trace,
|
||||
messages=slice_msgs,
|
||||
agent_key=f"turn{ti}",
|
||||
)
|
||||
if not root:
|
||||
continue
|
||||
trace.root_span_id = root.span_id
|
||||
if include_subagents:
|
||||
_attach_subagents(
|
||||
store,
|
||||
session_id,
|
||||
trace,
|
||||
agent_key=f"turn{ti}",
|
||||
window=(root.start, root.end),
|
||||
_depth=0,
|
||||
_max_depth=8,
|
||||
)
|
||||
trace.spans.sort(key=lambda s: (s.start, s.span_id))
|
||||
out.append(trace)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _build_session_spans(
|
||||
store: _SessionStore,
|
||||
session: Dict[str, Any],
|
||||
*,
|
||||
parent_span_id: Optional[str],
|
||||
trace: Trace,
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
agent_key: str = "root",
|
||||
) -> Optional[Span]:
|
||||
"""Append the AGENT span for one session (or a turn slice of it) plus its
|
||||
LLM/TOOL child spans.
|
||||
|
||||
``messages`` lets a caller pass a turn-scoped slice; ``agent_key`` keeps the
|
||||
AGENT span id unique per turn. Returns the session's root (AGENT) span, or
|
||||
``None`` for an empty session.
|
||||
"""
|
||||
session_id = session["id"]
|
||||
if messages is None:
|
||||
messages = store.get_messages(session_id)
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
msg_start = min(float(m["timestamp"]) for m in messages)
|
||||
msg_end = max(float(m["timestamp"]) for m in messages)
|
||||
started_at = float(session.get("started_at") or msg_start)
|
||||
|
||||
# Clamp the AGENT span to real message activity. Session ``started_at`` /
|
||||
# ``ended_at`` are unreliable for trace timing: ``ended_at`` can be a
|
||||
# cleanup/orphan reaper firing hours later (e.g. ``ws_orphan_reap``), and on
|
||||
# a turn slice ``started_at`` is the whole-session start, far before this
|
||||
# turn. Snap to them only when they sit right at the slice's edges, so a
|
||||
# span never balloons into inter-turn idle.
|
||||
activity_start = msg_start
|
||||
if msg_start - _END_GRACE_SECONDS <= started_at <= msg_start:
|
||||
activity_start = started_at
|
||||
activity_end = msg_end
|
||||
raw_ended = session.get("ended_at")
|
||||
if raw_ended is not None:
|
||||
ended_at = float(raw_ended)
|
||||
if msg_end <= ended_at <= msg_end + _END_GRACE_SECONDS:
|
||||
activity_end = ended_at
|
||||
|
||||
goal = _session_goal(messages, session)
|
||||
agent_span = Span(
|
||||
span_id=_mk_span_id("agent", session_id, agent_key),
|
||||
parent_id=parent_span_id,
|
||||
name=goal,
|
||||
kind=KIND_AGENT,
|
||||
start=activity_start,
|
||||
end=activity_end,
|
||||
status=STATUS_ERROR if session.get("end_reason") in {"error", "failed"} else STATUS_OK,
|
||||
session_id=session_id,
|
||||
attributes={
|
||||
"session.id": session_id,
|
||||
"session.source": session.get("source"),
|
||||
"llm.model_name": session.get("model"),
|
||||
"llm.token_count.prompt": session.get("input_tokens"),
|
||||
"llm.token_count.completion": session.get("output_tokens"),
|
||||
"llm.token_count.reasoning": session.get("reasoning_tokens"),
|
||||
"session.message_count": session.get("message_count"),
|
||||
"session.tool_call_count": session.get("tool_call_count"),
|
||||
"session.end_reason": session.get("end_reason"),
|
||||
},
|
||||
)
|
||||
trace.spans.append(agent_span)
|
||||
|
||||
# Pre-index tool results by tool_call_id so calls pair with their output.
|
||||
results_by_id: Dict[str, Dict[str, Any]] = {}
|
||||
for m in messages:
|
||||
if m.get("role") == "tool" and m.get("tool_call_id"):
|
||||
results_by_id[str(m["tool_call_id"])] = m
|
||||
|
||||
# Walk the turn timeline. An assistant message closes the LLM span that
|
||||
# began at the previous boundary; each tool_call it carries becomes a TOOL
|
||||
# span ending at its paired result.
|
||||
prev_boundary = activity_start
|
||||
for m in messages:
|
||||
role = m.get("role")
|
||||
ts = float(m["timestamp"])
|
||||
|
||||
if role == "assistant":
|
||||
llm_span = Span(
|
||||
span_id=_mk_span_id("llm", session_id, m["id"]),
|
||||
parent_id=agent_span.span_id,
|
||||
name=_llm_span_name(),
|
||||
kind=KIND_LLM,
|
||||
start=prev_boundary,
|
||||
end=ts,
|
||||
status=STATUS_OK,
|
||||
session_id=session_id,
|
||||
attributes={
|
||||
"llm.model_name": session.get("model"),
|
||||
"llm.token_count.completion": m.get("token_count"),
|
||||
"output.value": _short(_as_text(m.get("content")), 2000),
|
||||
"hermes.finish_reason": m.get("finish_reason"),
|
||||
"hermes.has_reasoning": bool(
|
||||
m.get("reasoning") or m.get("reasoning_content")
|
||||
),
|
||||
},
|
||||
)
|
||||
trace.spans.append(llm_span)
|
||||
|
||||
for call in m.get("tool_calls") or []:
|
||||
if not isinstance(call, dict):
|
||||
continue
|
||||
_append_tool_span(
|
||||
trace=trace,
|
||||
session=session,
|
||||
parent_span_id=agent_span.span_id,
|
||||
call=call,
|
||||
call_ts=ts,
|
||||
results_by_id=results_by_id,
|
||||
fallback_end=activity_end,
|
||||
)
|
||||
|
||||
prev_boundary = ts
|
||||
elif role in {"user", "tool"}:
|
||||
# User input and tool results define the next LLM span's start.
|
||||
prev_boundary = ts
|
||||
|
||||
return agent_span
|
||||
|
||||
|
||||
def _append_tool_span(
|
||||
*,
|
||||
trace: Trace,
|
||||
session: Dict[str, Any],
|
||||
parent_span_id: str,
|
||||
call: Dict[str, Any],
|
||||
call_ts: float,
|
||||
results_by_id: Dict[str, Dict[str, Any]],
|
||||
fallback_end: float,
|
||||
) -> None:
|
||||
session_id = session["id"]
|
||||
call_id = _tool_call_id(call)
|
||||
name = _tool_call_name(call)
|
||||
result = results_by_id.get(call_id) if call_id else None
|
||||
end = float(result["timestamp"]) if result else fallback_end
|
||||
status = STATUS_OK
|
||||
if result and _looks_like_error(result):
|
||||
status = STATUS_ERROR
|
||||
elif not result:
|
||||
status = STATUS_UNSET
|
||||
|
||||
args = _tool_call_args(call)
|
||||
span = Span(
|
||||
span_id=_mk_span_id("tool", session_id, call_id or f"{call_ts}:{name}"),
|
||||
parent_id=parent_span_id,
|
||||
name=name,
|
||||
kind=KIND_TOOL,
|
||||
start=call_ts,
|
||||
end=max(end, call_ts),
|
||||
status=status,
|
||||
session_id=session_id,
|
||||
attributes={
|
||||
"tool.name": name,
|
||||
"tool.call_id": call_id or None,
|
||||
"input.value": _short(_as_text(args), 2000),
|
||||
"output.value": _short(_as_text(result.get("content")), 2000) if result else None,
|
||||
"hermes.is_delegate": name in _DELEGATE_TOOL_NAMES,
|
||||
},
|
||||
)
|
||||
trace.spans.append(span)
|
||||
|
||||
|
||||
def _attach_subagents(
|
||||
store: _SessionStore,
|
||||
session_id: str,
|
||||
trace: Trace,
|
||||
*,
|
||||
agent_key: str = "root",
|
||||
window: Optional[tuple] = None,
|
||||
_depth: int,
|
||||
_max_depth: int,
|
||||
) -> None:
|
||||
"""Nest each delegate child session under the tool span that spawned it.
|
||||
|
||||
Children are matched to ``delegate_task`` tool spans by start-time proximity
|
||||
(each child consumed once). Unmatched children attach to the session's AGENT
|
||||
span so they are never dropped from the trace. ``window`` (start, end) limits
|
||||
attachment to children spawned during a turn slice.
|
||||
"""
|
||||
child_ids = store.get_child_session_ids(session_id)
|
||||
if not child_ids:
|
||||
return
|
||||
|
||||
delegate_spans = sorted(
|
||||
(
|
||||
s
|
||||
for s in trace.spans
|
||||
if s.session_id == session_id
|
||||
and s.kind == KIND_TOOL
|
||||
and s.attributes.get("hermes.is_delegate")
|
||||
),
|
||||
key=lambda s: s.start,
|
||||
)
|
||||
agent_span_id = _mk_span_id("agent", session_id, agent_key)
|
||||
|
||||
children = []
|
||||
for cid in child_ids:
|
||||
csess = store.get_session(cid)
|
||||
if not csess:
|
||||
continue
|
||||
if window is not None:
|
||||
cstart = float(csess.get("started_at") or 0.0)
|
||||
if not (window[0] - 1.0 <= cstart <= window[1] + 1.0):
|
||||
continue
|
||||
children.append(csess)
|
||||
children.sort(key=lambda c: float(c.get("started_at") or 0.0))
|
||||
|
||||
used: set = set()
|
||||
for csess in children:
|
||||
cstart = float(csess.get("started_at") or 0.0)
|
||||
parent_span_id = agent_span_id
|
||||
best = None
|
||||
best_gap = None
|
||||
for ds in delegate_spans:
|
||||
if ds.span_id in used:
|
||||
continue
|
||||
gap = abs(ds.start - cstart)
|
||||
if best_gap is None or gap < best_gap:
|
||||
best, best_gap = ds, gap
|
||||
if best is not None:
|
||||
used.add(best.span_id)
|
||||
parent_span_id = best.span_id
|
||||
|
||||
child_root = _build_session_spans(
|
||||
store, csess, parent_span_id=parent_span_id, trace=trace
|
||||
)
|
||||
if child_root and _depth + 1 < _max_depth:
|
||||
_attach_subagents(
|
||||
store, csess["id"], trace, _depth=_depth + 1, _max_depth=_max_depth
|
||||
)
|
||||
|
||||
|
||||
def _session_goal(messages: List[Dict[str, Any]], session: Dict[str, Any]) -> str:
|
||||
"""A human-readable label for an AGENT span.
|
||||
|
||||
Prefer the first user message in the given slice so per-turn spans get their
|
||||
own prompt as a label (the session title is identical across every turn).
|
||||
Fall back to the session title, then a short id.
|
||||
"""
|
||||
for m in messages:
|
||||
if m.get("role") == "user":
|
||||
text = _as_text(m.get("content")).strip()
|
||||
if text:
|
||||
return _short(text, 120)
|
||||
title = session.get("title")
|
||||
if title:
|
||||
return _short(str(title), 120)
|
||||
return f"session {str(session.get('id', ''))[:8]}"
|
||||
170
agent/trace_export.py
Normal file
170
agent/trace_export.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Render a :class:`~agent.trace_builder.Trace` into portable file formats.
|
||||
|
||||
Two formats, both hand-built (no OTel SDK dependency):
|
||||
|
||||
* **OTLP/JSON** with OpenInference semantic conventions — the industry standard
|
||||
for LLM/agent traces. Ingestible by Arize Phoenix and any OpenTelemetry
|
||||
backend, so we can confirm our spans are correct in a real viewer.
|
||||
* **Chrome Trace Event format** — each session becomes its own track, viewable
|
||||
by dropping the file into https://ui.perfetto.dev or ``chrome://tracing``.
|
||||
|
||||
Keeping these as plain dict/JSON builders means the trace layer has zero new
|
||||
third-party dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from agent.trace_builder import STATUS_ERROR, STATUS_OK, Trace
|
||||
|
||||
_SERVICE_NAME = "hermes-agent"
|
||||
_SCOPE_NAME = "hermes.tracing"
|
||||
|
||||
|
||||
def _hex_id(value: str, nbytes: int) -> str:
|
||||
"""Deterministic hex id of ``nbytes`` bytes from an arbitrary string."""
|
||||
digest = hashlib.sha1(value.encode("utf-8")).hexdigest()
|
||||
return digest[: nbytes * 2]
|
||||
|
||||
|
||||
def _otlp_any_value(value: Any) -> Dict[str, Any]:
|
||||
if isinstance(value, bool):
|
||||
return {"boolValue": value}
|
||||
if isinstance(value, int):
|
||||
return {"intValue": str(value)}
|
||||
if isinstance(value, float):
|
||||
return {"doubleValue": value}
|
||||
return {"stringValue": str(value)}
|
||||
|
||||
|
||||
def _otlp_attributes(attrs: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
for key, value in attrs.items():
|
||||
if value is None:
|
||||
continue
|
||||
out.append({"key": key, "value": _otlp_any_value(value)})
|
||||
return out
|
||||
|
||||
|
||||
def _otlp_status(status: str) -> Dict[str, Any]:
|
||||
if status == STATUS_OK:
|
||||
return {"code": 1}
|
||||
if status == STATUS_ERROR:
|
||||
return {"code": 2}
|
||||
return {"code": 0}
|
||||
|
||||
|
||||
def _to_nanos(seconds: float) -> str:
|
||||
return str(int(seconds * 1_000_000_000))
|
||||
|
||||
|
||||
def to_otlp_json(trace: Trace) -> Dict[str, Any]:
|
||||
"""Build an OTLP/JSON ``TracesData`` document with OpenInference attributes."""
|
||||
trace_hex = _hex_id(trace.trace_id, 16)
|
||||
otlp_spans: List[Dict[str, Any]] = []
|
||||
|
||||
for span in trace.spans:
|
||||
attributes = dict(span.attributes)
|
||||
# OpenInference: the GenAI span kind travels as an attribute; the OTLP
|
||||
# SpanKind stays INTERNAL (1).
|
||||
attributes["openinference.span.kind"] = span.kind
|
||||
if span.session_id:
|
||||
attributes.setdefault("session.id", span.session_id)
|
||||
|
||||
otlp_span: Dict[str, Any] = {
|
||||
"traceId": trace_hex,
|
||||
"spanId": _hex_id(span.span_id, 8),
|
||||
"name": span.name,
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": _to_nanos(span.start),
|
||||
"endTimeUnixNano": _to_nanos(span.end),
|
||||
"attributes": _otlp_attributes(attributes),
|
||||
"status": _otlp_status(span.status),
|
||||
}
|
||||
if span.parent_id:
|
||||
otlp_span["parentSpanId"] = _hex_id(span.parent_id, 8)
|
||||
otlp_spans.append(otlp_span)
|
||||
|
||||
return {
|
||||
"resourceSpans": [
|
||||
{
|
||||
"resource": {
|
||||
"attributes": _otlp_attributes(
|
||||
{
|
||||
"service.name": _SERVICE_NAME,
|
||||
"session.id": trace.root_session_id,
|
||||
"hermes.source": trace.metadata.get("source"),
|
||||
}
|
||||
)
|
||||
},
|
||||
"scopeSpans": [
|
||||
{
|
||||
"scope": {"name": _SCOPE_NAME},
|
||||
"spans": otlp_spans,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def to_chrome_trace(trace: Trace) -> Dict[str, Any]:
|
||||
"""Build a Chrome Trace Event document (one track per session)."""
|
||||
base = trace.start
|
||||
events: List[Dict[str, Any]] = []
|
||||
|
||||
# Stable, compact track ids per session — each session is its own lane.
|
||||
tids: Dict[str, int] = {}
|
||||
|
||||
def tid_for(session_id: str) -> int:
|
||||
if session_id not in tids:
|
||||
tids[session_id] = len(tids) + 1
|
||||
return tids[session_id]
|
||||
|
||||
for span in trace.spans:
|
||||
sid = span.session_id or trace.root_session_id
|
||||
events.append(
|
||||
{
|
||||
"name": span.name,
|
||||
"cat": span.kind,
|
||||
"ph": "X",
|
||||
"ts": (span.start - base) * 1_000_000,
|
||||
"dur": max(0.0, span.duration) * 1_000_000,
|
||||
"pid": 1,
|
||||
"tid": tid_for(sid),
|
||||
"args": {k: v for k, v in span.attributes.items() if v is not None},
|
||||
}
|
||||
)
|
||||
|
||||
# Name each track after its session (root first) for legible lanes.
|
||||
for session_id, tid in tids.items():
|
||||
label = "root" if session_id == trace.root_session_id else f"subagent {session_id[:8]}"
|
||||
events.append(
|
||||
{
|
||||
"name": "thread_name",
|
||||
"ph": "M",
|
||||
"pid": 1,
|
||||
"tid": tid,
|
||||
"args": {"name": label},
|
||||
}
|
||||
)
|
||||
|
||||
return {"traceEvents": events, "displayTimeUnit": "ms"}
|
||||
|
||||
|
||||
def dumps(trace: Trace, fmt: str = "otlp", *, indent: int = 2) -> str:
|
||||
"""Serialize ``trace`` to a JSON string in the requested format."""
|
||||
fmt = (fmt or "otlp").lower()
|
||||
if fmt in {"otlp", "otlp-json", "openinference"}:
|
||||
doc = to_otlp_json(trace)
|
||||
elif fmt in {"chrome", "perfetto", "trace-event"}:
|
||||
doc = to_chrome_trace(trace)
|
||||
else:
|
||||
raise ValueError(f"unknown trace format: {fmt!r} (use 'otlp' or 'chrome')")
|
||||
return json.dumps(doc, ensure_ascii=False, indent=indent, default=str)
|
||||
|
||||
|
||||
__all__ = ["dumps", "to_chrome_trace", "to_otlp_json"]
|
||||
@@ -17,5 +17,5 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
"iconLibrary": "tabler"
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const fs = require('node:fs')
|
||||
|
||||
const _READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m
|
||||
|
||||
// The announcement clock starts the instant the backend process is spawned —
|
||||
@@ -94,9 +96,76 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
|
||||
})
|
||||
}
|
||||
|
||||
function readDashboardReadyFile(readyFile) {
|
||||
if (!readyFile) return null
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(readyFile, 'utf8'))
|
||||
const port = Number(parsed?.port)
|
||||
return Number.isInteger(port) && port > 0 ? port : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnnounceTimeoutMs()) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let done = false
|
||||
let interval = null
|
||||
|
||||
function cleanup() {
|
||||
if (done) return
|
||||
done = true
|
||||
clearTimeout(timer)
|
||||
if (interval) clearInterval(interval)
|
||||
child.off('exit', onExit)
|
||||
child.off('error', onError)
|
||||
}
|
||||
|
||||
function check() {
|
||||
const port = readDashboardReadyFile(readyFile)
|
||||
if (port) {
|
||||
cleanup()
|
||||
resolve(port)
|
||||
}
|
||||
}
|
||||
|
||||
function onExit(code, signal) {
|
||||
cleanup()
|
||||
reject(new Error(`Hermes backend: exited before port announcement (${signal || code})`))
|
||||
}
|
||||
|
||||
function onError(err) {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error(`Timed out waiting for Hermes backend port announcement (${timeoutMs}ms)`))
|
||||
}, timeoutMs)
|
||||
|
||||
child.on('exit', onExit)
|
||||
child.on('error', onError)
|
||||
interval = setInterval(check, 50)
|
||||
if (typeof interval.unref === 'function') interval.unref()
|
||||
check()
|
||||
})
|
||||
}
|
||||
|
||||
function waitForDashboardPortAnnouncement(child, options = {}) {
|
||||
const timeoutMs = options.timeoutMs ?? resolvePortAnnounceTimeoutMs()
|
||||
if (options.readyFile) {
|
||||
return waitForDashboardReadyFile(options.readyFile, child, timeoutMs)
|
||||
}
|
||||
return waitForDashboardPort(child, timeoutMs)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
waitForDashboardPort,
|
||||
waitForDashboardPortAnnouncement,
|
||||
waitForDashboardReadyFile,
|
||||
readDashboardReadyFile,
|
||||
resolvePortAnnounceTimeoutMs,
|
||||
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
MIN_PORT_ANNOUNCE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
@@ -14,12 +14,18 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { EventEmitter } = require('node:events')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const {
|
||||
readDashboardReadyFile,
|
||||
waitForDashboardPort,
|
||||
waitForDashboardPortAnnouncement,
|
||||
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
|
||||
@@ -119,3 +125,75 @@ test('a late announcement after timeout does not throw (listeners torn down)', a
|
||||
child.stdout.emit('data', 'HERMES_DASHBOARD_READY port=9999\n')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ready-file port announcement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mkTmpReadyFile() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-ready-test-'))
|
||||
return {
|
||||
dir,
|
||||
file: path.join(dir, 'ready.json'),
|
||||
cleanup: () => fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
test('readDashboardReadyFile returns a valid port from JSON', () => {
|
||||
const tmp = mkTmpReadyFile()
|
||||
try {
|
||||
fs.writeFileSync(tmp.file, JSON.stringify({ port: 4567 }))
|
||||
assert.equal(readDashboardReadyFile(tmp.file), 4567)
|
||||
} finally {
|
||||
tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('readDashboardReadyFile ignores missing, malformed, or invalid files', () => {
|
||||
const tmp = mkTmpReadyFile()
|
||||
try {
|
||||
assert.equal(readDashboardReadyFile(tmp.file), null)
|
||||
fs.writeFileSync(tmp.file, '{')
|
||||
assert.equal(readDashboardReadyFile(tmp.file), null)
|
||||
fs.writeFileSync(tmp.file, JSON.stringify({ port: 0 }))
|
||||
assert.equal(readDashboardReadyFile(tmp.file), null)
|
||||
} finally {
|
||||
tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('waitForDashboardReadyFile resolves when the ready file appears', async () => {
|
||||
const tmp = mkTmpReadyFile()
|
||||
const child = makeFakeChild()
|
||||
try {
|
||||
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
|
||||
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 8765 })), 20)
|
||||
assert.equal(await p, 8765)
|
||||
} finally {
|
||||
tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('waitForDashboardPortAnnouncement uses ready file when provided', async () => {
|
||||
const tmp = mkTmpReadyFile()
|
||||
const child = makeFakeChild()
|
||||
try {
|
||||
const p = waitForDashboardPortAnnouncement(child, { readyFile: tmp.file, timeoutMs: 1000 })
|
||||
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 9876 })), 20)
|
||||
assert.equal(await p, 9876)
|
||||
} finally {
|
||||
tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('waitForDashboardReadyFile rejects when the child exits before file readiness', async () => {
|
||||
const tmp = mkTmpReadyFile()
|
||||
const child = makeFakeChild()
|
||||
try {
|
||||
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
|
||||
child.emit('exit', 1, null)
|
||||
await assert.rejects(p, /exited before port announcement/)
|
||||
} finally {
|
||||
tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
96
apps/desktop/electron/git-repo-scan.cjs
Normal file
96
apps/desktop/electron/git-repo-scan.cjs
Normal file
@@ -0,0 +1,96 @@
|
||||
'use strict'
|
||||
|
||||
// Repo-first discovery: walk bounded roots for git repos using only Node's `fs`
|
||||
// — no native addon, so it just works for anyone who pulls main (no
|
||||
// electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git`
|
||||
// (don't descend into a repo), cap depth, and skip heavy non-repo trees so the
|
||||
// first scan stays fast. Results are cached by the backend after the first run.
|
||||
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const fsp = fs.promises
|
||||
|
||||
// Shallow on purpose: real projects live a few levels under home
|
||||
// (`~/www/repo`, `~/code/org/repo`); deeper `.git` dirs are almost always
|
||||
// fixtures/vendored/eval checkouts (e.g. `~/www/ha-evals/tasks/*/repo`). Repos
|
||||
// you actually use but keep deeper still surface via session-derived discovery,
|
||||
// so this only prunes noise, never repos with history.
|
||||
const DEFAULT_MAX_DEPTH = 3
|
||||
const MAX_CONCURRENCY = 32
|
||||
|
||||
// Big trees that are never themselves repos and would waste the walk. Anything
|
||||
// hidden (dotdirs like .cache/.Trash/.npm) is skipped wholesale below, so this
|
||||
// only needs the non-hidden heavyweights.
|
||||
const JUNK_DIRS = new Set(['Applications', 'Library', 'node_modules', 'site-packages', 'vendor', 'venv'])
|
||||
|
||||
async function mapLimit(items, limit, fn) {
|
||||
let cursor = 0
|
||||
|
||||
async function worker() {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor
|
||||
cursor += 1
|
||||
await fn(items[index])
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan `roots` (default: the home dir) for git repositories. Returns deduped
|
||||
* `{ root, label }` entries. `options.maxDepth` caps recursion (default 3).
|
||||
*/
|
||||
async function scanGitRepos(roots, options = {}) {
|
||||
const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH
|
||||
const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
|
||||
const found = new Map()
|
||||
|
||||
async function walk(dir, depth) {
|
||||
if (depth > maxDepth) {
|
||||
return
|
||||
}
|
||||
|
||||
let entries
|
||||
try {
|
||||
entries = await fsp.readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return // unreadable / permission denied
|
||||
}
|
||||
|
||||
// A `.git` DIRECTORY marks a real repo root (a main checkout). A `.git`
|
||||
// FILE is a linked worktree or submodule — those belong to their parent
|
||||
// repo as lanes, not as separate projects, so we don't list them (and we
|
||||
// keep descending in case a real repo sits deeper). This is what kills the
|
||||
// worktree/eval-repo duplicate explosion.
|
||||
if (entries.some(entry => entry.name === '.git' && entry.isDirectory())) {
|
||||
const root = dir.replace(/[/\\]+$/, '')
|
||||
found.set(root, path.basename(root) || root)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const subdirs = []
|
||||
for (const entry of entries) {
|
||||
// Real directories only (skip symlinks to avoid loops), no hidden dirs, no
|
||||
// known heavy trees.
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.') || JUNK_DIRS.has(entry.name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
subdirs.push(path.join(dir, entry.name))
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
return [...found.entries()].map(([root, label]) => ({ label, root }))
|
||||
}
|
||||
|
||||
module.exports = { scanGitRepos }
|
||||
684
apps/desktop/electron/git-review-ops.cjs
Normal file
684
apps/desktop/electron/git-review-ops.cjs
Normal file
@@ -0,0 +1,684 @@
|
||||
'use strict'
|
||||
|
||||
// Git ops backing the coding rail + Codex-style review pane. Built on `simple-git`
|
||||
// (a maintained wrapper around the system git binary — same git the rest of the
|
||||
// app shells to, no native build) so we read structured status()/diffSummary()
|
||||
// results instead of hand-parsing porcelain. Reads degrade to null/empty on a
|
||||
// non-repo / remote backend; mutations reject so the renderer can toast.
|
||||
|
||||
const { execFile } = require('node:child_process')
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
|
||||
const simpleGit = require('simple-git')
|
||||
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
|
||||
const COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000
|
||||
const COMMIT_CONTEXT_UNTRACKED_MAX = 80
|
||||
const UNTRACKED_LINE_COUNT_CONCURRENCY = 16
|
||||
const UNTRACKED_LINE_COUNT_MAX_BYTES = 1024 * 1024
|
||||
|
||||
// GUI-launched Electron apps on macOS inherit only a minimal PATH (no
|
||||
// /opt/homebrew/bin or /usr/local/bin), so `gh` — and the `git` gh shells out
|
||||
// to — aren't found. Augment PATH with the resolved gh dir + the common
|
||||
// package-manager bins so gh runs the same way it does in a terminal.
|
||||
function ghEnv(ghBin) {
|
||||
const extra = [ghBin ? path.dirname(ghBin) : '', '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin'].filter(
|
||||
dir => dir && dir !== '.'
|
||||
)
|
||||
|
||||
return { ...process.env, PATH: [...extra, process.env.PATH].filter(Boolean).join(path.delimiter) }
|
||||
}
|
||||
|
||||
// Run the `gh` CLI in a repo. Resolves { ok, stdout } so callers branch on
|
||||
// availability/auth without a throw. gh missing/unauthed → ok:false.
|
||||
function runGh(args, cwd, ghBin) {
|
||||
return new Promise(resolve => {
|
||||
execFile(
|
||||
ghBin || 'gh',
|
||||
args,
|
||||
{ cwd, env: ghEnv(ghBin), windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 },
|
||||
(err, stdout) => resolve({ ok: !err, stdout: String(stdout || '') })
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function gitFor(cwd, gitBin) {
|
||||
return simpleGit({ baseDir: cwd, binary: gitBin || 'git', maxConcurrentProcesses: 4, trimmed: false })
|
||||
}
|
||||
|
||||
// simple-git reports renames as `old => new` (and `dir/{old => new}/f`); resolve
|
||||
// to the NEW path so the row addresses the real file for diff/stage.
|
||||
function resolveRenamePath(raw) {
|
||||
const path = String(raw || '').trim()
|
||||
|
||||
if (!path.includes(' => ')) {
|
||||
return path
|
||||
}
|
||||
|
||||
const brace = path.match(/^(.*)\{(.*) => (.*)\}(.*)$/)
|
||||
|
||||
if (brace) {
|
||||
const [, prefix, , to, suffix] = brace
|
||||
|
||||
return `${prefix}${to}${suffix}`.replace(/\/{2,}/g, '/')
|
||||
}
|
||||
|
||||
return path.split(' => ').pop().trim()
|
||||
}
|
||||
|
||||
// DiffResult.files → Map<path, {added, removed}> (binary files carry no line
|
||||
// delta).
|
||||
function countsByPath(summary) {
|
||||
const map = new Map()
|
||||
|
||||
for (const file of summary.files) {
|
||||
map.set(resolveRenamePath(file.file), {
|
||||
added: file.binary ? 0 : file.insertions,
|
||||
removed: file.binary ? 0 : file.deletions
|
||||
})
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
// Untracked files don't appear in diffSummary(); count insertions from disk so
|
||||
// the review tree can show +N for new files (matches an all-add diff view).
|
||||
// Insertions = line count: newline bytes, plus one for a final unterminated
|
||||
// line. Binary (NUL byte) → 0, mirroring git numstat's "-".
|
||||
async function untrackedInsertions(cwd, relPath) {
|
||||
try {
|
||||
const fullPath = path.join(cwd, relPath)
|
||||
const stat = await fs.stat(fullPath)
|
||||
|
||||
if (!stat.isFile() || stat.size > UNTRACKED_LINE_COUNT_MAX_BYTES) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const buf = await fs.readFile(fullPath)
|
||||
|
||||
if (buf.includes(0)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let lines = 0
|
||||
|
||||
for (const byte of buf) {
|
||||
if (byte === 10) {
|
||||
lines++
|
||||
}
|
||||
}
|
||||
|
||||
return buf.length > 0 && buf[buf.length - 1] !== 10 ? lines + 1 : lines
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function capText(text, maxChars, label = 'truncated') {
|
||||
const value = String(text || '')
|
||||
|
||||
if (value.length <= maxChars) {
|
||||
return value
|
||||
}
|
||||
|
||||
return `${value.slice(0, maxChars)}\n# ${label}: ${value.length - maxChars} chars omitted\n`
|
||||
}
|
||||
|
||||
async function fillUntrackedCounts(cwd, files) {
|
||||
const pending = files.filter(file => file.status === '?' && file.added === 0 && file.removed === 0)
|
||||
|
||||
for (let i = 0; i < pending.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) {
|
||||
await Promise.all(
|
||||
pending.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(async file => {
|
||||
file.added = await untrackedInsertions(cwd, file.path)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the base ref for "all branch changes": merge-base with the remote
|
||||
// default branch (origin/HEAD), falling back to common trunk names.
|
||||
async function branchBase(git) {
|
||||
const candidates = []
|
||||
|
||||
try {
|
||||
const head = (await git.revparse(['--abbrev-ref', 'origin/HEAD'])).trim()
|
||||
|
||||
if (head) {
|
||||
candidates.push(head)
|
||||
}
|
||||
} catch {
|
||||
// No origin/HEAD configured.
|
||||
}
|
||||
|
||||
candidates.push('origin/main', 'origin/master', 'main', 'master')
|
||||
|
||||
for (const ref of candidates) {
|
||||
try {
|
||||
const base = (await git.raw(['merge-base', 'HEAD', ref])).trim()
|
||||
|
||||
if (base) {
|
||||
return base
|
||||
}
|
||||
} catch {
|
||||
// Ref doesn't exist; try the next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Resolve the repo's default branch NAME ("main" / "master" / …), preferring
|
||||
// the remote's HEAD, then common local trunk names. Null when none is found
|
||||
// (e.g. a fresh repo with only a feature branch). Used to offer "branch off the
|
||||
// trunk" regardless of which branch you're currently on.
|
||||
async function defaultBranchName(git) {
|
||||
try {
|
||||
const head = (await git.revparse(['--abbrev-ref', 'origin/HEAD'])).trim()
|
||||
|
||||
// "origin/main" → "main"; skip the bare "origin/HEAD" placeholder.
|
||||
if (head && head !== 'origin/HEAD') {
|
||||
return head.replace(/^origin\//, '')
|
||||
}
|
||||
} catch {
|
||||
// No origin/HEAD configured.
|
||||
}
|
||||
|
||||
// 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'
|
||||
]) {
|
||||
try {
|
||||
await git.raw(['rev-parse', '--verify', '--quiet', ref])
|
||||
|
||||
return ref.replace(/^refs\/(?:heads|remotes\/origin)\//, '')
|
||||
} catch {
|
||||
// Ref doesn't exist; try the next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// A status file's single-letter classification, preferring the staged (index)
|
||||
// code over the worktree code; untracked wins (simple-git marks both '?').
|
||||
function statusLetter(file) {
|
||||
if (file.index === '?' || file.working_dir === '?') {
|
||||
return '?'
|
||||
}
|
||||
|
||||
const code = file.index && file.index !== ' ' ? file.index : file.working_dir
|
||||
|
||||
return (code || 'M').toUpperCase()
|
||||
}
|
||||
|
||||
const isStaged = file => Boolean(file.index && file.index !== ' ' && file.index !== '?')
|
||||
|
||||
async function reviewList(repoPath, scope, baseRef, gitBin) {
|
||||
let cwd
|
||||
|
||||
try {
|
||||
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review list' })
|
||||
} catch {
|
||||
return { files: [], base: null }
|
||||
}
|
||||
|
||||
const git = gitFor(cwd, gitBin)
|
||||
|
||||
try {
|
||||
if (scope === 'branch' || scope === 'lastTurn') {
|
||||
const base = scope === 'branch' ? await branchBase(git) : baseRef
|
||||
|
||||
if (!base) {
|
||||
return { files: [], base: null }
|
||||
}
|
||||
|
||||
const range = scope === 'branch' ? `${base}...HEAD` : base
|
||||
const summary = await git.diffSummary([range])
|
||||
const files = summary.files.map(file => ({
|
||||
path: resolveRenamePath(file.file),
|
||||
added: file.binary ? 0 : file.insertions,
|
||||
removed: file.binary ? 0 : file.deletions,
|
||||
status: 'M',
|
||||
staged: false
|
||||
}))
|
||||
|
||||
// "Last turn" also surfaces files created since the baseline (untracked).
|
||||
if (scope === 'lastTurn') {
|
||||
const status = await git.status()
|
||||
|
||||
for (const path of status.not_added) {
|
||||
if (!files.some(f => f.path === path)) {
|
||||
files.push({ path, added: 0, removed: 0, status: '?', staged: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
files.sort((a, b) => a.path.localeCompare(b.path))
|
||||
await fillUntrackedCounts(cwd, files)
|
||||
|
||||
return { files, base }
|
||||
}
|
||||
|
||||
// Default: uncommitted (staged + unstaged + untracked), one row per path.
|
||||
const [status, staged, unstaged] = await Promise.all([
|
||||
git.status(),
|
||||
git.diffSummary(['--cached']),
|
||||
git.diffSummary([])
|
||||
])
|
||||
const stagedCounts = countsByPath(staged)
|
||||
const unstagedCounts = countsByPath(unstaged)
|
||||
|
||||
const files = status.files.map(file => {
|
||||
const filePath = resolveRenamePath(file.path)
|
||||
const sc = stagedCounts.get(filePath) || { added: 0, removed: 0 }
|
||||
const uc = unstagedCounts.get(filePath) || { added: 0, removed: 0 }
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
added: sc.added + uc.added,
|
||||
removed: sc.removed + uc.removed,
|
||||
status: statusLetter(file),
|
||||
staged: isStaged(file)
|
||||
}
|
||||
})
|
||||
|
||||
files.sort((a, b) => a.path.localeCompare(b.path))
|
||||
await fillUntrackedCounts(cwd, files)
|
||||
|
||||
return { files, base: null }
|
||||
} catch {
|
||||
return { files: [], base: null }
|
||||
}
|
||||
}
|
||||
|
||||
async function reviewDiff(repoPath, filePath, scope, baseRef, staged, gitBin) {
|
||||
let cwd
|
||||
|
||||
try {
|
||||
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review diff' })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
const git = gitFor(cwd, gitBin)
|
||||
const safe = args => git.diff(args).catch(() => '')
|
||||
|
||||
if (scope === 'branch') {
|
||||
const base = await branchBase(git)
|
||||
|
||||
return base ? safe([`${base}...HEAD`, '--', filePath]) : ''
|
||||
}
|
||||
|
||||
if (scope === 'lastTurn') {
|
||||
return baseRef ? safe([baseRef, '--', filePath]) : ''
|
||||
}
|
||||
|
||||
if (staged) {
|
||||
return safe(['--cached', '--', filePath])
|
||||
}
|
||||
|
||||
const worktree = await safe(['--', filePath])
|
||||
|
||||
if (worktree.trim()) {
|
||||
return worktree
|
||||
}
|
||||
|
||||
// Untracked file: no worktree diff exists, so synthesize an all-add diff via
|
||||
// --no-index (exits non-zero by design when files differ, so go around
|
||||
// simple-git's reject-on-nonzero with a raw execFile).
|
||||
return new Promise(resolve => {
|
||||
execFile(
|
||||
gitBin || 'git',
|
||||
['diff', '--no-index', '--', '/dev/null', filePath],
|
||||
{ cwd, windowsHide: true, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 },
|
||||
(_err, stdout) => resolve(String(stdout || ''))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Working-tree-vs-HEAD diff for ONE file — the "what changed since the last
|
||||
// commit" view used by the file preview. Unlike reviewDiff this never synthesizes
|
||||
// a full-add for a clean tracked file (so a pristine file shows no diff); it only
|
||||
// all-adds a genuinely untracked file.
|
||||
async function fileDiffVsHead(repoPath, filePath, gitBin) {
|
||||
let cwd
|
||||
|
||||
try {
|
||||
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'File diff' })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
const git = gitFor(cwd, gitBin)
|
||||
const head = await git.diff(['HEAD', '--', filePath]).catch(() => '')
|
||||
|
||||
if (head.trim()) {
|
||||
return head
|
||||
}
|
||||
|
||||
// No tracked changes vs HEAD. Only synthesize an all-add diff for a file git
|
||||
// doesn't know yet; a clean tracked file must return empty.
|
||||
const status = await git.raw(['status', '--porcelain', '--', filePath]).catch(() => '')
|
||||
|
||||
if (!status.trim().startsWith('??')) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
execFile(
|
||||
gitBin || 'git',
|
||||
['diff', '--no-index', '--', '/dev/null', filePath],
|
||||
{ cwd, windowsHide: true, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 },
|
||||
(_err, stdout) => resolve(String(stdout || ''))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function reviewStage(repoPath, filePath, gitBin) {
|
||||
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review stage' })
|
||||
|
||||
await gitFor(cwd, gitBin).raw(filePath ? ['add', '--', filePath] : ['add', '-A'])
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async function reviewUnstage(repoPath, filePath, gitBin) {
|
||||
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review unstage' })
|
||||
|
||||
await gitFor(cwd, gitBin).raw(filePath ? ['reset', '-q', 'HEAD', '--', filePath] : ['reset', '-q', 'HEAD'])
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// Discard changes back to the committed state. Destructive — the renderer
|
||||
// confirms first. Restores tracked files and removes untracked ones.
|
||||
async function reviewRevert(repoPath, filePath, gitBin) {
|
||||
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review revert' })
|
||||
const git = gitFor(cwd, gitBin)
|
||||
|
||||
if (filePath) {
|
||||
await git.raw(['checkout', 'HEAD', '--', filePath]).catch(() => undefined)
|
||||
await git.raw(['clean', '-fd', '--', filePath]).catch(() => undefined)
|
||||
} else {
|
||||
await git.raw(['checkout', 'HEAD', '--', '.']).catch(() => undefined)
|
||||
await git.raw(['clean', '-fd']).catch(() => undefined)
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// Resolve a ref to a commit sha (captures the turn baseline for "Last turn").
|
||||
async function reviewRevParse(repoPath, ref, gitBin) {
|
||||
let cwd
|
||||
|
||||
try {
|
||||
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review rev-parse' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return (await gitFor(cwd, gitBin).revparse([ref || 'HEAD'])).trim() || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Commit the working tree. Mirrors VS Code: if nothing is staged, stage
|
||||
// everything first ("commit all"), then commit. Optionally push afterward,
|
||||
// setting upstream on the first push.
|
||||
async function reviewCommit(repoPath, message, push, gitBin) {
|
||||
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review commit' })
|
||||
const git = gitFor(cwd, gitBin)
|
||||
const status = await git.status()
|
||||
|
||||
if (status.staged.length === 0) {
|
||||
await git.raw(['add', '-A'])
|
||||
}
|
||||
|
||||
await git.commit(message)
|
||||
|
||||
if (push) {
|
||||
const fresh = await git.status()
|
||||
|
||||
if (fresh.tracking) {
|
||||
await git.push()
|
||||
} else if (fresh.current) {
|
||||
await git.raw(['push', '-u', 'origin', fresh.current])
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// Gather the context the model needs to draft a commit message: the diff of
|
||||
// what *will* be committed (staged when anything is staged, else everything
|
||||
// vs HEAD — mirroring reviewCommit's "stage all when nothing staged" rule),
|
||||
// the names of untracked files (which carry no diff), and recent commit
|
||||
// subjects for style. Diff is capped so the payload stays bounded. Reads only.
|
||||
async function reviewCommitContext(repoPath, gitBin) {
|
||||
let cwd
|
||||
|
||||
try {
|
||||
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review commit context' })
|
||||
} catch {
|
||||
return { diff: '', recent: '' }
|
||||
}
|
||||
|
||||
const git = gitFor(cwd, gitBin)
|
||||
const safe = args => git.diff(args).catch(() => '')
|
||||
|
||||
let status
|
||||
try {
|
||||
status = await git.status()
|
||||
} catch {
|
||||
return { diff: '', recent: '' }
|
||||
}
|
||||
|
||||
// What will land: staged changes if any, otherwise all tracked changes vs HEAD.
|
||||
let diff = capText(
|
||||
status.staged.length > 0 ? await safe(['--cached']) : await safe(['HEAD']),
|
||||
COMMIT_CONTEXT_DIFF_MAX_CHARS,
|
||||
'diff truncated for commit-message generation'
|
||||
)
|
||||
|
||||
// Untracked files have no diff — list them so new files aren't invisible.
|
||||
const untracked = status.not_added || []
|
||||
if (untracked.length > 0) {
|
||||
const visible = untracked.slice(0, COMMIT_CONTEXT_UNTRACKED_MAX)
|
||||
const omitted = untracked.length - visible.length
|
||||
const note =
|
||||
`\n# New (untracked) files:\n${visible.map(p => `# ${p}`).join('\n')}\n` +
|
||||
(omitted > 0 ? `# ... ${omitted} more omitted\n` : '')
|
||||
|
||||
diff = diff ? `${diff}${note}` : note
|
||||
}
|
||||
|
||||
const recent = await git.raw(['log', '-n', '10', '--pretty=format:%s']).catch(() => '')
|
||||
|
||||
return { diff: diff || '', recent: String(recent || '').trim() }
|
||||
}
|
||||
|
||||
async function reviewPush(repoPath, gitBin) {
|
||||
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review push' })
|
||||
const git = gitFor(cwd, gitBin)
|
||||
const status = await git.status()
|
||||
|
||||
if (status.tracking) {
|
||||
await git.push()
|
||||
} else if (status.current) {
|
||||
await git.raw(['push', '-u', 'origin', status.current])
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// gh availability + auth + whether this branch already has a PR. Reads only;
|
||||
// drives the PR button's enabled/label state. `ghReady` is false when gh is
|
||||
// missing OR not authenticated — either way the PR action can't run.
|
||||
async function reviewShipInfo(repoPath, ghBin) {
|
||||
let cwd
|
||||
|
||||
try {
|
||||
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review ship info' })
|
||||
} catch {
|
||||
return { ghReady: false, pr: null }
|
||||
}
|
||||
|
||||
const auth = await runGh(['auth', 'status'], cwd, ghBin)
|
||||
|
||||
if (!auth.ok) {
|
||||
return { ghReady: false, pr: null }
|
||||
}
|
||||
|
||||
const view = await runGh(['pr', 'view', '--json', 'url,state,number'], cwd, ghBin)
|
||||
|
||||
if (!view.ok) {
|
||||
// gh exits non-zero when no PR exists for the branch — that's not an error.
|
||||
return { ghReady: true, pr: null }
|
||||
}
|
||||
|
||||
try {
|
||||
const pr = JSON.parse(view.stdout)
|
||||
|
||||
return { ghReady: true, pr: pr && pr.url ? { url: pr.url, state: pr.state, number: pr.number } : null }
|
||||
} catch {
|
||||
return { ghReady: true, pr: null }
|
||||
}
|
||||
}
|
||||
|
||||
// Create a PR for the current branch (pushing first so gh has a remote ref),
|
||||
// letting gh fill title/body from the commits. Returns the new PR url.
|
||||
async function reviewCreatePr(repoPath, gitBin, ghBin) {
|
||||
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review create PR' })
|
||||
|
||||
await reviewPush(repoPath, gitBin).catch(() => undefined)
|
||||
|
||||
const created = await runGh(['pr', 'create', '--fill'], cwd, ghBin)
|
||||
|
||||
if (!created.ok) {
|
||||
throw new Error('gh pr create failed (is gh installed and authenticated?)')
|
||||
}
|
||||
|
||||
const url = created.stdout.trim().split('\n').filter(Boolean).pop() || ''
|
||||
|
||||
return { url }
|
||||
}
|
||||
|
||||
// Compact working-tree status for the composer coding rail: branch, ahead/behind,
|
||||
// per-state change counts, +/- vs HEAD, and a capped changed-file list.
|
||||
async function repoStatus(repoPath, gitBin) {
|
||||
let cwd
|
||||
|
||||
try {
|
||||
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Repo status' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
// Session cwds can point at a deleted worktree for a moment (or forever in a
|
||||
// stale row). simple-git throws at construction time on a missing baseDir, so
|
||||
// fail soft and hide the coding rail instead of spamming IPC handler errors.
|
||||
try {
|
||||
const stat = await fs.stat(cwd)
|
||||
if (!stat.isDirectory()) {
|
||||
return null
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
let git
|
||||
try {
|
||||
git = gitFor(cwd, gitBin)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
let status
|
||||
|
||||
try {
|
||||
status = await git.status()
|
||||
} catch {
|
||||
// Not a repo / git unavailable / remote backend.
|
||||
return null
|
||||
}
|
||||
|
||||
const detached = typeof status.detached === 'boolean' ? status.detached : !status.current
|
||||
const files = status.files.map(file => ({
|
||||
path: file.path,
|
||||
staged: isStaged(file),
|
||||
unstaged: Boolean(file.working_dir && file.working_dir !== ' ' && file.working_dir !== '?'),
|
||||
untracked: file.index === '?' || file.working_dir === '?',
|
||||
conflicted: file.index === 'U' || file.working_dir === 'U'
|
||||
}))
|
||||
|
||||
const result = {
|
||||
branch: detached ? null : status.current || null,
|
||||
defaultBranch: await defaultBranchName(git),
|
||||
detached,
|
||||
ahead: status.ahead || 0,
|
||||
behind: status.behind || 0,
|
||||
staged: files.filter(f => f.staged).length,
|
||||
unstaged: files.filter(f => f.unstaged).length,
|
||||
untracked: status.not_added.length,
|
||||
conflicted: status.conflicted.length,
|
||||
changed: files.length,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
files: files.slice(0, 200)
|
||||
}
|
||||
|
||||
// +/- vs HEAD (staged + unstaged tracked changes). No HEAD yet → leave 0.
|
||||
try {
|
||||
const summary = await git.diffSummary(['HEAD'])
|
||||
result.added = summary.insertions
|
||||
result.removed = summary.deletions
|
||||
} catch {
|
||||
// No commits yet.
|
||||
}
|
||||
|
||||
// `git diff HEAD` ignores untracked files, so a turn that only creates new
|
||||
// files (the common case — a fresh module, a demo dir) showed +0 in the rail
|
||||
// while the review pane counted them. Fold untracked insertions into `added`
|
||||
// so the rail matches reality. Bounded (size cap + concurrency) like the
|
||||
// review tree; only the capped file slice is counted so a huge untracked tree
|
||||
// can't stall the probe.
|
||||
try {
|
||||
const untracked = status.not_added.slice(0, 500)
|
||||
for (let i = 0; i < untracked.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) {
|
||||
const batch = await Promise.all(
|
||||
untracked.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(path => untrackedInsertions(cwd, path))
|
||||
)
|
||||
result.added += batch.reduce((sum, n) => sum + n, 0)
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: a probe failure just leaves untracked lines uncounted.
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
branchBase,
|
||||
fileDiffVsHead,
|
||||
repoStatus,
|
||||
resolveRenamePath,
|
||||
reviewCommit,
|
||||
reviewCommitContext,
|
||||
reviewCreatePr,
|
||||
reviewDiff,
|
||||
reviewList,
|
||||
reviewPush,
|
||||
reviewRevParse,
|
||||
reviewRevert,
|
||||
reviewShipInfo,
|
||||
reviewStage,
|
||||
reviewUnstage
|
||||
}
|
||||
22
apps/desktop/electron/git-review-ops.test.cjs
Normal file
22
apps/desktop/electron/git-review-ops.test.cjs
Normal file
@@ -0,0 +1,22 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { resolveRenamePath } = require('./git-review-ops.cjs')
|
||||
|
||||
test('resolveRenamePath: plain path is unchanged', () => {
|
||||
assert.equal(resolveRenamePath('src/a.ts'), 'src/a.ts')
|
||||
})
|
||||
|
||||
test('resolveRenamePath: simple rename resolves to the new path', () => {
|
||||
assert.equal(resolveRenamePath('old.ts => new.ts'), 'new.ts')
|
||||
})
|
||||
|
||||
test('resolveRenamePath: brace rename resolves to the new path', () => {
|
||||
assert.equal(resolveRenamePath('src/{old => new}/file.ts'), 'src/new/file.ts')
|
||||
})
|
||||
|
||||
test('resolveRenamePath: brace rename collapsing a segment', () => {
|
||||
assert.equal(resolveRenamePath('src/{lib => }/file.ts'), 'src/file.ts')
|
||||
})
|
||||
350
apps/desktop/electron/git-worktree-ops.cjs
Normal file
350
apps/desktop/electron/git-worktree-ops.cjs
Normal file
@@ -0,0 +1,350 @@
|
||||
'use strict'
|
||||
|
||||
// Git-driven worktree operations for the desktop "Start work" flow: spin up a
|
||||
// fresh worktree the lightest way (`git worktree add -b`), list real worktrees,
|
||||
// and remove them. Git is the source of truth; the renderer just drives these.
|
||||
|
||||
const path = require('node:path')
|
||||
const fs = require('node:fs')
|
||||
const { execFile } = require('node:child_process')
|
||||
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
|
||||
function runGit(gitBin, args, cwd) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
gitBin,
|
||||
args,
|
||||
{ cwd, windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
err.stderr = String(stderr || '')
|
||||
reject(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
resolve(String(stdout || ''))
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Parse `git worktree list --porcelain`. The first record is the main worktree.
|
||||
function parseWorktrees(out) {
|
||||
const trees = []
|
||||
let cur = null
|
||||
|
||||
for (const line of out.split('\n')) {
|
||||
if (line.startsWith('worktree ')) {
|
||||
if (cur) {
|
||||
trees.push(cur)
|
||||
}
|
||||
|
||||
cur = { path: line.slice(9).trim(), branch: null, detached: false, bare: false, locked: false }
|
||||
} else if (!cur) {
|
||||
continue
|
||||
} else if (line.startsWith('branch ')) {
|
||||
cur.branch = line
|
||||
.slice(7)
|
||||
.trim()
|
||||
.replace(/^refs\/heads\//, '')
|
||||
} else if (line === 'detached') {
|
||||
cur.detached = true
|
||||
} else if (line === 'bare') {
|
||||
cur.bare = true
|
||||
} else if (line.startsWith('locked')) {
|
||||
cur.locked = true
|
||||
}
|
||||
}
|
||||
|
||||
if (cur) {
|
||||
trees.push(cur)
|
||||
}
|
||||
|
||||
return trees
|
||||
}
|
||||
|
||||
async function listWorktrees(repoPath, gitBin) {
|
||||
let resolved
|
||||
|
||||
try {
|
||||
resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree list' })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const out = await runGit(gitBin, ['worktree', 'list', '--porcelain'], resolved)
|
||||
|
||||
return parseWorktrees(out).map((tree, index) => ({
|
||||
path: tree.path,
|
||||
branch: tree.branch,
|
||||
isMain: index === 0,
|
||||
detached: tree.detached,
|
||||
locked: tree.locked
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// A git-ref-safe branch name (spaces → "-", drop forbidden chars, trim edges),
|
||||
// or "" when nothing usable remains. Mirrors the renderer's `gitRef`, so a bad
|
||||
// value can't reach `git` no matter the caller (the GUI also enforces live).
|
||||
function sanitizeBranch(name) {
|
||||
return String(name || '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\w./-]/g, '')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/\/{2,}/g, '/')
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(/^[-./]+|[-./]+$/g, '')
|
||||
}
|
||||
|
||||
function slugify(name) {
|
||||
const slug = String(name || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 40)
|
||||
.replace(/-+$/g, '')
|
||||
|
||||
return slug || 'work'
|
||||
}
|
||||
|
||||
const TRUNK_BRANCHES = ['main', 'master']
|
||||
|
||||
async function gitLine(gitBin, args, cwd) {
|
||||
try {
|
||||
return (await runGit(gitBin, args, cwd)).trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultBranch(gitBin, cwd) {
|
||||
const remote = (
|
||||
await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd)
|
||||
).replace(/^origin\//, '')
|
||||
|
||||
if (remote) {
|
||||
return remote
|
||||
}
|
||||
|
||||
const configured = await gitLine(gitBin, ['config', '--get', 'init.defaultBranch'], cwd)
|
||||
|
||||
if (configured) {
|
||||
return configured
|
||||
}
|
||||
|
||||
for (const branch of TRUNK_BRANCHES) {
|
||||
if (await gitLine(gitBin, ['show-ref', '--verify', `refs/heads/${branch}`], cwd)) {
|
||||
return branch
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
// A brand-new project folder isn't a git repo — and a freshly-init'd one has no
|
||||
// commit to branch from — so `git worktree add` would fail. Make the dir a repo
|
||||
// with a root commit on the user's behalf so worktrees "just work". No-op for a
|
||||
// repo that already has commits; never touches the user's files (the seed commit
|
||||
// is `--allow-empty`), and never inits a dir that already lives inside a repo.
|
||||
async function ensureGitRepo(gitBin, dir) {
|
||||
let needsRoot = false
|
||||
|
||||
try {
|
||||
const inside = (await runGit(gitBin, ['rev-parse', '--is-inside-work-tree'], dir)).trim()
|
||||
|
||||
if (inside !== 'true') {
|
||||
await runGit(gitBin, ['init'], dir)
|
||||
needsRoot = true
|
||||
} else {
|
||||
// Repo exists; a worktree still needs a HEAD to branch from.
|
||||
try {
|
||||
await runGit(gitBin, ['rev-parse', '--verify', 'HEAD'], dir)
|
||||
} catch {
|
||||
needsRoot = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await runGit(gitBin, ['init'], dir)
|
||||
needsRoot = true
|
||||
}
|
||||
|
||||
if (needsRoot) {
|
||||
// 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'
|
||||
],
|
||||
dir
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the repo's MAIN worktree root, so `.worktrees/` always nests under the
|
||||
// primary checkout even when called from a linked worktree.
|
||||
async function mainRoot(gitBin, cwd) {
|
||||
const list = await listWorktrees(cwd, gitBin)
|
||||
const main = list.find(tree => tree.isMain)
|
||||
|
||||
return main ? main.path : cwd
|
||||
}
|
||||
|
||||
function uniqueDir(base) {
|
||||
let dir = base
|
||||
let n = 1
|
||||
|
||||
while (fs.existsSync(dir)) {
|
||||
n += 1
|
||||
dir = `${base}-${n}`
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
async function addExistingBranchWorktree(gitBin, root, name) {
|
||||
const branch = sanitizeBranch(name)
|
||||
|
||||
if (!branch) {
|
||||
throw new Error('Branch name is required.')
|
||||
}
|
||||
|
||||
if (branch === (await defaultBranch(gitBin, root))) {
|
||||
await runGit(gitBin, ['switch', branch], root)
|
||||
|
||||
return { path: root, branch, repoRoot: root }
|
||||
}
|
||||
|
||||
const dir = uniqueDir(path.join(root, '.worktrees', slugify(branch)))
|
||||
await runGit(gitBin, ['worktree', 'add', dir, branch], root)
|
||||
|
||||
return { path: dir, branch, repoRoot: root }
|
||||
}
|
||||
|
||||
async function addWorktree(repoPath, options, gitBin) {
|
||||
const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree add' })
|
||||
// A new project's folder may not be a git repo yet — init it (with a root
|
||||
// commit) so the worktree has something to branch from.
|
||||
await ensureGitRepo(gitBin, resolved)
|
||||
const root = await mainRoot(gitBin, resolved)
|
||||
const opts = options || {}
|
||||
|
||||
if (opts.existingBranch) {
|
||||
return addExistingBranchWorktree(gitBin, root, opts.existingBranch)
|
||||
}
|
||||
|
||||
const slug = slugify(opts.name || `work-${Date.now().toString(36)}`)
|
||||
const branch = sanitizeBranch(opts.branch) || `hermes/${slug}`
|
||||
const dir = uniqueDir(path.join(root, '.worktrees', slug))
|
||||
|
||||
const args = ['worktree', 'add', '-b', branch, dir]
|
||||
|
||||
if (opts.base) {
|
||||
args.push(String(opts.base))
|
||||
}
|
||||
|
||||
try {
|
||||
await runGit(gitBin, args, root)
|
||||
} catch (err) {
|
||||
// Branch name may already exist — retry checking out the existing branch
|
||||
// into a fresh worktree dir instead of failing the whole flow.
|
||||
if (/already exists/i.test(err.stderr || '')) {
|
||||
await runGit(gitBin, ['worktree', 'add', dir, branch], root)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
return { path: dir, branch, repoRoot: root }
|
||||
}
|
||||
|
||||
async function removeWorktree(repoPath, worktreePath, options, gitBin) {
|
||||
const resolvedRepo = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree remove (repo)' })
|
||||
const resolvedTree = resolveRequestedPathForIpc(worktreePath, { purpose: 'Worktree remove (tree)' })
|
||||
const root = await mainRoot(gitBin, resolvedRepo)
|
||||
const args = ['worktree', 'remove']
|
||||
|
||||
if (options && options.force) {
|
||||
args.push('--force')
|
||||
}
|
||||
|
||||
args.push(resolvedTree)
|
||||
await runGit(gitBin, args, root)
|
||||
|
||||
return { removed: resolvedTree }
|
||||
}
|
||||
|
||||
// List local branches for the "convert a branch into a worktree" picker, most
|
||||
// recently committed first. Each carries whether it's already checked out in a
|
||||
// worktree and, when checked out, that worktree's path. Empty on a non-repo /
|
||||
// remote backend where the probe can't run.
|
||||
async function listBranches(repoPath, gitBin) {
|
||||
let resolved
|
||||
|
||||
try {
|
||||
resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Branch list' })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const out = await runGit(
|
||||
gitBin,
|
||||
['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'],
|
||||
resolved
|
||||
)
|
||||
const trees = await listWorktrees(resolved, gitBin)
|
||||
const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path]))
|
||||
const trunk = await defaultBranch(gitBin, resolved)
|
||||
|
||||
return out
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.map(name => ({
|
||||
name,
|
||||
checkedOut: pathByBranch.has(name),
|
||||
isDefault: Boolean(trunk && name === trunk),
|
||||
worktreePath: pathByBranch.get(name) || null
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function switchBranch(repoPath, branch, gitBin) {
|
||||
const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Branch switch' })
|
||||
const target = sanitizeBranch(branch)
|
||||
|
||||
if (!target) {
|
||||
throw new Error('Branch name is required.')
|
||||
}
|
||||
|
||||
await runGit(gitBin, ['switch', target], resolved)
|
||||
|
||||
return { branch: target }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addWorktree,
|
||||
ensureGitRepo,
|
||||
listBranches,
|
||||
listWorktrees,
|
||||
parseWorktrees,
|
||||
removeWorktree,
|
||||
sanitizeBranch,
|
||||
switchBranch
|
||||
}
|
||||
214
apps/desktop/electron/git-worktree-ops.test.cjs
Normal file
214
apps/desktop/electron/git-worktree-ops.test.cjs
Normal file
@@ -0,0 +1,214 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
|
||||
const {
|
||||
addWorktree,
|
||||
ensureGitRepo,
|
||||
listBranches,
|
||||
parseWorktrees,
|
||||
sanitizeBranch,
|
||||
switchBranch
|
||||
} = require('./git-worktree-ops.cjs')
|
||||
|
||||
test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => {
|
||||
assert.equal(sanitizeBranch('beach vibes'), 'beach-vibes')
|
||||
assert.equal(sanitizeBranch('feat/cool thing'), 'feat/cool-thing')
|
||||
assert.equal(sanitizeBranch(' wip~^:? '), 'wip')
|
||||
assert.equal(sanitizeBranch('///'), '')
|
||||
})
|
||||
|
||||
test('parseWorktrees: main checkout + linked worktree', () => {
|
||||
const out = [
|
||||
'worktree /repo',
|
||||
'HEAD abc123',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /repo/.worktrees/feat',
|
||||
'HEAD def456',
|
||||
'branch refs/heads/hermes/feat',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
const trees = parseWorktrees(out)
|
||||
|
||||
assert.equal(trees.length, 2)
|
||||
assert.equal(trees[0].path, '/repo')
|
||||
assert.equal(trees[0].branch, 'main')
|
||||
assert.equal(trees[1].path, '/repo/.worktrees/feat')
|
||||
assert.equal(trees[1].branch, 'hermes/feat')
|
||||
})
|
||||
|
||||
test('parseWorktrees: detached + locked flags', () => {
|
||||
const out = ['worktree /repo/wt', 'HEAD abc', 'detached', 'locked reason', ''].join('\n')
|
||||
const trees = parseWorktrees(out)
|
||||
|
||||
assert.equal(trees.length, 1)
|
||||
assert.equal(trees[0].detached, true)
|
||||
assert.equal(trees[0].locked, true)
|
||||
assert.equal(trees[0].branch, null)
|
||||
})
|
||||
|
||||
test('parseWorktrees: empty input', () => {
|
||||
assert.deepEqual(parseWorktrees(''), [])
|
||||
})
|
||||
|
||||
test('ensureGitRepo: inits a plain dir with a root commit so worktrees branch', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-wt-'))
|
||||
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
|
||||
|
||||
try {
|
||||
await ensureGitRepo('git', dir)
|
||||
assert.match(git('rev-parse', '--verify', 'HEAD'), /^[0-9a-f]{7,}$/)
|
||||
|
||||
// The whole point: a worktree can now branch off the seeded root commit.
|
||||
execFileSync('git', ['worktree', 'add', '-b', 'wt', path.join(dir, '.worktrees', 'wt')], { cwd: dir })
|
||||
assert.ok(fs.existsSync(path.join(dir, '.worktrees', 'wt')))
|
||||
|
||||
// Idempotent: an already-committed repo gets no extra commit.
|
||||
await ensureGitRepo('git', dir)
|
||||
assert.equal(git('rev-list', '--count', 'HEAD'), '1')
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('switchBranch: switches a normal checkout branch', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-switch-'))
|
||||
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
|
||||
|
||||
try {
|
||||
await ensureGitRepo('git', dir)
|
||||
execFileSync('git', ['branch', 'feature'], { cwd: dir })
|
||||
|
||||
await switchBranch(dir, 'feature', 'git')
|
||||
|
||||
assert.equal(git('branch', '--show-current'), 'feature')
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('listBranches: lists locals and flags the checked-out branch', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-'))
|
||||
|
||||
try {
|
||||
await ensureGitRepo('git', dir)
|
||||
const current = execFileSync('git', ['branch', '--show-current'], { cwd: dir }).toString().trim()
|
||||
execFileSync('git', ['branch', 'feature'], { cwd: dir })
|
||||
|
||||
const branches = await listBranches(dir, 'git')
|
||||
const names = branches.map(b => b.name).sort()
|
||||
|
||||
assert.deepEqual(names, [current, 'feature'].sort())
|
||||
// The repo's own checkout is flagged; the unused branch is convertible.
|
||||
assert.equal(branches.find(b => b.name === current).checkedOut, true)
|
||||
assert.equal(branches.find(b => b.name === current).isDefault, true)
|
||||
assert.equal(fs.realpathSync(branches.find(b => b.name === current).worktreePath), fs.realpathSync(dir))
|
||||
assert.equal(branches.find(b => b.name === 'feature').checkedOut, false)
|
||||
assert.equal(branches.find(b => b.name === 'feature').isDefault, false)
|
||||
assert.equal(branches.find(b => b.name === 'feature').worktreePath, null)
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('listBranches: flags a free default branch as default, not checked out', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-default-'))
|
||||
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
|
||||
|
||||
try {
|
||||
await ensureGitRepo('git', dir)
|
||||
const trunk = git('branch', '--show-current')
|
||||
execFileSync('git', ['switch', '-c', 'rawr'], { cwd: dir })
|
||||
|
||||
const branches = await listBranches(dir, 'git')
|
||||
const defaultBranch = branches.find(b => b.name === trunk)
|
||||
|
||||
assert.equal(defaultBranch.checkedOut, false)
|
||||
assert.equal(defaultBranch.isDefault, true)
|
||||
assert.equal(defaultBranch.worktreePath, null)
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('listBranches: a branch claimed by a worktree is flagged checked out', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-wt-'))
|
||||
|
||||
try {
|
||||
await ensureGitRepo('git', dir)
|
||||
execFileSync('git', ['branch', 'feature'], { cwd: dir })
|
||||
// addWorktree converts the existing "feature" branch into a worktree.
|
||||
const result = await addWorktree(dir, { existingBranch: 'feature' }, 'git')
|
||||
|
||||
assert.equal(result.branch, 'feature')
|
||||
assert.ok(fs.existsSync(result.path))
|
||||
|
||||
const branches = await listBranches(dir, 'git')
|
||||
|
||||
assert.equal(branches.find(b => b.name === 'feature').checkedOut, true)
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('listBranches: empty on a non-repo path', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-nonrepo-'))
|
||||
|
||||
try {
|
||||
assert.deepEqual(await listBranches(dir, 'git'), [])
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('addWorktree: existingBranch checks the branch out without a new branch', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-convert-'))
|
||||
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
|
||||
|
||||
try {
|
||||
await ensureGitRepo('git', dir)
|
||||
execFileSync('git', ['branch', 'cool/feature'], { cwd: dir })
|
||||
|
||||
const before = git('branch', '--list').split('\n').length
|
||||
const result = await addWorktree(dir, { existingBranch: 'cool/feature' }, 'git')
|
||||
|
||||
// No new branch was created — only the existing one is checked out.
|
||||
assert.equal(git('branch', '--list').split('\n').length, before)
|
||||
assert.equal(result.branch, 'cool/feature')
|
||||
// Dir is named off the branch slug, nested under the main repo's .worktrees.
|
||||
assert.match(result.path, /[/\\]\.worktrees[/\\]cool-feature/)
|
||||
assert.equal(
|
||||
execFileSync('git', ['branch', '--show-current'], { cwd: result.path }).toString().trim(),
|
||||
'cool/feature'
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('addWorktree: existing default branch switches the main checkout, not .worktrees/main', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-convert-default-'))
|
||||
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
|
||||
|
||||
try {
|
||||
await ensureGitRepo('git', dir)
|
||||
const trunk = git('branch', '--show-current')
|
||||
execFileSync('git', ['switch', '-c', 'rawr'], { cwd: dir })
|
||||
|
||||
const result = await addWorktree(dir, { existingBranch: trunk }, 'git')
|
||||
|
||||
assert.equal(result.branch, trunk)
|
||||
assert.equal(fs.realpathSync(result.path), fs.realpathSync(dir))
|
||||
assert.equal(git('branch', '--show-current'), trunk)
|
||||
assert.equal(fs.existsSync(path.join(dir, '.worktrees', trunk)), false)
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -1,174 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
// Resolve git-worktree relationships for a set of session cwds, reading git's
|
||||
// on-disk metadata directly (no `git` spawn per path):
|
||||
//
|
||||
// - A normal checkout has a `.git` DIRECTORY at its root → it's the main
|
||||
// worktree; its repo root IS that directory's parent.
|
||||
// - A linked worktree has a `.git` FILE: `gitdir: <repo>/.git/worktrees/<name>`.
|
||||
// That admin dir's `commondir` points back at the shared `<repo>/.git`, whose
|
||||
// parent is the main repo root.
|
||||
//
|
||||
// Grouping by repoRoot therefore clusters a repo's main checkout with all of its
|
||||
// linked worktrees, regardless of how the worktree directories are named. The
|
||||
// branch (read from the worktree's own HEAD) gives each worktree a meaningful
|
||||
// label.
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
|
||||
// Walk up from `start` to the nearest ancestor that carries a `.git` entry
|
||||
// (file for a linked worktree, dir for the main checkout). Capped so a stray
|
||||
// path can't loop forever.
|
||||
function findGitHost(start, fsImpl) {
|
||||
let dir = start
|
||||
|
||||
for (let i = 0; i < 64; i += 1) {
|
||||
const dotgit = path.join(dir, '.git')
|
||||
|
||||
try {
|
||||
if (fsImpl.existsSync(dotgit)) {
|
||||
return dir
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const parent = path.dirname(dir)
|
||||
|
||||
if (parent === dir) {
|
||||
return null
|
||||
}
|
||||
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function readBranch(gitDir, fsImpl) {
|
||||
try {
|
||||
const head = fsImpl.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim()
|
||||
const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/)
|
||||
|
||||
if (ref) {
|
||||
return ref[1]
|
||||
}
|
||||
|
||||
// Detached HEAD: surface a short sha so the worktree still gets a label.
|
||||
return /^[0-9a-f]{7,40}$/i.test(head) ? head.slice(0, 8) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Given the directory that owns the `.git` entry, resolve its worktree identity.
|
||||
function resolveFromHost(host, fsImpl) {
|
||||
const dotgit = path.join(host, '.git')
|
||||
let stat
|
||||
|
||||
try {
|
||||
stat = fsImpl.statSync(dotgit)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
return {
|
||||
repoRoot: host,
|
||||
worktreeRoot: host,
|
||||
isMainWorktree: true,
|
||||
branch: readBranch(dotgit, fsImpl)
|
||||
}
|
||||
}
|
||||
|
||||
// Linked worktree: `.git` is a file pointing at the admin dir.
|
||||
let contents
|
||||
|
||||
try {
|
||||
contents = fsImpl.readFileSync(dotgit, 'utf8').trim()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = contents.match(/^gitdir:\s*(.+)$/m)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const adminDir = path.resolve(host, match[1].trim())
|
||||
|
||||
// `commondir` resolves to the shared `<repo>/.git`; fall back to walking two
|
||||
// levels up from `<repo>/.git/worktrees/<name>` if it's missing.
|
||||
let commonDir
|
||||
|
||||
try {
|
||||
const rel = fsImpl.readFileSync(path.join(adminDir, 'commondir'), 'utf8').trim()
|
||||
commonDir = path.resolve(adminDir, rel)
|
||||
} catch {
|
||||
commonDir = path.dirname(path.dirname(adminDir))
|
||||
}
|
||||
|
||||
return {
|
||||
repoRoot: path.dirname(commonDir),
|
||||
worktreeRoot: host,
|
||||
isMainWorktree: false,
|
||||
branch: readBranch(adminDir, fsImpl)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWorktree(startPath, fsImpl = fs) {
|
||||
let resolved
|
||||
|
||||
try {
|
||||
resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Worktree lookup' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
let start = resolved
|
||||
|
||||
try {
|
||||
const stat = fsImpl.statSync(resolved)
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
start = path.dirname(resolved)
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const host = findGitHost(start, fsImpl)
|
||||
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
|
||||
return resolveFromHost(host, fsImpl)
|
||||
}
|
||||
|
||||
// Batch entry point for the renderer: maps each requested cwd to its worktree
|
||||
// info (or null when it isn't inside a git checkout / can't be read). Dedupes so
|
||||
// many sessions sharing a cwd cost one lookup.
|
||||
async function worktreesForIpc(cwds, options = {}) {
|
||||
const fsImpl = options.fs || fs
|
||||
const list = Array.isArray(cwds) ? cwds : []
|
||||
const out = {}
|
||||
|
||||
for (const cwd of list) {
|
||||
if (typeof cwd !== 'string' || !cwd.trim() || cwd in out) {
|
||||
continue
|
||||
}
|
||||
|
||||
out[cwd] = resolveWorktree(cwd, fsImpl)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveWorktree,
|
||||
worktreesForIpc
|
||||
}
|
||||
@@ -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,7 +21,6 @@ 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')
|
||||
@@ -38,11 +37,13 @@ const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
const { createLinkTitleWindow } = require('./link-title-window.cjs')
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
|
||||
const { waitForDashboardPort } = require('./backend-ready.cjs')
|
||||
const { waitForDashboardPortAnnouncement } = require('./backend-ready.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
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 {
|
||||
@@ -55,7 +56,23 @@ const {
|
||||
buildRelaunchScript
|
||||
} = require('./update-relaunch.cjs')
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const { worktreesForIpc } = require('./git-worktrees.cjs')
|
||||
const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs')
|
||||
const {
|
||||
fileDiffVsHead,
|
||||
repoStatus,
|
||||
reviewCommit,
|
||||
reviewCommitContext,
|
||||
reviewCreatePr,
|
||||
reviewDiff,
|
||||
reviewList,
|
||||
reviewPush,
|
||||
reviewRevParse,
|
||||
reviewRevert,
|
||||
reviewShipInfo,
|
||||
reviewStage,
|
||||
reviewUnstage
|
||||
} = require('./git-review-ops.cjs')
|
||||
const { scanGitRepos } = require('./git-repo-scan.cjs')
|
||||
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
|
||||
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
|
||||
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
|
||||
@@ -170,6 +187,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
|
||||
@@ -302,9 +329,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
|
||||
@@ -382,14 +407,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'),
|
||||
@@ -503,25 +524,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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -744,6 +788,9 @@ let rendererReloadTimes = []
|
||||
// instead of re-running install.ps1 in a hot loop. Cleared explicitly by
|
||||
// the renderer's "Reload and retry" path or by quitting the app.
|
||||
let bootstrapFailure = null
|
||||
// Latched non-bootstrap backend spawn failure — stops getConnection() from
|
||||
// respawning hermes dashboard children in a tight loop while boot is broken.
|
||||
let backendStartFailure = null
|
||||
// Active first-launch install, so the renderer's Cancel button (and app quit)
|
||||
// can abort the in-flight install.sh/ps1 instead of leaving it running.
|
||||
let bootstrapAbortController = null
|
||||
@@ -1254,6 +1301,36 @@ function isCommandScript(command) {
|
||||
return IS_WINDOWS && /\.(cmd|bat)$/i.test(command || '')
|
||||
}
|
||||
|
||||
function unwrapWindowsVenvHermesCommand(command, dashboardArgs) {
|
||||
if (!IS_WINDOWS || !command || isCommandScript(command)) return null
|
||||
|
||||
const resolved = path.resolve(String(command))
|
||||
if (!/^hermes(?:\.exe)?$/i.test(path.basename(resolved))) return null
|
||||
|
||||
const scriptsDir = path.dirname(resolved)
|
||||
if (path.basename(scriptsDir).toLowerCase() !== 'scripts') return null
|
||||
|
||||
const venvRoot = path.dirname(scriptsDir)
|
||||
const python = getNoConsoleVenvPython(venvRoot)
|
||||
if (!fileExists(python)) return null
|
||||
|
||||
const root = path.dirname(venvRoot)
|
||||
return {
|
||||
label: `existing Hermes no-console Python at ${python}`,
|
||||
command: python,
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
bootstrap: false,
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome: HERMES_HOME,
|
||||
pythonPathEntries: [...(directoryExists(root) ? [root] : []), ...getVenvSitePackagesEntries(venvRoot)],
|
||||
venvRoot
|
||||
}),
|
||||
kind: 'python',
|
||||
readyFile: true,
|
||||
shell: false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeExecutablePathForCompare(commandPath) {
|
||||
if (!commandPath) return null
|
||||
|
||||
@@ -1474,6 +1551,97 @@ function getVenvPython(venvRoot) {
|
||||
return path.join(venvRoot, IS_WINDOWS ? path.join('Scripts', 'python.exe') : path.join('bin', 'python'))
|
||||
}
|
||||
|
||||
function readVenvHome(venvRoot) {
|
||||
try {
|
||||
const cfg = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8')
|
||||
const match = cfg.match(/^home\s*=\s*(.+?)\s*$/im)
|
||||
return match ? match[1].trim() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getNoConsoleVenvPython(venvRoot) {
|
||||
if (!IS_WINDOWS) return getVenvPython(venvRoot)
|
||||
|
||||
// Prefer the venv's own pythonw shim — it carries pyvenv.cfg / site-packages
|
||||
// wiring. Falling back to the base uv/python.org pythonw.exe skips the venv
|
||||
// and breaks imports (yaml, hermes_cli, …) even when PYTHONPATH is patched.
|
||||
const venvPythonw = path.join(venvRoot, 'Scripts', 'pythonw.exe')
|
||||
if (fileExists(venvPythonw)) return venvPythonw
|
||||
|
||||
const baseHome = readVenvHome(venvRoot)
|
||||
if (baseHome) {
|
||||
const basePythonw = path.join(baseHome, 'pythonw.exe')
|
||||
if (fileExists(basePythonw)) return basePythonw
|
||||
}
|
||||
|
||||
return venvPythonw
|
||||
}
|
||||
|
||||
function toNoConsolePython(pythonPath) {
|
||||
if (!IS_WINDOWS || !pythonPath) return pythonPath
|
||||
|
||||
const resolved = String(pythonPath)
|
||||
if (/pythonw\.exe$/i.test(resolved)) return resolved
|
||||
|
||||
if (/python\.exe$/i.test(resolved)) {
|
||||
const pythonw = path.join(path.dirname(resolved), 'pythonw.exe')
|
||||
if (fileExists(pythonw)) return pythonw
|
||||
}
|
||||
|
||||
return pythonPath
|
||||
}
|
||||
|
||||
function applyWindowsNoConsoleSpawnHints(backend) {
|
||||
if (!IS_WINDOWS || !backend?.command) return backend
|
||||
|
||||
const usesHermesModule =
|
||||
backend.kind === 'python' ||
|
||||
(Array.isArray(backend.args) && backend.args[0] === '-m' && backend.args[1] === 'hermes_cli.main')
|
||||
|
||||
if (!usesHermesModule) return backend
|
||||
|
||||
backend.command = toNoConsolePython(backend.command)
|
||||
if (/pythonw\.exe$/i.test(path.basename(String(backend.command || '')))) {
|
||||
backend.readyFile = true
|
||||
}
|
||||
|
||||
return backend
|
||||
}
|
||||
|
||||
function getVenvSitePackagesEntries(venvRoot) {
|
||||
const entries = []
|
||||
if (!venvRoot) return entries
|
||||
|
||||
if (IS_WINDOWS) {
|
||||
const sitePackages = path.join(venvRoot, 'Lib', 'site-packages')
|
||||
if (directoryExists(sitePackages)) entries.push(sitePackages)
|
||||
return entries
|
||||
}
|
||||
|
||||
const version = (() => {
|
||||
try {
|
||||
const cfg = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8')
|
||||
const match = cfg.match(/^version_info\s*=\s*(\d+\.\d+)/im)
|
||||
return match ? match[1].trim() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
if (version) {
|
||||
const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages')
|
||||
if (directoryExists(sitePackages)) entries.push(sitePackages)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function makeDashboardReadyFile() {
|
||||
const dir = path.join(app.getPath('userData'), 'backend-ready')
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
return path.join(dir, `dashboard-${process.pid}-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.json`)
|
||||
}
|
||||
|
||||
// resolveGitBinary — locate git.exe on Windows. A fresh installer-driven
|
||||
// install only has PortableGit under %LOCALAPPDATA%\hermes\git (never on
|
||||
// PATH), so a bare spawn('git') ENOENTs and self-update checks fail with
|
||||
@@ -1503,6 +1671,30 @@ function resolveGitBinary() {
|
||||
return _gitBinaryCache
|
||||
}
|
||||
|
||||
// resolveGhBinary — locate the GitHub CLI. GUI-launched apps get a minimal PATH
|
||||
// that omits Homebrew (/opt/homebrew/bin, /usr/local/bin) where `gh` usually
|
||||
// lives, so a bare spawn('gh') ENOENTs even though `gh` works in the user's
|
||||
// terminal. Check the common install locations first, then PATH. Cached.
|
||||
let _ghBinaryCache = null
|
||||
function resolveGhBinary() {
|
||||
if (_ghBinaryCache) return _ghBinaryCache
|
||||
|
||||
const candidates = []
|
||||
|
||||
if (IS_WINDOWS) {
|
||||
candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'GitHub CLI', 'gh.exe'))
|
||||
if (process.env.LOCALAPPDATA) {
|
||||
candidates.push(path.join(process.env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links', 'gh.exe'))
|
||||
}
|
||||
} else {
|
||||
const home = app.getPath('home')
|
||||
candidates.push('/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh', path.join(home, '.local', 'bin', 'gh'))
|
||||
}
|
||||
|
||||
_ghBinaryCache = candidates.find(fileExists) || findOnPath('gh') || 'gh'
|
||||
return _ghBinaryCache
|
||||
}
|
||||
|
||||
function recentHermesLog() {
|
||||
return hermesLog.slice(-20).join('\n')
|
||||
}
|
||||
@@ -1982,7 +2174,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)
|
||||
@@ -2065,7 +2258,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.
|
||||
@@ -2590,20 +2785,24 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
|
||||
const python = findPythonForRoot(root)
|
||||
if (!python) return null
|
||||
|
||||
return {
|
||||
const venvRoot = path.join(root, 'venv')
|
||||
const venvPython = getVenvPython(venvRoot)
|
||||
const command = IS_WINDOWS && fileExists(venvPython) ? getNoConsoleVenvPython(venvRoot) : toNoConsolePython(python)
|
||||
|
||||
return applyWindowsNoConsoleSpawnHints({
|
||||
kind: 'python',
|
||||
label,
|
||||
command: python,
|
||||
command,
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome: HERMES_HOME,
|
||||
pythonPathEntries: [root],
|
||||
venvRoot: path.join(root, 'venv')
|
||||
venvRoot
|
||||
}),
|
||||
root,
|
||||
bootstrap: Boolean(options.bootstrap),
|
||||
shell: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// createActiveBackend — build a backend pointing at ACTIVE_HERMES_ROOT, the
|
||||
@@ -2612,11 +2811,12 @@ 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())
|
||||
|
||||
return {
|
||||
return applyWindowsNoConsoleSpawnHints({
|
||||
kind: 'python',
|
||||
label: `Hermes at ${ACTIVE_HERMES_ROOT}`,
|
||||
command: fileExists(venvPython) ? venvPython : findSystemPython(),
|
||||
command,
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome: HERMES_HOME,
|
||||
@@ -2626,7 +2826,7 @@ function createActiveBackend(dashboardArgs) {
|
||||
root: ACTIVE_HERMES_ROOT,
|
||||
bootstrap: true,
|
||||
shell: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function resolveHermesBackend(dashboardArgs) {
|
||||
@@ -2687,6 +2887,11 @@ function resolveHermesBackend(dashboardArgs) {
|
||||
}
|
||||
|
||||
if (hermesCommand) {
|
||||
const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs)
|
||||
if (unwrapped) {
|
||||
return unwrapped
|
||||
}
|
||||
|
||||
// Smoke-test the candidate before trusting it. A `hermes` shim
|
||||
// left behind by a half-uninstalled pip install (or a venv
|
||||
// entry-point pointing at a deleted interpreter) still resolves
|
||||
@@ -2696,15 +2901,17 @@ function resolveHermesBackend(dashboardArgs) {
|
||||
// and lets the resolver fall through to step 6 / bootstrap.
|
||||
const shellForProbe = isCommandScript(hermesCommand)
|
||||
if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) {
|
||||
return {
|
||||
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.`
|
||||
@@ -2726,15 +2933,15 @@ function resolveHermesBackend(dashboardArgs) {
|
||||
// failure, fall through to step 6 so the bootstrap runner pulls
|
||||
// a uv-managed 3.11 into %LOCALAPPDATA%\hermes\hermes-agent\venv.
|
||||
if (canImportHermesCli(python)) {
|
||||
return {
|
||||
return applyWindowsNoConsoleSpawnHints({
|
||||
kind: 'python',
|
||||
label: `installed hermes_cli module via ${python}`,
|
||||
command: python,
|
||||
command: toNoConsolePython(python),
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
bootstrap: false,
|
||||
env: {},
|
||||
shell: false
|
||||
}
|
||||
})
|
||||
}
|
||||
rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`)
|
||||
}
|
||||
@@ -2768,7 +2975,7 @@ function resolveHermesBackend(dashboardArgs) {
|
||||
async function ensureRuntime(backend) {
|
||||
if (!backend.bootstrap) {
|
||||
await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32)
|
||||
return backend
|
||||
return applyWindowsNoConsoleSpawnHints(backend)
|
||||
}
|
||||
|
||||
// backend.kind === 'bootstrap-needed' means resolveHermesBackend couldn't
|
||||
@@ -2784,7 +2991,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
|
||||
@@ -2908,7 +3117,7 @@ async function ensureRuntime(backend) {
|
||||
)
|
||||
}
|
||||
|
||||
backend.command = venvPython
|
||||
backend.command = getNoConsoleVenvPython(VENV_ROOT)
|
||||
backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})`
|
||||
updateBootProgress({
|
||||
phase: 'runtime.ready',
|
||||
@@ -2917,10 +3126,9 @@ async function ensureRuntime(backend) {
|
||||
running: true,
|
||||
error: null
|
||||
})
|
||||
return backend
|
||||
return applyWindowsNoConsoleSpawnHints(backend)
|
||||
}
|
||||
|
||||
|
||||
function fetchJson(url, token, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
|
||||
@@ -3579,11 +3787,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() {
|
||||
@@ -4831,6 +5035,7 @@ function resetBootProgressForReconnect() {
|
||||
|
||||
function resetHermesConnection() {
|
||||
connectionPromise = null
|
||||
backendStartFailure = null
|
||||
|
||||
if (hermesProcess && !hermesProcess.killed) {
|
||||
hermesProcess.kill('SIGTERM')
|
||||
@@ -4992,6 +5197,7 @@ async function spawnPoolBackend(profile, entry) {
|
||||
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||||
const hermesCwd = resolveHermesCwd()
|
||||
const webDist = resolveWebDist()
|
||||
const readyFile = backend.readyFile ? makeDashboardReadyFile() : null
|
||||
|
||||
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
|
||||
|
||||
@@ -5012,7 +5218,8 @@ async function spawnPoolBackend(profile, entry) {
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
HERMES_WEB_DIST: webDist
|
||||
HERMES_WEB_DIST: webDist,
|
||||
...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {})
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
@@ -5045,7 +5252,10 @@ async function spawnPoolBackend(profile, entry) {
|
||||
})
|
||||
|
||||
// Discover the ephemeral port the child bound to
|
||||
const port = await Promise.race([waitForDashboardPort(child), startFailed])
|
||||
const port = await Promise.race([waitForDashboardPortAnnouncement(child, { readyFile }), startFailed])
|
||||
if (readyFile) {
|
||||
fs.unlink(readyFile, () => {})
|
||||
}
|
||||
entry.port = port
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
@@ -5158,6 +5368,9 @@ async function startHermes() {
|
||||
if (bootstrapFailure) {
|
||||
throw bootstrapFailure
|
||||
}
|
||||
if (backendStartFailure) {
|
||||
throw backendStartFailure
|
||||
}
|
||||
if (connectionPromise) return connectionPromise
|
||||
|
||||
connectionPromise = (async () => {
|
||||
@@ -5211,6 +5424,7 @@ async function startHermes() {
|
||||
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||||
const hermesCwd = resolveHermesCwd()
|
||||
const webDist = resolveWebDist()
|
||||
const readyFile = backend.readyFile ? makeDashboardReadyFile() : null
|
||||
|
||||
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
|
||||
rememberLog(`Starting Hermes backend via ${backend.label}`)
|
||||
@@ -5237,7 +5451,8 @@ async function startHermes() {
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
HERMES_WEB_DIST: webDist
|
||||
HERMES_WEB_DIST: webDist,
|
||||
...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {})
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
@@ -5293,12 +5508,19 @@ 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([waitForDashboardPort(hermesProcess), backendStartFailed])
|
||||
const port = await Promise.race([
|
||||
waitForDashboardPortAnnouncement(hermesProcess, { readyFile }),
|
||||
backendStartFailed
|
||||
])
|
||||
if (readyFile) {
|
||||
fs.unlink(readyFile, () => {})
|
||||
}
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
|
||||
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
|
||||
backendReady = true
|
||||
backendStartFailure = null
|
||||
const authToken = await adoptServedDashboardToken(baseUrl, token, {
|
||||
// The exit/error handlers null hermesProcess when the child dies.
|
||||
childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed,
|
||||
@@ -5324,6 +5546,7 @@ async function startHermes() {
|
||||
}
|
||||
})().catch(error => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
backendStartFailure = error instanceof Error ? error : new Error(message)
|
||||
updateBootProgress(
|
||||
{
|
||||
error: message,
|
||||
@@ -5623,7 +5846,7 @@ function createWindow() {
|
||||
if (!nativeThemeListenerInstalled) {
|
||||
nativeThemeListenerInstalled = true
|
||||
nativeTheme.on('updated', () => {
|
||||
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
|
||||
applyTitleBarOverlay(mainWindow)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5807,19 +6030,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
|
||||
@@ -5889,6 +6125,7 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
|
||||
await teardownPrimaryBackendAndWait()
|
||||
bootstrapFailure = null
|
||||
backendStartFailure = null
|
||||
bootstrapState = {
|
||||
active: false,
|
||||
manifest: null,
|
||||
@@ -5915,6 +6152,7 @@ ipcMain.handle('hermes:bootstrap:repair', async () => {
|
||||
rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`)
|
||||
}
|
||||
bootstrapFailure = null
|
||||
backendStartFailure = null
|
||||
resetHermesConnection()
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -6283,11 +6521,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) =>
|
||||
@@ -6307,7 +6555,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).
|
||||
@@ -6596,7 +6844,160 @@ ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dir
|
||||
|
||||
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
|
||||
|
||||
ipcMain.handle('hermes:fs:worktrees', async (_event, cwds) => worktreesForIpc(cwds))
|
||||
// Reveal a path in the OS file manager (Finder / Explorer / Files).
|
||||
ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
|
||||
const target = String(targetPath || '').trim()
|
||||
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
shell.showItemInFolder(target)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// Rename a file/folder in place. The renderer passes the existing path + a new
|
||||
// base name; the destination is resolved in the SAME parent dir so a rename can
|
||||
// never move the item elsewhere or traverse out. Rejects on a name collision.
|
||||
ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => {
|
||||
const src = String(targetPath || '').trim()
|
||||
const name = String(newName || '').trim()
|
||||
|
||||
if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
|
||||
throw new Error('Invalid rename')
|
||||
}
|
||||
|
||||
const dst = path.join(path.dirname(src), name)
|
||||
|
||||
if (dst === src) {
|
||||
return { path: dst }
|
||||
}
|
||||
|
||||
if (fs.existsSync(dst)) {
|
||||
throw new Error(`"${name}" already exists`)
|
||||
}
|
||||
|
||||
await fs.promises.rename(src, dst)
|
||||
|
||||
return { path: dst }
|
||||
})
|
||||
|
||||
// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path
|
||||
// is hardened (resolveRequestedPathForIpc) and the parent must already exist —
|
||||
// this never creates directory trees or escapes the allowed roots, and content
|
||||
// is size-capped so it can't be abused as a bulk-write primitive.
|
||||
ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => {
|
||||
const raw = String(filePath || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
throw new Error('Invalid path')
|
||||
}
|
||||
|
||||
const text = String(content ?? '')
|
||||
|
||||
if (text.length > 1_000_000) {
|
||||
throw new Error('Content too large')
|
||||
}
|
||||
|
||||
const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' })
|
||||
|
||||
if (!directoryExists(path.dirname(resolved))) {
|
||||
throw new Error('Parent directory does not exist')
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(resolved, text, 'utf8')
|
||||
|
||||
return { path: resolved }
|
||||
})
|
||||
|
||||
// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete"
|
||||
// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform.
|
||||
ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => {
|
||||
const target = String(targetPath || '').trim()
|
||||
|
||||
if (!target) {
|
||||
throw new Error('Invalid delete')
|
||||
}
|
||||
|
||||
await shell.trashItem(target)
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// 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:worktreeAdd', async (_event, repoPath, options) =>
|
||||
addWorktree(repoPath, options || {}, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) =>
|
||||
removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
|
||||
switchBranch(repoPath, branch, 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
|
||||
// hides cleanly rather than erroring.
|
||||
ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary()))
|
||||
|
||||
// Codex-style review pane: list changed files for a scope, fetch one file's
|
||||
// unified diff, and stage / unstage / revert. Reads return empty on failure;
|
||||
// mutations reject so the renderer can toast.
|
||||
ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) =>
|
||||
reviewList(repoPath, scope, baseRef, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) =>
|
||||
reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary())
|
||||
)
|
||||
// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view).
|
||||
ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) =>
|
||||
fileDiffVsHead(repoPath, filePath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) =>
|
||||
reviewStage(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) =>
|
||||
reviewUnstage(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) =>
|
||||
reviewRevert(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) =>
|
||||
reviewRevParse(repoPath, ref, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) =>
|
||||
reviewCommit(repoPath, message, Boolean(push), resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) =>
|
||||
reviewCommitContext(repoPath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary()))
|
||||
ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary()))
|
||||
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
|
||||
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
|
||||
)
|
||||
|
||||
// Repo-first project discovery: scan bounded roots for git repos (pure fs walk,
|
||||
// no native addon). Never throws to the renderer — failures yield an empty list.
|
||||
ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
|
||||
try {
|
||||
return await scanGitRepos(roots || [], options || {})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
||||
if (!nodePty) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
|
||||
@@ -82,7 +82,35 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
|
||||
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
|
||||
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
|
||||
worktrees: cwds => ipcRenderer.invoke('hermes:fs:worktrees', cwds),
|
||||
revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath),
|
||||
renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName),
|
||||
writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content),
|
||||
trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath),
|
||||
git: {
|
||||
worktreeList: repoPath => ipcRenderer.invoke('hermes:git:worktreeList', repoPath),
|
||||
worktreeAdd: (repoPath, options) => ipcRenderer.invoke('hermes:git:worktreeAdd', repoPath, options),
|
||||
worktreeRemove: (repoPath, worktreePath, options) =>
|
||||
ipcRenderer.invoke('hermes:git:worktreeRemove', repoPath, worktreePath, options),
|
||||
branchSwitch: (repoPath, branch) => ipcRenderer.invoke('hermes:git:branchSwitch', repoPath, branch),
|
||||
branchList: repoPath => ipcRenderer.invoke('hermes:git:branchList', repoPath),
|
||||
repoStatus: repoPath => ipcRenderer.invoke('hermes:git:repoStatus', repoPath),
|
||||
fileDiff: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:fileDiff', repoPath, filePath),
|
||||
scanRepos: (roots, options) => ipcRenderer.invoke('hermes:git:scanRepos', roots, options),
|
||||
review: {
|
||||
list: (repoPath, scope, baseRef) => ipcRenderer.invoke('hermes:git:review:list', repoPath, scope, baseRef),
|
||||
diff: (repoPath, filePath, scope, baseRef, staged) =>
|
||||
ipcRenderer.invoke('hermes:git:review:diff', repoPath, filePath, scope, baseRef, staged),
|
||||
stage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:stage', repoPath, filePath),
|
||||
unstage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:unstage', repoPath, filePath),
|
||||
revert: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:revert', repoPath, filePath),
|
||||
revParse: (repoPath, ref) => ipcRenderer.invoke('hermes:git:review:revParse', repoPath, ref),
|
||||
commit: (repoPath, message, push) => ipcRenderer.invoke('hermes:git:review:commit', repoPath, message, push),
|
||||
commitContext: repoPath => ipcRenderer.invoke('hermes:git:review:commitContext', repoPath),
|
||||
push: repoPath => ipcRenderer.invoke('hermes:git:review:push', repoPath),
|
||||
shipInfo: repoPath => ipcRenderer.invoke('hermes:git:review:shipInfo', repoPath),
|
||||
createPr: repoPath => ipcRenderer.invoke('hermes:git:review:createPr', repoPath)
|
||||
}
|
||||
},
|
||||
terminal: {
|
||||
dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id),
|
||||
resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size),
|
||||
|
||||
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()
|
||||
|
||||
@@ -12,7 +12,8 @@ function readElectronFile(name) {
|
||||
}
|
||||
|
||||
function requireHiddenChildOptions(source, needle) {
|
||||
const index = source.indexOf(needle)
|
||||
const match = needle instanceof RegExp ? needle.exec(source) : null
|
||||
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(
|
||||
@@ -28,14 +29,28 @@ test('desktop background child processes opt into hidden Windows consoles', () =
|
||||
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
|
||||
|
||||
requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
|
||||
requireHiddenChildOptions(source, 'execFileSync(pyExe')
|
||||
requireHiddenChildOptions(source, 'spawn(resolveGitBinary()')
|
||||
requireHiddenChildOptions(source, /execFileSync\(\s*pyExe/)
|
||||
requireHiddenChildOptions(source, /spawn\(\s*resolveGitBinary\(\)/)
|
||||
requireHiddenChildOptions(source, "execFileSync('taskkill'")
|
||||
requireHiddenChildOptions(source, 'spawn(command, args')
|
||||
requireHiddenChildOptions(source, /spawn\(\s*command,\s*args/)
|
||||
requireHiddenChildOptions(source, "spawn('curl'")
|
||||
requireHiddenChildOptions(source, 'spawn(backend.command, backend.args')
|
||||
requireHiddenChildOptions(source, 'hermesProcess = spawn(backend.command, backend.args')
|
||||
requireHiddenChildOptions(source, "spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary']")
|
||||
requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/)
|
||||
requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/)
|
||||
requireHiddenChildOptions(source, /spawn\(\s*py,\s*\['-m', 'hermes_cli\.main', 'uninstall', '--gui-summary'\]/)
|
||||
|
||||
assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, dashboardArgs\)/)
|
||||
assert.match(source, /existing Hermes no-console Python at/)
|
||||
assert.match(source, /function getNoConsoleVenvPython\(venvRoot\)/)
|
||||
assert.match(source, /function toNoConsolePython\(pythonPath\)/)
|
||||
assert.match(source, /function applyWindowsNoConsoleSpawnHints\(backend\)/)
|
||||
assert.match(source, /function readVenvHome\(venvRoot\)/)
|
||||
assert.match(source, /path\.join\(venvRoot, 'Scripts', 'pythonw\.exe'\)/)
|
||||
assert.match(source, /backendStartFailure/)
|
||||
assert.match(source, /HERMES_DESKTOP_READY_FILE/)
|
||||
assert.match(source, /readyFile: true/)
|
||||
assert.match(source, /function getVenvSitePackagesEntries\(venvRoot\)/)
|
||||
assert.match(source, /path\.join\(venvRoot, 'Lib', 'site-packages'\)/)
|
||||
assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.dashboardArgs\]/)
|
||||
})
|
||||
|
||||
test('intentional or interactive desktop child processes stay documented', () => {
|
||||
|
||||
@@ -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,7 +18,7 @@
|
||||
"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 && 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",
|
||||
"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",
|
||||
@@ -37,7 +37,7 @@
|
||||
"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/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/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 +51,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",
|
||||
@@ -74,6 +80,9 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"dnd-core": "^14.0.1",
|
||||
"hast-util-from-html-isomorphic": "^2.0.0",
|
||||
"hast-util-to-text": "^4.0.2",
|
||||
@@ -93,6 +102,7 @@
|
||||
"remark-math": "^6.0.0",
|
||||
"remend": "^1.3.0",
|
||||
"shiki": "^4.0.2",
|
||||
"simple-git": "^3.36.0",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
@@ -108,6 +118,9 @@
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/d3-selection": "^3.0.11",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.14",
|
||||
|
||||
33
apps/desktop/scripts/bundle-electron-main.mjs
Normal file
33
apps/desktop/scripts/bundle-electron-main.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
// bundle-electron-main.mjs — bundles electron/main.cjs into a single
|
||||
// self-contained file so the nix build doesn't need to ship node_modules/.
|
||||
//
|
||||
// `electron` is provided by the runtime; `node-pty` is staged separately
|
||||
// via stage-native-deps.cjs. `preload.cjs` is NOT require()'d by main —
|
||||
// Electron loads it via path.join(__dirname, 'preload.cjs') — so it stays
|
||||
// as a separate file and doesn't need bundling.
|
||||
import { build } from 'esbuild'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { renameSync } from 'node:fs'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const root = resolve(here, '..')
|
||||
const entry = resolve(root, 'electron/main.cjs')
|
||||
const tmp = resolve(root, 'electron/main.bundled.cjs')
|
||||
|
||||
await build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node20',
|
||||
outfile: tmp,
|
||||
external: ['electron', 'node-pty'],
|
||||
logLevel: 'info'
|
||||
})
|
||||
|
||||
// Overwrite the original with the bundled version.
|
||||
renameSync(tmp, entry)
|
||||
|
||||
console.log(`bundled ${entry}`)
|
||||
194
apps/desktop/src/app/agents/build-live-trace.ts
Normal file
194
apps/desktop/src/app/agents/build-live-trace.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import type { LiveTurn } from '@/store/live-turn'
|
||||
import type { SubagentProgress } from '@/store/subagents'
|
||||
import type { TraceDoc, TraceSpan, TraceSpanStatus } from '@/store/trace'
|
||||
|
||||
const TERMINAL_SUB = new Set(['completed', 'failed', 'interrupted'])
|
||||
|
||||
/**
|
||||
* Stitch the live turn + subagent stream into a TraceDoc (client time, epoch
|
||||
* seconds) in the SAME shape the DB trace produces, so the waterfall renders the
|
||||
* in-flight turn with no separate code path. LLM spans are the gaps between tool
|
||||
* calls; subagents nest under the `delegate_task` tool span that spawned them.
|
||||
*
|
||||
* `live=false` finalizes the snapshot (running → ok) so a settled turn stops
|
||||
* pulsing. Returns null when there's nothing in flight to draw.
|
||||
*/
|
||||
export function buildLiveTrace(
|
||||
turn: LiveTurn | undefined,
|
||||
subs: SubagentProgress[],
|
||||
nowMs: number,
|
||||
rootLabel?: string,
|
||||
live = true
|
||||
): null | TraceDoc {
|
||||
const tools = turn?.tools ?? []
|
||||
|
||||
// Robust to opening/reloading mid-turn (when message.start was never captured
|
||||
// in this renderer): build from whatever live data exists — the captured turn,
|
||||
// its tools, or streamed subagents.
|
||||
if (!turn?.busy && tools.length === 0 && subs.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nowSec = nowMs / 1000
|
||||
|
||||
const startCandidates = [turn?.turnStart, ...subs.map(s => s.startedAt)].filter(
|
||||
(n): n is number => typeof n === 'number'
|
||||
)
|
||||
|
||||
const startSec = (startCandidates.length ? Math.min(...startCandidates) : nowMs) / 1000
|
||||
const rootId = 'live:root'
|
||||
|
||||
const spans: TraceSpan[] = [
|
||||
{
|
||||
id: rootId,
|
||||
parentId: null,
|
||||
name: rootLabel || 'Current turn',
|
||||
kind: 'AGENT',
|
||||
start: startSec,
|
||||
end: nowSec,
|
||||
duration: Math.max(0, nowSec - startSec),
|
||||
status: live ? 'running' : 'ok',
|
||||
sessionId: null,
|
||||
attributes: {}
|
||||
}
|
||||
]
|
||||
|
||||
const toolSpanIds = new Set<string>()
|
||||
const sortedTools = [...tools].sort((a, b) => a.start - b.start)
|
||||
let prev = startSec
|
||||
let llmIdx = 0
|
||||
|
||||
const pushLlm = (start: number, end: number, running: boolean, output?: string) => {
|
||||
if (end - start <= 0.05) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = output?.trim()
|
||||
|
||||
spans.push({
|
||||
id: `live:llm:${llmIdx++}`,
|
||||
parentId: rootId,
|
||||
name: 'llm',
|
||||
kind: 'LLM',
|
||||
start,
|
||||
end,
|
||||
duration: end - start,
|
||||
status: running ? 'running' : 'ok',
|
||||
sessionId: null,
|
||||
attributes: text ? { 'output.value': text } : {}
|
||||
})
|
||||
}
|
||||
|
||||
for (const t of sortedTools) {
|
||||
const ts = t.start / 1000
|
||||
const te = (t.end ?? nowMs) / 1000
|
||||
pushLlm(prev, ts, false)
|
||||
const spanId = `live:tool:${t.id}`
|
||||
toolSpanIds.add(spanId)
|
||||
spans.push({
|
||||
id: spanId,
|
||||
parentId: rootId,
|
||||
name: t.name,
|
||||
kind: 'TOOL',
|
||||
start: ts,
|
||||
end: Math.max(te, ts),
|
||||
duration: Math.max(0, te - ts),
|
||||
status: t.status,
|
||||
sessionId: null,
|
||||
attributes: { 'tool.name': t.name }
|
||||
})
|
||||
prev = Math.max(prev, te)
|
||||
}
|
||||
|
||||
// Trailing llm = the model's response after the last tool. Show it ONLY when
|
||||
// there were tools (it's a distinct segment, and it's what streams/grows during
|
||||
// "reporting back"). A pure no-tool turn skips it — the root already is the
|
||||
// response, so a lone "llm" child would just duplicate it.
|
||||
if (sortedTools.length > 0) {
|
||||
pushLlm(prev, nowSec, live, turn?.replyText)
|
||||
} else if (turn?.replyText.trim()) {
|
||||
// No-tool turn: the root IS the response, so don't add a redundant llm row —
|
||||
// instead hang the streamed reply on the root so selecting it shows the text.
|
||||
spans[0].attributes = { ...spans[0].attributes, 'output.value': turn.replyText.trim() }
|
||||
}
|
||||
|
||||
// Nest each subagent under: another subagent (parentId), else the delegate_task
|
||||
// tool span that spawned it. Native subagents don't carry the tool id, so match
|
||||
// by the nearest delegate span that started at/before the subagent.
|
||||
const subIds = new Set(subs.map(s => s.id))
|
||||
|
||||
const delegateSpans = sortedTools
|
||||
.filter(t => t.name === 'delegate_task')
|
||||
.map(t => ({ id: `live:tool:${t.id}`, startMs: t.start }))
|
||||
|
||||
for (const s of subs) {
|
||||
const start = s.startedAt / 1000
|
||||
const terminal = TERMINAL_SUB.has(s.status)
|
||||
const end = terminal && s.durationSeconds ? start + s.durationSeconds : nowSec
|
||||
|
||||
const status: TraceSpanStatus =
|
||||
s.status === 'failed' || s.status === 'interrupted' ? 'error' : terminal ? 'ok' : 'running'
|
||||
|
||||
let parentId = rootId
|
||||
|
||||
if (s.parentId && subIds.has(s.parentId)) {
|
||||
parentId = `live:sub:${s.parentId}`
|
||||
} else {
|
||||
const idMatch = /^delegate-tool:(.+):\d+$/.exec(s.id)
|
||||
|
||||
if (idMatch && toolSpanIds.has(`live:tool:${idMatch[1]}`)) {
|
||||
parentId = `live:tool:${idMatch[1]}`
|
||||
} else {
|
||||
let best: null | { id: string; startMs: number } = null
|
||||
|
||||
for (const d of delegateSpans) {
|
||||
if (d.startMs <= s.startedAt + 1000 && (!best || d.startMs > best.startMs)) {
|
||||
best = d
|
||||
}
|
||||
}
|
||||
|
||||
if (best) {
|
||||
parentId = best.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spans.push({
|
||||
id: `live:sub:${s.id}`,
|
||||
parentId,
|
||||
name: s.goal || 'subagent',
|
||||
kind: 'AGENT',
|
||||
start,
|
||||
end: Math.max(end, start),
|
||||
duration: Math.max(0, end - start),
|
||||
status,
|
||||
sessionId: s.sessionId ?? null,
|
||||
attributes: {
|
||||
'llm.model_name': s.model,
|
||||
'llm.token_count.completion': s.outputTokens,
|
||||
'llm.token_count.prompt': s.inputTokens
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Finalized snapshot (turn settled): no span should keep a 'running' status,
|
||||
// or its bar pulses forever.
|
||||
if (!live) {
|
||||
for (const sp of spans) {
|
||||
if (sp.status === 'running') {
|
||||
sp.status = 'ok'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
traceId: 'live',
|
||||
rootSessionId: 'live',
|
||||
rootSpanId: rootId,
|
||||
start: startSec,
|
||||
end: nowSec,
|
||||
duration: Math.max(0, nowSec - startSec),
|
||||
metadata: { live: true },
|
||||
spans
|
||||
}
|
||||
}
|
||||
10
apps/desktop/src/app/agents/empty-state.tsx
Normal file
10
apps/desktop/src/app/agents/empty-state.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
|
||||
export function EmptyState({ icon, text }: { icon: string; text: string }) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center">
|
||||
<Codicon className="text-muted-foreground/50" name={icon} size="1.25rem" />
|
||||
<p className="text-xs text-muted-foreground/70">{text}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
apps/desktop/src/app/agents/format.ts
Normal file
12
apps/desktop/src/app/agents/format.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/** Span/trace duration (seconds) → "120ms" / "1.50s" / "2m 3s". */
|
||||
export function fmtDuration(s: number): string {
|
||||
if (s < 1) {
|
||||
return `${Math.round(s * 1000)}ms`
|
||||
}
|
||||
|
||||
if (s < 60) {
|
||||
return `${s.toFixed(s < 10 ? 2 : 1)}s`
|
||||
}
|
||||
|
||||
return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`
|
||||
}
|
||||
112
apps/desktop/src/app/agents/hooks/use-session-trace.ts
Normal file
112
apps/desktop/src/app/agents/hooks/use-session-trace.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import {
|
||||
$trace,
|
||||
$traceError,
|
||||
$traceLoading,
|
||||
$traceTurns,
|
||||
setTrace,
|
||||
toTraceDoc,
|
||||
toTurnSummaries,
|
||||
type TraceDoc
|
||||
} from '@/store/trace'
|
||||
|
||||
/**
|
||||
* Fetch the execution trace for a session from the gateway (`trace.get`) and
|
||||
* publish it into the trace store. Re-fetches when the session or turn changes.
|
||||
*/
|
||||
export function useSessionTrace(sessionId: null | string, turn?: number) {
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!sessionId) {
|
||||
setTrace(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
$traceLoading.set(true)
|
||||
$traceError.set(null)
|
||||
|
||||
try {
|
||||
const params: Record<string, unknown> = { session_id: sessionId }
|
||||
|
||||
if (typeof turn === 'number') {
|
||||
params.turn = turn
|
||||
}
|
||||
|
||||
const wire = await requestGateway<Record<string, unknown>>('trace.get', params)
|
||||
setTrace(toTraceDoc(wire))
|
||||
} catch (error) {
|
||||
$traceError.set(error instanceof Error ? error.message : String(error))
|
||||
$trace.set(null)
|
||||
} finally {
|
||||
$traceLoading.set(false)
|
||||
}
|
||||
}, [requestGateway, sessionId, turn])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the per-turn summaries for a session (`trace.turns`) so the overlay can
|
||||
* offer a turn strip. Publishes into the trace store.
|
||||
*/
|
||||
export function useTraceTurns(sessionId: null | string) {
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
|
||||
const reloadTurns = useCallback(async () => {
|
||||
if (!sessionId) {
|
||||
$traceTurns.set([])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const wire = await requestGateway<{ turns?: unknown[] }>('trace.turns', { session_id: sessionId })
|
||||
$traceTurns.set(toTurnSummaries(wire as { turns?: never[] }))
|
||||
} catch {
|
||||
// Keep the previous list on a transient failure — never blank it, or the
|
||||
// nav (and any latest-index math) flickers mid-turn.
|
||||
}
|
||||
}, [requestGateway, sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
void reloadTurns()
|
||||
}, [reloadTurns])
|
||||
|
||||
return { reloadTurns }
|
||||
}
|
||||
|
||||
/**
|
||||
* Imperative one-shot fetch of a single turn's trace, WITHOUT touching the
|
||||
* `$trace` store. The agents overlay uses this to grab a just-finished turn's
|
||||
* exact DB trace and fold it into the live view, instead of letting the
|
||||
* declarative `useSessionTrace` swap the store (which would race the live
|
||||
* stitch and churn the view).
|
||||
*/
|
||||
export function useTraceFetcher() {
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
|
||||
const fetchTurn = useCallback(
|
||||
async (sessionId: null | string, turn: number): Promise<null | TraceDoc> => {
|
||||
if (!sessionId) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const wire = await requestGateway<Record<string, unknown>>('trace.get', { session_id: sessionId, turn })
|
||||
|
||||
return toTraceDoc(wire)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
[requestGateway]
|
||||
)
|
||||
|
||||
return { fetchTurn }
|
||||
}
|
||||
215
apps/desktop/src/app/agents/hooks/use-trace-view.ts
Normal file
215
apps/desktop/src/app/agents/hooks/use-trace-view.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { $liveTurnBySession } from '@/store/live-turn'
|
||||
import { $activeSessionId, $messages } from '@/store/session'
|
||||
import { $subagentsBySession } from '@/store/subagents'
|
||||
import {
|
||||
$hoveredSpanId,
|
||||
$selectedSpanId,
|
||||
$trace,
|
||||
$traceError,
|
||||
$traceLoading,
|
||||
$traceSelection,
|
||||
$traceTurns,
|
||||
rebaseTrace,
|
||||
type TraceDoc
|
||||
} from '@/store/trace'
|
||||
|
||||
import { buildLiveTrace } from '../build-live-trace'
|
||||
|
||||
import { useSessionTrace, useTraceFetcher, useTraceTurns } from './use-session-trace'
|
||||
|
||||
export interface TraceView {
|
||||
activeIndex: null | number
|
||||
error: null | string
|
||||
liveIndex: null | number
|
||||
loading: boolean
|
||||
selectTurn: (index: number) => void
|
||||
selection: ReturnType<typeof $traceSelection.get>
|
||||
sessionId: null | string
|
||||
trace: null | TraceDoc
|
||||
}
|
||||
|
||||
/**
|
||||
* The agents overlay's view-model: resolves the one trace to render from three
|
||||
* sources — the live event stitch while a followed turn is in flight, the
|
||||
* server-exact DB trace it folds into once settled, and any pinned/historical
|
||||
* turn or whole-session view. Keeps the route root pure layout.
|
||||
*/
|
||||
export function useTraceView(): TraceView {
|
||||
const sessionId = useStore($activeSessionId)
|
||||
const turns = useStore($traceTurns)
|
||||
const dbTrace = useStore($trace)
|
||||
const loading = useStore($traceLoading)
|
||||
const error = useStore($traceError)
|
||||
const subsBySession = useStore($subagentsBySession)
|
||||
const liveTurnBySession = useStore($liveTurnBySession)
|
||||
const selection = useStore($traceSelection)
|
||||
|
||||
const [nowMs, setNowMs] = useState(() => Date.now())
|
||||
// Frozen last live render — kept after a turn settles so the time mapping (and
|
||||
// thus the view) can't shift. Cleared on session switch.
|
||||
const liveTraceRef = useRef<null | TraceDoc>(null)
|
||||
const finalizedRef = useRef(false)
|
||||
// The just-finished followed turn re-fetched from the DB and rebased onto the
|
||||
// live start: settled exactness folded into the live view (the "B" in A→B).
|
||||
const [foldedTrace, setFoldedTrace] = useState<null | TraceDoc>(null)
|
||||
const sessionRef = useRef(sessionId)
|
||||
|
||||
if (sessionRef.current !== sessionId) {
|
||||
sessionRef.current = sessionId
|
||||
liveTraceRef.current = null
|
||||
finalizedRef.current = false
|
||||
}
|
||||
|
||||
const liveSubs = useMemo(() => (sessionId ? (subsBySession[sessionId] ?? []) : []), [sessionId, subsBySession])
|
||||
const liveTurn = sessionId ? liveTurnBySession[sessionId] : undefined
|
||||
// Live = turn busy OR subagents in flight (robust to opening mid-turn).
|
||||
const isLive = !!liveTurn?.busy || liveSubs.some(s => s.status === 'running' || s.status === 'queued')
|
||||
const hasLiveData = isLive || (liveTurn?.tools.length ?? 0) > 0 || liveSubs.length > 0
|
||||
|
||||
const following = selection === 'latest'
|
||||
const latestIndex = turns.length - 1
|
||||
// Latch onto the live stitch while following: once a turn has produced live
|
||||
// data we keep showing it (frozen, then folded with DB exactness after it
|
||||
// settles) and NEVER let the declarative DB fetch swap the store under us.
|
||||
// That swap + the turn-list reload races were the churn ("before subagents →
|
||||
// all → end"). DB-by-store is only for an explicitly pinned turn/all, or the
|
||||
// latest turn on a fresh idle open (no live data this session).
|
||||
const showLive = following && (hasLiveData || liveTraceRef.current !== null)
|
||||
|
||||
const activeIndex =
|
||||
selection === 'all' ? null : selection === 'latest' ? (latestIndex >= 0 ? latestIndex : null) : selection
|
||||
|
||||
const liveIndex = isLive && latestIndex >= 0 ? latestIndex : null
|
||||
|
||||
const { reloadTurns } = useTraceTurns(sessionId)
|
||||
const { fetchTurn } = useTraceFetcher()
|
||||
|
||||
// DB fetch target: a pinned turn number, else the latest settled turn. Skipped
|
||||
// entirely (undefined) while we render the live stitch or the whole session.
|
||||
const dbTurnArg =
|
||||
showLive || selection === 'all'
|
||||
? undefined
|
||||
: typeof selection === 'number'
|
||||
? selection
|
||||
: latestIndex >= 0
|
||||
? latestIndex
|
||||
: undefined
|
||||
|
||||
useSessionTrace(sessionId, dbTurnArg)
|
||||
|
||||
// Drop the ephemeral hover/selection when the panel closes so a stale span
|
||||
// can't auto-zoom the next time it opens.
|
||||
useEffect(
|
||||
() => () => {
|
||||
$selectedSpanId.set(null)
|
||||
$hoveredSpanId.set(null)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Follow-latest on session switch.
|
||||
useEffect(() => {
|
||||
$traceSelection.set('latest')
|
||||
setFoldedTrace(null)
|
||||
}, [sessionId])
|
||||
|
||||
// A new live turn (or leaving 'latest') invalidates a folded snapshot.
|
||||
useEffect(() => {
|
||||
if (isLive || selection !== 'latest') {
|
||||
setFoldedTrace(null)
|
||||
}
|
||||
}, [isLive, selection])
|
||||
|
||||
// While the turn streams, tick so running bars grow toward "now".
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = window.setInterval(() => setNowMs(Date.now()), 400)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [isLive])
|
||||
|
||||
// Refresh the turn list on live edges so the nav reflects the in-flight /
|
||||
// finished turn. Does NOT touch the displayed (live) trace.
|
||||
const prevLive = useRef(isLive)
|
||||
useEffect(() => {
|
||||
if (isLive !== prevLive.current) {
|
||||
void reloadTurns()
|
||||
}
|
||||
|
||||
prevLive.current = isLive
|
||||
}, [isLive, reloadTurns])
|
||||
|
||||
// Fold: once a followed turn settles, pull its exact DB trace and rebase it
|
||||
// onto the frozen live start, then swap. rebaseTrace keeps it on the same
|
||||
// on-screen window, and the waterfall preserves the view across the tmap
|
||||
// change, so the swap is seamless — approximate live bars become server-exact
|
||||
// in place, with no reframe.
|
||||
useEffect(() => {
|
||||
if (!following || isLive || !sessionId || !liveTraceRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const liveStart = liveTraceRef.current.start
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
await reloadTurns()
|
||||
const idx = $traceTurns.get().length - 1
|
||||
|
||||
if (idx < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const db = await fetchTurn(sessionId, idx)
|
||||
|
||||
if (!cancelled && db && db.spans.length > 0) {
|
||||
setFoldedTrace(rebaseTrace(db, liveStart))
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [following, isLive, sessionId, reloadTurns, fetchTurn])
|
||||
|
||||
const trace = useMemo<null | TraceDoc>(() => {
|
||||
if (!showLive) {
|
||||
return dbTrace
|
||||
}
|
||||
|
||||
const lastUser = $messages.get().findLast(m => m.role === 'user' && !m.hidden)
|
||||
const rootLabel = lastUser ? chatMessageText(lastUser).trim().slice(0, 80) : undefined
|
||||
|
||||
if (isLive) {
|
||||
finalizedRef.current = false
|
||||
liveTraceRef.current = buildLiveTrace(liveTurn, liveSubs, nowMs, rootLabel, true)
|
||||
|
||||
return liveTraceRef.current
|
||||
}
|
||||
|
||||
// Settled & folded: server-exact spans rebased onto the live start.
|
||||
if (foldedTrace) {
|
||||
return foldedTrace
|
||||
}
|
||||
|
||||
// Settled, fold not in yet: build the finalized snapshot once (running → ok,
|
||||
// no pulse) and freeze it as the bridge until the DB fold lands.
|
||||
if (!finalizedRef.current) {
|
||||
liveTraceRef.current = buildLiveTrace(liveTurn, liveSubs, nowMs, rootLabel, false)
|
||||
finalizedRef.current = true
|
||||
}
|
||||
|
||||
return liveTraceRef.current
|
||||
}, [showLive, isLive, dbTrace, liveTurn, liveSubs, nowMs, foldedTrace])
|
||||
|
||||
const selectTurn = (index: number) => $traceSelection.set(index === latestIndex ? 'latest' : index)
|
||||
|
||||
return { activeIndex, error, liveIndex, loading, selectTurn, selection, sessionId, trace }
|
||||
}
|
||||
@@ -1,397 +1,60 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
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 { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$subagentsBySession,
|
||||
allSubagents,
|
||||
buildSubagentTree,
|
||||
type SubagentNode,
|
||||
type SubagentStatus,
|
||||
type SubagentStreamEntry
|
||||
} from '@/store/subagents'
|
||||
import { $traceSelection } from '@/store/trace'
|
||||
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
|
||||
// Mirrors statusGlyph() in tool-fallback.tsx so subagent rows speak the
|
||||
// same visual vocabulary as the chat tool blocks.
|
||||
function statusGlyph(status: SubagentStatus, a: Translations['agents']): ReactNode {
|
||||
if (status === 'running' || status === 'queued') {
|
||||
return (
|
||||
<GlyphSpinner
|
||||
ariaLabel={a.running}
|
||||
className="size-3.5 shrink-0 text-[0.95rem] text-muted-foreground/80"
|
||||
spinner="breathe"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'failed' || status === 'interrupted') {
|
||||
return <AlertCircle aria-label={a.failed} className="size-3.5 shrink-0 text-destructive" />
|
||||
}
|
||||
|
||||
return <CheckCircle2 aria-label={a.done} className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
|
||||
}
|
||||
|
||||
const STREAM_TONE: Record<SubagentStreamEntry['kind'], string> = {
|
||||
progress: 'text-muted-foreground/75',
|
||||
summary: 'text-foreground/85',
|
||||
thinking: 'text-muted-foreground/80',
|
||||
tool: 'text-foreground/85'
|
||||
}
|
||||
|
||||
function streamGlyph(entry: SubagentStreamEntry): ReactNode {
|
||||
if (entry.isError) {
|
||||
return <AlertCircle aria-hidden className="mt-0.5 size-3 shrink-0 text-destructive" />
|
||||
}
|
||||
|
||||
if (entry.kind === 'tool') {
|
||||
return <span aria-hidden className="mt-0.5 size-1.5 shrink-0 rounded-full bg-foreground/55" />
|
||||
}
|
||||
|
||||
if (entry.kind === 'summary') {
|
||||
return <CheckCircle2 aria-hidden className="mt-0.5 size-3 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
|
||||
}
|
||||
|
||||
if (entry.kind === 'thinking') {
|
||||
return (
|
||||
<span aria-hidden className="font-mono text-[0.7rem] leading-none text-muted-foreground/70">
|
||||
…
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return <span aria-hidden className="mt-0.5 size-1 shrink-0 rounded-full bg-muted-foreground/55" />
|
||||
}
|
||||
import { EmptyState } from './empty-state'
|
||||
import { fmtDuration } from './format'
|
||||
import { useTraceView } from './hooks/use-trace-view'
|
||||
import { SpanInspector } from './span-inspector'
|
||||
import { ROW_HEIGHT, TraceWaterfall } from './trace-waterfall'
|
||||
import { TurnStrip } from './turn-strip'
|
||||
|
||||
interface AgentsViewProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function AgentsView({ onClose }: AgentsViewProps) {
|
||||
const { t } = useI18n()
|
||||
const subagentsBySession = useStore($subagentsBySession)
|
||||
|
||||
// Aggregate every session, matching the status-bar indicator — a subagent
|
||||
// running in a background session must still be visible here, or the two
|
||||
// desync ("Agents N running" vs an empty tree).
|
||||
const tree = useMemo(() => buildSubagentTree(allSubagents(subagentsBySession)), [subagentsBySession])
|
||||
const { activeIndex, error, liveIndex, loading, selectTurn, selection, sessionId, trace } = useTraceView()
|
||||
const hasTrace = !!trace && trace.spans.length > 0
|
||||
|
||||
return (
|
||||
<OverlayView
|
||||
closeLabel={t.agents.close}
|
||||
contentClassName="px-5 pt-5 pb-4 sm:px-6"
|
||||
closeLabel="Close"
|
||||
contentClassName="flex h-full flex-col px-4 py-4 sm:px-5"
|
||||
onClose={onClose}
|
||||
rootClassName="mx-auto max-w-3xl"
|
||||
rootClassName="mx-auto flex h-full w-full max-w-6xl flex-col"
|
||||
>
|
||||
<header className="mb-3 shrink-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">{t.agents.title}</h2>
|
||||
<p className="text-xs text-muted-foreground/80">{t.agents.subtitle}</p>
|
||||
<header className="mb-2 flex shrink-0 items-center justify-between gap-3 pl-2">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">Trace</h2>
|
||||
<p className="truncate text-xs text-muted-foreground/80">
|
||||
{sessionId ? `Execution waterfall · ${sessionId.slice(0, 16)}` : 'No active session'}
|
||||
{hasTrace ? ` · ${trace.spans.length} spans · ${fmtDuration(trace.duration)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<TurnStrip
|
||||
activeIndex={activeIndex}
|
||||
allActive={selection === 'all'}
|
||||
liveIndex={liveIndex}
|
||||
onAll={() => $traceSelection.set('all')}
|
||||
onTurn={selectTurn}
|
||||
/>
|
||||
</header>
|
||||
<SubagentTree tree={tree} />
|
||||
|
||||
{hasTrace ? (
|
||||
<div className="flex min-h-0 flex-1 gap-3 overflow-hidden">
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<TraceWaterfall trace={trace} viewKey={`${sessionId ?? ''}:${selection}`} />
|
||||
</div>
|
||||
<div className="flex w-72 shrink-0 flex-col overflow-y-auto" style={{ paddingTop: ROW_HEIGHT }}>
|
||||
<SpanInspector trace={trace} />
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<EmptyState icon="warning" text={error} />
|
||||
) : (
|
||||
<EmptyState icon={loading ? 'loading~spin' : 'hubot'} text={loading ? 'Loading trace…' : 'No trace yet'} />
|
||||
)}
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
const fmtDuration = (seconds: number | undefined, a: Translations['agents']) => {
|
||||
if (!seconds || seconds <= 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (seconds < 60) {
|
||||
return a.durationSeconds(seconds.toFixed(1))
|
||||
}
|
||||
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.round(seconds % 60)
|
||||
|
||||
return a.durationMinutes(m, s)
|
||||
}
|
||||
|
||||
const fmtTokens = (value: number | undefined, a: Translations['agents']) => {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return value >= 1000 ? a.tokensK((value / 1000).toFixed(1)) : a.tokens(value)
|
||||
}
|
||||
|
||||
const fmtAge = (updatedAt: number, nowMs: number, a: Translations['agents']) => {
|
||||
const s = Math.max(0, Math.round((nowMs - updatedAt) / 1000))
|
||||
|
||||
if (s < 2) {
|
||||
return a.ageNow
|
||||
}
|
||||
|
||||
if (s < 60) {
|
||||
return a.ageSeconds(s)
|
||||
}
|
||||
|
||||
const m = Math.floor(s / 60)
|
||||
|
||||
if (m < 60) {
|
||||
return a.ageMinutes(m)
|
||||
}
|
||||
|
||||
return a.ageHours(Math.floor(m / 60))
|
||||
}
|
||||
|
||||
const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] =>
|
||||
nodes.flatMap(node => [node, ...flatten(node.children)])
|
||||
|
||||
interface RootGroup {
|
||||
id: string
|
||||
delegationIndex: number
|
||||
nodes: SubagentNode[]
|
||||
taskCount: number
|
||||
}
|
||||
|
||||
function groupDelegations(roots: readonly SubagentNode[]): RootGroup[] {
|
||||
const groups: RootGroup[] = []
|
||||
let n = 0
|
||||
|
||||
for (const node of roots) {
|
||||
const prev = groups.at(-1)
|
||||
const prevTail = prev?.nodes.at(-1)
|
||||
const closeInTime = prevTail ? Math.abs(node.startedAt - prevTail.startedAt) <= 5_000 : false
|
||||
const sameShape = prev && node.taskCount > 1 && prev.taskCount === node.taskCount
|
||||
const uniqueStep = prev ? !prev.nodes.some(item => item.taskIndex === node.taskIndex) : false
|
||||
|
||||
if (prev && sameShape && closeInTime && uniqueStep) {
|
||||
prev.nodes.push(node)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (node.taskCount > 1) {
|
||||
n += 1
|
||||
groups.push({ id: `delegation-${n}`, delegationIndex: n, nodes: [node], taskCount: node.taskCount })
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
groups.push({ id: node.id, delegationIndex: 0, nodes: [node], taskCount: node.taskCount })
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
function SubagentTree({ tree }: { tree: SubagentNode[] }) {
|
||||
const { t } = useI18n()
|
||||
const flat = useMemo(() => flatten(tree), [tree])
|
||||
const groups = useMemo(() => groupDelegations(tree), [tree])
|
||||
const [nowMs, setNowMs] = useState(() => Date.now())
|
||||
|
||||
const active = flat.filter(n => n.status === 'running' || n.status === 'queued').length
|
||||
const failed = flat.filter(n => n.status === 'failed' || n.status === 'interrupted').length
|
||||
const tools = flat.reduce((sum, n) => sum + (n.toolCount ?? 0), 0)
|
||||
const files = flat.reduce((sum, n) => sum + n.filesRead.length + n.filesWritten.length, 0)
|
||||
const tokens = flat.reduce((sum, n) => sum + (n.inputTokens ?? 0) + (n.outputTokens ?? 0), 0)
|
||||
const cost = flat.reduce((sum, n) => sum + (n.costUsd ?? 0), 0)
|
||||
|
||||
useEffect(() => {
|
||||
if (active <= 0 || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const id = window.setInterval(() => setNowMs(Date.now()), 500)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [active])
|
||||
|
||||
if (tree.length === 0) {
|
||||
return (
|
||||
<div className="grid place-items-center gap-3 py-12 text-center">
|
||||
<Sparkles className="size-6 text-muted-foreground/60" />
|
||||
<p className="text-sm font-medium text-foreground/90">{t.agents.emptyTitle}</p>
|
||||
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">{t.agents.emptyDesc}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const summary = [
|
||||
t.agents.agentsCount(flat.length),
|
||||
active > 0 ? t.agents.activeCount(active) : '',
|
||||
failed > 0 ? t.agents.failedCount(failed) : '',
|
||||
tools > 0 ? t.agents.toolsCount(tools) : '',
|
||||
files > 0 ? t.agents.filesCount(files) : '',
|
||||
tokens > 0 ? fmtTokens(tokens, t.agents) : '',
|
||||
cost > 0 ? `$${cost.toFixed(2)}` : ''
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-4 overflow-hidden">
|
||||
<p className="shrink-0 text-[0.7rem] text-muted-foreground/70">{summary.join(' · ')}</p>
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain pr-1">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
{groups.map(group => (
|
||||
<DelegationGroup group={group} key={group.id} nowMs={nowMs} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number }) {
|
||||
const { t } = useI18n()
|
||||
|
||||
if (group.nodes.length === 1 && group.taskCount <= 1) {
|
||||
return <SubagentRow node={group.nodes[0]!} nowMs={nowMs} />
|
||||
}
|
||||
|
||||
const activeWorkers = group.nodes.filter(n => n.status === 'running' || n.status === 'queued').length
|
||||
|
||||
return (
|
||||
<section className="grid min-w-0 gap-3">
|
||||
<p className="text-[0.66rem] font-medium uppercase tracking-wider text-muted-foreground/70">
|
||||
{group.delegationIndex > 0 ? t.agents.delegation(group.delegationIndex) : ''}{' '}
|
||||
<span className="text-muted-foreground/50">·</span> {t.agents.workers(group.nodes.length)}
|
||||
{activeWorkers > 0 ? <span className="text-primary/85"> · {t.agents.workersActive(activeWorkers)}</span> : null}
|
||||
</p>
|
||||
<div className="grid min-w-0 gap-4">
|
||||
{group.nodes.map(node => (
|
||||
<SubagentRow key={node.id} node={node} nowMs={nowMs} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function StreamLine({
|
||||
active,
|
||||
entry,
|
||||
parentRunning,
|
||||
rowKey
|
||||
}: {
|
||||
active: boolean
|
||||
entry: SubagentStreamEntry
|
||||
parentRunning: boolean
|
||||
rowKey: string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const enterRef = useEnterAnimation(parentRunning, `subagent-stream:${rowKey}`)
|
||||
const isMono = entry.kind === 'tool'
|
||||
const tone = entry.isError ? 'text-destructive' : STREAM_TONE[entry.kind]
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-baseline gap-2 text-[0.72rem] leading-relaxed" ref={enterRef}>
|
||||
<span className="flex h-[0.95rem] shrink-0 items-center">{streamGlyph(entry)}</span>
|
||||
<span className={cn('min-w-0 flex-1 wrap-anywhere', tone, isMono && 'font-mono text-[0.69rem]')}>
|
||||
{entry.text}
|
||||
{active ? (
|
||||
<GlyphSpinner
|
||||
ariaLabel={t.agents.streaming}
|
||||
className="ml-1 inline-block size-2.5 align-middle text-muted-foreground/70"
|
||||
spinner="breathe"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: number; nowMs: number }) {
|
||||
const { t } = useI18n()
|
||||
const running = node.status === 'running' || node.status === 'queued'
|
||||
const elapsed = useElapsedSeconds(running, `subagent:${node.id}`)
|
||||
|
||||
const durationSeconds =
|
||||
typeof node.durationSeconds === 'number' ? Math.max(0, Math.round(node.durationSeconds)) : elapsed
|
||||
|
||||
const [open, setOpen] = useState(() => running || depth < 2)
|
||||
const enterRef = useEnterAnimation(true, `subagent-row:${node.id}`)
|
||||
|
||||
useEffect(() => {
|
||||
if (running) {
|
||||
setOpen(true)
|
||||
}
|
||||
}, [running])
|
||||
|
||||
const visibleRows = open ? node.stream.slice(-10) : node.stream.slice(-2)
|
||||
const fileLines = [...node.filesWritten.map(p => `+ ${p}`), ...node.filesRead.map(p => `· ${p}`)]
|
||||
|
||||
const subtitle = [
|
||||
node.model,
|
||||
fmtDuration(durationSeconds, t.agents),
|
||||
node.toolCount ? t.agents.toolsCount(node.toolCount) : '',
|
||||
fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0), t.agents),
|
||||
t.agents.updatedAgo(fmtAge(node.updatedAt, nowMs, t.agents))
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className={cn('grid min-w-0 max-w-full gap-2', depth > 0 && 'pl-4')} data-slot="tool-block" ref={enterRef}>
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className="group flex w-full min-w-0 items-start gap-2.5 text-left"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
type="button"
|
||||
>
|
||||
<span className="mt-0.5 flex h-[1.1rem] shrink-0 items-center">{statusGlyph(node.status, t.agents)}</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
'wrap-anywhere text-[0.82rem] font-medium leading-[1.1rem] text-foreground/90 transition-colors group-hover:text-foreground',
|
||||
running && 'shimmer text-foreground/65'
|
||||
)}
|
||||
>
|
||||
{node.goal}
|
||||
</span>
|
||||
{subtitle.length > 0 ? (
|
||||
<FadeText className="text-[0.66rem] leading-[1.05rem] text-muted-foreground/65">
|
||||
{subtitle.join(' · ')}
|
||||
</FadeText>
|
||||
) : null}
|
||||
</span>
|
||||
{running ? <ActivityTimerText className="mt-1 shrink-0 text-[0.6rem]" seconds={durationSeconds} /> : null}
|
||||
</button>
|
||||
|
||||
{visibleRows.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-1 pl-6" data-selectable-text="true">
|
||||
{visibleRows.map((entry, i) => (
|
||||
<StreamLine
|
||||
active={running && i === visibleRows.length - 1}
|
||||
entry={entry}
|
||||
key={`${entry.kind}:${entry.at}:${i}`}
|
||||
parentRunning={running}
|
||||
rowKey={`${node.id}:${entry.kind}:${entry.at}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{open && fileLines.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-0.5 pl-6" data-selectable-text="true">
|
||||
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">
|
||||
{t.agents.files}
|
||||
</p>
|
||||
{fileLines.slice(0, 8).map(line => (
|
||||
<p className="wrap-break-word font-mono text-[0.67rem] leading-relaxed text-muted-foreground/80" key={line}>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
{fileLines.length > 8 ? (
|
||||
<p className="font-mono text-[0.67rem] leading-relaxed text-muted-foreground/65">
|
||||
{t.agents.moreFiles(fileLines.length - 8)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{node.children.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-3 pl-6">
|
||||
{node.children.map(child => (
|
||||
<SubagentRow depth={depth + 1} key={child.id} node={child} nowMs={nowMs} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
110
apps/desktop/src/app/agents/span-inspector.tsx
Normal file
110
apps/desktop/src/app/agents/span-inspector.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { $hoveredSpanId, $selectedSpanId, type TraceDoc } from '@/store/trace'
|
||||
|
||||
import { fmtDuration } from './format'
|
||||
import { ROW_HEIGHT } from './trace-waterfall'
|
||||
|
||||
const fmtInt = (n: number) => n.toLocaleString()
|
||||
|
||||
export function SpanInspector({ trace }: { trace: null | TraceDoc }) {
|
||||
const selectedId = useStore($selectedSpanId)
|
||||
const hoveredId = useStore($hoveredSpanId)
|
||||
// Hover previews; the clicked span stays pinned when nothing is hovered.
|
||||
const activeId = hoveredId ?? selectedId
|
||||
|
||||
const span = useMemo(() => trace?.spans.find(s => s.id === activeId) ?? null, [trace, activeId])
|
||||
|
||||
if (!span) {
|
||||
return (
|
||||
<div className="flex items-center text-[0.7rem] text-muted-foreground/55" style={{ height: ROW_HEIGHT }}>
|
||||
Select a span to inspect its details.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const attrs = span.attributes
|
||||
const num = (key: string) => (typeof attrs[key] === 'number' ? (attrs[key] as number) : undefined)
|
||||
|
||||
const meta: [string, string][] = [['kind', span.kind], ['status', span.status]]
|
||||
|
||||
// Where the span sits in the trace, then how long it ran.
|
||||
if (trace) {
|
||||
meta.push(['started', `+${fmtDuration(Math.max(0, span.start - trace.start))}`])
|
||||
}
|
||||
|
||||
meta.push(['duration', fmtDuration(span.duration)])
|
||||
|
||||
// Push an attribute row when present; numbers are thousands-formatted.
|
||||
const push = (label: string, key: string) => {
|
||||
const v = attrs[key]
|
||||
|
||||
if (v !== undefined && v !== null && v !== '') {
|
||||
meta.push([label, typeof v === 'number' ? fmtInt(v) : String(v)])
|
||||
}
|
||||
}
|
||||
|
||||
push('model', 'llm.model_name')
|
||||
push('tokens in', 'llm.token_count.prompt')
|
||||
push('tokens out', 'llm.token_count.completion')
|
||||
|
||||
const treason = num('llm.token_count.reasoning')
|
||||
|
||||
if (treason) {
|
||||
meta.push(['reasoning', fmtInt(treason)])
|
||||
}
|
||||
|
||||
const tin = num('llm.token_count.prompt')
|
||||
const tout = num('llm.token_count.completion')
|
||||
|
||||
if (tin !== undefined || tout !== undefined) {
|
||||
meta.push(['tokens total', fmtInt((tin ?? 0) + (tout ?? 0) + (treason ?? 0))])
|
||||
}
|
||||
|
||||
push('finish', 'hermes.finish_reason')
|
||||
push('tool', 'tool.name')
|
||||
// Container (AGENT) spans carry session shape; subagents expose their id.
|
||||
push('source', 'session.source')
|
||||
push('messages', 'session.message_count')
|
||||
push('tool calls', 'session.tool_call_count')
|
||||
|
||||
if (span.sessionId) {
|
||||
meta.push(['session', span.sessionId.slice(0, 12)])
|
||||
}
|
||||
|
||||
const input = attrs['input.value']
|
||||
const output = attrs['output.value']
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 pb-3">
|
||||
<p
|
||||
className="flex items-center text-[0.82rem] font-medium break-words text-foreground/90"
|
||||
style={{ minHeight: ROW_HEIGHT }}
|
||||
>
|
||||
{span.name}
|
||||
</p>
|
||||
<dl className="grid grid-cols-[6rem_1fr] gap-x-3 gap-y-1 text-[0.7rem]">
|
||||
{meta.map(([k, v]) => (
|
||||
<div className="contents" key={k}>
|
||||
<dt className="truncate text-muted-foreground/55">{k}</dt>
|
||||
<dd className="min-w-0 break-words text-foreground/85">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{input ? <InspectorBlock label="input" value={String(input)} /> : null}
|
||||
{output ? <InspectorBlock label="output" value={String(output)} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InspectorBlock({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-[0.6rem] font-medium tracking-wider text-muted-foreground/50 uppercase">{label}</span>
|
||||
<pre className="max-h-40 overflow-auto rounded bg-foreground/5 p-2 text-[0.66rem] break-words whitespace-pre-wrap text-foreground/80">
|
||||
{value}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
apps/desktop/src/app/agents/span-style.ts
Normal file
56
apps/desktop/src/app/agents/span-style.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { toolIconName } from '@/components/assistant-ui/tool-fallback-model'
|
||||
import type { TraceSpanKind, TraceSpanNode } from '@/store/trace'
|
||||
|
||||
// Category → bar classes. Tailwind -500 family reads at even weight on dark;
|
||||
// error tint overrides downstream.
|
||||
const KIND_BAR: Record<TraceSpanKind, string> = {
|
||||
AGENT: 'bg-violet-500/70',
|
||||
CHAIN: 'bg-slate-500/70',
|
||||
LLM: 'bg-sky-500/70',
|
||||
TOOL: 'bg-emerald-500/70'
|
||||
}
|
||||
|
||||
const TOOL_BAR: Record<string, string> = {
|
||||
delegate_task: 'bg-violet-500/70',
|
||||
patch: 'bg-amber-500/70',
|
||||
read_file: 'bg-cyan-500/70',
|
||||
search_files: 'bg-cyan-500/70',
|
||||
terminal: 'bg-zinc-400/70',
|
||||
web_extract: 'bg-teal-500/70',
|
||||
web_search: 'bg-teal-500/70',
|
||||
write_file: 'bg-amber-500/70'
|
||||
}
|
||||
|
||||
/** Bar color for a span: error tint wins, then per-tool, then per-kind. */
|
||||
export function barClass(node: TraceSpanNode): string {
|
||||
if (node.status === 'error') {
|
||||
return 'bg-red-500/80'
|
||||
}
|
||||
|
||||
const tool = String(node.attributes['tool.name'] ?? '')
|
||||
|
||||
if (node.kind === 'TOOL' && TOOL_BAR[tool]) {
|
||||
return TOOL_BAR[tool]
|
||||
}
|
||||
|
||||
return KIND_BAR[node.kind]
|
||||
}
|
||||
|
||||
/** Icon for a span, reusing the thread's tool icons so the trace matches what
|
||||
* the conversation shows mid-thread. */
|
||||
export function spanIconName(node: TraceSpanNode): string {
|
||||
if (node.kind === 'TOOL') {
|
||||
return toolIconName(String(node.attributes['tool.name'] ?? 'tools'))
|
||||
}
|
||||
|
||||
// The root span is the user's turn (its label is the prompt) — mark it human.
|
||||
if (node.kind === 'AGENT' && node.parentId === null) {
|
||||
return 'account'
|
||||
}
|
||||
|
||||
if (node.kind === 'AGENT' || node.kind === 'LLM') {
|
||||
return 'hubot'
|
||||
}
|
||||
|
||||
return 'list-tree'
|
||||
}
|
||||
67
apps/desktop/src/app/agents/time-map.test.ts
Normal file
67
apps/desktop/src/app/agents/time-map.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { TraceSpanNode } from '@/store/trace'
|
||||
|
||||
import { buildTimeMap, niceTicks } from './time-map'
|
||||
|
||||
function node(kind: TraceSpanNode['kind'], start: number, end: number): TraceSpanNode {
|
||||
return {
|
||||
id: `${kind}:${start}`,
|
||||
parentId: null,
|
||||
name: kind,
|
||||
kind,
|
||||
start,
|
||||
end,
|
||||
duration: end - start,
|
||||
status: 'ok',
|
||||
sessionId: null,
|
||||
attributes: {},
|
||||
depth: 0,
|
||||
children: []
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildTimeMap', () => {
|
||||
it('maps busy time 1:1 and compresses idle gaps', () => {
|
||||
// Two 1s busy spans separated by a 100s idle gap.
|
||||
const nodes = [node('TOOL', 0, 1), node('TOOL', 101, 102)]
|
||||
const map = buildTimeMap(nodes, 0, 102)
|
||||
|
||||
// Endpoints anchor.
|
||||
expect(map.toV(0)).toBe(0)
|
||||
expect(map.toReal(0)).toBe(0)
|
||||
expect(map.toReal(map.totalV)).toBeCloseTo(102)
|
||||
|
||||
// The 100s gap is collapsed: virtual width ≪ real width.
|
||||
expect(map.totalV).toBeLessThan(20)
|
||||
expect(map.gaps).toHaveLength(1)
|
||||
expect(map.gaps[0]!.r1 - map.gaps[0]!.r0).toBeCloseTo(100)
|
||||
})
|
||||
|
||||
it('round-trips real↔virtual within busy regions', () => {
|
||||
const map = buildTimeMap([node('LLM', 10, 20)], 10, 20)
|
||||
|
||||
expect(map.toReal(map.toV(15))).toBeCloseTo(15)
|
||||
})
|
||||
|
||||
it('survives degenerate input (only container spans)', () => {
|
||||
const map = buildTimeMap([node('AGENT', 0, 0)], 0, 0)
|
||||
|
||||
expect(map.totalV).toBeGreaterThan(0)
|
||||
expect(Number.isFinite(map.toReal(0))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('niceTicks', () => {
|
||||
it('returns ascending ticks covering the range', () => {
|
||||
const ticks = niceTicks(0, 10)
|
||||
|
||||
expect(ticks.length).toBeGreaterThan(1)
|
||||
expect(ticks).toEqual([...ticks].sort((a, b) => a - b))
|
||||
expect(ticks.at(-1)!).toBeLessThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('degenerates safely on a zero-width range', () => {
|
||||
expect(niceTicks(5, 5)).toEqual([5])
|
||||
})
|
||||
})
|
||||
146
apps/desktop/src/app/agents/time-map.ts
Normal file
146
apps/desktop/src/app/agents/time-map.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { TraceSpanNode } from '@/store/trace'
|
||||
|
||||
// Time compression. "All" spans a whole session, mostly idle between turns. We
|
||||
// build a compressed coordinate space (virtual units) where idle gaps collapse
|
||||
// to a fixed width, and route positioning / zoom / ticks through it — the
|
||||
// approach Sentry's compressed trace timeline uses. Real busy time maps 1:1;
|
||||
// long idle gaps shrink and get a marker.
|
||||
|
||||
export interface TimeSeg {
|
||||
gap: boolean
|
||||
r0: number
|
||||
r1: number
|
||||
v0: number
|
||||
v1: number
|
||||
}
|
||||
|
||||
export interface TimeMap {
|
||||
gaps: TimeSeg[]
|
||||
toReal: (v: number) => number
|
||||
toV: (t: number) => number
|
||||
totalV: number
|
||||
}
|
||||
|
||||
export function buildTimeMap(nodes: TraceSpanNode[], fullStart: number, fullEnd: number): TimeMap {
|
||||
// Busy = actual work (LLM/TOOL). AGENT/CHAIN containers span whole turns
|
||||
// including idle, so they'd hide every gap — exclude them from gap detection.
|
||||
const busy = nodes
|
||||
.filter(n => n.kind === 'LLM' || n.kind === 'TOOL')
|
||||
.map(n => [n.start, Math.max(n.end, n.start)] as [number, number])
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
|
||||
const merged: [number, number][] = []
|
||||
|
||||
for (const [s, e] of busy) {
|
||||
const last = merged.at(-1)
|
||||
|
||||
if (last && s <= last[1]) {
|
||||
last[1] = Math.max(last[1], e)
|
||||
} else {
|
||||
merged.push([s, e])
|
||||
}
|
||||
}
|
||||
|
||||
if (merged.length === 0) {
|
||||
merged.push([fullStart, fullEnd])
|
||||
}
|
||||
|
||||
const activeTotal = merged.reduce((sum, [s, e]) => sum + (e - s), 0) || 1
|
||||
const compressed = Math.min(Math.max(activeTotal * 0.04, 0.5), 4)
|
||||
|
||||
const segs: TimeSeg[] = []
|
||||
let v = 0
|
||||
|
||||
const add = (r0: number, r1: number, gap: boolean) => {
|
||||
if (r1 <= r0) {
|
||||
return
|
||||
}
|
||||
|
||||
const vlen = gap ? Math.min(r1 - r0, compressed) : r1 - r0
|
||||
segs.push({ gap, r0, r1, v0: v, v1: v + vlen })
|
||||
v += vlen
|
||||
}
|
||||
|
||||
let cursor = fullStart
|
||||
|
||||
if (merged[0]![0] > cursor) {
|
||||
add(cursor, merged[0]![0], true)
|
||||
cursor = merged[0]![0]
|
||||
}
|
||||
|
||||
for (let i = 0; i < merged.length; i++) {
|
||||
const [s, e] = merged[i]!
|
||||
add(Math.max(s, cursor), e, false)
|
||||
cursor = Math.max(cursor, e)
|
||||
const next = merged[i + 1]
|
||||
|
||||
if (next && next[0] > cursor) {
|
||||
add(cursor, next[0], true)
|
||||
cursor = next[0]
|
||||
}
|
||||
}
|
||||
|
||||
if (fullEnd > cursor) {
|
||||
add(cursor, fullEnd, true)
|
||||
}
|
||||
|
||||
// Degenerate input (e.g. a live trace with only AGENT spans, or a zero-width
|
||||
// range at spawn) yields no segments — guarantee at least one so toV/toReal
|
||||
// never index into an empty array.
|
||||
if (segs.length === 0) {
|
||||
const r1 = Math.max(fullEnd, fullStart + 0.001)
|
||||
segs.push({ gap: false, r0: fullStart, r1, v0: 0, v1: r1 - fullStart })
|
||||
v = r1 - fullStart
|
||||
}
|
||||
|
||||
const totalV = v || 1
|
||||
|
||||
const toV = (t: number) => {
|
||||
if (t <= segs[0]!.r0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
for (const s of segs) {
|
||||
if (t <= s.r1) {
|
||||
return s.v0 + ((t - s.r0) / (s.r1 - s.r0 || 1)) * (s.v1 - s.v0)
|
||||
}
|
||||
}
|
||||
|
||||
return totalV
|
||||
}
|
||||
|
||||
const toReal = (vv: number) => {
|
||||
for (const s of segs) {
|
||||
if (vv <= s.v1) {
|
||||
return s.r0 + ((vv - s.v0) / (s.v1 - s.v0 || 1)) * (s.r1 - s.r0)
|
||||
}
|
||||
}
|
||||
|
||||
return fullEnd
|
||||
}
|
||||
|
||||
const gaps = segs.filter(s => s.gap && s.r1 - s.r0 > s.v1 - s.v0 + 1e-6)
|
||||
|
||||
return { gaps, toReal, toV, totalV }
|
||||
}
|
||||
|
||||
/** "Nice" axis ticks (1/2/5 × 10ⁿ) covering [start, end] in virtual units. */
|
||||
export function niceTicks(start: number, end: number, target = 6): number[] {
|
||||
const span = end - start
|
||||
|
||||
if (span <= 0) {
|
||||
return [start]
|
||||
}
|
||||
|
||||
const raw = span / target
|
||||
const mag = 10 ** Math.floor(Math.log10(raw))
|
||||
const norm = raw / mag
|
||||
const step = (norm >= 5 ? 5 : norm >= 2 ? 2 : 1) * mag
|
||||
const ticks: number[] = []
|
||||
|
||||
for (let t = Math.ceil(start / step) * step; t <= end; t += step) {
|
||||
ticks.push(t)
|
||||
}
|
||||
|
||||
return ticks
|
||||
}
|
||||
544
apps/desktop/src/app/agents/trace-waterfall.tsx
Normal file
544
apps/desktop/src/app/agents/trace-waterfall.tsx
Normal file
@@ -0,0 +1,544 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { scaleLinear } from 'd3-scale'
|
||||
import { select } from 'd3-selection'
|
||||
import {
|
||||
zoom as d3Zoom,
|
||||
type D3ZoomEvent,
|
||||
type ZoomBehavior,
|
||||
zoomIdentity,
|
||||
type ZoomTransform,
|
||||
zoomTransform
|
||||
} from 'd3-zoom'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ToolIcon } from '@/components/ui/tool-icon'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$hoveredSpanId,
|
||||
$selectedSpanId,
|
||||
$traceLabelsCollapsed,
|
||||
clearHoveredSpan,
|
||||
flattenSpanTree,
|
||||
type TraceDoc,
|
||||
type TraceSpanNode
|
||||
} from '@/store/trace'
|
||||
|
||||
import { fmtDuration } from './format'
|
||||
import { barClass, spanIconName } from './span-style'
|
||||
import { buildTimeMap, niceTicks } from './time-map'
|
||||
|
||||
export const ROW_HEIGHT = 26
|
||||
const LABEL_WIDTH = 280
|
||||
const LABEL_MAX_WIDTH = 240
|
||||
const MAX_ZOOM = 5000
|
||||
|
||||
export function TraceWaterfall({ trace, viewKey }: { trace: TraceDoc; viewKey: string }) {
|
||||
const nodes = useMemo(() => flattenSpanTree(trace), [trace])
|
||||
const selectedId = useStore($selectedSpanId)
|
||||
const hoveredId = useStore($hoveredSpanId)
|
||||
const collapsed = useStore($traceLabelsCollapsed)
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
const zoomRef = useRef<ZoomBehavior<HTMLDivElement, unknown> | null>(null)
|
||||
const [size, setSize] = useState({ height: 0, width: 0 })
|
||||
const [availHeight, setAvailHeight] = useState(0)
|
||||
const [transform, setTransform] = useState<ZoomTransform>(zoomIdentity)
|
||||
|
||||
// Measure the track so the time scale + zoom extents track its real width.
|
||||
useLayoutEffect(() => {
|
||||
const el = trackRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
if (entry) {
|
||||
setSize({ height: entry.contentRect.height, width: entry.contentRect.width })
|
||||
}
|
||||
})
|
||||
|
||||
ro.observe(el)
|
||||
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// Available height of the scroll area, so the track can fill it when there are
|
||||
// few rows (and still grow + scroll when there are many).
|
||||
useLayoutEffect(() => {
|
||||
const el = bodyRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
if (entry) {
|
||||
setAvailHeight(entry.contentRect.height)
|
||||
}
|
||||
})
|
||||
|
||||
ro.observe(el)
|
||||
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
const tmap = useMemo(
|
||||
() => buildTimeMap(nodes, trace.start, trace.end || trace.start + 1),
|
||||
[nodes, trace.start, trace.end]
|
||||
)
|
||||
|
||||
const xScale = useMemo(
|
||||
() => scaleLinear().domain([0, tmap.totalV]).range([0, Math.max(1, size.width)]),
|
||||
[tmap.totalV, size.width]
|
||||
)
|
||||
|
||||
// d3-zoom owns drag-pan + click/drag separation; we drive the wheel ourselves.
|
||||
// Attached ONCE on mount (not gated on measured size) so the gesture is live
|
||||
// immediately — gating on size.width was the regression that "lost" zoom when
|
||||
// the grid delayed the first measurement.
|
||||
useEffect(() => {
|
||||
const el = trackRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const behavior = d3Zoom<HTMLDivElement, unknown>()
|
||||
.scaleExtent([1, MAX_ZOOM])
|
||||
.clickDistance(4)
|
||||
.filter((event: Event) => event.type !== 'wheel' && !(event as MouseEvent).button)
|
||||
.on('zoom', (event: D3ZoomEvent<HTMLDivElement, unknown>) => setTransform(event.transform))
|
||||
|
||||
zoomRef.current = behavior
|
||||
const sel = select(el)
|
||||
sel.call(behavior)
|
||||
sel.on('dblclick.zoom', null)
|
||||
|
||||
// Wheel routing on the track itself:
|
||||
// ⌘/Ctrl + wheel → zoom toward the cursor · horizontal/shift wheel → pan
|
||||
// time · plain vertical wheel → fall through so the row list scrolls.
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const px = e.clientX - el.getBoundingClientRect().left
|
||||
behavior.scaleBy(select(el), Math.exp(-e.deltaY * 0.002), [px, 0])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const horizontal = Math.abs(e.deltaX) > Math.abs(e.deltaY)
|
||||
const dx = horizontal ? e.deltaX : e.shiftKey ? e.deltaY : 0
|
||||
|
||||
if (!dx) {
|
||||
return // plain vertical → let the list scroll
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
behavior.translateBy(select(el), -dx / zoomTransform(el).k, 0)
|
||||
}
|
||||
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
|
||||
return () => {
|
||||
sel.on('.zoom', null)
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Vertical drag scrubs the actual scroll container (the body owns row
|
||||
// position; labels + canvas stay in sync because they share this scroller).
|
||||
useEffect(() => {
|
||||
const scroller = bodyRef.current
|
||||
|
||||
if (!scroller) {
|
||||
return
|
||||
}
|
||||
|
||||
let lastY: null | number = null
|
||||
|
||||
const stop = () => {
|
||||
lastY = null
|
||||
window.removeEventListener('mousemove', move, { capture: true })
|
||||
window.removeEventListener('mouseup', stop, { capture: true })
|
||||
}
|
||||
|
||||
const move = (e: MouseEvent) => {
|
||||
if (!(e.buttons & 1) || lastY === null) {
|
||||
stop()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
scroller.scrollTop -= e.clientY - lastY
|
||||
lastY = e.clientY
|
||||
}
|
||||
|
||||
const start = (e: MouseEvent) => {
|
||||
if (e.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
lastY = e.clientY
|
||||
window.addEventListener('mousemove', move, { capture: true })
|
||||
window.addEventListener('mouseup', stop, { capture: true })
|
||||
}
|
||||
|
||||
scroller.addEventListener('mousedown', start, { capture: true })
|
||||
|
||||
return () => {
|
||||
scroller.removeEventListener('mousedown', start, { capture: true })
|
||||
stop()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Keep the pan/zoom extents in sync with the measured track size.
|
||||
useEffect(() => {
|
||||
const behavior = zoomRef.current
|
||||
|
||||
if (!behavior || size.width === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
behavior
|
||||
.extent([
|
||||
[0, 0],
|
||||
[size.width, size.height]
|
||||
])
|
||||
.translateExtent([
|
||||
[0, 0],
|
||||
[size.width, size.height]
|
||||
])
|
||||
}, [size.width, size.height])
|
||||
|
||||
// Reset the viewport only on an explicit navigation (session switch or pinning
|
||||
// a different turn) — NOT when the followed turn settles live→DB, and not on
|
||||
// live ticks. Keeps the user's zoom/pan; no auto-nav.
|
||||
useEffect(() => {
|
||||
const el = trackRef.current
|
||||
|
||||
if (el && zoomRef.current) {
|
||||
select(el).call(zoomRef.current.transform, zoomIdentity)
|
||||
} else {
|
||||
setTransform(zoomIdentity)
|
||||
}
|
||||
}, [viewKey])
|
||||
|
||||
// Preserve the visible *real-time* window across tmap changes that AREN'T a
|
||||
// nav (same viewKey): live ticks growing the trace, and the live→DB fold where
|
||||
// the compressed axis (totalV) shifts. Without this, a zoomed-in view would
|
||||
// drift each tick and the fold would reframe/jump. We translate the OLD view's
|
||||
// edges back to real time, re-project them through the NEW map, and re-apply
|
||||
// the transform — so the same span stays put while the bars get more exact.
|
||||
const prevTmapRef = useRef(tmap)
|
||||
const prevViewKeyRef = useRef(viewKey)
|
||||
useLayoutEffect(() => {
|
||||
const el = trackRef.current
|
||||
const behavior = zoomRef.current
|
||||
const width = size.width
|
||||
|
||||
// Nav reset owns viewKey changes; just resync our baselines and bail.
|
||||
if (viewKey !== prevViewKeyRef.current) {
|
||||
prevViewKeyRef.current = viewKey
|
||||
prevTmapRef.current = tmap
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const oldMap = prevTmapRef.current
|
||||
|
||||
if (oldMap === tmap || !el || !behavior || width === 0) {
|
||||
prevTmapRef.current = tmap
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const t = zoomTransform(el)
|
||||
prevTmapRef.current = tmap
|
||||
|
||||
// Full view (identity) → keep showing the whole trace as it grows; nothing
|
||||
// to preserve, and re-projecting would fight the natural "fit all".
|
||||
if (t.k <= 1.0001 && Math.abs(t.x) < 0.5) {
|
||||
return
|
||||
}
|
||||
|
||||
const clampV = (v: number, total: number) => Math.max(0, Math.min(total, v))
|
||||
const oldXs = scaleLinear().domain([0, oldMap.totalV]).range([0, width])
|
||||
const zxOld = t.rescaleX(oldXs)
|
||||
const realStart = oldMap.toReal(clampV(zxOld.invert(0), oldMap.totalV))
|
||||
const realEnd = oldMap.toReal(clampV(zxOld.invert(width), oldMap.totalV))
|
||||
|
||||
const newXs = scaleLinear().domain([0, tmap.totalV]).range([0, width])
|
||||
const bx0 = newXs(tmap.toV(realStart))
|
||||
const bx1 = newXs(tmap.toV(realEnd))
|
||||
const k = Math.max(1, Math.min(MAX_ZOOM, width / Math.max(1, bx1 - bx0)))
|
||||
const x = Math.min(0, Math.max(width * (1 - k), -k * bx0))
|
||||
|
||||
select(el).call(behavior.transform, zoomIdentity.scale(k).translate(x / k, 0))
|
||||
}, [tmap, viewKey, size.width])
|
||||
|
||||
// Latest nodes/tmap read via refs so the zoom-to-span effect can fire ONLY on
|
||||
// a selection change — never on live ticks (which would re-snap the view).
|
||||
const nodesRef = useRef(nodes)
|
||||
nodesRef.current = nodes
|
||||
const tmapRef = useRef(tmap)
|
||||
tmapRef.current = tmap
|
||||
|
||||
// Selecting a span (clicking a row) zooms the timeline to frame it (~70%) and
|
||||
// scrolls to it. Keyed on the selection alone so it never fights live updates.
|
||||
useEffect(() => {
|
||||
const el = trackRef.current
|
||||
const behavior = zoomRef.current
|
||||
|
||||
if (!el || !behavior || !selectedId) {
|
||||
return
|
||||
}
|
||||
|
||||
const span = nodesRef.current.find(n => n.id === selectedId)
|
||||
const width = el.clientWidth
|
||||
|
||||
if (!span || !width) {
|
||||
return
|
||||
}
|
||||
|
||||
const map = tmapRef.current
|
||||
const xs = scaleLinear().domain([0, map.totalV]).range([0, width])
|
||||
const bx0 = xs(map.toV(span.start))
|
||||
const bx1 = xs(map.toV(span.end))
|
||||
const bw = Math.max(2, bx1 - bx0)
|
||||
const k = Math.max(1, Math.min(MAX_ZOOM, (width * 0.7) / bw))
|
||||
const center = (bx0 + bx1) / 2
|
||||
// Clamp the translate so the view can't slide past the start (left gap) or
|
||||
// the end — matching d3's translateExtent, but on the target so there's no
|
||||
// "show gap then snap back".
|
||||
const x = Math.min(0, Math.max(width * (1 - k), width / 2 - k * center))
|
||||
const next = zoomIdentity.scale(k).translate(x / k, 0)
|
||||
select(el).transition().duration(250).call(behavior.transform, next)
|
||||
}, [selectedId])
|
||||
|
||||
const view = useMemo(() => {
|
||||
if (size.width === 0) {
|
||||
return { end: tmap.totalV, start: 0 }
|
||||
}
|
||||
|
||||
const zx = transform.rescaleX(xScale)
|
||||
|
||||
return { end: zx.invert(size.width), start: zx.invert(0) }
|
||||
}, [transform, xScale, size.width, tmap.totalV])
|
||||
|
||||
// view is in compressed (virtual) coordinates.
|
||||
const viewSpan = Math.max(1e-6, view.end - view.start)
|
||||
const ticks = useMemo(() => niceTicks(view.start, view.end), [view.start, view.end])
|
||||
// pctV: a virtual coord → %; pct: a real time → % (via the compression map).
|
||||
const pctV = (vv: number) => ((vv - view.start) / viewSpan) * 100
|
||||
const pct = (t: number) => pctV(tmap.toV(t))
|
||||
|
||||
const resetView = () => {
|
||||
const el = trackRef.current
|
||||
|
||||
if (el && zoomRef.current) {
|
||||
select(el).transition().duration(200).call(zoomRef.current.transform, zoomIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
// One column template shared by the ruler and the body so the time axis and
|
||||
// the bars can never drift out of alignment. Column 1 is the label tree (a
|
||||
// thin gutter when collapsed, just wide enough for the expand toggle); the
|
||||
// `gap-x-2` between the columns separates labels from the chart.
|
||||
const cols = `${collapsed ? '1.5rem' : `${LABEL_WIDTH}px`} minmax(0, 1fr)`
|
||||
// Fill the scroll area when rows are few; grow + scroll when they're many.
|
||||
const bodyHeight = Math.max(nodes.length * ROW_HEIGHT, availHeight)
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Ruler — shares the body's column template; column 1 holds the collapse
|
||||
toggle (in flow so it never overlaps labels), column 2 the time axis. */}
|
||||
<div
|
||||
className="grid shrink-0 items-center gap-x-2 text-[0.62rem] text-muted-foreground/70"
|
||||
style={{ gridTemplateColumns: cols, height: ROW_HEIGHT }}
|
||||
>
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
className="rounded p-0.5 text-muted-foreground/55 hover:bg-foreground/10 hover:text-foreground"
|
||||
onClick={() => $traceLabelsCollapsed.set(!collapsed)}
|
||||
title={collapsed ? 'Show labels' : 'Hide labels'}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name={collapsed ? 'chevron-right' : 'chevron-left'} size="0.9rem" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative h-full overflow-hidden">
|
||||
{ticks.map(t => {
|
||||
const left = pctV(t)
|
||||
|
||||
if (left < 0 || left > 100) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Edge ticks align inward so the first/last label isn't sliced by the
|
||||
// track's clip; interior ticks center on their gridline.
|
||||
const edge = left < 4 ? 'left' : left > 96 ? 'right' : 'center'
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute top-1/2 -translate-y-1/2 px-1 whitespace-nowrap tabular-nums',
|
||||
edge === 'left' ? 'translate-x-0' : edge === 'right' ? '-translate-x-full' : '-translate-x-1/2'
|
||||
)}
|
||||
key={t}
|
||||
style={{ left: `${left}%` }}
|
||||
>
|
||||
{fmtDuration(tmap.toReal(t) - trace.start)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body — same column template; vertically scrolls labels + track together.
|
||||
scrollbar-gutter stays stable so switching between a short turn (no
|
||||
scrollbar) and the full session (scrollbar) never reflows the track. */}
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-gutter:stable]"
|
||||
ref={bodyRef}
|
||||
>
|
||||
<div className="grid min-h-full gap-x-2" style={{ gridTemplateColumns: cols }}>
|
||||
{/* Column 1: label index (empty cell when collapsed keeps the grid 2-wide) */}
|
||||
{collapsed ? (
|
||||
<div />
|
||||
) : (
|
||||
<div>
|
||||
{nodes.map(node => (
|
||||
<SpanLabel active={node.id === selectedId || node.id === hoveredId} key={node.id} node={node} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column 2: time track */}
|
||||
<div
|
||||
className="relative cursor-grab touch-none overflow-hidden select-none active:cursor-grabbing"
|
||||
onClick={() => $selectedSpanId.set(null)}
|
||||
onDoubleClick={resetView}
|
||||
ref={trackRef}
|
||||
style={{ height: bodyHeight }}
|
||||
>
|
||||
{ticks.map(t => {
|
||||
const left = pctV(t)
|
||||
|
||||
// Skip the flush-left gridline at t=0 — it reads as a border.
|
||||
if (left <= 0.1 || left > 100) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 bottom-0 w-px bg-border/30"
|
||||
key={t}
|
||||
style={{ left: `${left}%` }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Collapsed-idle markers: a dashed seam where dead time was removed. */}
|
||||
{tmap.gaps.map(g => {
|
||||
const left = pctV(g.v0)
|
||||
const right = pctV(g.v1)
|
||||
|
||||
if (right < 0 || left > 100) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 bottom-0 flex items-start justify-center border-l border-dashed border-border/50 bg-foreground/[0.03]"
|
||||
key={`gap-${g.v0}`}
|
||||
style={{ left: `${left}%`, width: `${Math.max(0.4, right - left)}%` }}
|
||||
>
|
||||
<span className="mt-0.5 rounded bg-background/70 px-1 text-[0.55rem] whitespace-nowrap text-muted-foreground/55">
|
||||
{fmtDuration(g.r1 - g.r0)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{nodes.map((node, i) => {
|
||||
const left = pct(node.start)
|
||||
const width = (tmap.toV(node.end) - tmap.toV(node.start)) / viewSpan
|
||||
const active = node.id === selectedId || node.id === hoveredId
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 left-0 transition-colors duration-100 ease-out hover:bg-foreground/[0.035] hover:transition-none',
|
||||
active && 'bg-foreground/[0.035]'
|
||||
)}
|
||||
key={node.id}
|
||||
onMouseEnter={() => $hoveredSpanId.set(node.id)}
|
||||
onMouseLeave={() => clearHoveredSpan(node.id)}
|
||||
style={{ height: ROW_HEIGHT, top: i * ROW_HEIGHT }}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
'absolute top-1/2 h-4 -translate-y-1/2 overflow-hidden rounded-[3px] transition-[filter] hover:brightness-125',
|
||||
barClass(node),
|
||||
node.status === 'running' && 'animate-pulse',
|
||||
active && 'ring-1 ring-foreground/70'
|
||||
)}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
$selectedSpanId.set(node.id)
|
||||
}}
|
||||
style={{ left: `${left}%`, minWidth: 2, width: `${width * 100}%` }}
|
||||
type="button"
|
||||
/>
|
||||
{/* On-lane label: name + duration, anchored at the bar start.
|
||||
Only when the start is in view AND the bar is wide enough to
|
||||
host it (or it's active) — otherwise sliver bars (e.g. a
|
||||
sub-second span pinned at t=0) leave a floating duration
|
||||
stuck at the left edge. The left tree always has the full
|
||||
label, so slivers lose nothing. */}
|
||||
{left >= 0 && left < 100 && (active || width * size.width >= 32) ? (
|
||||
<span
|
||||
className="pointer-events-none absolute inset-y-0 flex items-center gap-1.5 truncate pl-1.5 text-[0.6rem] text-white/85"
|
||||
style={{ left: `${left}%`, maxWidth: LABEL_MAX_WIDTH }}
|
||||
>
|
||||
<span className="truncate">{node.name}</span>
|
||||
<span className="shrink-0 text-white/55">{fmtDuration(node.duration)}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SpanLabel({ active, node }: { active: boolean; node: TraceSpanNode }) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-1.5 truncate pr-2 pl-2 text-left text-[0.72rem] text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:text-foreground hover:transition-none',
|
||||
active && 'bg-(--ui-row-active-background) text-foreground'
|
||||
)}
|
||||
onClick={() => $selectedSpanId.set(node.id)}
|
||||
onMouseEnter={() => $hoveredSpanId.set(node.id)}
|
||||
onMouseLeave={() => clearHoveredSpan(node.id)}
|
||||
style={{ height: ROW_HEIGHT, paddingLeft: 8 + node.depth * 14 }}
|
||||
type="button"
|
||||
>
|
||||
<ToolIcon
|
||||
className={cn('shrink-0', node.status === 'error' ? 'text-red-500' : 'text-muted-foreground/60')}
|
||||
name={spanIconName(node)}
|
||||
size="0.8rem"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-foreground/85">{node.name}</span>
|
||||
<span className="shrink-0 tabular-nums text-[0.62rem] text-muted-foreground/55">{fmtDuration(node.duration)}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
73
apps/desktop/src/app/agents/turn-strip.tsx
Normal file
73
apps/desktop/src/app/agents/turn-strip.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $traceTurns } from '@/store/trace'
|
||||
|
||||
interface TurnStripProps {
|
||||
activeIndex: null | number
|
||||
allActive: boolean
|
||||
liveIndex: null | number
|
||||
onAll: () => void
|
||||
onTurn: (index: number) => void
|
||||
}
|
||||
|
||||
// Turn nav as a row of timeline bars (à la the thread timeline). Each button is
|
||||
// full header height (the hit target); the bar inside is short when inactive,
|
||||
// full height when active/live.
|
||||
export function TurnStrip({ activeIndex, allActive, liveIndex, onAll, onTurn }: TurnStripProps) {
|
||||
const turns = useStore($traceTurns)
|
||||
|
||||
if (turns.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 self-stretch items-center gap-2 overflow-x-auto pt-7">
|
||||
<button
|
||||
aria-label="All turns"
|
||||
className={cn(
|
||||
'flex h-full shrink-0 items-center gap-1 rounded px-1 text-[0.6rem] font-medium tracking-wide uppercase transition-colors',
|
||||
allActive ? 'text-foreground' : 'text-muted-foreground/45 hover:text-foreground/80'
|
||||
)}
|
||||
onClick={onAll}
|
||||
title="All turns"
|
||||
type="button"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<div className="flex h-full items-center gap-px">
|
||||
{turns.map(turn => {
|
||||
const active = turn.index === activeIndex
|
||||
const live = turn.index === liveIndex
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={`Turn ${turn.index + 1}`}
|
||||
className="group flex h-full items-center px-px"
|
||||
key={turn.index}
|
||||
onClick={() => onTurn(turn.index)}
|
||||
onMouseEnter={() => onTurn(turn.index)}
|
||||
title={`#${turn.index + 1} · ${turn.label}`}
|
||||
type="button"
|
||||
>
|
||||
{/* Fixed-height box so the strip never grows when a bar activates;
|
||||
only the inner fill changes height. */}
|
||||
<span className="flex h-4 w-[3px] items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
'w-full rounded-full transition-all duration-100 ease-out group-hover:transition-none',
|
||||
live
|
||||
? 'h-full animate-pulse bg-emerald-500'
|
||||
: active
|
||||
? 'h-full bg-foreground'
|
||||
: 'h-1/2 bg-foreground/25 group-hover:h-3/4 group-hover:bg-foreground/50'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
* steal focus from the composer effect.
|
||||
*/
|
||||
|
||||
import { RICH_INPUT_SLOT } from './rich-editor'
|
||||
import type { InlineRefInput } from './inline-refs'
|
||||
import { RICH_INPUT_SLOT } from './rich-editor'
|
||||
|
||||
export type ComposerTarget = 'edit' | 'main'
|
||||
export type ComposerInsertMode = 'block' | 'inline'
|
||||
@@ -34,8 +34,14 @@ interface InsertRefsDetail {
|
||||
const FOCUS_EVENT = 'hermes:composer-focus'
|
||||
const INSERT_EVENT = 'hermes:composer-insert'
|
||||
const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
|
||||
const SUBMIT_EVENT = 'hermes:composer-submit'
|
||||
const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle'
|
||||
|
||||
interface SubmitDetail {
|
||||
target: ComposerTarget
|
||||
text: string
|
||||
}
|
||||
|
||||
let activeTarget: ComposerTarget = 'main'
|
||||
|
||||
const resolve = (target: ComposerTarget | 'active') => (target === 'active' ? activeTarget : target)
|
||||
@@ -106,6 +112,23 @@ export const requestComposerInsertRefs = (
|
||||
export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) =>
|
||||
subscribe<InsertRefsDetail>(INSERT_REFS_EVENT, handler)
|
||||
|
||||
/** Submit a prompt through a composer as if the user typed + sent it. Lets
|
||||
* external panels (e.g. the review pane's "let the agent ship it" button) hand
|
||||
* the agent a task without the user round-tripping through the input. */
|
||||
export const requestComposerSubmit = (
|
||||
text: string,
|
||||
{ target = 'active' }: { target?: ComposerTarget | 'active' } = {}
|
||||
) => {
|
||||
const trimmed = text.trim()
|
||||
|
||||
if (trimmed) {
|
||||
dispatch<SubmitDetail>(SUBMIT_EVENT, { target: resolve(target), text: trimmed })
|
||||
}
|
||||
}
|
||||
|
||||
export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) =>
|
||||
subscribe<SubmitDetail>(SUBMIT_EVENT, handler)
|
||||
|
||||
/** Toggle the active composer's voice conversation — the `composer.voice`
|
||||
* hotkey (Ctrl+B) reaching into the composer that owns the voice state. */
|
||||
export const requestVoiceToggle = () => dispatch<{ at: number }>(VOICE_TOGGLE_EVENT, { at: Date.now() })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -45,8 +45,8 @@ import {
|
||||
$composerPoppedOut,
|
||||
POPOUT_WIDTH_REM,
|
||||
readPopoutBounds,
|
||||
setComposerPoppedOut,
|
||||
setComposerPopoutPosition
|
||||
setComposerPopoutPosition,
|
||||
setComposerPoppedOut
|
||||
} from '@/store/composer-popout'
|
||||
import {
|
||||
$queuedPromptsBySession,
|
||||
@@ -60,8 +60,10 @@ import {
|
||||
updateQueuedPrompt
|
||||
} from '@/store/composer-queue'
|
||||
import { $statusItemsBySession } from '@/store/composer-status'
|
||||
import { $previewStatusBySession } from '@/store/preview-status'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { $previewStatusBySession } from '@/store/preview-status'
|
||||
import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
|
||||
import { toggleReview } from '@/store/review'
|
||||
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
@@ -80,6 +82,7 @@ import {
|
||||
onComposerFocusRequest,
|
||||
onComposerInsertRefsRequest,
|
||||
onComposerInsertRequest,
|
||||
onComposerSubmitRequest,
|
||||
onComposerVoiceToggleRequest
|
||||
} from './focus'
|
||||
import { HelpHint } from './help-hint'
|
||||
@@ -108,6 +111,7 @@ import {
|
||||
slashChipElement
|
||||
} from './rich-editor'
|
||||
import { ComposerStatusStack } from './status-stack'
|
||||
import { CodingStatusRow } from './status-stack/coding-row'
|
||||
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
@@ -274,14 +278,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)
|
||||
@@ -780,6 +787,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
|
||||
}
|
||||
|
||||
@@ -812,8 +829,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)
|
||||
@@ -840,7 +856,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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -980,10 +1003,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)
|
||||
|
||||
@@ -1351,6 +1371,80 @@ export function ChatBar({
|
||||
}
|
||||
}, [setComposerText])
|
||||
|
||||
// Hand a worktree off to the controller: open a fresh session anchored there,
|
||||
// carrying the composer draft as its first turn. Clearing here means the draft
|
||||
// travels to the new session instead of getting stashed under this one.
|
||||
const openInWorktree = useCallback(
|
||||
(path: string) => {
|
||||
const text = draftRef.current
|
||||
clearDraft()
|
||||
clearComposerAttachments()
|
||||
requestStartWorkSession(path, text)
|
||||
},
|
||||
[clearDraft]
|
||||
)
|
||||
|
||||
// Branch off into a NEW worktree (base = branch name, or current HEAD). A
|
||||
// create failure throws back to the row (which toasts) before we touch the
|
||||
// draft; a missing cwd / remote backend no-ops (the row hides the affordance).
|
||||
const handleBranchOff = useCallback(
|
||||
async (branch: string, base?: string) => {
|
||||
const repoPath = cwd?.trim()
|
||||
const result = repoPath && (await startWorkInRepo(repoPath, { base, branch, name: branch }))
|
||||
|
||||
if (result) {
|
||||
openInWorktree(result.path)
|
||||
}
|
||||
},
|
||||
[cwd, openInWorktree]
|
||||
)
|
||||
|
||||
// Convert an EXISTING branch into a fresh worktree + session (no new branch).
|
||||
// Mirrors handleBranchOff's hand-off: create the worktree, then open a session
|
||||
// anchored there carrying the draft.
|
||||
const handleConvertBranch = useCallback(
|
||||
async (branch: string, path?: null | string, isDefault?: boolean) => {
|
||||
if (path?.trim()) {
|
||||
openInWorktree(path)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const repoPath = cwd?.trim()
|
||||
|
||||
if (repoPath && isDefault) {
|
||||
await switchBranchInRepo(repoPath, branch)
|
||||
openInWorktree(repoPath)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const result = repoPath && (await startWorkInRepo(repoPath, { existingBranch: branch }))
|
||||
|
||||
if (result) {
|
||||
openInWorktree(result.path)
|
||||
}
|
||||
},
|
||||
[cwd, openInWorktree]
|
||||
)
|
||||
|
||||
const handleListBranches = useCallback(async () => {
|
||||
const repoPath = cwd?.trim()
|
||||
|
||||
return repoPath ? listRepoBranches(repoPath) : []
|
||||
}, [cwd])
|
||||
|
||||
const handleSwitchBranch = useCallback(
|
||||
async (branch: string) => {
|
||||
const repoPath = cwd?.trim()
|
||||
|
||||
if (repoPath) {
|
||||
await switchBranchInRepo(repoPath, branch)
|
||||
}
|
||||
},
|
||||
[cwd]
|
||||
)
|
||||
|
||||
const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
|
||||
draftRef.current = text
|
||||
setComposerText(text)
|
||||
@@ -1674,6 +1768,43 @@ export function ChatBar({
|
||||
}
|
||||
}, [autoDrainNext, busy, queuedPrompts.length])
|
||||
|
||||
// Esc cancels the in-flight turn when the CHAT has focus — not just the
|
||||
// composer input (which has its own handler above). Clicking into the
|
||||
// transcript and hitting Esc now stops the run, matching the Stop button.
|
||||
// Intentional only: we bail if (a) the composer/another field already
|
||||
// handled Esc (defaultPrevented), (b) focus is in any input/textarea/
|
||||
// contenteditable (you're typing, not stopping), or (c) a dialog/popover is
|
||||
// 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) {
|
||||
return
|
||||
}
|
||||
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
|
||||
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (document.querySelector('[role="dialog"],[role="alertdialog"],[data-radix-popper-content-wrapper]')) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
triggerHaptic('cancel')
|
||||
void Promise.resolve(onCancel())
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: globalThis.KeyboardEvent) => escCancelRef.current(event)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
// Queue-edit cleanup: on session swap the scope effect already stashed the
|
||||
// edit snapshot; only restore into the composer when still on the same scope.
|
||||
useEffect(() => {
|
||||
@@ -1706,6 +1837,22 @@ export function ChatBar({
|
||||
.catch(restore)
|
||||
}
|
||||
|
||||
// External "submit this prompt" requests (e.g. the review pane's agent-ship
|
||||
// button) route through the same send path. A ref keeps the listener stable
|
||||
// while always calling the latest dispatchSubmit closure.
|
||||
const dispatchSubmitRef = useRef(dispatchSubmit)
|
||||
dispatchSubmitRef.current = dispatchSubmit
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
onComposerSubmitRequest(({ target, text }) => {
|
||||
if (target === 'main' && !inputDisabled) {
|
||||
dispatchSubmitRef.current(text)
|
||||
}
|
||||
}),
|
||||
[inputDisabled]
|
||||
)
|
||||
|
||||
const submitDraft = () => {
|
||||
if (disabled) {
|
||||
return
|
||||
@@ -2099,7 +2246,7 @@ export function ChatBar({
|
||||
<div className="relative w-full rounded-[inherit]">
|
||||
<div
|
||||
className={cn(
|
||||
'group/composer-surface relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out focus-within:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
|
||||
'group/composer-surface relative z-4 isolate grid grid-rows-[auto_1fr] overflow-hidden rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))]',
|
||||
COMPOSER_DROP_FADE_CLASS,
|
||||
dragActive && COMPOSER_DROP_ACTIVE_CLASS
|
||||
)}
|
||||
@@ -2114,10 +2261,20 @@ export function ChatBar({
|
||||
composerSurfaceGlass
|
||||
)}
|
||||
/>
|
||||
<CodingStatusRow
|
||||
onBranchOff={handleBranchOff}
|
||||
onConvertBranch={handleConvertBranch}
|
||||
onListBranches={handleListBranches}
|
||||
onOpen={toggleReview}
|
||||
onOpenWorktree={openInWorktree}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
/>
|
||||
<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>
|
||||
|
||||
469
apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx
Normal file
469
apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx
Normal file
@@ -0,0 +1,469 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { DiffCount } from '@/components/ui/diff-count'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { SanitizedInput } from '@/components/ui/sanitized-input'
|
||||
import type { HermesGitBranch } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { gitRef } from '@/lib/sanitize'
|
||||
import { $repoStatus, $repoWorktrees } from '@/store/coding-status'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $newWorktreeRequest } from '@/store/projects'
|
||||
|
||||
// Tiny uppercase section header, matching the composer "+" menu's labels.
|
||||
const MENU_SECTION = 'text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)'
|
||||
|
||||
interface BranchActionCopy {
|
||||
branchCreateWorktree: string
|
||||
branchOpenExisting: string
|
||||
branchSwitchHome: string
|
||||
}
|
||||
|
||||
const branchActionLabel = (branch: HermesGitBranch, copy: BranchActionCopy) => {
|
||||
if (branch.checkedOut) {
|
||||
return copy.branchOpenExisting
|
||||
}
|
||||
|
||||
return branch.isDefault ? copy.branchSwitchHome : copy.branchCreateWorktree
|
||||
}
|
||||
|
||||
interface CodingStatusRowProps {
|
||||
/** Branch the current draft off into a fresh worktree + session, based on
|
||||
* `base` (a branch name; omitted = current HEAD). The composer owns the
|
||||
* draft, so it supplies the orchestration; the row just collects the new
|
||||
* branch name + base. Omitted (e.g. remote backend) hides the affordance. */
|
||||
onBranchOff?: (branch: string, base?: string) => Promise<void>
|
||||
/** Check an existing branch out into a fresh worktree + session (no new
|
||||
* branch). Drives the dialog's "convert a branch" picker. */
|
||||
onConvertBranch?: (branch: string, path?: null | string, isDefault?: boolean) => Promise<void>
|
||||
/** List the repo's local branches for the "convert a branch" picker. */
|
||||
onListBranches?: () => Promise<HermesGitBranch[]>
|
||||
/** Open the review pane (changed files + diffs). */
|
||||
onOpen?: () => void
|
||||
/** Jump into an existing worktree (open a fresh session anchored there). */
|
||||
onOpenWorktree?: (path: string) => void
|
||||
/** Switch the current repo checkout to another branch. */
|
||||
onSwitchBranch?: (branch: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* The always-on coding-context row, the BASE of the composer status stack:
|
||||
* current branch, dirty summary (+/-), and ahead/behind. A touch more prominent
|
||||
* than the per-turn rows above it (larger branch label, accent glyph), and the
|
||||
* entry point to the review pane. Hidden when the active session isn't in a
|
||||
* local git repo (the probe returns null).
|
||||
*/
|
||||
export const CodingStatusRow = memo(function CodingStatusRow({
|
||||
onBranchOff,
|
||||
onConvertBranch,
|
||||
onListBranches,
|
||||
onOpen,
|
||||
onOpenWorktree,
|
||||
onSwitchBranch
|
||||
}: CodingStatusRowProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.statusStack.coding
|
||||
const p = t.sidebar.projects
|
||||
const status = useStore($repoStatus)
|
||||
const worktrees = useStore($repoWorktrees)
|
||||
|
||||
const [branchOpen, setBranchOpen] = useState(false)
|
||||
const [branchName, setBranchName] = useState('')
|
||||
const [branchBase, setBranchBase] = useState<string | undefined>(undefined)
|
||||
const [branchPending, setBranchPending] = useState(false)
|
||||
const [convertMode, setConvertMode] = useState(false)
|
||||
const [branches, setBranches] = useState<HermesGitBranch[]>([])
|
||||
const [branchesLoading, setBranchesLoading] = useState(false)
|
||||
|
||||
const loadBranches = useCallback(async () => {
|
||||
if (!onListBranches) {
|
||||
return
|
||||
}
|
||||
|
||||
setBranchesLoading(true)
|
||||
|
||||
try {
|
||||
setBranches(await onListBranches())
|
||||
} catch {
|
||||
setBranches([])
|
||||
} finally {
|
||||
setBranchesLoading(false)
|
||||
}
|
||||
}, [onListBranches])
|
||||
|
||||
// Open the name dialog for a chosen base. Deferred so the dropdown finishes
|
||||
// closing before the dialog grabs focus (Radix focus-trap handoff races
|
||||
// otherwise).
|
||||
const startBranch = (base: string | undefined) => {
|
||||
setBranchBase(base)
|
||||
setBranchName('')
|
||||
setConvertMode(false)
|
||||
setTimeout(() => setBranchOpen(true), 0)
|
||||
}
|
||||
|
||||
const startConvert = () => {
|
||||
setBranchBase(undefined)
|
||||
setBranchName('')
|
||||
setConvertMode(true)
|
||||
void loadBranches()
|
||||
setTimeout(() => setBranchOpen(true), 0)
|
||||
}
|
||||
|
||||
const enterConvert = () => {
|
||||
setConvertMode(true)
|
||||
void loadBranches()
|
||||
}
|
||||
|
||||
const convertBranch = async (branch: HermesGitBranch) => {
|
||||
if (branchPending || !branch || !onConvertBranch) {
|
||||
return
|
||||
}
|
||||
|
||||
setBranchPending(true)
|
||||
|
||||
try {
|
||||
await onConvertBranch(branch.name, branch.worktreePath, branch.isDefault)
|
||||
setBranchOpen(false)
|
||||
} catch (err) {
|
||||
notifyError(err, p.startWorkFailed)
|
||||
} finally {
|
||||
setBranchPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Global ⌘⇧B (workspace.newWorktree): open the name dialog for a worktree off
|
||||
// current HEAD. The rail only renders inside a repo, so the hotkey naturally
|
||||
// no-ops elsewhere. Guarded by a token ref so it fires on the keypress, not on
|
||||
// mount or unrelated re-renders.
|
||||
const worktreeReq = useStore($newWorktreeRequest)
|
||||
const lastWorktreeReqRef = useRef(worktreeReq)
|
||||
|
||||
useEffect(() => {
|
||||
if (worktreeReq === lastWorktreeReqRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastWorktreeReqRef.current = worktreeReq
|
||||
|
||||
if (!onBranchOff) {
|
||||
return
|
||||
}
|
||||
|
||||
setBranchBase(undefined)
|
||||
setBranchName('')
|
||||
setConvertMode(false)
|
||||
setBranchOpen(true)
|
||||
}, [onBranchOff, worktreeReq])
|
||||
|
||||
const submitBranch = async () => {
|
||||
const branch = branchName.trim()
|
||||
|
||||
if (branchPending || !branch || !onBranchOff) {
|
||||
return
|
||||
}
|
||||
|
||||
setBranchPending(true)
|
||||
|
||||
try {
|
||||
await onBranchOff(branch, branchBase)
|
||||
setBranchOpen(false)
|
||||
setBranchName('')
|
||||
} catch (err) {
|
||||
notifyError(err, p.startWorkFailed)
|
||||
} finally {
|
||||
setBranchPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const switchToBranch = async (branch: string) => {
|
||||
if (!onSwitchBranch) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await onSwitchBranch(branch)
|
||||
} catch (err) {
|
||||
notifyError(err, s.switchFailed(branch))
|
||||
}
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return null
|
||||
}
|
||||
|
||||
const branchLabel = status.detached ? s.detached : status.branch || s.noBranch
|
||||
// The kebab offers branching off the trunk and/or the current branch. The
|
||||
// worktree-add bases the new branch on `base` (a branch name; undefined =
|
||||
// current HEAD). We dedupe so "on main" shows a single trunk entry, and fall
|
||||
// back to a plain off-HEAD branch when no trunk is detected.
|
||||
const current = status.detached ? null : status.branch
|
||||
const branchTargets: { base: string | undefined; label: string }[] = []
|
||||
|
||||
// Current branch first (the 99% "branch off where I am"), then the trunk just
|
||||
// below it ("New branch from main"), deduped when they're the same.
|
||||
if (current) {
|
||||
branchTargets.push({ base: current, label: s.branchOffFrom(current) })
|
||||
}
|
||||
|
||||
if (status.defaultBranch && status.defaultBranch !== current) {
|
||||
branchTargets.push({ base: status.defaultBranch, label: s.branchOffFrom(status.defaultBranch) })
|
||||
}
|
||||
|
||||
if (branchTargets.length === 0) {
|
||||
branchTargets.push({ base: undefined, label: s.newBranch })
|
||||
}
|
||||
|
||||
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.
|
||||
const otherWorktrees = onOpenWorktree
|
||||
? worktrees.filter(w => w.path && !w.detached && w.branch && w.branch !== current)
|
||||
: []
|
||||
|
||||
const hasLineDelta = status.added > 0 || status.removed > 0
|
||||
// Untracked files carry no line delta vs HEAD, so surface them as a count when
|
||||
// they're the only change (otherwise +/- tells the story).
|
||||
const untrackedOnly = !hasLineDelta && status.untracked > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusRow
|
||||
// The base "where am I working" strip is part of the composer surface
|
||||
// itself, so it inherits the composer's width and clipped top radius.
|
||||
className="coding-status-bar min-h-7 rounded-t-[inherit] rounded-b-none border-b border-(--ui-stroke-tertiary) px-3.5 py-1.5 hover:bg-transparent"
|
||||
// Static branch glyph — never the loading spinner. This row only renders
|
||||
// once `status` exists, so a spinner here only ever fired on *refreshes*
|
||||
// of an already-loaded repo (window focus, turn settle), reading as an
|
||||
// annoying icon "blip" with no first-load value. Refreshes are silent.
|
||||
leading={<Codicon className="text-(--ui-green)" name="git-branch" size="0.8rem" />}
|
||||
onActivate={onOpen}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<span
|
||||
className="min-w-0 truncate text-xs font-normal text-muted-foreground/92 transition-colors group-hover/status-row:text-foreground/90"
|
||||
title={branchLabel}
|
||||
>
|
||||
{branchLabel}
|
||||
</span>
|
||||
|
||||
{/* Branch actions kebab — same pattern as the session/worktree rows.
|
||||
ALWAYS laid out; only its opacity flips on hover/focus/open, so
|
||||
revealing it never reflows the row (no layout shift). pointer-events
|
||||
follow opacity so the invisible trigger isn't clickable at rest. */}
|
||||
{onBranchOff && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={s.newBranch}
|
||||
className="pointer-events-none size-4 shrink-0 text-muted-foreground/60 opacity-0 transition hover:text-foreground group-hover/status-row:pointer-events-auto group-hover/status-row:opacity-100 group-focus-within/status-row:pointer-events-auto group-focus-within/status-row:opacity-100 data-[state=open]:pointer-events-auto data-[state=open]:opacity-100"
|
||||
onClick={event => event.stopPropagation()}
|
||||
onKeyDown={event => {
|
||||
// The row's onActivate also fires on Enter/Space; keep it from
|
||||
// opening the review pane when the kebab is the focus target.
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.stopPropagation()
|
||||
}
|
||||
}}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="kebab-vertical" size="0.8rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
{/* The row sits at the bottom of the screen (above the composer),
|
||||
so the menu opens upward. */}
|
||||
<DropdownMenuContent align="end" className="w-60" side="top" sideOffset={6}>
|
||||
<DropdownMenuLabel className={MENU_SECTION}>{s.newBranch}</DropdownMenuLabel>
|
||||
{branchTargets.map(target => (
|
||||
<DropdownMenuItem key={target.base ?? '__head__'} onSelect={() => startBranch(target.base)}>
|
||||
<span className="truncate">{target.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
{switchTarget && (
|
||||
<DropdownMenuItem onSelect={() => void switchToBranch(switchTarget)}>
|
||||
<span className="truncate">{s.switchTo(switchTarget)}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className={MENU_SECTION}>{s.worktrees}</DropdownMenuLabel>
|
||||
{otherWorktrees.map(worktree => (
|
||||
<DropdownMenuItem key={worktree.path} onSelect={() => onOpenWorktree?.(worktree.path)}>
|
||||
<span className="truncate">{worktree.branch}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{/* Create a fresh worktree off the current HEAD (the generic
|
||||
"spin up a worktree here", mirroring the sidebar's + button). */}
|
||||
<DropdownMenuItem onSelect={() => startBranch(undefined)}>
|
||||
<span className="truncate">{p.startWork}</span>
|
||||
</DropdownMenuItem>
|
||||
{/* Check an EXISTING branch out into a worktree (no new branch). */}
|
||||
{onConvertBranch && (
|
||||
<DropdownMenuItem onSelect={() => startConvert()}>
|
||||
<span className="truncate">{p.convertBranch}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(status.ahead > 0 || status.behind > 0) && (
|
||||
<span className="ml-auto flex shrink-0 items-center gap-1.5 text-[0.68rem] leading-4 text-muted-foreground/75 tabular-nums">
|
||||
{status.ahead > 0 && (
|
||||
<span className="flex items-center gap-0.5" title={s.ahead(status.ahead)}>
|
||||
<span aria-hidden>↑</span>
|
||||
{status.ahead}
|
||||
</span>
|
||||
)}
|
||||
{status.behind > 0 && (
|
||||
<span className="flex items-center gap-0.5" title={s.behind(status.behind)}>
|
||||
<span aria-hidden>↓</span>
|
||||
{status.behind}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{hasLineDelta ? (
|
||||
<DiffCount
|
||||
added={status.added}
|
||||
className={`text-[0.72rem] leading-4 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
|
||||
removed={status.removed}
|
||||
/>
|
||||
) : untrackedOnly ? (
|
||||
<span
|
||||
className={`shrink-0 text-[0.72rem] leading-4 text-amber-500/90 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
|
||||
>
|
||||
{s.changed(status.untracked)}
|
||||
</span>
|
||||
) : null}
|
||||
</StatusRow>
|
||||
|
||||
<Dialog onOpenChange={open => !branchPending && setBranchOpen(open)} open={branchOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{convertMode ? p.convertBranchTitle : p.newWorktreeTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{convertMode ? p.convertBranchDesc : p.newWorktreeDesc}
|
||||
{!convertMode && branchBase && (
|
||||
<span className="mt-1 block text-(--ui-text-secondary)">{s.branchOffFrom(branchBase)}</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{convertMode ? (
|
||||
<Command
|
||||
className="rounded-md border border-(--ui-stroke-tertiary)"
|
||||
// The branch name is the authoritative key; filter on it directly.
|
||||
filter={(value, search) => (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}
|
||||
>
|
||||
<CommandInput autoFocus disabled={branchPending} placeholder={p.convertBranchPlaceholder} />
|
||||
<CommandList className="max-h-64">
|
||||
<CommandEmpty>{branchesLoading ? p.branchesLoading : p.noBranches}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{branches.map(branch => (
|
||||
<CommandItem
|
||||
disabled={branchPending}
|
||||
key={branch.name}
|
||||
onSelect={() => void convertBranch(branch)}
|
||||
value={branch.name}
|
||||
>
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" />
|
||||
<span className="truncate">{branch.name}</span>
|
||||
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">
|
||||
{branchActionLabel(branch, p)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
) : (
|
||||
<SanitizedInput
|
||||
autoFocus
|
||||
disabled={branchPending}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void submitBranch()
|
||||
} else if (event.key === 'Escape') {
|
||||
setBranchOpen(false)
|
||||
}
|
||||
}}
|
||||
onValueChange={setBranchName}
|
||||
placeholder={p.branchPlaceholder}
|
||||
sanitize={gitRef}
|
||||
value={branchName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{convertMode ? (
|
||||
<DialogFooter className="sm:justify-start">
|
||||
<Button
|
||||
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
|
||||
disabled={branchPending}
|
||||
onClick={() => setConvertMode(false)}
|
||||
type="button"
|
||||
variant="link"
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
) : (
|
||||
<DialogFooter className="sm:justify-between">
|
||||
{onConvertBranch ? (
|
||||
<Button
|
||||
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
|
||||
disabled={branchPending}
|
||||
onClick={enterConvert}
|
||||
type="button"
|
||||
variant="link"
|
||||
>
|
||||
{p.convertBranchInstead}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button disabled={branchPending} onClick={() => setBranchOpen(false)} type="button" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={branchPending || !branchName.trim()}
|
||||
onClick={() => void submitBranch()}
|
||||
type="button"
|
||||
>
|
||||
{p.startWork}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -30,6 +30,19 @@ import { StatusItemRow } from './status-row'
|
||||
// emit no event when they die). Only armed while a running row is on screen.
|
||||
const BACKGROUND_POLL_MS = 5_000
|
||||
|
||||
// A localhost/loopback preview is only meaningful while its dev server is up, so
|
||||
// we tie it to a live background process rather than persisting dismissals or
|
||||
// letting dead URLs pile up. File previews (a real on-disk artifact) stand alone.
|
||||
const isLocalhostPreview = (target: string): boolean => /\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0)\b/i.test(target)
|
||||
|
||||
// Real codicons per group (no sparkles): a checklist for todos, a bot for
|
||||
// subagents, a background process glyph for background tasks.
|
||||
const GROUP_ICON: Record<StatusGroup['type'], string> = {
|
||||
todo: 'checklist',
|
||||
subagent: 'hubot',
|
||||
background: 'server-process'
|
||||
}
|
||||
|
||||
const groupLabel = (group: StatusGroup, s: Translations['statusStack']) => {
|
||||
if (group.type === 'todo') {
|
||||
return s.todos(group.items.filter(i => i.todoStatus === 'completed').length, group.items.length)
|
||||
@@ -74,6 +87,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
|
||||
const hasRunningBackground = groups.some(g => g.type === 'background' && g.items.some(i => i.state === 'running'))
|
||||
|
||||
// Drop localhost previews once no dev server is left running — that's what made
|
||||
// dead `localhost:5174` chips stick around. On-disk file previews are kept.
|
||||
const visiblePreviews = previews.filter(item => hasRunningBackground || !isLocalhostPreview(item.target))
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !hasRunningBackground) {
|
||||
return
|
||||
@@ -89,6 +106,18 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
const openSubagent = (item: ComposerStatusItem) =>
|
||||
item.sessionId ? void openSessionInNewWindow(item.sessionId, { watch: true }) : openAgents()
|
||||
|
||||
// Preview links live as child rows of the background group — a localhost dev
|
||||
// server and its preview are the same thing — so they no longer float as an
|
||||
// odd, differently-indented standalone block under the stack.
|
||||
const previewRows =
|
||||
visiblePreviews.length > 0 && sessionId
|
||||
? visiblePreviews.map(item => (
|
||||
<PreviewStatusRow item={item} key={item.id} onDismiss={id => dismissPreviewArtifact(sessionId, id)} />
|
||||
))
|
||||
: []
|
||||
|
||||
const hasBackgroundGroup = groups.some(g => g.type === 'background')
|
||||
|
||||
const sections: { key: string; node: ReactNode }[] = groups.map(group => ({
|
||||
key: group.type,
|
||||
node: (
|
||||
@@ -107,11 +136,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
) : undefined
|
||||
}
|
||||
defaultCollapsed={group.type !== 'todo'}
|
||||
icon={
|
||||
group.type === 'todo' ? (
|
||||
<Codicon className="text-muted-foreground/70" name="checklist" size="0.8rem" />
|
||||
) : undefined
|
||||
}
|
||||
icon={<Codicon className="text-muted-foreground/70" name={GROUP_ICON[group.type]} size="0.8rem" />}
|
||||
label={groupLabel(group, t.statusStack)}
|
||||
>
|
||||
{group.items.map(item => (
|
||||
@@ -120,25 +145,20 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
key={item.id}
|
||||
onDismiss={sessionId ? id => dismissBackgroundProcess(sessionId, id) : undefined}
|
||||
onOpen={() => openSubagent(item)}
|
||||
onStop={sessionId ? id => stopBackgroundProcess(sessionId, id) : undefined}
|
||||
onStop={sessionId ? id => void stopBackgroundProcess(sessionId, id) : undefined}
|
||||
/>
|
||||
))}
|
||||
{group.type === 'background' && previewRows}
|
||||
</StatusSection>
|
||||
)
|
||||
}))
|
||||
|
||||
if (previews.length > 0 && sessionId) {
|
||||
// No background group to host them (e.g. a standalone on-disk file preview):
|
||||
// keep the previews as their own row block so they don't disappear.
|
||||
if (previewRows.length > 0 && !hasBackgroundGroup) {
|
||||
sections.push({
|
||||
key: 'preview',
|
||||
// Not a collapsible group — preview links just sit there, one line each,
|
||||
// each individually closeable.
|
||||
node: (
|
||||
<div className="px-1 py-0.5">
|
||||
{previews.map(item => (
|
||||
<PreviewStatusRow item={item} key={item.id} onDismiss={id => dismissPreviewArtifact(sessionId, id)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
node: <div className="px-1 py-0.5">{previewRows}</div>
|
||||
})
|
||||
}
|
||||
|
||||
@@ -190,12 +210,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
|
||||
return (
|
||||
<div
|
||||
// Sits above the composer (bottom-full), nudged down by the shell's 0.5rem
|
||||
// top pad (pt-2 on composer-root) plus 1px so its bottom edge overlaps the
|
||||
// composer surface's top border. z BELOW the surface (z-4) so the surface's
|
||||
// top border paints over our transparent bottom border — one seam, no
|
||||
// double line.
|
||||
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-[calc(0.5rem+1px)] overflow-y-auto"
|
||||
// Sits in the overlay lane above the composer. The composer root has pt-2
|
||||
// before the actual surface; translate by that amount so the stack returns
|
||||
// to its original attachment point without intruding into the repo strip.
|
||||
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-2 overflow-y-auto"
|
||||
onPointerDownCapture={() => blurComposerInput()}
|
||||
ref={stackRef}
|
||||
>
|
||||
@@ -205,17 +223,19 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
Rounded top, square bottom; the bottom border is TRANSPARENT — the
|
||||
composer surface's visible top border (which sits at a higher z) is the
|
||||
single shared seam, so the two read as one fused capsule. */}
|
||||
<div className={cn(composerDockCard('top'), 'mx-2 rounded-b-none border-b border-b-transparent pt-0.5 pb-1')}>
|
||||
<div
|
||||
className={cn(
|
||||
'transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{sections.map(section => (
|
||||
<div key={section.key}>{section.node}</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
composerDockCard('top'),
|
||||
// Inset (mx-2) so the stack reads slightly narrower than the composer
|
||||
// surface below it — the original look.
|
||||
'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent pt-0.5',
|
||||
'transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{sections.map(section => (
|
||||
<div key={section.key}>{section.node}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ChevronRight, X } from '@/lib/icons'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { PREVIEW_PANE_ID } from '@/store/layout'
|
||||
@@ -76,50 +75,52 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss
|
||||
|
||||
return (
|
||||
<StatusRow
|
||||
leading={<ChevronRight aria-hidden className="size-3 text-muted-foreground/80" />}
|
||||
onActivate={() => void togglePreview()}
|
||||
leading={
|
||||
<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.)
|
||||
onActivate={event => {
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
void togglePreview()
|
||||
} else {
|
||||
void openInBrowser()
|
||||
}
|
||||
}}
|
||||
trailing={
|
||||
<span className="-my-1 flex items-center gap-0.5">
|
||||
<Tip label={t.preview.openInBrowser}>
|
||||
<Button
|
||||
aria-label={t.preview.openInBrowser}
|
||||
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
void openInBrowser()
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="link-external" size="0.75rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={t.statusStack.dismiss}>
|
||||
<Button
|
||||
aria-label={t.statusStack.dismiss}
|
||||
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
onDismiss(item.id)
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</Tip>
|
||||
</span>
|
||||
<Tip label={t.statusStack.dismiss}>
|
||||
<Button
|
||||
aria-label={t.statusStack.dismiss}
|
||||
className="-my-1 size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
onDismiss(item.id)
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
}
|
||||
trailingVisible
|
||||
>
|
||||
<span className="min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4 text-foreground/92" title={item.target}>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className={cn('shrink-0 text-[0.62rem] leading-4 text-muted-foreground/70', opening && 'animate-pulse')}>
|
||||
{opening ? t.preview.opening : isOpen ? t.preview.hide : t.preview.openPreview}
|
||||
</span>
|
||||
<Tip
|
||||
label={
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span>{item.target}</span>
|
||||
<span className="opacity-70">{t.preview.linkHint}</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span className="min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4 text-foreground/92">{item.label}</span>
|
||||
</Tip>
|
||||
</StatusRow>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -8,7 +8,6 @@ import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { ArrowUpRight, X } from '@/lib/icons'
|
||||
import type { TodoStatus } from '@/lib/todos'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ComposerStatusItem } from '@/store/composer-status'
|
||||
@@ -50,7 +49,7 @@ function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']):
|
||||
return (
|
||||
<GlyphSpinner
|
||||
ariaLabel={s.running}
|
||||
className="text-[0.9rem] leading-none text-muted-foreground/80"
|
||||
className="text-[0.85rem] leading-none text-muted-foreground/80"
|
||||
spinner="braille"
|
||||
/>
|
||||
)
|
||||
@@ -117,11 +116,11 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X size={12} />
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : canOpen ? (
|
||||
<ArrowUpRight aria-hidden className="size-3.5 text-muted-foreground/55" />
|
||||
<Codicon aria-hidden className="text-muted-foreground/55" name="link-external" size="0.85rem" />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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,7 +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) => 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
|
||||
@@ -317,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 { 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,31 @@ 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 { 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
|
||||
const SOURCE_CHUNK_LINES = 200
|
||||
const SOURCE_LINE_PX = 20
|
||||
const SOURCE_OVERSCAN_LINES = 400
|
||||
|
||||
type EmptyStateTone = 'neutral' | 'warning'
|
||||
|
||||
@@ -126,6 +142,8 @@ interface LocalPreviewState {
|
||||
binary?: boolean
|
||||
byteSize?: number
|
||||
dataUrl?: string
|
||||
/** Working-tree-vs-HEAD unified diff, when the file has uncommitted changes. */
|
||||
diff?: string
|
||||
error?: string
|
||||
language?: string
|
||||
loading: boolean
|
||||
@@ -133,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
|
||||
@@ -299,27 +330,92 @@ function MarkdownPreview({ text }: { text: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
|
||||
function PreviewModeSwitcher({
|
||||
active,
|
||||
modes,
|
||||
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,
|
||||
rendered: t.preview.renderedPreview,
|
||||
source: t.preview.source
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-transparent px-3 py-1 backdrop-blur">
|
||||
<button
|
||||
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
{asSource ? t.preview.renderedPreview : t.preview.source}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
// Gutter and Shiki output share `font-mono text-xs leading-relaxed py-3` so
|
||||
// each line aligns vertically. The selection overlay relies on the same
|
||||
// `text-xs * leading-relaxed = 1.21875rem` line-height to position itself.
|
||||
const SOURCE_LINE_HEIGHT_REM = 1.21875
|
||||
const SOURCE_PAD_Y_REM = 0.75
|
||||
// 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
|
||||
@@ -337,7 +433,18 @@ function startLineDrag(event: ReactDragEvent<HTMLElement>, filePath: string, { e
|
||||
|
||||
function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) {
|
||||
const { t } = useI18n()
|
||||
const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text])
|
||||
const chunks = useMemo(() => chunkTextLines(text, SOURCE_CHUNK_LINES), [text])
|
||||
const lastChunk = chunks.at(-1)
|
||||
const totalLines = lastChunk ? lastChunk.start + lastChunk.lines.length : 0
|
||||
|
||||
const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({
|
||||
overscanRows: SOURCE_OVERSCAN_LINES,
|
||||
rowPx: SOURCE_LINE_PX,
|
||||
rowsPerChunk: SOURCE_CHUNK_LINES,
|
||||
totalRows: totalLines
|
||||
})
|
||||
|
||||
const visibleChunks = chunks.slice(startChunk, endChunk + 1)
|
||||
const [selection, setSelection] = useState<LineSelection | null>(null)
|
||||
const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end
|
||||
|
||||
@@ -394,69 +501,97 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
|
||||
}, [filePath, selection])
|
||||
|
||||
return (
|
||||
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed">
|
||||
<div className="select-none py-3 text-right text-muted-foreground/55">
|
||||
{Array.from({ length: lineCount }, (_, index) => {
|
||||
const line = index + 1
|
||||
const selected = inSelection(line)
|
||||
<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 }} />}
|
||||
{visibleChunks.map(chunk => (
|
||||
<Fragment key={chunk.start}>
|
||||
<div className="select-none text-right text-muted-foreground/55">
|
||||
{chunk.lines.map((_lineText, offset) => {
|
||||
const line = chunk.start + offset + 1
|
||||
const selected = inSelection(line)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'cursor-pointer px-3 tabular-nums transition-colors',
|
||||
selected
|
||||
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
|
||||
: 'hover:text-foreground'
|
||||
)}
|
||||
draggable
|
||||
key={line}
|
||||
onClick={event => handleLineClick(event, line)}
|
||||
onDragStart={event => handleDragStart(event, line)}
|
||||
title={t.preview.sourceLineTitle}
|
||||
>
|
||||
{line}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'h-5 w-9 cursor-pointer pr-2 leading-5 tabular-nums transition-colors',
|
||||
selected
|
||||
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
|
||||
: 'hover:text-foreground'
|
||||
)}
|
||||
draggable
|
||||
key={line}
|
||||
onClick={event => handleLineClick(event, line)}
|
||||
onDragStart={event => handleDragStart(event, line)}
|
||||
title={t.preview.sourceLineTitle}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!"
|
||||
data-selectable-text="true"
|
||||
>
|
||||
{selection && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 bg-amber-200/35 dark:bg-amber-300/10"
|
||||
style={{
|
||||
top: `calc(${SOURCE_PAD_Y_REM}rem + ${selection.start - 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`,
|
||||
height: `calc(${selection.end - selection.start + 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
defaultColor="light-dark()"
|
||||
delay={80}
|
||||
language={language || 'text'}
|
||||
showLanguage={false}
|
||||
theme={SHIKI_THEME}
|
||||
>
|
||||
{text}
|
||||
</ShikiHighlighter>
|
||||
<div className="preview-source-code min-w-0 [&_pre]:m-0" data-selectable-text="true">
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
defaultColor="light-dark()"
|
||||
delay={80}
|
||||
language={language || 'text'}
|
||||
showLanguage={false}
|
||||
theme={SHIKI_THEME}
|
||||
>
|
||||
{chunk.text}
|
||||
</ShikiHighlighter>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
{afterRows > 0 && <div aria-hidden className="col-span-2" style={{ height: afterRows * SOURCE_LINE_PX }} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type PreviewViewMode = 'diff' | 'rendered' | 'source'
|
||||
|
||||
export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
|
||||
const { t } = useI18n()
|
||||
const [state, setState] = useState<LocalPreviewState>({ loading: true })
|
||||
const [forcePreview, setForcePreview] = useState(false)
|
||||
const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false)
|
||||
// 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
|
||||
// the same path as plain text files. `previewKind === 'binary'` arrives
|
||||
// when the file is forcibly previewed past the binary refusal screen.
|
||||
@@ -508,6 +643,22 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
text: shouldBlock ? undefined : result.text,
|
||||
truncated: result.truncated
|
||||
})
|
||||
|
||||
// Best-effort: fetch the file's working-tree-vs-HEAD diff so the
|
||||
// preview can offer a DIFF view when there are uncommitted changes.
|
||||
// Empty (clean file / not a repo / remote) just hides the option.
|
||||
if (!shouldBlock) {
|
||||
try {
|
||||
const root = await desktopGitRoot(filePath)
|
||||
const diff = root ? await desktopFileDiff(root, filePath) : ''
|
||||
|
||||
if (active && diff.trim()) {
|
||||
setState(prev => (prev.text === result.text ? { ...prev, diff } : prev))
|
||||
}
|
||||
} catch {
|
||||
// No diff available; the preview just shows source.
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (active) {
|
||||
@@ -524,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} />
|
||||
@@ -544,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"
|
||||
@@ -571,29 +899,79 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
|
||||
if (isText && state.text !== undefined) {
|
||||
const isMarkdown = (state.language || target.language) === 'markdown'
|
||||
const showRendered = isMarkdown && !renderMarkdownAsSource
|
||||
const hasDiff = Boolean(state.diff && state.diff.trim())
|
||||
// Order the toggle reads left→right; default lands on the most useful view.
|
||||
const modes: PreviewViewMode[] = []
|
||||
|
||||
if (isMarkdown) {
|
||||
modes.push('rendered')
|
||||
}
|
||||
|
||||
modes.push('source')
|
||||
|
||||
if (hasDiff) {
|
||||
modes.push('diff')
|
||||
}
|
||||
|
||||
const autoMode: PreviewViewMode = hasDiff ? 'diff' : isMarkdown ? 'rendered' : 'source'
|
||||
const mode = userMode && modes.includes(userMode) ? userMode : autoMode
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto 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>
|
||||
)}
|
||||
{isMarkdown && <PreviewToggle asSource={!showRendered} onToggle={() => setRenderMarkdownAsSource(s => !s)} />}
|
||||
{showRendered ? (
|
||||
<MarkdownPreview text={state.text} />
|
||||
) : (
|
||||
<SourceView filePath={filePath} language={state.language || 'text'} text={state.text} />
|
||||
)}
|
||||
<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} />
|
||||
) : mode === 'diff' ? (
|
||||
<FileDiffPanel
|
||||
className="mx-0 mb-0 h-full max-h-none"
|
||||
diff={state.diff ?? ''}
|
||||
fullText={state.text}
|
||||
path={filePath}
|
||||
showLineNumbers
|
||||
/>
|
||||
) : (
|
||||
<SourceView
|
||||
filePath={filePath}
|
||||
language={shikiLanguageForFilename(filePath) || state.language || 'text'}
|
||||
text={state.text}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
|
||||
|
||||
@@ -3,10 +3,19 @@ import { useEffect, useMemo } from 'react'
|
||||
|
||||
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { translateNow, useI18n } from '@/i18n'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$panesFlipped,
|
||||
$rightRailActiveTabId,
|
||||
RIGHT_RAIL_PREVIEW_TAB_ID,
|
||||
type RightRailTabId,
|
||||
@@ -16,10 +25,13 @@ import {
|
||||
$filePreviewTabs,
|
||||
$previewReloadRequest,
|
||||
$previewTarget,
|
||||
closeOtherRightRailTabs,
|
||||
closeRightRail,
|
||||
closeRightRailTab,
|
||||
closeRightRailTabsToRight,
|
||||
type PreviewTarget
|
||||
} from '@/store/preview'
|
||||
import { $dirtyPreviewUrls } from '@/store/preview-edit'
|
||||
|
||||
import { PreviewPane } from './preview-pane'
|
||||
|
||||
@@ -56,12 +68,16 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
const { t } = useI18n()
|
||||
const previewReloadRequest = useStore($previewReloadRequest)
|
||||
const activeTabId = useStore($rightRailActiveTabId)
|
||||
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]
|
||||
@@ -82,68 +98,109 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
const isPreview = activeTab.id === RIGHT_RAIL_PREVIEW_TAB_ID
|
||||
|
||||
return (
|
||||
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-tertiary) bg-(--ui-editor-surface-background) text-(--ui-text-tertiary)">
|
||||
<aside
|
||||
className={cn(
|
||||
'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
|
||||
className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map(tab => {
|
||||
{tabs.map((tab, index) => {
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)',
|
||||
active
|
||||
? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]'
|
||||
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
key={tab.id}
|
||||
// Middle-click closes the tab, matching browser/IDE muscle
|
||||
// memory. `onMouseDown` swallows the middle-button press so
|
||||
// Chromium doesn't switch into autoscroll mode.
|
||||
onAuxClick={event => {
|
||||
if (event.button !== 1) {
|
||||
return
|
||||
}
|
||||
<ContextMenu key={tab.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)',
|
||||
active
|
||||
? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]'
|
||||
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
// Middle-click closes the tab, matching browser/IDE muscle
|
||||
// memory. `onMouseDown` swallows the middle-button press so
|
||||
// Chromium doesn't switch into autoscroll mode.
|
||||
onAuxClick={event => {
|
||||
if (event.button !== 1) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
closeRightRailTab(tab.id)
|
||||
}}
|
||||
onMouseDown={event => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
|
||||
)}
|
||||
<Tip label={tab.label}>
|
||||
<button
|
||||
aria-selected={active}
|
||||
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
|
||||
onClick={() => selectRightRailTab(tab.id)}
|
||||
role="tab"
|
||||
type="button"
|
||||
event.preventDefault()
|
||||
closeRightRailTab(tab.id)
|
||||
}}
|
||||
onMouseDown={event => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="block min-w-0 truncate">{tab.label}</span>
|
||||
</button>
|
||||
</Tip>
|
||||
<span
|
||||
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"
|
||||
/>
|
||||
<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"
|
||||
onClick={() => closeRightRailTab(tab.id)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</button>
|
||||
</div>
|
||||
{active && (
|
||||
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
|
||||
)}
|
||||
<Tip label={tab.target.path || tab.target.url || tab.label}>
|
||||
<button
|
||||
aria-selected={active}
|
||||
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
|
||||
onClick={() => selectRightRailTab(tab.id)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
<span className="block min-w-0 truncate">{tab.label}</span>
|
||||
</button>
|
||||
</Tip>
|
||||
<span
|
||||
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"
|
||||
onClick={() => closeRightRailTab(tab.id)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</button>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={() => closeRightRailTab(tab.id)}>
|
||||
{t.common.close}
|
||||
<span className="ml-auto pl-4 text-(--ui-text-tertiary)">{formatCombo('mod+w')}</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!hasOthers} onSelect={() => closeOtherRightRailTabs(tab.id)}>
|
||||
{t.preview.closeOthers}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!hasTabsToRight} onSelect={() => closeRightRailTabsToRight(tab.id)}>
|
||||
{t.preview.closeToRight}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={closeRightRail}>{t.preview.closeAll}</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
155
apps/desktop/src/app/chat/sidebar/chrome.tsx
Normal file
155
apps/desktop/src/app/chat/sidebar/chrome.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import type * as React from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Shared, content-agnostic sidebar chrome — used by both the flat session
|
||||
// sections and the project/workspace tree, so it lives outside either to keep
|
||||
// imports one-directional (no index <-> projects cycle).
|
||||
|
||||
/** `loaded/total` when there's more on the server, else just the loaded count. */
|
||||
export const countLabel = (loaded: number, total: number): string =>
|
||||
total > loaded ? `${loaded}/${total}` : String(loaded)
|
||||
|
||||
/** The muted count chip next to a section/workspace label. */
|
||||
export function SidebarCount({ children }: { children: React.ReactNode }) {
|
||||
return <span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>
|
||||
}
|
||||
|
||||
// ── Row geometry (session row is canonical — everything composes these) ─────
|
||||
//
|
||||
// Height lives ONLY on SidebarRowShell (min-h-[1.625rem]). Inset children
|
||||
// stretch to fill the cell and center content internally — never items-center
|
||||
// on the shell grid, or short clusters (projects) float 1–2px off sessions.
|
||||
|
||||
const rowMinH = 'min-h-[1.625rem]'
|
||||
const rowPadX = 'pl-2 pr-1'
|
||||
const rowGap = 'gap-1.5'
|
||||
const rowLead = 'grid size-3.5 shrink-0 place-items-center'
|
||||
const rowInset = cn(rowPadX, rowGap, 'flex h-full min-w-0 items-center self-stretch py-0.5')
|
||||
const rowLabel = 'min-w-0 truncate text-[0.8125rem] leading-none text-(--ui-text-secondary)'
|
||||
|
||||
/** Codicon size in sidebar row leads — matches the file tree (`tree.tsx`). */
|
||||
export const SIDEBAR_LEAD_ICON_SIZE = '0.875rem' as const
|
||||
|
||||
/** Vertical stack of rows (gap-px, single column). */
|
||||
export function SidebarRowStack({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('grid grid-cols-[minmax(0,1fr)] gap-px', className)} {...props} />
|
||||
}
|
||||
|
||||
/** Nested rows (session previews, worktree bodies). */
|
||||
export function SidebarRowNest({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <SidebarRowStack className={cn('pb-1 pl-4', className)} {...props} />
|
||||
}
|
||||
|
||||
/** Outer grid — sole owner of row height. */
|
||||
export function SidebarRowShell({
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & { actions?: React.ReactNode }) {
|
||||
return (
|
||||
<div className={cn(rowMinH, 'grid grid-cols-[minmax(0,1fr)_auto] items-stretch rounded-md', className)} {...props}>
|
||||
{children}
|
||||
{actions ? <div className="flex shrink-0 items-center self-center">{actions}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Multi-control left cluster (project rows). */
|
||||
export function SidebarRowCluster({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn(rowInset, className)} {...props} />
|
||||
}
|
||||
|
||||
/** Session row main tap target. */
|
||||
export function SidebarRowBody({ className, ...props }: React.ComponentProps<'button'>) {
|
||||
return <button className={cn(rowInset, 'bg-transparent text-left', className)} type="button" {...props} />
|
||||
}
|
||||
|
||||
/** Tappable label — underline/truncate live on the inner span, not the button. */
|
||||
export function SidebarRowLink({
|
||||
className,
|
||||
labelClassName,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & { labelClassName?: string }) {
|
||||
return (
|
||||
<button className={cn('min-w-0 shrink bg-transparent p-0 text-left', className)} type="button" {...props}>
|
||||
<span className={cn(rowLabel, labelClassName)}>{children}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Fixed leading column (dot, icon, drag handle). */
|
||||
export function SidebarRowLead({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return <span className={cn(rowLead, className)} {...props} />
|
||||
}
|
||||
|
||||
/** Standard row label typography. */
|
||||
export function SidebarRowLabel({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return <span className={cn(rowLabel, className)} {...props} />
|
||||
}
|
||||
|
||||
/** Dot ↔ grabber swap for dnd-kit reorder rows. */
|
||||
export function SidebarRowGrab({
|
||||
ariaLabel,
|
||||
children,
|
||||
className,
|
||||
dragging = false,
|
||||
dragHandleProps,
|
||||
leadClassName
|
||||
}: {
|
||||
ariaLabel: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
dragging?: boolean
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLElement>
|
||||
leadClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<SidebarRowLead
|
||||
{...dragHandleProps}
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'group/handle relative cursor-grab touch-none overflow-hidden active:cursor-grabbing',
|
||||
leadClassName,
|
||||
className
|
||||
)}
|
||||
data-reorder-handle
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<span className="grid size-full place-items-center transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0">
|
||||
{children}
|
||||
</span>
|
||||
<Codicon
|
||||
className={cn(
|
||||
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/handle:opacity-80 group-focus-within/handle:opacity-80 hover:text-(--ui-text-secondary)',
|
||||
dragging && 'text-(--ui-text-secondary) opacity-100'
|
||||
)}
|
||||
name="grabber"
|
||||
size="0.75rem"
|
||||
/>
|
||||
</SidebarRowLead>
|
||||
)
|
||||
}
|
||||
|
||||
/** Icon/dot slot inside SidebarRowLead — caps visual size so rows align. */
|
||||
export function SidebarRowLeadGlyph({
|
||||
children,
|
||||
className,
|
||||
style
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn('grid size-full place-items-center text-(--ui-text-tertiary) [&_.codicon]:leading-none', className)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getCronJobRuns, type SessionInfo } from '@/hermes'
|
||||
@@ -328,7 +329,7 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s
|
||||
<div className="mb-1 ml-[1.375rem] flex flex-col gap-px">
|
||||
{runs === null ? (
|
||||
<div className="flex items-center gap-1.5 py-1 pl-1 text-[0.6875rem] text-(--ui-text-tertiary)">
|
||||
<Codicon name="loading" size="0.75rem" spinning />
|
||||
<GlyphSpinner ariaLabel={c.loading} className="text-[0.75rem]" />
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="py-1 pl-1 text-[0.6875rem] text-(--ui-text-tertiary)">{c.noRuns}</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
interface SidebarLoadMoreRowProps {
|
||||
@@ -7,24 +8,26 @@ interface SidebarLoadMoreRowProps {
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
// "Load N more" affordance shared by the recents, messaging, and cron sections.
|
||||
// The chevron sits in the same w-3.5 column the rows use for their dot, so it
|
||||
// lines up with the list above.
|
||||
// Compact "load more" affordance shared by recents, messaging, and cron. Kept
|
||||
// intentionally identical to workspace "show more" controls (ellipsis button)
|
||||
// so pagination reads as one interaction everywhere.
|
||||
export function SidebarLoadMoreRow({ step, onClick, loading = false }: SidebarLoadMoreRowProps) {
|
||||
const { t } = useI18n()
|
||||
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
|
||||
aria-label={label}
|
||||
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent disabled:hover:text-(--ui-text-tertiary)"
|
||||
disabled={loading}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span className="grid w-3.5 shrink-0 place-items-center">
|
||||
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
{loading ? (
|
||||
<GlyphSpinner ariaLabel={label} className="text-[0.75rem]" />
|
||||
) : (
|
||||
<Codicon name="ellipsis" size="0.75rem" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/** New ids first, then ids still present in the persisted order. */
|
||||
export function reconcileFreshFirst(currentIds: string[], orderIds: string[]): string[] {
|
||||
const current = new Set(currentIds)
|
||||
const retained = orderIds.filter(id => current.has(id))
|
||||
const retainedSet = new Set(retained)
|
||||
|
||||
return [...currentIds.filter(id => !retainedSet.has(id)), ...retained]
|
||||
}
|
||||
|
||||
export function resolveManualSessionOrderIds(currentIds: string[], orderIds: string[], manual: boolean): string[] {
|
||||
if (!manual || !currentIds.length || !orderIds.length) {
|
||||
return []
|
||||
@@ -10,8 +19,5 @@ export function resolveManualSessionOrderIds(currentIds: string[], orderIds: str
|
||||
return []
|
||||
}
|
||||
|
||||
const retainedSet = new Set(retained)
|
||||
const fresh = currentIds.filter(id => !retainedSet.has(id))
|
||||
|
||||
return [...fresh, ...retained]
|
||||
return reconcileFreshFirst(currentIds, orderIds)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ColorSwatches } from '@/components/ui/color-swatches'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
@@ -131,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.
|
||||
@@ -481,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>
|
||||
@@ -494,30 +503,14 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
|
||||
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
|
||||
side="top"
|
||||
>
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{PROFILE_SWATCHES.map(swatch => (
|
||||
<button
|
||||
aria-label={p.setColor(swatch)}
|
||||
className="size-5 rounded-full transition-transform hover:scale-110"
|
||||
key={swatch}
|
||||
onClick={() => pickColor(swatch)}
|
||||
style={{
|
||||
backgroundColor: swatch,
|
||||
boxShadow: swatch === color ? '0 0 0 2px var(--ui-bg-elevated), 0 0 0 3.5px currentColor' : undefined,
|
||||
color: swatch
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="mt-2 flex w-full items-center justify-center gap-1.5 rounded-md py-1 text-xs text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
onClick={() => pickColor(null)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="sync" size="0.75rem" />
|
||||
{p.autoColor}
|
||||
</button>
|
||||
<ColorSwatches
|
||||
clearIcon="sync"
|
||||
clearLabel={p.autoColor}
|
||||
onChange={pickColor}
|
||||
swatches={PROFILE_SWATCHES}
|
||||
swatchLabel={p.setColor}
|
||||
value={color}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
296
apps/desktop/src/app/chat/sidebar/project-dialog.tsx
Normal file
296
apps/desktop/src/app/chat/sidebar/project-dialog.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
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 { GenerateButton } from '@/components/ui/generate-button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { type ProjectIdeaTemplate, randomIdeaTemplates } from '@/lib/project-idea-templates'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import {
|
||||
$projectDialog,
|
||||
addProjectFolder,
|
||||
closeProjectDialog,
|
||||
createProject,
|
||||
generateProjectIdea,
|
||||
pickProjectFolder,
|
||||
renameProject
|
||||
} from '@/store/projects'
|
||||
|
||||
// Single dialog mounted once in the sidebar; it renders create / rename /
|
||||
// add-folder flows driven by the $projectDialog atom. Folders are chosen via
|
||||
// the native directory picker (reused from the default-project-dir setting).
|
||||
export function ProjectDialog() {
|
||||
const { t } = useI18n()
|
||||
const p = t.sidebar.projects
|
||||
const state = useStore($projectDialog)
|
||||
const open = state !== null
|
||||
const mode = state?.mode ?? 'create'
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [folders, setFolders] = useState<string[]>([])
|
||||
const [idea, setIdea] = useState('')
|
||||
const [templates, setTemplates] = useState<ProjectIdeaTemplate[]>([])
|
||||
const [generatingIdea, setGeneratingIdea] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const nameRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(state?.name ?? '')
|
||||
setFolders([])
|
||||
setIdea('')
|
||||
setTemplates(randomIdeaTemplates())
|
||||
setGeneratingIdea(false)
|
||||
setSubmitting(false)
|
||||
|
||||
if (mode !== 'add-folder') {
|
||||
window.setTimeout(() => nameRef.current?.select(), 0)
|
||||
}
|
||||
}
|
||||
}, [open, mode, state?.name])
|
||||
|
||||
const onOpenChange = (next: boolean) => {
|
||||
if (!next) {
|
||||
closeProjectDialog()
|
||||
}
|
||||
}
|
||||
|
||||
// One submit beat for every flow: guard re-entry, run the write, close on
|
||||
// success, surface a toast on failure. Callers pass only the write.
|
||||
const runSubmit = async (write: () => Promise<unknown>) => {
|
||||
if (submitting) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
await write()
|
||||
closeProjectDialog()
|
||||
} catch (err) {
|
||||
notifyError(err, p.createFailed)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const pickFolder = async () => {
|
||||
const dir = await pickProjectFolder()
|
||||
|
||||
if (!dir) {
|
||||
return
|
||||
}
|
||||
|
||||
const projectId = state?.projectId
|
||||
|
||||
if (mode === 'add-folder' && projectId) {
|
||||
await runSubmit(() => addProjectFolder(projectId, dir))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setFolders(prev => (prev.includes(dir) ? prev : [...prev, dir]))
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
const trimmed = name.trim()
|
||||
const projectId = state?.projectId
|
||||
|
||||
if (mode === 'rename' && projectId) {
|
||||
if (trimmed) {
|
||||
await runSubmit(() => renameProject(projectId, trimmed))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// A project owns sessions by folder (cwd-prefix), so creation requires at
|
||||
// least one — a folder-less project couldn't hold a session anyway.
|
||||
if (mode === 'create' && trimmed && folders.length) {
|
||||
await runSubmit(() => createProject({ folders, idea: idea.trim() || undefined, name: trimmed, use: true }))
|
||||
}
|
||||
}
|
||||
|
||||
const generateIdea = async () => {
|
||||
if (generatingIdea) {
|
||||
return
|
||||
}
|
||||
|
||||
setGeneratingIdea(true)
|
||||
|
||||
try {
|
||||
const text = await generateProjectIdea(name)
|
||||
|
||||
if (text) {
|
||||
setIdea(text)
|
||||
}
|
||||
} finally {
|
||||
setGeneratingIdea(false)
|
||||
}
|
||||
}
|
||||
|
||||
const title = mode === 'rename' ? p.renameTitle : mode === 'add-folder' ? p.addFolderTitle : p.createTitle
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{mode === 'create' && <DialogDescription>{p.createDesc}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
|
||||
{mode !== 'add-folder' && (
|
||||
<Input
|
||||
autoFocus
|
||||
disabled={submitting}
|
||||
onChange={event => setName(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
} else if (event.key === 'Escape') {
|
||||
onOpenChange(false)
|
||||
}
|
||||
}}
|
||||
placeholder={p.namePlaceholder}
|
||||
ref={nameRef}
|
||||
value={name}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'create' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[0.6875rem] font-medium text-(--ui-text-tertiary)">{p.foldersLabel}</span>
|
||||
{folders.length === 0 ? (
|
||||
<span className="text-[0.75rem] text-(--ui-text-quaternary)">{p.noFolders}</span>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{folders.map((folder, index) => (
|
||||
<li
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md bg-(--ui-control-hover-background) px-2 py-1 text-[0.75rem]'
|
||||
)}
|
||||
key={folder}
|
||||
>
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="folder" size="0.75rem" />
|
||||
<span className="min-w-0 flex-1 truncate" title={folder}>
|
||||
{folder}
|
||||
</span>
|
||||
{index === 0 && (
|
||||
<span className="shrink-0 text-[0.625rem] uppercase text-(--ui-text-quaternary)">
|
||||
{p.primaryBadge}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
aria-label={p.removeFolder}
|
||||
className="size-5 shrink-0 text-(--ui-text-quaternary) hover:text-foreground"
|
||||
onClick={() => setFolders(prev => prev.filter(f => f !== folder))}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Button
|
||||
className="self-start"
|
||||
disabled={submitting}
|
||||
onClick={() => void pickFolder()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="0.75rem" />
|
||||
{p.addFolder}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'create' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[0.6875rem] font-medium text-(--ui-text-tertiary)">{p.ideaLabel}</span>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
className="min-h-20 pr-8 text-[0.8125rem]"
|
||||
disabled={submitting}
|
||||
onChange={event => setIdea(event.target.value)}
|
||||
placeholder={p.ideaPlaceholder}
|
||||
value={idea}
|
||||
/>
|
||||
<GenerateButton
|
||||
className="absolute top-1 right-1"
|
||||
disabled={submitting}
|
||||
generating={generatingIdea}
|
||||
generatingLabel={p.ideaGenerating}
|
||||
label={p.ideaGenerate}
|
||||
onGenerate={() => void generateIdea()}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{templates.map(template => (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-full border border-(--ui-stroke-tertiary) px-2 py-0.5 text-[0.6875rem] text-(--ui-text-secondary) transition-colors hover:border-(--ui-stroke-secondary) hover:bg-(--ui-control-hover-background) hover:text-foreground disabled:opacity-50"
|
||||
disabled={submitting}
|
||||
key={template.label}
|
||||
onClick={() => setIdea(template.idea)}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden>{template.emoji}</span>
|
||||
{template.label}
|
||||
</button>
|
||||
))}
|
||||
<Button
|
||||
aria-label={p.ideaShuffle}
|
||||
className="size-5 text-(--ui-text-quaternary) hover:text-foreground"
|
||||
disabled={submitting}
|
||||
onClick={() => setTemplates(randomIdeaTemplates())}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.75rem" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'add-folder' && (
|
||||
<Button disabled={submitting} onClick={() => void pickFolder()} type="button">
|
||||
<Codicon name="folder-opened" size="0.875rem" />
|
||||
{p.addFolder}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{mode !== 'add-folder' && (
|
||||
<DialogFooter>
|
||||
<Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={submitting || !name.trim() || (mode === 'create' && folders.length === 0)}
|
||||
onClick={() => void submit()}
|
||||
type="button"
|
||||
>
|
||||
{mode === 'rename' ? t.common.save : p.create}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
275
apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx
Normal file
275
apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
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 type { HermesGitWorktree } from '@/global'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { $dismissedWorktreeIds, dismissWorktree } from '@/store/layout'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { removeWorktreePath } from '@/store/projects'
|
||||
|
||||
import { SidebarRowStack } from '../chrome'
|
||||
|
||||
import { useWorkspaceNodeOpen } from './model'
|
||||
import { SidebarWorkspaceGroup } from './workspace-group'
|
||||
import {
|
||||
mergeRepoWorktreeGroups,
|
||||
overlayRepoLanes,
|
||||
type SidebarProjectTree,
|
||||
type SidebarSessionGroup,
|
||||
type SidebarWorkspaceTree
|
||||
} from './workspace-groups'
|
||||
import { WorkspaceAddButton, WorkspaceHeader } from './workspace-header'
|
||||
|
||||
// The entered project's body. Main-checkout sessions render directly — no
|
||||
// redundant repo/branch header (the breadcrumb already names the project). Only
|
||||
// linked worktrees nest, shown by branch. Multi-folder projects keep per-repo
|
||||
// headers so the folders stay distinguishable.
|
||||
export function EnteredProjectContent({
|
||||
project,
|
||||
renderRows,
|
||||
onNewSession,
|
||||
repoWorktrees,
|
||||
liveSessions,
|
||||
removedSessionIds
|
||||
}: {
|
||||
project: SidebarProjectTree
|
||||
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
||||
onNewSession?: (path: null | string) => void
|
||||
repoWorktrees?: Record<string, HermesGitWorktree[]>
|
||||
liveSessions?: SessionInfo[]
|
||||
removedSessionIds?: ReadonlySet<string>
|
||||
}) {
|
||||
if (!project.repos.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const single = project.repos.length === 1
|
||||
|
||||
return (
|
||||
<>
|
||||
{project.repos.map(repo => (
|
||||
<RepoFlatSection
|
||||
discoveredWorktrees={repo.path ? repoWorktrees?.[repo.path] : undefined}
|
||||
key={repo.id}
|
||||
liveSessions={liveSessions}
|
||||
onNewSession={onNewSession}
|
||||
removedSessionIds={removedSessionIds}
|
||||
renderRows={renderRows}
|
||||
repo={repo}
|
||||
showHeader={!single}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RepoFlatSection({
|
||||
repo,
|
||||
showHeader,
|
||||
renderRows,
|
||||
onNewSession,
|
||||
discoveredWorktrees,
|
||||
liveSessions,
|
||||
removedSessionIds
|
||||
}: {
|
||||
repo: SidebarWorkspaceTree
|
||||
showHeader: boolean
|
||||
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
||||
onNewSession?: (path: null | string) => void
|
||||
discoveredWorktrees?: HermesGitWorktree[]
|
||||
liveSessions?: SessionInfo[]
|
||||
removedSessionIds?: ReadonlySet<string>
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
const [open, toggleOpen] = useWorkspaceNodeOpen(repo.id)
|
||||
const dismissedWorktrees = useStore($dismissedWorktreeIds)
|
||||
|
||||
// The repo's session lanes already come fully built from the backend; this
|
||||
// only injects empty VISUAL lanes from a live `git worktree list`.
|
||||
const mergedGroups = useMemo(() => mergeRepoWorktreeGroups(repo, discoveredWorktrees), [repo, discoveredWorktrees])
|
||||
|
||||
// Optimistic placement runs against the MERGED lane set (backend + visual
|
||||
// git-worktree lanes) so out-of-tree/sibling worktrees — which exist as visual
|
||||
// lanes before the snapshot carries their sessions — get the new row. The
|
||||
// overlay drops lanes it empties, so re-merge to restore still-real worktrees.
|
||||
const overlaidGroups = useMemo(() => {
|
||||
if (!(liveSessions?.length || removedSessionIds?.size)) {
|
||||
return mergedGroups
|
||||
}
|
||||
|
||||
const { groups } = overlayRepoLanes({ ...repo, groups: mergedGroups }, liveSessions ?? [], removedSessionIds)
|
||||
|
||||
return mergeRepoWorktreeGroups({ id: repo.id, path: repo.path, groups }, discoveredWorktrees)
|
||||
}, [repo, mergedGroups, discoveredWorktrees, liveSessions, removedSessionIds])
|
||||
|
||||
const discoveredWorktreePaths = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(discoveredWorktrees ?? [])
|
||||
.map(worktree => worktree.path?.trim())
|
||||
.filter((path): path is string => Boolean(path))
|
||||
),
|
||||
[discoveredWorktrees]
|
||||
)
|
||||
|
||||
// Main lanes are always visible; linked worktrees can be user-dismissed.
|
||||
// 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))
|
||||
)
|
||||
|
||||
const repoCount = ordered.reduce((sum, group) => sum + group.sessions.length, 0)
|
||||
|
||||
// Removal asks how: actually `git worktree remove` it, or just hide the lane
|
||||
// and leave the worktree on disk. A dirty worktree escalates to a force prompt
|
||||
// instead of erroring (those changes are usually throwaway).
|
||||
const [removeTarget, setRemoveTarget] = useState<null | SidebarSessionGroup>(null)
|
||||
const [forceTarget, setForceTarget] = useState<null | SidebarSessionGroup>(null)
|
||||
|
||||
const removeViaGit = async (group: SidebarSessionGroup, force = false) => {
|
||||
if (!repo.path || !group.path) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await removeWorktreePath(repo.path, group.path, { force })
|
||||
dismissWorktree(group.id)
|
||||
} catch (err) {
|
||||
// git refuses a non-force remove on a dirty/locked worktree — offer force
|
||||
// rather than dead-ending on an error toast.
|
||||
if (!force && /force|modified|untracked|dirty|locked|contains/i.test(String((err as Error)?.message ?? ''))) {
|
||||
setForceTarget(group)
|
||||
} else {
|
||||
notifyError(err, s.projects.removeWorktreeFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{ordered.map(group => (
|
||||
<SidebarWorkspaceGroup
|
||||
group={group}
|
||||
key={group.id}
|
||||
// The kanban bucket is read-only: it aggregates many task worktrees, so
|
||||
// "new session here" and "remove worktree" have no single target.
|
||||
onNewSession={group.isKanban ? undefined : onNewSession}
|
||||
onRemove={group.isMain || group.isKanban ? undefined : () => setRemoveTarget(group)}
|
||||
renderRows={renderRows}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
// Both removal prompts share the shape (hide-from-sidebar + cancel + a
|
||||
// destructive action); only the copy and the destructive handler differ.
|
||||
const worktreeDialog = (
|
||||
target: null | SidebarSessionGroup,
|
||||
setTarget: (next: null | SidebarSessionGroup) => void,
|
||||
description: string,
|
||||
destructiveLabel: string,
|
||||
onDestructive: (group: SidebarSessionGroup) => void
|
||||
) => (
|
||||
<Dialog onOpenChange={isOpen => !isOpen && setTarget(null)} open={Boolean(target)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`${s.projects.removeWorktree} "${target?.label ?? ''}"?`}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setTarget(null)} variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (target) {
|
||||
dismissWorktree(target.id)
|
||||
}
|
||||
|
||||
setTarget(null)
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
{s.projects.removeFromSidebar}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTarget(null)
|
||||
|
||||
if (target) {
|
||||
onDestructive(target)
|
||||
}
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
{destructiveLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
const removeDialog = (
|
||||
<>
|
||||
{worktreeDialog(
|
||||
removeTarget,
|
||||
setRemoveTarget,
|
||||
s.projects.removeWorktreeConfirm,
|
||||
s.projects.removeWorktree,
|
||||
group => void removeViaGit(group)
|
||||
)}
|
||||
{worktreeDialog(
|
||||
forceTarget,
|
||||
setForceTarget,
|
||||
s.projects.removeWorktreeDirty,
|
||||
s.projects.forceRemove,
|
||||
group => void removeViaGit(group, true)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
if (!showHeader) {
|
||||
return (
|
||||
<>
|
||||
{body}
|
||||
{removeDialog}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarRowStack>
|
||||
<WorkspaceHeader
|
||||
action={
|
||||
onNewSession && (
|
||||
<WorkspaceAddButton label={s.newSessionIn(repo.label)} onClick={() => onNewSession(repo.path)} />
|
||||
)
|
||||
}
|
||||
count={repoCount}
|
||||
emphasis
|
||||
icon={<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="repo" size="0.75rem" />}
|
||||
label={repo.label}
|
||||
onToggle={toggleOpen}
|
||||
open={open}
|
||||
title={repo.path ?? undefined}
|
||||
/>
|
||||
{open && <SidebarRowStack className="pl-2.5">{body}</SidebarRowStack>}
|
||||
{removeDialog}
|
||||
</SidebarRowStack>
|
||||
)
|
||||
}
|
||||
15
apps/desktop/src/app/chat/sidebar/projects/index.ts
Normal file
15
apps/desktop/src/app/chat/sidebar/projects/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Public surface of the project/worktree sidebar, consumed by the sidebar root.
|
||||
export { EnteredProjectContent } from './entered-content'
|
||||
export { PROJECT_PREVIEW_COUNT, projectTreeCwd, sortProjectsForOverview, useRepoWorktreeMap } from './model'
|
||||
export { ProjectBackRow, ProjectOverviewRow } from './overview-row'
|
||||
export { ProjectMenu } from './project-menu'
|
||||
export { SidebarWorkspaceGroup } from './workspace-group'
|
||||
export {
|
||||
overlayLiveLanes,
|
||||
overlayLivePreviews,
|
||||
sessionRecency,
|
||||
type SidebarProjectTree,
|
||||
type SidebarSessionGroup,
|
||||
type SidebarWorkspaceTree
|
||||
} from './workspace-groups'
|
||||
export { StartWorkButton } from './workspace-header'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user