mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 12:18:44 +08:00
Compare commits
143 Commits
salvage/40
...
migrate/pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a38066054 | ||
|
|
d2ac38cf6c | ||
|
|
a51c7397b4 | ||
|
|
c977436cbd | ||
|
|
3c13471cdd | ||
|
|
91da240eb8 | ||
|
|
f2a7adba54 | ||
|
|
d532c67292 | ||
|
|
38c700e230 | ||
|
|
156176a44e | ||
|
|
6e133c1d79 | ||
|
|
a3e9410247 | ||
|
|
f080fedf13 | ||
|
|
736ffb3bc1 | ||
|
|
552adbe082 | ||
|
|
036c767354 | ||
|
|
984d78d57d | ||
|
|
a317e54935 | ||
|
|
f6f363662e | ||
|
|
3fa15b33dd | ||
|
|
410cb743bf | ||
|
|
2912d94370 | ||
|
|
e2cc24e331 | ||
|
|
fa8fd513ea | ||
|
|
f10a330aee | ||
|
|
490c486ff6 | ||
|
|
9d72680ca3 | ||
|
|
1a4010edf5 | ||
|
|
621bf3a873 | ||
|
|
1fb99b1f22 | ||
|
|
02aad08acf | ||
|
|
9e63109522 | ||
|
|
136dae779e | ||
|
|
0507e4630d | ||
|
|
349a3f601c | ||
|
|
ed81cfe3de | ||
|
|
5a3092b601 | ||
|
|
4b9862eb7f | ||
|
|
b55ac45264 | ||
|
|
330ca4585b | ||
|
|
591e6fb8f4 | ||
|
|
ffe665277c | ||
|
|
a216ff839b | ||
|
|
f5c3fc319c | ||
|
|
3c8f1dee8d | ||
|
|
3763355f08 | ||
|
|
e18f14d928 | ||
|
|
0524c9b34e | ||
|
|
2d099fed1e | ||
|
|
3289d4adf2 | ||
|
|
7223f22d65 | ||
|
|
ce4e74b350 | ||
|
|
03392b67d6 | ||
|
|
fe0b3f2338 | ||
|
|
44c0c2d4ac | ||
|
|
eb70ab894b | ||
|
|
846821d8c0 | ||
|
|
210f4e706a | ||
|
|
5dee40fcc0 | ||
|
|
8720023e96 | ||
|
|
fe2942a5aa | ||
|
|
bec07964be | ||
|
|
b08662b782 | ||
|
|
fc086da8bd | ||
|
|
40cea4d58d | ||
|
|
bb53edc773 | ||
|
|
d17c953a57 | ||
|
|
fda66c488b | ||
|
|
fd4c8b404b | ||
|
|
3eeca4613d | ||
|
|
5b55f4fe8e | ||
|
|
b13ab0b9a8 | ||
|
|
c3d750c1ae | ||
|
|
d47f919ef1 | ||
|
|
fe8920db18 | ||
|
|
887295ba54 | ||
|
|
89929553b4 | ||
|
|
f9ea4927f2 | ||
|
|
0e0d704f2d | ||
|
|
89040e0db3 | ||
|
|
6701c611ba | ||
|
|
b2b4d97bbb | ||
|
|
365437e4aa | ||
|
|
97524344ad | ||
|
|
8f7567c325 | ||
|
|
5a36f76a00 | ||
|
|
c0424b06af | ||
|
|
56f833efa4 | ||
|
|
f4a73abbd0 | ||
|
|
5b43bf7d02 | ||
|
|
f2e8234307 | ||
|
|
7db7a9462d | ||
|
|
675fb10240 | ||
|
|
4bf52022e5 | ||
|
|
0416f852f2 | ||
|
|
1c0437dfc5 | ||
|
|
d165933c56 | ||
|
|
1238d08e0c | ||
|
|
66adeef11a | ||
|
|
f993d76874 | ||
|
|
f491260365 | ||
|
|
f033b7dbfb | ||
|
|
b2bd31c724 | ||
|
|
de0469e02b | ||
|
|
c79e3fd0ba | ||
|
|
7c4aa3e4da | ||
|
|
ccaa5165a0 | ||
|
|
471a5fc5c9 | ||
|
|
ef7e5168b5 | ||
|
|
c37c6eaf29 | ||
|
|
ad0f6db151 | ||
|
|
ebed881d46 | ||
|
|
d4a7bfd3aa | ||
|
|
003110c107 | ||
|
|
146e77684b | ||
|
|
abbf050241 | ||
|
|
2820d87ea5 | ||
|
|
3e2d758816 | ||
|
|
c4c5548eb4 | ||
|
|
628f9040df | ||
|
|
7cf7300a07 | ||
|
|
8b23b2bc01 | ||
|
|
e3ae035921 | ||
|
|
e9b8dd236c | ||
|
|
06ecc5535c | ||
|
|
74c8f51e95 | ||
|
|
182092c5fd | ||
|
|
021ea2a21b | ||
|
|
258984fcb9 | ||
|
|
5e2b83a8ad | ||
|
|
d1771114ed | ||
|
|
e8c837c921 | ||
|
|
5abe45674d | ||
|
|
3606307339 | ||
|
|
59c273ba3a | ||
|
|
2666638192 | ||
|
|
fd234bad62 | ||
|
|
54e7b74f7f | ||
|
|
3a46262c7c | ||
|
|
9d31577590 | ||
|
|
6bbc5eefa0 | ||
|
|
40386f33ec | ||
|
|
2e0c9083db |
@@ -68,6 +68,24 @@ def _ra():
|
||||
return run_agent
|
||||
|
||||
|
||||
def _build_codex_gpt55_autoraise_notice(autoraise: Dict[str, float]) -> str:
|
||||
"""Build the one-time notice shown when Codex gpt-5.5 raises compaction.
|
||||
|
||||
``autoraise`` is ``{"from": <old_ratio>, "to": <new_ratio>}``. The same
|
||||
text is printed inline for CLI users and replayed via ``status_callback``
|
||||
for gateway users, so it must be self-contained and include the exact
|
||||
opt-back-out command.
|
||||
"""
|
||||
from_pct = int(round(autoraise["from"] * 100))
|
||||
to_pct = int(round(autoraise["to"] * 100))
|
||||
return (
|
||||
f"ℹ Codex gpt-5.5 caps context at 272K, so auto-compaction was raised "
|
||||
f"to {to_pct}% (from {from_pct}%) to use more of the window before "
|
||||
f"summarizing.\n"
|
||||
f" Opt back out: hermes config set compression.codex_gpt55_autoraise false"
|
||||
)
|
||||
|
||||
|
||||
def _normalized_custom_base_url(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
@@ -867,6 +885,14 @@ def init_agent(
|
||||
headers["x-anthropic-beta"] = _FINE_GRAINED
|
||||
client_kwargs["default_headers"] = headers
|
||||
|
||||
# User-configured request headers (model.default_headers in
|
||||
# config.yaml) override provider/SDK defaults. Lets custom
|
||||
# OpenAI-compatible endpoints behind a gateway/WAF that rejects the
|
||||
# OpenAI SDK's identifying headers swap in a plain User-Agent. (#40033)
|
||||
# client_kwargs is the same dict object as agent._client_kwargs, so
|
||||
# this mutation is reflected in the client built just below.
|
||||
agent._apply_user_default_headers()
|
||||
|
||||
agent.api_key = client_kwargs.get("api_key", "")
|
||||
agent.base_url = client_kwargs.get("base_url", agent.base_url)
|
||||
try:
|
||||
@@ -1240,11 +1266,41 @@ def init_agent(
|
||||
if not isinstance(_compression_cfg, dict):
|
||||
_compression_cfg = {}
|
||||
compression_threshold = float(_compression_cfg.get("threshold", 0.50))
|
||||
# Per-model/route compaction-threshold override. Codex gpt-5.5 raises to
|
||||
# 85% (the Codex backend caps the window at 272K, so the default 50% would
|
||||
# compact at ~136K — half the usable context). Gated by an opt-out config
|
||||
# flag so the user can fall back to the global threshold; when the override
|
||||
# fires we stash a one-time notification (replayed on the first turn) that
|
||||
# tells the user what changed and how to revert.
|
||||
_codex_gpt55_autoraise = str(
|
||||
_compression_cfg.get("codex_gpt55_autoraise", True)
|
||||
).lower() in {"true", "1", "yes"}
|
||||
agent._compression_threshold_autoraised = None
|
||||
try:
|
||||
from agent.auxiliary_client import _compression_threshold_for_model as _cthresh_fn
|
||||
_model_cthresh = _cthresh_fn(agent.model)
|
||||
from agent.auxiliary_client import (
|
||||
_compression_threshold_for_model as _cthresh_fn,
|
||||
_is_codex_gpt55 as _is_codex_gpt55_fn,
|
||||
)
|
||||
_model_cthresh = _cthresh_fn(
|
||||
agent.model,
|
||||
agent.provider,
|
||||
allow_codex_gpt55_autoraise=_codex_gpt55_autoraise,
|
||||
)
|
||||
if _model_cthresh is not None:
|
||||
_prev_threshold = compression_threshold
|
||||
compression_threshold = _model_cthresh
|
||||
# Notify only for the Codex gpt-5.5 autoraise (the Arcee Trinity
|
||||
# override is a long-standing silent default). Skip the notice when
|
||||
# the user's global threshold already meets/exceeds the raised
|
||||
# value, since nothing actually changed for them.
|
||||
if (
|
||||
_is_codex_gpt55_fn(agent.model, agent.provider)
|
||||
and _model_cthresh > _prev_threshold + 1e-9
|
||||
):
|
||||
agent._compression_threshold_autoraised = {
|
||||
"from": _prev_threshold,
|
||||
"to": _model_cthresh,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"}
|
||||
@@ -1621,11 +1677,24 @@ def init_agent(
|
||||
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(compression_threshold*100)}% = {agent.context_compressor.threshold_tokens:,})")
|
||||
else:
|
||||
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)")
|
||||
# One-time notice when the Codex gpt-5.5 autoraise kicked in, with the
|
||||
# exact opt-back-out command. Printed inline at startup for CLI users;
|
||||
# gateway users get the same text replayed via _compression_warning on
|
||||
# turn 1 (set below, after the warning slot is initialized).
|
||||
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
|
||||
if _autoraise and compression_enabled:
|
||||
print(_build_codex_gpt55_autoraise_notice(_autoraise))
|
||||
|
||||
# Check immediately so CLI users see the warning at startup.
|
||||
# Gateway status_callback is not yet wired, so any warning is stored
|
||||
# in _compression_warning and replayed in the first run_conversation().
|
||||
agent._compression_warning = None
|
||||
# Gateway parity for the Codex gpt-5.5 autoraise notice: the startup print
|
||||
# above only reaches the CLI, so stash the same text here to be replayed
|
||||
# through status_callback on the first turn (Telegram/Discord/Slack/etc.).
|
||||
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
|
||||
if _autoraise and compression_enabled:
|
||||
agent._compression_warning = _build_codex_gpt55_autoraise_notice(_autoraise)
|
||||
# Lazy feasibility check: deferred to the first turn that approaches the
|
||||
# compression threshold. Running it eagerly here costs ~400ms cold (network
|
||||
# probe of the auxiliary provider chain + /models lookup) on every agent
|
||||
|
||||
@@ -1620,13 +1620,37 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
|
||||
def invoke_tool(agent, function_name: str, function_args: dict, effective_task_id: str,
|
||||
tool_call_id: Optional[str] = None, messages: list = None,
|
||||
pre_tool_block_checked: bool = False) -> str:
|
||||
pre_tool_block_checked: bool = False,
|
||||
skip_tool_request_middleware: bool = False,
|
||||
tool_request_middleware_trace: Optional[List[Dict[str, Any]]] = None) -> str:
|
||||
"""Invoke a single tool and return the result string. No display logic.
|
||||
|
||||
Handles both agent-level tools (todo, memory, etc.) and registry-dispatched
|
||||
tools. Used by the concurrent execution path; the sequential path retains
|
||||
its own inline invocation for backward-compatible display handling.
|
||||
"""
|
||||
if not isinstance(function_args, dict):
|
||||
function_args = {}
|
||||
|
||||
_tool_middleware_trace = list(tool_request_middleware_trace or [])
|
||||
try:
|
||||
from hermes_cli.middleware import apply_tool_request_middleware
|
||||
|
||||
if not skip_tool_request_middleware:
|
||||
_tool_request_mw = apply_tool_request_middleware(
|
||||
function_name,
|
||||
function_args,
|
||||
task_id=effective_task_id or "",
|
||||
session_id=getattr(agent, "session_id", "") or "",
|
||||
tool_call_id=tool_call_id or "",
|
||||
turn_id=getattr(agent, "_current_turn_id", "") or "",
|
||||
api_request_id=getattr(agent, "_current_api_request_id", "") or "",
|
||||
)
|
||||
function_args = _tool_request_mw.payload
|
||||
_tool_middleware_trace = _tool_request_mw.trace
|
||||
except Exception as _mw_err:
|
||||
logger.debug("tool_request middleware error: %s", _mw_err)
|
||||
|
||||
# Check plugin hooks for a block directive before executing anything.
|
||||
block_message: Optional[str] = None
|
||||
if not pre_tool_block_checked:
|
||||
@@ -1640,6 +1664,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
||||
tool_call_id=tool_call_id or "",
|
||||
turn_id=getattr(agent, "_current_turn_id", "") or "",
|
||||
api_request_id=getattr(agent, "_current_api_request_id", "") or "",
|
||||
middleware_trace=list(_tool_middleware_trace),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1659,6 +1684,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
||||
status="blocked",
|
||||
error_type="plugin_block",
|
||||
error_message=block_message,
|
||||
middleware_trace=list(_tool_middleware_trace),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1666,12 +1692,13 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
||||
|
||||
tool_start_time = time.monotonic()
|
||||
|
||||
def _finish_agent_tool(result: Any) -> Any:
|
||||
def _finish_agent_tool(result: Any, observed_args: Optional[dict] = None) -> Any:
|
||||
hook_args = observed_args if isinstance(observed_args, dict) else function_args
|
||||
try:
|
||||
from model_tools import _emit_post_tool_call_hook
|
||||
_emit_post_tool_call_hook(
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
function_args=hook_args,
|
||||
result=result,
|
||||
task_id=effective_task_id or "",
|
||||
session_id=getattr(agent, "session_id", "") or "",
|
||||
@@ -1679,89 +1706,116 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
||||
turn_id=getattr(agent, "_current_turn_id", "") or "",
|
||||
api_request_id=getattr(agent, "_current_api_request_id", "") or "",
|
||||
duration_ms=int((time.monotonic() - tool_start_time) * 1000),
|
||||
middleware_trace=list(_tool_middleware_trace),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
if function_name == "todo":
|
||||
from tools.todo_tool import todo_tool as _todo_tool
|
||||
return _finish_agent_tool(
|
||||
_todo_tool(
|
||||
todos=function_args.get("todos"),
|
||||
merge=function_args.get("merge", False),
|
||||
store=agent._todo_store,
|
||||
def _execute(next_args: dict) -> Any:
|
||||
from tools.todo_tool import todo_tool as _todo_tool
|
||||
return _finish_agent_tool(
|
||||
_todo_tool(
|
||||
todos=next_args.get("todos"),
|
||||
merge=next_args.get("merge", False),
|
||||
store=agent._todo_store,
|
||||
),
|
||||
next_args,
|
||||
)
|
||||
)
|
||||
elif function_name == "session_search":
|
||||
session_db = agent._get_session_db_for_recall()
|
||||
if not session_db:
|
||||
from hermes_state import format_session_db_unavailable
|
||||
return _finish_agent_tool(json.dumps({"success": False, "error": format_session_db_unavailable()}))
|
||||
from tools.session_search_tool import session_search as _session_search
|
||||
return _finish_agent_tool(
|
||||
_session_search(
|
||||
query=function_args.get("query", ""),
|
||||
role_filter=function_args.get("role_filter"),
|
||||
limit=function_args.get("limit", 3),
|
||||
session_id=function_args.get("session_id"),
|
||||
around_message_id=function_args.get("around_message_id"),
|
||||
window=function_args.get("window", 5),
|
||||
sort=function_args.get("sort"),
|
||||
db=session_db,
|
||||
current_session_id=agent.session_id,
|
||||
def _execute(next_args: dict) -> Any:
|
||||
session_db = agent._get_session_db_for_recall()
|
||||
if not session_db:
|
||||
from hermes_state import format_session_db_unavailable
|
||||
return _finish_agent_tool(json.dumps({"success": False, "error": format_session_db_unavailable()}), next_args)
|
||||
from tools.session_search_tool import session_search as _session_search
|
||||
return _finish_agent_tool(
|
||||
_session_search(
|
||||
query=next_args.get("query", ""),
|
||||
role_filter=next_args.get("role_filter"),
|
||||
limit=next_args.get("limit", 3),
|
||||
session_id=next_args.get("session_id"),
|
||||
around_message_id=next_args.get("around_message_id"),
|
||||
window=next_args.get("window", 5),
|
||||
sort=next_args.get("sort"),
|
||||
db=session_db,
|
||||
current_session_id=agent.session_id,
|
||||
),
|
||||
next_args,
|
||||
)
|
||||
)
|
||||
elif function_name == "memory":
|
||||
target = function_args.get("target", "memory")
|
||||
from tools.memory_tool import memory_tool as _memory_tool
|
||||
result = _memory_tool(
|
||||
action=function_args.get("action"),
|
||||
target=target,
|
||||
content=function_args.get("content"),
|
||||
old_text=function_args.get("old_text"),
|
||||
store=agent._memory_store,
|
||||
)
|
||||
# Bridge: notify external memory provider of built-in memory writes
|
||||
if agent._memory_manager and function_args.get("action") in {"add", "replace"}:
|
||||
try:
|
||||
agent._memory_manager.on_memory_write(
|
||||
function_args.get("action", ""),
|
||||
target,
|
||||
function_args.get("content", ""),
|
||||
metadata=agent._build_memory_write_metadata(
|
||||
task_id=effective_task_id,
|
||||
tool_call_id=tool_call_id,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return _finish_agent_tool(result)
|
||||
elif agent._memory_manager and agent._memory_manager.has_tool(function_name):
|
||||
return _finish_agent_tool(agent._memory_manager.handle_tool_call(function_name, function_args))
|
||||
elif function_name == "clarify":
|
||||
from tools.clarify_tool import clarify_tool as _clarify_tool
|
||||
return _finish_agent_tool(
|
||||
_clarify_tool(
|
||||
question=function_args.get("question", ""),
|
||||
choices=function_args.get("choices"),
|
||||
callback=agent.clarify_callback,
|
||||
def _execute(next_args: dict) -> Any:
|
||||
target = next_args.get("target", "memory")
|
||||
from tools.memory_tool import memory_tool as _memory_tool
|
||||
result = _memory_tool(
|
||||
action=next_args.get("action"),
|
||||
target=target,
|
||||
content=next_args.get("content"),
|
||||
old_text=next_args.get("old_text"),
|
||||
store=agent._memory_store,
|
||||
)
|
||||
# Bridge: notify external memory provider of built-in memory writes
|
||||
if agent._memory_manager and next_args.get("action") in {"add", "replace"}:
|
||||
try:
|
||||
agent._memory_manager.on_memory_write(
|
||||
next_args.get("action", ""),
|
||||
target,
|
||||
next_args.get("content", ""),
|
||||
metadata=agent._build_memory_write_metadata(
|
||||
task_id=effective_task_id,
|
||||
tool_call_id=tool_call_id,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return _finish_agent_tool(result, next_args)
|
||||
elif agent._memory_manager and agent._memory_manager.has_tool(function_name):
|
||||
def _execute(next_args: dict) -> Any:
|
||||
return _finish_agent_tool(agent._memory_manager.handle_tool_call(function_name, next_args), next_args)
|
||||
elif function_name == "clarify":
|
||||
def _execute(next_args: dict) -> Any:
|
||||
from tools.clarify_tool import clarify_tool as _clarify_tool
|
||||
return _finish_agent_tool(
|
||||
_clarify_tool(
|
||||
question=next_args.get("question", ""),
|
||||
choices=next_args.get("choices"),
|
||||
callback=agent.clarify_callback,
|
||||
),
|
||||
next_args,
|
||||
)
|
||||
)
|
||||
elif function_name == "delegate_task":
|
||||
return _finish_agent_tool(agent._dispatch_delegate_task(function_args))
|
||||
def _execute(next_args: dict) -> Any:
|
||||
return _finish_agent_tool(agent._dispatch_delegate_task(next_args), next_args)
|
||||
else:
|
||||
return _ra().handle_function_call(
|
||||
function_name, function_args, effective_task_id,
|
||||
tool_call_id=tool_call_id,
|
||||
session_id=agent.session_id or "",
|
||||
turn_id=getattr(agent, "_current_turn_id", "") or "",
|
||||
api_request_id=getattr(agent, "_current_api_request_id", "") or "",
|
||||
enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None,
|
||||
skip_pre_tool_call_hook=True,
|
||||
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||||
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||||
)
|
||||
def _execute(next_args: dict) -> Any:
|
||||
return _ra().handle_function_call(
|
||||
function_name, next_args, effective_task_id,
|
||||
tool_call_id=tool_call_id,
|
||||
session_id=agent.session_id or "",
|
||||
turn_id=getattr(agent, "_current_turn_id", "") or "",
|
||||
api_request_id=getattr(agent, "_current_api_request_id", "") or "",
|
||||
enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None,
|
||||
skip_pre_tool_call_hook=True,
|
||||
skip_tool_request_middleware=True,
|
||||
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||||
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||||
tool_request_middleware_trace=list(_tool_middleware_trace),
|
||||
)
|
||||
|
||||
from hermes_cli.middleware import run_tool_execution_middleware
|
||||
|
||||
return run_tool_execution_middleware(
|
||||
function_name,
|
||||
function_args,
|
||||
lambda next_args: _execute(next_args if isinstance(next_args, dict) else function_args),
|
||||
original_args=function_args,
|
||||
task_id=effective_task_id or "",
|
||||
session_id=getattr(agent, "session_id", "") or "",
|
||||
tool_call_id=tool_call_id or "",
|
||||
turn_id=getattr(agent, "_current_turn_id", "") or "",
|
||||
api_request_id=getattr(agent, "_current_api_request_id", "") or "",
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -202,6 +202,35 @@ def _is_arcee_trinity_thinking(model: Optional[str]) -> bool:
|
||||
return bare == "trinity-large-thinking"
|
||||
|
||||
|
||||
# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.5.
|
||||
# The raw OpenAI API and OpenRouter expose 1.05M for the same slug, but the
|
||||
# Codex backend hard-caps at 272K (verified live: a ~330K-token request to
|
||||
# chatgpt.com/backend-api/codex/responses is rejected with
|
||||
# ``context_length_exceeded`` while ~250K succeeds). With a 272K ceiling the
|
||||
# default 50% compaction trigger fires at ~136K — wasteful, since the model
|
||||
# can hold far more raw context before summarization actually buys anything.
|
||||
# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.5
|
||||
# sessions use the window they actually have.
|
||||
_CODEX_GPT55_COMPACTION_THRESHOLD = 0.85
|
||||
|
||||
|
||||
def _is_codex_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool:
|
||||
"""True for gpt-5.5 accessed through the ChatGPT Codex OAuth backend.
|
||||
|
||||
Matches only the Codex OAuth route (provider ``openai-codex``), not the
|
||||
direct OpenAI API, OpenRouter, or GitHub Copilot paths — those expose a
|
||||
larger context window for the same slug and must keep the user's default
|
||||
compaction threshold. ``gpt-5.5-pro`` and dated snapshots
|
||||
(``gpt-5.5-2026-04-23``) are matched via prefix so the override tracks the
|
||||
family without re-listing every variant.
|
||||
"""
|
||||
prov = (provider or "").strip().lower()
|
||||
if prov != "openai-codex":
|
||||
return False
|
||||
bare = (model or "").strip().lower().rsplit("/", 1)[-1]
|
||||
return bare == "gpt-5.5" or bare.startswith("gpt-5.5-") or bare.startswith("gpt-5.5.")
|
||||
|
||||
|
||||
def _fixed_temperature_for_model(
|
||||
model: Optional[str],
|
||||
base_url: Optional[str] = None,
|
||||
@@ -224,18 +253,32 @@ def _fixed_temperature_for_model(
|
||||
return None
|
||||
|
||||
|
||||
def _compression_threshold_for_model(model: Optional[str]) -> Optional[float]:
|
||||
def _compression_threshold_for_model(
|
||||
model: Optional[str],
|
||||
provider: Optional[str] = None,
|
||||
*,
|
||||
allow_codex_gpt55_autoraise: bool = True,
|
||||
) -> Optional[float]:
|
||||
"""Return a context-compression threshold override for specific models.
|
||||
|
||||
The threshold is the fraction of the model's context window that must be
|
||||
consumed before Hermes triggers summarization. Higher values delay
|
||||
compression and preserve more raw context.
|
||||
|
||||
Per-model/route overrides:
|
||||
- Arcee Trinity Large Thinking → 0.75 (preserve reasoning context).
|
||||
- gpt-5.5 on the Codex OAuth route → 0.85, because Codex caps the window
|
||||
at 272K and the default 50% trigger would compact at ~136K. Gated by
|
||||
``allow_codex_gpt55_autoraise`` so the user can opt back down to the
|
||||
global default (the caller passes the config flag through here).
|
||||
|
||||
Returns a float in (0, 1] to override the global ``compression.threshold``
|
||||
config value, or ``None`` to leave the user's config value unchanged.
|
||||
"""
|
||||
if _is_arcee_trinity_thinking(model):
|
||||
return 0.75
|
||||
if allow_codex_gpt55_autoraise and _is_codex_gpt55(model, provider):
|
||||
return _CODEX_GPT55_COMPACTION_THRESHOLD
|
||||
return None
|
||||
|
||||
# Default auxiliary models for direct API-key providers (cheap/fast for side tasks)
|
||||
@@ -314,6 +357,35 @@ _OR_HEADERS_BASE = {
|
||||
_TRUTHY_ENV_VALUES = frozenset({"1", "true", "yes", "on"})
|
||||
|
||||
|
||||
def _apply_user_default_headers(headers: dict | None) -> dict | None:
|
||||
"""Merge user-configured ``model.default_headers`` onto resolved headers.
|
||||
|
||||
User values take precedence over provider/SDK defaults, mirroring the main
|
||||
agent client (``AIAgent._apply_user_default_headers``). This lets a
|
||||
``custom`` OpenAI-compatible endpoint behind a gateway/WAF that rejects the
|
||||
OpenAI SDK's identifying headers (``User-Agent: OpenAI/Python ...``,
|
||||
``X-Stainless-*``) override them for auxiliary calls too — otherwise the
|
||||
main turn would succeed but title/compression/vision calls to the same
|
||||
endpoint would still fail. (#40033)
|
||||
|
||||
Returns the merged dict, or the original ``headers`` (possibly ``None``)
|
||||
when nothing is configured. No allocation when there are no overrides.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import cfg_get, load_config
|
||||
user_headers = cfg_get(load_config(), "model", "default_headers")
|
||||
except Exception:
|
||||
return headers
|
||||
if not isinstance(user_headers, dict) or not user_headers:
|
||||
return headers
|
||||
merged = dict(headers or {})
|
||||
for key, value in user_headers.items():
|
||||
if value is None:
|
||||
continue
|
||||
merged[str(key)] = str(value)
|
||||
return merged or headers
|
||||
|
||||
|
||||
def build_or_headers(or_config: dict | None = None) -> dict:
|
||||
"""Build OpenRouter headers, optionally including response-cache headers.
|
||||
|
||||
@@ -1452,6 +1524,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
extra["default_headers"] = dict(_ph_aux.default_headers)
|
||||
except Exception:
|
||||
pass
|
||||
_merged_aux = _apply_user_default_headers(extra.get("default_headers"))
|
||||
if _merged_aux:
|
||||
extra["default_headers"] = _merged_aux
|
||||
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
|
||||
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
|
||||
return _client, model
|
||||
@@ -1489,6 +1564,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
extra["default_headers"] = dict(_ph_aux2.default_headers)
|
||||
except Exception:
|
||||
pass
|
||||
_merged_aux2 = _apply_user_default_headers(extra.get("default_headers"))
|
||||
if _merged_aux2:
|
||||
extra["default_headers"] = _merged_aux2
|
||||
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
|
||||
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
|
||||
return _client, model
|
||||
@@ -1879,6 +1957,13 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
|
||||
logger.debug("Auxiliary client: custom endpoint (%s, api_mode=%s)", model, custom_mode or "chat_completions")
|
||||
_clean_base, _dq = _extract_url_query_params(custom_base)
|
||||
_extra = {"default_query": _dq} if _dq else {}
|
||||
# User-configured model.default_headers override the SDK's identifying
|
||||
# headers (User-Agent: OpenAI/Python ..., X-Stainless-*) on this custom
|
||||
# endpoint's auxiliary calls too — matching the main agent client so the
|
||||
# whole session reaches a gateway/WAF that rejects the SDK fingerprint. (#40033)
|
||||
_custom_headers = _apply_user_default_headers(None)
|
||||
if _custom_headers:
|
||||
_extra["default_headers"] = _custom_headers
|
||||
if custom_mode == "codex_responses":
|
||||
real_client = OpenAI(api_key=custom_key, base_url=_clean_base, **_extra)
|
||||
return CodexAuxiliaryClient(real_client, model), model
|
||||
@@ -3248,6 +3333,9 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
|
||||
async_kwargs["default_headers"] = dict(_ph_async.default_headers)
|
||||
except Exception:
|
||||
pass
|
||||
_merged_async = _apply_user_default_headers(async_kwargs.get("default_headers"))
|
||||
if _merged_async:
|
||||
async_kwargs["default_headers"] = _merged_async
|
||||
return AsyncOpenAI(**async_kwargs), model
|
||||
|
||||
|
||||
@@ -3535,6 +3623,9 @@ def resolve_provider_client(
|
||||
extra["default_headers"] = dict(_ph_custom.default_headers)
|
||||
except Exception:
|
||||
pass
|
||||
_merged_custom = _apply_user_default_headers(extra.get("default_headers"))
|
||||
if _merged_custom:
|
||||
extra["default_headers"] = _merged_custom
|
||||
client = OpenAI(api_key=custom_key, base_url=_clean_base, **extra)
|
||||
client = _wrap_if_needed(client, final_model, custom_base, custom_key)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
@@ -3611,6 +3702,9 @@ def resolve_provider_client(
|
||||
raw_base_for_wrap = custom_base
|
||||
_clean_base2, _dq2 = _extract_url_query_params(openai_base)
|
||||
_extra2 = {"default_query": _dq2} if _dq2 else {}
|
||||
_headers2 = _apply_user_default_headers(_extra2.get("default_headers"))
|
||||
if _headers2:
|
||||
_extra2["default_headers"] = _headers2
|
||||
logger.debug(
|
||||
"resolve_provider_client: named custom provider %r (%s, api_mode=%s)",
|
||||
provider, final_model, entry_api_mode or "chat_completions")
|
||||
@@ -3633,6 +3727,9 @@ def resolve_provider_client(
|
||||
_fallback_base = _to_openai_base_url(custom_base)
|
||||
_fb_clean, _fb_dq = _extract_url_query_params(_fallback_base)
|
||||
_fb_extra = {"default_query": _fb_dq} if _fb_dq else {}
|
||||
_fb_headers = _apply_user_default_headers(_fb_extra.get("default_headers"))
|
||||
if _fb_headers:
|
||||
_fb_extra["default_headers"] = _fb_headers
|
||||
client = OpenAI(api_key=custom_key, base_url=_fb_clean, **_fb_extra)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
@@ -3781,6 +3878,9 @@ def resolve_provider_client(
|
||||
headers.update(_ph_main.default_headers)
|
||||
except Exception:
|
||||
pass
|
||||
_merged_main = _apply_user_default_headers(headers)
|
||||
if _merged_main:
|
||||
headers = _merged_main
|
||||
client = OpenAI(api_key=api_key, base_url=base_url,
|
||||
**({"default_headers": headers} if headers else {}))
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ from agent.message_sanitization import (
|
||||
_repair_tool_call_arguments,
|
||||
)
|
||||
from tools.terminal_tool import is_persistent_env
|
||||
from utils import base_url_host_matches, base_url_hostname
|
||||
from utils import base_url_host_matches, base_url_hostname, env_int
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1936,6 +1936,20 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
),
|
||||
))
|
||||
|
||||
# Zero-chunk guard: stream yielded nothing usable — a provider/upstream
|
||||
# error or malformed SSE, not a legitimate empty completion. Raise so the
|
||||
# retry machinery handles it instead of fabricating a successful turn.
|
||||
if (
|
||||
finish_reason is None
|
||||
and not content_parts
|
||||
and not reasoning_parts
|
||||
and not tool_calls_acc
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Provider returned an empty stream with no finish_reason "
|
||||
"(possible upstream error or malformed SSE response)."
|
||||
)
|
||||
|
||||
effective_finish_reason = finish_reason or "stop"
|
||||
if has_truncated_tool_args:
|
||||
effective_finish_reason = "length"
|
||||
@@ -2044,7 +2058,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
def _call():
|
||||
import httpx as _httpx
|
||||
|
||||
_max_stream_retries = int(os.getenv("HERMES_STREAM_RETRIES", 2))
|
||||
_max_stream_retries = env_int("HERMES_STREAM_RETRIES", 2)
|
||||
|
||||
try:
|
||||
for _stream_attempt in range(_max_stream_retries + 1):
|
||||
|
||||
@@ -600,6 +600,19 @@ def run_conversation(
|
||||
|
||||
active_system_prompt = agent._cached_system_prompt
|
||||
|
||||
# Crash-resilience: persist the inbound user turn as soon as the session row
|
||||
# has a valid system prompt, before any provider call or tool execution can
|
||||
# hang/kill the process. The normal end-of-turn persist still runs later;
|
||||
# _last_flushed_db_idx makes this idempotent and prevents duplicate rows.
|
||||
try:
|
||||
agent._persist_session(messages, conversation_history)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Early turn-start session persistence failed for session=%s",
|
||||
agent.session_id or "none",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# ── Preflight context compression ──
|
||||
# Before entering the main loop, check if the loaded conversation
|
||||
# history already exceeds the model's context threshold. This handles
|
||||
@@ -641,7 +654,14 @@ def run_conversation(
|
||||
# Skipped when deferring — a deferred estimate is known to over-count
|
||||
# vs the last real provider prompt, so trusting it for the display
|
||||
# would re-introduce the very desync we're avoiding.
|
||||
if _preflight_tokens > (_compressor.last_prompt_tokens or 0):
|
||||
_last = _compressor.last_prompt_tokens
|
||||
# Do NOT overwrite the -1 sentinel. compress_context() sets
|
||||
# last_prompt_tokens=-1 right after compression to mark "no real API
|
||||
# usage yet". `(x or 0)` evaluates to -1 (truthy) for the sentinel,
|
||||
# so the old comparison was always True and clobbered the sentinel
|
||||
# with a schema-inflated rough estimate — re-triggering compression
|
||||
# on the next turn (#36718). Treat any negative value as "no data".
|
||||
if _last >= 0 and _preflight_tokens > _last:
|
||||
_compressor.last_prompt_tokens = _preflight_tokens
|
||||
|
||||
if _preflight_deferred:
|
||||
@@ -1239,6 +1259,28 @@ def run_conversation(
|
||||
_sanitize_structure_non_ascii(api_kwargs)
|
||||
if agent.api_mode == "codex_responses":
|
||||
api_kwargs = agent._get_transport().preflight_kwargs(api_kwargs, allow_stream=False)
|
||||
try:
|
||||
from hermes_cli.middleware import apply_llm_request_middleware
|
||||
|
||||
_llm_request_mw = apply_llm_request_middleware(
|
||||
api_kwargs,
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
api_request_id=api_request_id,
|
||||
session_id=agent.session_id or "",
|
||||
platform=agent.platform or "",
|
||||
model=agent.model,
|
||||
provider=agent.provider,
|
||||
base_url=agent.base_url,
|
||||
api_mode=agent.api_mode,
|
||||
api_call_count=api_call_count,
|
||||
)
|
||||
api_kwargs = _llm_request_mw.payload
|
||||
_original_api_kwargs = _llm_request_mw.original_payload
|
||||
_llm_middleware_trace = _llm_request_mw.trace
|
||||
except Exception:
|
||||
_original_api_kwargs = dict(api_kwargs)
|
||||
_llm_middleware_trace = []
|
||||
|
||||
try:
|
||||
from hermes_cli.plugins import (
|
||||
@@ -1291,6 +1333,7 @@ def run_conversation(
|
||||
request_char_count=total_chars,
|
||||
max_tokens=agent.max_tokens,
|
||||
started_at=api_start_time,
|
||||
middleware_trace=list(_llm_middleware_trace),
|
||||
request=_request_payload,
|
||||
)
|
||||
except Exception:
|
||||
@@ -1349,7 +1392,24 @@ def run_conversation(
|
||||
)
|
||||
return agent._interruptible_api_call(next_api_kwargs)
|
||||
|
||||
response = _perform_api_call(api_kwargs)
|
||||
from hermes_cli.middleware import run_llm_execution_middleware
|
||||
|
||||
response = run_llm_execution_middleware(
|
||||
api_kwargs,
|
||||
_perform_api_call,
|
||||
original_request=_original_api_kwargs,
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
api_request_id=api_request_id,
|
||||
session_id=agent.session_id or "",
|
||||
platform=agent.platform or "",
|
||||
model=agent.model,
|
||||
provider=agent.provider,
|
||||
base_url=agent.base_url,
|
||||
api_mode=agent.api_mode,
|
||||
api_call_count=api_call_count,
|
||||
middleware_trace=list(_llm_middleware_trace),
|
||||
)
|
||||
|
||||
api_duration = time.time() - api_start_time
|
||||
|
||||
|
||||
@@ -281,9 +281,28 @@ class MemoryManager:
|
||||
|
||||
self._providers.append(provider)
|
||||
|
||||
# Core tool names are reserved — a memory provider must never register
|
||||
# a tool that shadows a built-in (e.g. ``clarify``, ``delegate_task``).
|
||||
# Built-ins always win, so such a tool is dropped at agent init and
|
||||
# would otherwise linger in ``_tool_to_provider`` and hijack dispatch
|
||||
# (#40466). Reject it here, at the door, so it never enters the routing
|
||||
# table at all — matching the built-ins-always-win invariant used by
|
||||
# the TTS/browser/search provider registries.
|
||||
from toolsets import _HERMES_CORE_TOOLS
|
||||
|
||||
_core_tool_names = set(_HERMES_CORE_TOOLS)
|
||||
|
||||
# Index tool names → provider for routing
|
||||
for schema in provider.get_tool_schemas():
|
||||
tool_name = schema.get("name", "")
|
||||
if tool_name in _core_tool_names:
|
||||
logger.warning(
|
||||
"Memory provider '%s' tool '%s' shadows a reserved core "
|
||||
"tool name; registration ignored. Core tools always win — "
|
||||
"rename the provider's tool to something unique.",
|
||||
provider.name, tool_name,
|
||||
)
|
||||
continue
|
||||
if tool_name and tool_name not in self._tool_to_provider:
|
||||
self._tool_to_provider[tool_name] = provider
|
||||
elif tool_name in self._tool_to_provider:
|
||||
@@ -413,13 +432,24 @@ class MemoryManager:
|
||||
# -- Tools ---------------------------------------------------------------
|
||||
|
||||
def get_all_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
"""Collect tool schemas from all providers."""
|
||||
"""Collect tool schemas from all providers.
|
||||
|
||||
Reserved core tool names (``clarify``, ``delegate_task``, etc.) are
|
||||
skipped — they are rejected from the routing table in
|
||||
:meth:`add_provider`, so the manager must not advertise a schema it
|
||||
will never route. Built-ins always win (#40466).
|
||||
"""
|
||||
from toolsets import _HERMES_CORE_TOOLS
|
||||
|
||||
_core_tool_names = set(_HERMES_CORE_TOOLS)
|
||||
schemas = []
|
||||
seen = set()
|
||||
for provider in self._providers:
|
||||
try:
|
||||
for schema in provider.get_tool_schemas():
|
||||
name = schema.get("name", "")
|
||||
if name in _core_tool_names:
|
||||
continue
|
||||
if name and name not in seen:
|
||||
schemas.append(schema)
|
||||
seen.add(name)
|
||||
|
||||
@@ -964,6 +964,10 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
|
||||
is_output_cap_error = (
|
||||
"max_tokens" in error_lower
|
||||
and ("available_tokens" in error_lower or "available tokens" in error_lower)
|
||||
) or (
|
||||
# OpenRouter/Nous phrasing of the same condition.
|
||||
"in the output" in error_lower
|
||||
and "maximum context length" in error_lower
|
||||
)
|
||||
if not is_output_cap_error:
|
||||
return None
|
||||
@@ -982,6 +986,19 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
|
||||
tokens = int(match.group(1))
|
||||
if tokens >= 1:
|
||||
return tokens
|
||||
|
||||
# OpenRouter/Nous format: "maximum context length is N … (A of text input,
|
||||
# B of tool input, C in the output)". Available output = ctx - text - tool.
|
||||
_m_ctx = re.search(r'maximum context length is (\d+)', error_lower)
|
||||
_m_parts = re.search(
|
||||
r'\((\d+)\s+of text input,\s*(\d+)\s+of tool input,\s*(\d+)\s+in the output\)',
|
||||
error_lower,
|
||||
)
|
||||
if _m_ctx and _m_parts:
|
||||
_available = int(_m_ctx.group(1)) - int(_m_parts.group(1)) - int(_m_parts.group(2))
|
||||
if _available >= 1:
|
||||
return _available
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -324,8 +324,11 @@ def install_bws(*, force: bool = False) -> Path:
|
||||
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
member = _pick_zip_member(zf, _platform_binary_name())
|
||||
zf.extract(member, tmp)
|
||||
extracted = tmp / member
|
||||
# Zip-slip guard: a malicious archive can carry member names like
|
||||
# ``../../etc/cron.d/x`` or absolute paths. ``ZipFile.extract``
|
||||
# joins the member onto ``tmp`` without verifying the result stays
|
||||
# inside it, so validate containment before touching the disk.
|
||||
extracted = _safe_extract_member(zf, member, tmp)
|
||||
|
||||
# Move into place atomically. We write to a sibling tempfile in
|
||||
# the final directory so the rename can't cross filesystems.
|
||||
@@ -395,6 +398,33 @@ def _pick_zip_member(zf: zipfile.ZipFile, binary_name: str) -> str:
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _safe_extract_member(
|
||||
zf: zipfile.ZipFile, member: str, dest_dir: Path
|
||||
) -> Path:
|
||||
"""Extract a single archive member, refusing path traversal.
|
||||
|
||||
``ZipFile.extract`` will happily honour member names containing
|
||||
``../`` or absolute paths, letting a malicious archive write outside
|
||||
``dest_dir`` (a "zip-slip"). We resolve the would-be target and
|
||||
confirm it stays within ``dest_dir`` before extracting.
|
||||
"""
|
||||
dest_root = os.path.realpath(dest_dir)
|
||||
target = os.path.realpath(os.path.join(dest_root, member))
|
||||
# ``commonpath`` raises ValueError for e.g. different drives on
|
||||
# Windows; treat that as an escape too.
|
||||
try:
|
||||
contained = os.path.commonpath([dest_root, target]) == dest_root
|
||||
except ValueError:
|
||||
contained = False
|
||||
if not contained or target == dest_root:
|
||||
raise RuntimeError(
|
||||
f"Refusing to extract unsafe archive member {member!r}: "
|
||||
f"it escapes the extraction directory"
|
||||
)
|
||||
zf.extract(member, dest_root)
|
||||
return Path(target)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Secret fetch + apply
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -70,6 +70,7 @@ def _emit_terminal_post_tool_call(
|
||||
status: str | None = None,
|
||||
error_type: str | None = None,
|
||||
error_message: str | None = None,
|
||||
middleware_trace: Optional[list[dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
try:
|
||||
from model_tools import _emit_post_tool_call_hook
|
||||
@@ -86,6 +87,7 @@ def _emit_terminal_post_tool_call(
|
||||
status=status,
|
||||
error_type=error_type,
|
||||
error_message=error_message,
|
||||
middleware_trace=list(middleware_trace or []),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -111,6 +113,7 @@ def _emit_cancelled_terminal_post_tool_call(
|
||||
start_time: float,
|
||||
reason: str = "user interrupt",
|
||||
error_type: str = "keyboard_interrupt",
|
||||
middleware_trace: Optional[list[dict[str, Any]]] = None,
|
||||
) -> str:
|
||||
result = _cancelled_tool_result(reason)
|
||||
_emit_terminal_post_tool_call(
|
||||
@@ -124,6 +127,7 @@ def _emit_cancelled_terminal_post_tool_call(
|
||||
status="cancelled",
|
||||
error_type=error_type,
|
||||
error_message=f"Tool execution cancelled by {reason}",
|
||||
middleware_trace=list(middleware_trace or []),
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -177,6 +181,65 @@ def _tool_search_scoped_names(agent) -> frozenset:
|
||||
return names
|
||||
|
||||
|
||||
def _apply_tool_request_middleware_for_agent(
|
||||
agent,
|
||||
*,
|
||||
function_name: str,
|
||||
function_args: dict,
|
||||
effective_task_id: str,
|
||||
tool_call_id: str,
|
||||
) -> tuple[dict, list[dict[str, Any]]]:
|
||||
try:
|
||||
from hermes_cli.middleware import apply_tool_request_middleware
|
||||
|
||||
result = apply_tool_request_middleware(
|
||||
function_name,
|
||||
function_args,
|
||||
task_id=effective_task_id or "",
|
||||
session_id=getattr(agent, "session_id", "") or "",
|
||||
tool_call_id=tool_call_id or "",
|
||||
turn_id=getattr(agent, "_current_turn_id", "") or "",
|
||||
api_request_id=getattr(agent, "_current_api_request_id", "") or "",
|
||||
)
|
||||
payload = result.payload if isinstance(result.payload, dict) else function_args
|
||||
return payload, list(result.trace)
|
||||
except Exception as exc:
|
||||
logger.debug("tool_request middleware error: %s", exc)
|
||||
return function_args, []
|
||||
|
||||
|
||||
def _run_agent_tool_execution_middleware(
|
||||
agent,
|
||||
*,
|
||||
function_name: str,
|
||||
function_args: dict,
|
||||
effective_task_id: str,
|
||||
tool_call_id: str,
|
||||
execute,
|
||||
) -> tuple[Any, dict]:
|
||||
observed_args = function_args
|
||||
|
||||
def _execute(next_args: dict) -> Any:
|
||||
nonlocal observed_args
|
||||
observed_args = next_args if isinstance(next_args, dict) else function_args
|
||||
return execute(observed_args)
|
||||
|
||||
from hermes_cli.middleware import run_tool_execution_middleware
|
||||
|
||||
result = run_tool_execution_middleware(
|
||||
function_name,
|
||||
function_args,
|
||||
_execute,
|
||||
original_args=function_args,
|
||||
task_id=effective_task_id or "",
|
||||
session_id=getattr(agent, "session_id", "") or "",
|
||||
tool_call_id=tool_call_id or "",
|
||||
turn_id=getattr(agent, "_current_turn_id", "") or "",
|
||||
api_request_id=getattr(agent, "_current_api_request_id", "") or "",
|
||||
)
|
||||
return result, observed_args
|
||||
|
||||
|
||||
def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None:
|
||||
"""Execute multiple tool calls concurrently using a thread pool.
|
||||
|
||||
@@ -198,7 +261,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
return
|
||||
|
||||
# ── Parse args + pre-execution bookkeeping ───────────────────────
|
||||
parsed_calls = [] # list of (tool_call, function_name, function_args)
|
||||
parsed_calls = [] # list of (tool_call, function_name, function_args, middleware_trace, block_result, blocked_by_guardrail)
|
||||
for tool_call in tool_calls:
|
||||
function_name = tool_call.function.name
|
||||
|
||||
@@ -250,6 +313,14 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
function_args, middleware_trace = _apply_tool_request_middleware_for_agent(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
)
|
||||
|
||||
# ── Block evaluation (BEFORE checkpoint preflight) ───────────
|
||||
# We must know whether the tool will execute before touching
|
||||
# checkpoint state (dedup slot, real snapshots).
|
||||
@@ -268,6 +339,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
status="blocked",
|
||||
error_type="tool_scope_block",
|
||||
error_message=_ts_scope_block,
|
||||
middleware_trace=list(middleware_trace),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
@@ -280,6 +352,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
turn_id=getattr(agent, "_current_turn_id", "") or "",
|
||||
api_request_id=getattr(agent, "_current_api_request_id", "") or "",
|
||||
middleware_trace=list(middleware_trace),
|
||||
)
|
||||
except Exception:
|
||||
block_message = None
|
||||
@@ -296,6 +369,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
status="blocked",
|
||||
error_type="plugin_block",
|
||||
error_message=block_message,
|
||||
middleware_trace=list(middleware_trace),
|
||||
)
|
||||
else:
|
||||
guardrail_decision = agent._tool_guardrails.before_call(function_name, function_args)
|
||||
@@ -312,6 +386,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
status="blocked",
|
||||
error_type="guardrail_block",
|
||||
error_message=getattr(guardrail_decision, "message", None) or "Tool blocked by guardrail policy",
|
||||
middleware_trace=list(middleware_trace),
|
||||
)
|
||||
|
||||
# ── Checkpoint preflight (only for tools that will execute) ──
|
||||
@@ -338,13 +413,13 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
parsed_calls.append((tool_call, function_name, function_args, block_result, blocked_by_guardrail))
|
||||
parsed_calls.append((tool_call, function_name, function_args, middleware_trace, block_result, blocked_by_guardrail))
|
||||
|
||||
# ── Logging / callbacks ──────────────────────────────────────────
|
||||
tool_names_str = ", ".join(name for _, name, _, _, _ in parsed_calls)
|
||||
tool_names_str = ", ".join(name for _, name, _, _, _, _ in parsed_calls)
|
||||
if not agent.quiet_mode:
|
||||
print(f" ⚡ Concurrent: {num_tools} tool calls — {tool_names_str}")
|
||||
for i, (tc, name, args, block_result, blocked_by_guardrail) in enumerate(parsed_calls, 1):
|
||||
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)
|
||||
if agent.verbose_logging:
|
||||
print(f" 📞 Tool {i}: {name}({list(args.keys())})")
|
||||
@@ -353,7 +428,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
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}")
|
||||
|
||||
for tc, name, args, block_result, blocked_by_guardrail in parsed_calls:
|
||||
for tc, name, args, middleware_trace, block_result, blocked_by_guardrail in parsed_calls:
|
||||
if block_result is not None:
|
||||
continue
|
||||
if agent.tool_progress_callback:
|
||||
@@ -363,7 +438,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
except Exception as cb_err:
|
||||
logging.debug(f"Tool progress callback error: {cb_err}")
|
||||
|
||||
for tc, name, args, block_result, blocked_by_guardrail in parsed_calls:
|
||||
for tc, name, args, middleware_trace, block_result, blocked_by_guardrail in parsed_calls:
|
||||
if block_result is not None:
|
||||
continue
|
||||
if agent.tool_start_callback:
|
||||
@@ -373,18 +448,18 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
logging.debug(f"Tool start callback error: {cb_err}")
|
||||
|
||||
# ── Concurrent execution ─────────────────────────────────────────
|
||||
# Each slot holds (function_name, function_args, function_result, duration, error_flag, blocked_flag)
|
||||
# Each slot holds (function_name, function_args, function_result, duration, error_flag, blocked_flag, middleware_trace)
|
||||
results = [None] * num_tools
|
||||
for i, (tc, name, args, block_result, blocked_by_guardrail) in enumerate(parsed_calls):
|
||||
for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls):
|
||||
if block_result is not None:
|
||||
results[i] = (name, args, block_result, 0.0, True, True)
|
||||
results[i] = (name, args, block_result, 0.0, True, True, middleware_trace)
|
||||
|
||||
# Touch activity before launching workers so the gateway knows
|
||||
# we're executing tools (not stuck).
|
||||
agent._current_tool = tool_names_str
|
||||
agent._touch_activity(f"executing {num_tools} tools concurrently: {tool_names_str}")
|
||||
|
||||
def _run_tool(index, tool_call, function_name, function_args):
|
||||
def _run_tool(index, tool_call, function_name, function_args, middleware_trace):
|
||||
"""Worker function executed in a thread."""
|
||||
# Register this worker tid so the agent can fan out an interrupt
|
||||
# to it — see AIAgent.interrupt(). Must happen first thing, and
|
||||
@@ -423,6 +498,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
tool_call.id,
|
||||
messages=messages,
|
||||
pre_tool_block_checked=True,
|
||||
skip_tool_request_middleware=True,
|
||||
tool_request_middleware_trace=list(middleware_trace),
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
try:
|
||||
@@ -436,10 +513,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
start_time=start,
|
||||
middleware_trace=list(middleware_trace),
|
||||
)
|
||||
duration = time.time() - start
|
||||
logger.info("tool %s cancelled (%.2fs)", function_name, duration)
|
||||
results[index] = (function_name, function_args, result, duration, True, False)
|
||||
results[index] = (function_name, function_args, result, duration, True, False, middleware_trace)
|
||||
return
|
||||
except Exception as tool_error:
|
||||
result = f"Error executing tool '{function_name}': {tool_error}"
|
||||
@@ -450,7 +528,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
logger.info("tool %s failed (%.2fs): %s", function_name, duration, result[:200])
|
||||
else:
|
||||
logger.info("tool %s completed (%.2fs, %d chars)", function_name, duration, len(result))
|
||||
results[index] = (function_name, function_args, result, duration, is_error, False)
|
||||
results[index] = (function_name, function_args, result, duration, is_error, False, middleware_trace)
|
||||
finally:
|
||||
# Tear down worker-tid tracking. Clear any interrupt bit we may
|
||||
# have set so the next task scheduled onto this recycled tid
|
||||
@@ -475,7 +553,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
try:
|
||||
runnable_calls = [
|
||||
(i, tc, name, args)
|
||||
for i, (tc, name, args, block_result, blocked_by_guardrail) in enumerate(parsed_calls)
|
||||
for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls)
|
||||
if block_result is None
|
||||
]
|
||||
futures = []
|
||||
@@ -487,7 +565,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
# _approval_session_key) AND thread-local approval/sudo
|
||||
# callbacks into the worker thread; clears callbacks on exit.
|
||||
f = executor.submit(
|
||||
propagate_context_to_thread(_run_tool), i, tc, name, args
|
||||
propagate_context_to_thread(_run_tool), i, tc, name, args, parsed_calls[i][3]
|
||||
)
|
||||
futures.append(f)
|
||||
|
||||
@@ -545,7 +623,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
spinner.stop(f"⚡ {completed}/{num_tools} tools completed in {total_dur:.1f}s total")
|
||||
|
||||
# ── Post-execution: display per-tool results ─────────────────────
|
||||
for i, (tc, name, args, block_result, blocked_by_guardrail) in enumerate(parsed_calls):
|
||||
for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls):
|
||||
r = results[i]
|
||||
blocked = False
|
||||
if r is None:
|
||||
@@ -562,6 +640,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
status="cancelled",
|
||||
error_type="keyboard_interrupt",
|
||||
error_message="Tool execution cancelled by user interrupt",
|
||||
middleware_trace=list(middleware_trace),
|
||||
)
|
||||
else:
|
||||
function_result = f"Error executing tool '{name}': thread did not return a result"
|
||||
@@ -575,10 +654,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
status="error",
|
||||
error_type="thread_missing_result",
|
||||
error_message=function_result,
|
||||
middleware_trace=list(middleware_trace),
|
||||
)
|
||||
tool_duration = 0.0
|
||||
else:
|
||||
function_name, function_args, function_result, tool_duration, is_error, blocked = r
|
||||
function_name, function_args, function_result, tool_duration, is_error, blocked, middleware_trace = r
|
||||
|
||||
if not blocked:
|
||||
function_result = agent._append_guardrail_observation(
|
||||
@@ -738,6 +818,14 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
function_args, middleware_trace = _apply_tool_request_middleware_for_agent(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
)
|
||||
|
||||
# Check plugin hooks for a block directive before executing.
|
||||
_block_msg: Optional[str] = None
|
||||
_block_error_type = "plugin_block"
|
||||
@@ -755,6 +843,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
turn_id=getattr(agent, "_current_turn_id", "") or "",
|
||||
api_request_id=getattr(agent, "_current_api_request_id", "") or "",
|
||||
middleware_trace=list(middleware_trace),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -853,6 +942,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
status="blocked",
|
||||
error_type=_block_error_type,
|
||||
error_message=_block_msg,
|
||||
middleware_trace=list(middleware_trace),
|
||||
)
|
||||
elif _guardrail_block_decision is not None:
|
||||
# Tool blocked by tool-loop guardrail — synthesize exactly one
|
||||
@@ -869,71 +959,108 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
status="blocked",
|
||||
error_type="guardrail_block",
|
||||
error_message=getattr(_guardrail_block_decision, "message", None) or "Tool blocked by guardrail policy",
|
||||
middleware_trace=list(middleware_trace),
|
||||
)
|
||||
elif function_name == "todo":
|
||||
from tools.todo_tool import todo_tool as _todo_tool
|
||||
function_result = _todo_tool(
|
||||
todos=function_args.get("todos"),
|
||||
merge=function_args.get("merge", False),
|
||||
store=agent._todo_store,
|
||||
def _execute(next_args: dict) -> Any:
|
||||
from tools.todo_tool import todo_tool as _todo_tool
|
||||
return _todo_tool(
|
||||
todos=next_args.get("todos"),
|
||||
merge=next_args.get("merge", False),
|
||||
store=agent._todo_store,
|
||||
)
|
||||
function_result, function_args = _run_agent_tool_execution_middleware(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
execute=_execute,
|
||||
)
|
||||
tool_duration = time.time() - tool_start_time
|
||||
if agent._should_emit_quiet_tool_messages():
|
||||
agent._vprint(f" {_get_cute_tool_message_impl('todo', function_args, tool_duration, result=function_result)}")
|
||||
elif function_name == "session_search":
|
||||
session_db = agent._get_session_db_for_recall()
|
||||
if not session_db:
|
||||
from hermes_state import format_session_db_unavailable
|
||||
function_result = json.dumps({"success": False, "error": format_session_db_unavailable()})
|
||||
else:
|
||||
def _execute(next_args: dict) -> Any:
|
||||
session_db = agent._get_session_db_for_recall()
|
||||
if not session_db:
|
||||
from hermes_state import format_session_db_unavailable
|
||||
return json.dumps({"success": False, "error": format_session_db_unavailable()})
|
||||
from tools.session_search_tool import session_search as _session_search
|
||||
function_result = _session_search(
|
||||
query=function_args.get("query", ""),
|
||||
role_filter=function_args.get("role_filter"),
|
||||
limit=function_args.get("limit", 3),
|
||||
session_id=function_args.get("session_id"),
|
||||
around_message_id=function_args.get("around_message_id"),
|
||||
window=function_args.get("window", 5),
|
||||
sort=function_args.get("sort"),
|
||||
return _session_search(
|
||||
query=next_args.get("query", ""),
|
||||
role_filter=next_args.get("role_filter"),
|
||||
limit=next_args.get("limit", 3),
|
||||
session_id=next_args.get("session_id"),
|
||||
around_message_id=next_args.get("around_message_id"),
|
||||
window=next_args.get("window", 5),
|
||||
sort=next_args.get("sort"),
|
||||
db=session_db,
|
||||
current_session_id=agent.session_id,
|
||||
)
|
||||
function_result, function_args = _run_agent_tool_execution_middleware(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
execute=_execute,
|
||||
)
|
||||
tool_duration = time.time() - tool_start_time
|
||||
if agent._should_emit_quiet_tool_messages():
|
||||
agent._vprint(f" {_get_cute_tool_message_impl('session_search', function_args, tool_duration, result=function_result)}")
|
||||
elif function_name == "memory":
|
||||
target = function_args.get("target", "memory")
|
||||
from tools.memory_tool import memory_tool as _memory_tool
|
||||
function_result = _memory_tool(
|
||||
action=function_args.get("action"),
|
||||
target=target,
|
||||
content=function_args.get("content"),
|
||||
old_text=function_args.get("old_text"),
|
||||
store=agent._memory_store,
|
||||
def _execute(next_args: dict) -> Any:
|
||||
target = next_args.get("target", "memory")
|
||||
from tools.memory_tool import memory_tool as _memory_tool
|
||||
result = _memory_tool(
|
||||
action=next_args.get("action"),
|
||||
target=target,
|
||||
content=next_args.get("content"),
|
||||
old_text=next_args.get("old_text"),
|
||||
store=agent._memory_store,
|
||||
)
|
||||
# Bridge: notify external memory provider of built-in memory writes
|
||||
if agent._memory_manager and next_args.get("action") in {"add", "replace"}:
|
||||
try:
|
||||
agent._memory_manager.on_memory_write(
|
||||
next_args.get("action", ""),
|
||||
target,
|
||||
next_args.get("content", ""),
|
||||
metadata=agent._build_memory_write_metadata(
|
||||
task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", None),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
function_result, function_args = _run_agent_tool_execution_middleware(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
execute=_execute,
|
||||
)
|
||||
# Bridge: notify external memory provider of built-in memory writes
|
||||
if agent._memory_manager and function_args.get("action") in {"add", "replace"}:
|
||||
try:
|
||||
agent._memory_manager.on_memory_write(
|
||||
function_args.get("action", ""),
|
||||
target,
|
||||
function_args.get("content", ""),
|
||||
metadata=agent._build_memory_write_metadata(
|
||||
task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", None),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
tool_duration = time.time() - tool_start_time
|
||||
if agent._should_emit_quiet_tool_messages():
|
||||
agent._vprint(f" {_get_cute_tool_message_impl('memory', function_args, tool_duration, result=function_result)}")
|
||||
elif function_name == "clarify":
|
||||
from tools.clarify_tool import clarify_tool as _clarify_tool
|
||||
function_result = _clarify_tool(
|
||||
question=function_args.get("question", ""),
|
||||
choices=function_args.get("choices"),
|
||||
callback=agent.clarify_callback,
|
||||
def _execute(next_args: dict) -> Any:
|
||||
from tools.clarify_tool import clarify_tool as _clarify_tool
|
||||
return _clarify_tool(
|
||||
question=next_args.get("question", ""),
|
||||
choices=next_args.get("choices"),
|
||||
callback=agent.clarify_callback,
|
||||
)
|
||||
function_result, function_args = _run_agent_tool_execution_middleware(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
execute=_execute,
|
||||
)
|
||||
tool_duration = time.time() - tool_start_time
|
||||
if agent._should_emit_quiet_tool_messages():
|
||||
@@ -957,7 +1084,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
agent._delegate_spinner = spinner
|
||||
_delegate_result = None
|
||||
try:
|
||||
function_result = agent._dispatch_delegate_task(function_args)
|
||||
def _execute(next_args: dict) -> Any:
|
||||
return agent._dispatch_delegate_task(next_args)
|
||||
function_result, function_args = _run_agent_tool_execution_middleware(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
execute=_execute,
|
||||
)
|
||||
_delegate_result = function_result
|
||||
finally:
|
||||
agent._delegate_spinner = None
|
||||
@@ -978,7 +1114,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
spinner.start()
|
||||
_ce_result = None
|
||||
try:
|
||||
function_result = agent.context_compressor.handle_tool_call(function_name, function_args, messages=messages)
|
||||
def _execute(next_args: dict) -> Any:
|
||||
return agent.context_compressor.handle_tool_call(function_name, next_args, messages=messages)
|
||||
function_result, function_args = _run_agent_tool_execution_middleware(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
execute=_execute,
|
||||
)
|
||||
_ce_result = function_result
|
||||
except Exception as tool_error:
|
||||
function_result = json.dumps({"error": f"Context engine tool '{function_name}' failed: {tool_error}"})
|
||||
@@ -1002,7 +1147,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
spinner.start()
|
||||
_mem_result = None
|
||||
try:
|
||||
function_result = agent._memory_manager.handle_tool_call(function_name, function_args)
|
||||
def _execute(next_args: dict) -> Any:
|
||||
return agent._memory_manager.handle_tool_call(function_name, next_args)
|
||||
function_result, function_args = _run_agent_tool_execution_middleware(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
execute=_execute,
|
||||
)
|
||||
_mem_result = function_result
|
||||
except Exception as tool_error:
|
||||
function_result = json.dumps({"error": f"Memory tool '{function_name}' failed: {tool_error}"})
|
||||
@@ -1032,8 +1186,10 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
api_request_id=getattr(agent, "_current_api_request_id", "") or "",
|
||||
enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None,
|
||||
skip_pre_tool_call_hook=True,
|
||||
skip_tool_request_middleware=True,
|
||||
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||||
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||||
tool_request_middleware_trace=list(middleware_trace),
|
||||
)
|
||||
_spinner_result = function_result
|
||||
except KeyboardInterrupt:
|
||||
@@ -1044,6 +1200,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
start_time=tool_start_time,
|
||||
middleware_trace=list(middleware_trace),
|
||||
)
|
||||
_spinner_result = function_result
|
||||
try:
|
||||
@@ -1071,8 +1228,10 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
api_request_id=getattr(agent, "_current_api_request_id", "") or "",
|
||||
enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None,
|
||||
skip_pre_tool_call_hook=True,
|
||||
skip_tool_request_middleware=True,
|
||||
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||||
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||||
tool_request_middleware_trace=list(middleware_trace),
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
_emit_cancelled_terminal_post_tool_call(
|
||||
@@ -1082,6 +1241,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
start_time=tool_start_time,
|
||||
middleware_trace=list(middleware_trace),
|
||||
)
|
||||
try:
|
||||
agent.interrupt("keyboard interrupt")
|
||||
@@ -1126,6 +1286,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
duration_ms=int(tool_duration * 1000),
|
||||
middleware_trace=list(middleware_trace),
|
||||
)
|
||||
if not _execution_blocked:
|
||||
function_result = agent._append_guardrail_observation(
|
||||
|
||||
@@ -72,7 +72,7 @@ pub async fn run_script(
|
||||
|
||||
let mut child: Child = cmd
|
||||
.spawn()
|
||||
.with_context(|| format!("spawning {}", script_path.display()))?;
|
||||
.with_context(|| format!("spawning {} via {}", script_path.display(), interpreter_label()))?;
|
||||
|
||||
let stdout = child.stdout.take().expect("stdout was piped");
|
||||
let stderr = child.stderr.take().expect("stderr was piped");
|
||||
@@ -177,8 +177,9 @@ async fn recv_cancel(rx: &mut Option<CancelRx>) {
|
||||
fn build_command(script_path: &Path, args: &[String]) -> Command {
|
||||
// We want PowerShell 5.1 / 7. install.ps1 uses 5.1-safe syntax everywhere.
|
||||
// Prefer `powershell.exe` (5.1 baseline, present on every Windows since 7)
|
||||
// over `pwsh.exe` (7+, may not be present).
|
||||
let mut cmd = Command::new("powershell.exe");
|
||||
// over `pwsh.exe` (7+, may not be present). Resolve it by absolute path —
|
||||
// see `windows_powershell_exe`.
|
||||
let mut cmd = Command::new(windows_powershell_exe());
|
||||
cmd.arg("-NoProfile");
|
||||
cmd.arg("-ExecutionPolicy").arg("Bypass");
|
||||
cmd.arg("-File").arg(script_path);
|
||||
@@ -200,6 +201,60 @@ fn build_command(script_path: &Path, args: &[String]) -> Command {
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Canonical PowerShell 5.1 location under a Windows root (`%SystemRoot%`).
|
||||
/// Kept separate (and test-visible) so the path layout is unit-tested on any
|
||||
/// host, not just Windows.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn powershell_under_root(root: &Path) -> std::path::PathBuf {
|
||||
root.join("System32")
|
||||
.join("WindowsPowerShell")
|
||||
.join("v1.0")
|
||||
.join("powershell.exe")
|
||||
}
|
||||
|
||||
/// Resolves the PowerShell interpreter to spawn.
|
||||
///
|
||||
/// `Command::new("powershell.exe")` trusts PATH to contain
|
||||
/// `%SystemRoot%\System32\WindowsPowerShell\v1.0`. On machines whose PATH was
|
||||
/// trimmed or truncated (Windows silently drops entries once the variable grows
|
||||
/// past its length limit), that lookup fails and the spawn dies with
|
||||
/// "program not found" before install.ps1 ever runs — the installer then stalls
|
||||
/// at "0 of 0 steps". Resolve by absolute path first, then fall back to PATH
|
||||
/// (powershell 5.1, then pwsh 7), then a bare name as a last resort.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_powershell_exe() -> std::path::PathBuf {
|
||||
for var in ["SystemRoot", "windir"] {
|
||||
if let Ok(root) = std::env::var(var) {
|
||||
let candidate = powershell_under_root(Path::new(&root));
|
||||
if candidate.is_file() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for exe in ["powershell.exe", "pwsh.exe"] {
|
||||
if let Ok(found) = which::which(exe) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
std::path::PathBuf::from("powershell.exe")
|
||||
}
|
||||
|
||||
/// Human-readable interpreter name for spawn-failure context. On Windows this
|
||||
/// is the resolved PowerShell path so a missing/odd interpreter is obvious in
|
||||
/// the log (the old message only printed the script path, which read as if the
|
||||
/// .ps1 itself was missing).
|
||||
#[cfg(target_os = "windows")]
|
||||
fn interpreter_label() -> String {
|
||||
windows_powershell_exe().display().to_string()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn interpreter_label() -> String {
|
||||
"bash".to_string()
|
||||
}
|
||||
|
||||
/// Parses the LAST line of stdout that looks like a JSON object matching
|
||||
/// the install.ps1 stage-result contract: `{ok: bool, stage: string, ...}`.
|
||||
///
|
||||
@@ -289,4 +344,14 @@ info line
|
||||
let cwd = stable_script_cwd(script, Some("/"));
|
||||
assert_eq!(cwd, Some(Path::new("/")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powershell_under_root_uses_system32_v1_layout() {
|
||||
let resolved = powershell_under_root(Path::new("C:\\Windows"));
|
||||
let normalized = resolved.to_string_lossy().replace('\\', "/");
|
||||
assert!(
|
||||
normalized.ends_with("System32/WindowsPowerShell/v1.0/powershell.exe"),
|
||||
"unexpected powershell path: {normalized}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
167
apps/desktop/DESIGN.md
Normal file
167
apps/desktop/DESIGN.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Desktop Design System
|
||||
|
||||
Conventions for the Electron desktop app (`apps/desktop`). Read this before
|
||||
adding a component, overlay, or style. The rule of thumb: **one source per
|
||||
concern, tokens over literals, flat over boxed.** If you reach for a raw color,
|
||||
a one-off shadow, a bespoke button, or a hardcoded `px-*` on a control — stop,
|
||||
there's already a primitive for it.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Flat, not boxed.** No card-in-card, no divider borders inside a panel.
|
||||
Group with whitespace and a single hairline, never nested rounded boxes.
|
||||
2. **Borderless + shadow for elevation.** Overlays float on `shadow-nous` + a
|
||||
`--stroke-nous` hairline, not hard borders.
|
||||
3. **One primitive per concern.** One `Button`, one set of control variants,
|
||||
one `SearchField`, one `Loader`, one `ErrorState`. Migrate onto them; don't
|
||||
fork.
|
||||
4. **Tokens, not literals.** Reference CSS vars (`--ui-*`, `--shadow-nous`,
|
||||
`--theme-*`), never raw hex / ad-hoc rgba in components.
|
||||
5. **Style lives in the primitive.** Variants and sizes own padding, radius,
|
||||
color, chrome. Call sites pass a `variant`/`size`, not `className` overrides
|
||||
that re-specify those.
|
||||
|
||||
## Surfaces & elevation
|
||||
|
||||
Every overlay / dialog / toast (boot-failure, install, notifications,
|
||||
model-picker, onboarding, prompt-overlays, updates, base `Dialog`) uses:
|
||||
|
||||
```
|
||||
shadow-nous /* downward-weighted, layered contact→ambient falloff */
|
||||
border-(--stroke-nous) /* currentColor hairline, theme-adaptive */
|
||||
```
|
||||
|
||||
Both are CSS vars in `src/styles.css` — tune in one place, everything inherits.
|
||||
Don't add per-overlay `shadow-[…]` or `border-(--ui-stroke-secondary)`
|
||||
one-offs; if elevation needs to change, change the token.
|
||||
|
||||
## Stroke & color tokens
|
||||
|
||||
| Token | Use |
|
||||
| --- | --- |
|
||||
| `--ui-stroke-primary…quaternary` | hairlines, in descending strength |
|
||||
| `--ui-stroke-tertiary` | the default in-panel divider / list hairline |
|
||||
| `--stroke-nous` | the overlay hairline (pairs with `shadow-nous`) |
|
||||
| `--ui-text-primary / -secondary / -tertiary` | text hierarchy |
|
||||
| `--ui-bg-quaternary` | soft control fill (secondary button) |
|
||||
| `--chrome-action-hover` | hover fill for quiet controls |
|
||||
| `--theme-primary`, `--ui-accent` | brand/accent |
|
||||
|
||||
Never hardcode `border-gray-*`, `bg-white`, `text-black`, etc. The white tile in
|
||||
`BrandMark` is the one sanctioned literal (the mark needs a fixed backdrop).
|
||||
|
||||
## Buttons — one component
|
||||
|
||||
`src/components/ui/button.tsx` is the single source. Pick a `variant` + `size`;
|
||||
do **not** pass `h-*`, `px-*`, `py-*`, or icon-size overrides.
|
||||
|
||||
**Variants:** `default` (primary), `destructive`, `secondary` (soft fill —
|
||||
the default non-primary look), `outline` (transparent + 1px inset ring, no
|
||||
fill/shadow), `ghost`, `link`, `text` (boxless quiet inline — "Cancel",
|
||||
"Clear"), `textStrong` (bold underlined inline affordance — "Change",
|
||||
"Open logs").
|
||||
|
||||
**Sizes:** `default`, `xs`, `sm`, `lg`, `inline` (flush, zero box — for buttons
|
||||
that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), and the icon
|
||||
family `icon` / `icon-xs` / `icon-sm` / `icon-lg` / `icon-titlebar`.
|
||||
|
||||
Notes:
|
||||
- Text buttons are square (no radius) and sized by padding + line-height (no
|
||||
fixed heights). Only icon buttons carry the shared 4px radius.
|
||||
- SVGs inherit `size-3.5` (`size-3` at `xs`). Don't re-set icon size.
|
||||
- Polymorph with `asChild` when the button must render as a link/Slot.
|
||||
|
||||
## Form controls
|
||||
|
||||
- **`controlVariants`** (`src/components/ui/control.ts`) is the shared shape for
|
||||
`Input` / `Textarea` / `SelectTrigger`. New text-entry controls compose it.
|
||||
- **`SearchField`** — borderless, underline-on-focus, auto-width. The only
|
||||
search input. Don't build boxed search bars; don't wrap it in a bordered tile.
|
||||
Empty lists hide their search field.
|
||||
- **`SegmentedControl`** — the choice control for small mutually-exclusive sets
|
||||
(color mode, tool-call display, usage period). Replaces radio piles and
|
||||
pill rows.
|
||||
- **`Switch`** (`size="xs"`) — bare, with `aria-label`. No bordered text wrapper.
|
||||
|
||||
## Layout
|
||||
|
||||
- **Gutters:** `PAGE_INSET_X` (`src/app/layout-constants.ts`) for page side
|
||||
padding; `PAGE_INSET_NEG_X` to bleed a child to the edge. Don't hardcode
|
||||
`px-6`/`px-8` on pages.
|
||||
- **Master/detail overlays:** `OverlaySplitLayout` + `OverlaySidebar` /
|
||||
`OverlayMain`. Cron, profiles, etc. ride this — don't rebuild a titlebar
|
||||
shell.
|
||||
- **Rows:** `ListRow` (settings `primitives.tsx`) for label/description/action
|
||||
rows. Flat, flush-left; no per-row indentation that fights flush headers.
|
||||
- **No dividers between rows** unless the list genuinely needs them; prefer
|
||||
spacing. When you do need one, it's a single `--ui-stroke-tertiary` hairline.
|
||||
|
||||
## Feedback & empty/error/loading states
|
||||
|
||||
- **Loading:** `Loader` (`src/components/ui/loader.tsx`) — animated math/ascii
|
||||
curves (`lemniscate-bloom` for long ops). Never ship the literal text
|
||||
"Loading…".
|
||||
- **Errors:** `ErrorState` + the canonical `ErrorIcon` (no bg chip). One look
|
||||
for the React boundary, in-dialog errors, and the boot-failure banner. Pass
|
||||
nodes for title/description so Radix `DialogTitle`/`Description` can flow
|
||||
through for a11y.
|
||||
- **Logs:** `LogView` — no bg, hairline border, tight padding, small mono.
|
||||
Every place we surface raw logs uses it.
|
||||
- **Empty:** `EmptyState` / `EmptyPanel` — don't hand-roll centered empties.
|
||||
|
||||
## Iconography & brand
|
||||
|
||||
- **`Codicon`** is the icon set. No mixing icon libraries inline.
|
||||
- **`BrandMark`** (`src/components/brand-mark.tsx`) is the brand glyph — the
|
||||
`nous-girl` mark on a white tile, softly rounded, identical in light/dark.
|
||||
It replaced scattered Sparkles glyphs in updates / onboarding / about. Use it
|
||||
for hero/brand moments; don't reintroduce decorative star/sparkle icons.
|
||||
|
||||
## Motion
|
||||
|
||||
- Quick, functional transitions (~100ms on controls). Respect
|
||||
`prefers-reduced-motion` for anything beyond a fade.
|
||||
- Choreographed exits (e.g. onboarding's "matrix" fade-down) stagger per-element
|
||||
then settle the surface — the outer container's fade is *delayed* so it
|
||||
doesn't swallow the inner animation. Don't let a global fade race the detail.
|
||||
|
||||
## i18n
|
||||
|
||||
- Every user-facing string goes through `useI18n()` (`src/i18n/context.tsx`).
|
||||
No literals in JSX.
|
||||
- **Update all locales together** — `en`, `ja`, `zh`, `zh-hant`. A string change
|
||||
in `en.ts` that skips the others is a regression (drifted punctuation,
|
||||
stale labels). Keep trailing-punctuation and tone consistent across all four.
|
||||
|
||||
## State (TypeScript)
|
||||
|
||||
Mirrors the repo TS style (see root `AGENTS.md`):
|
||||
|
||||
- Shared/cross-component state → small **nanostores**, not prop-drilling.
|
||||
Each feature owns its atoms; shared atoms live in `src/store`.
|
||||
- Rendering components subscribe with `useStore`; non-render actions read with
|
||||
`$atom.get()`.
|
||||
- Colocated action modules over god hooks. A hook owns one narrow job.
|
||||
- Keep persistence beside the atom that owns it. Route roots stay thin.
|
||||
- Prefer `interface` for public props; extend React primitives
|
||||
(`React.ComponentProps<'button'>`, `Omit<…>`).
|
||||
|
||||
## Affordances
|
||||
|
||||
- `cursor-pointer` at the primitive level (Button, dropdown/select) — don't
|
||||
hardcode it per call site.
|
||||
- Global focus-ring reset; titlebar actions have no active-background state.
|
||||
- `Esc` closes every dismissable overlay/dialog (install/onboarding excluded);
|
||||
close is an x-icon, not the word "Close".
|
||||
|
||||
## Before you add something — checklist
|
||||
|
||||
- [ ] Reuse a primitive (`Button`, `SearchField`, `SegmentedControl`,
|
||||
`ListRow`, `Loader`, `ErrorState`, `LogView`) instead of forking one?
|
||||
- [ ] Tokens (`--ui-*`, `shadow-nous`, `--stroke-nous`) — zero raw colors /
|
||||
one-off shadows?
|
||||
- [ ] No `className` overriding a primitive's padding / size / radius / chrome?
|
||||
- [ ] Overlay uses `shadow-nous` + `border-(--stroke-nous)`, no hard border?
|
||||
- [ ] Flat — no card-in-card, no gratuitous row dividers?
|
||||
- [ ] All four locales updated for any new/changed string?
|
||||
- [ ] `cursor-pointer`, focus ring, and `Esc`-to-close behave?
|
||||
@@ -76,6 +76,21 @@ function bootstrapCacheDir(hermesHome) {
|
||||
return path.join(hermesHome, 'bootstrap-cache')
|
||||
}
|
||||
|
||||
// The install.sh / install.ps1 that ships inside the already-installed agent
|
||||
// checkout under ~/.hermes/hermes-agent. Used as a last-resort fallback when
|
||||
// the pinned commit can't be fetched from GitHub (e.g. a locally-built desktop
|
||||
// app stamped to an unpushed HEAD).
|
||||
function installedAgentInstallScript(hermesHome) {
|
||||
if (!hermesHome) return null
|
||||
const candidate = path.join(hermesHome, 'hermes-agent', 'scripts', installScriptName())
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.R_OK)
|
||||
return candidate
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function cachedScriptPath(hermesHome, commit) {
|
||||
return path.join(bootstrapCacheDir(hermesHome), `install-${commit}.${process.platform === 'win32' ? 'ps1' : 'sh'}`)
|
||||
}
|
||||
@@ -155,7 +170,7 @@ function downloadInstallScript(commit, destPath) {
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit }) {
|
||||
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/../..).
|
||||
@@ -189,18 +204,84 @@ async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome,
|
||||
type: 'log',
|
||||
line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub`
|
||||
})
|
||||
await downloadInstallScript(installStamp.commit, cached)
|
||||
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
|
||||
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
|
||||
try {
|
||||
await _download(installStamp.commit, cached)
|
||||
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
|
||||
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
|
||||
} catch (err) {
|
||||
// The pinned commit may not be fetchable from GitHub -- most commonly a
|
||||
// locally-built desktop app stamped to an unpushed HEAD (see
|
||||
// write-build-stamp.cjs fromLocalGit). Fall back to the installer that
|
||||
// ships inside the already-installed agent checkout so dev/self-builds can
|
||||
// still bootstrap instead of dying with a fatal 404.
|
||||
const installed = installedAgentInstallScript(hermesHome)
|
||||
if (installed) {
|
||||
emit({
|
||||
type: 'log',
|
||||
line:
|
||||
`[bootstrap] GitHub fetch failed (${err.message}); ` +
|
||||
`falling back to installed agent ${installScriptName()} at ${installed}`
|
||||
})
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(cached), { recursive: true })
|
||||
fs.copyFileSync(installed, cached)
|
||||
return { path: cached, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
|
||||
} catch {
|
||||
// Cache copy failed (read-only FS, etc.) -- use the source path directly.
|
||||
return { path: installed, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// powershell wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Canonical PowerShell 5.1 location under a Windows root (%SystemRoot%).
|
||||
function powershellUnderRoot(root) {
|
||||
return path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
||||
}
|
||||
|
||||
// Resolve the PowerShell interpreter to spawn.
|
||||
//
|
||||
// Spawning bare 'powershell.exe' trusts PATH to contain
|
||||
// %SystemRoot%\System32\WindowsPowerShell\v1.0. On machines whose PATH was
|
||||
// trimmed, truncated, or stored as a non-expanding REG_SZ (so %SystemRoot%
|
||||
// never expands), that lookup fails and the spawn dies with ENOENT before
|
||||
// install.ps1 ever runs — the installer stalls at "0 of 0 steps". Resolve by
|
||||
// absolute path first, then fall back to PATH (powershell 5.1, then pwsh 7),
|
||||
// then a bare name as a last resort.
|
||||
function resolveWindowsPowerShell() {
|
||||
for (const v of ['SystemRoot', 'windir']) {
|
||||
const root = process.env[v]
|
||||
if (root) {
|
||||
const candidate = powershellUnderRoot(root)
|
||||
try {
|
||||
if (fs.statSync(candidate).isFile()) return candidate
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
}
|
||||
const pathDirs = (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean)
|
||||
for (const exe of ['powershell.exe', 'pwsh.exe']) {
|
||||
for (const dir of pathDirs) {
|
||||
const candidate = path.join(dir, exe)
|
||||
try {
|
||||
if (fs.statSync(candidate).isFile()) return candidate
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'powershell.exe'
|
||||
}
|
||||
|
||||
function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ps = process.platform === 'win32' ? 'powershell.exe' : 'pwsh'
|
||||
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
|
||||
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
|
||||
|
||||
const child = spawn(ps, fullArgs, {
|
||||
@@ -633,5 +714,7 @@ module.exports = {
|
||||
// Exposed for testability
|
||||
parseStageResult,
|
||||
resolveLocalInstallScript,
|
||||
resolveInstallScript,
|
||||
installedAgentInstallScript,
|
||||
cachedScriptPath
|
||||
}
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
const {
|
||||
runBootstrap,
|
||||
resolveInstallScript,
|
||||
installedAgentInstallScript,
|
||||
cachedScriptPath
|
||||
} = require('./bootstrap-runner.cjs')
|
||||
|
||||
const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh'
|
||||
|
||||
function mkTmpHome() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-bootstrap-test-'))
|
||||
}
|
||||
|
||||
test('runBootstrap bails immediately when the signal is already aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
@@ -25,3 +39,100 @@ test('runBootstrap bails immediately when the signal is already aborted', async
|
||||
'should emit a cancelled failure event'
|
||||
)
|
||||
})
|
||||
|
||||
test('installedAgentInstallScript resolves the installer in the agent checkout', () => {
|
||||
const home = mkTmpHome()
|
||||
try {
|
||||
assert.equal(installedAgentInstallScript(home), null, 'absent before the checkout exists')
|
||||
|
||||
const scriptsDir = path.join(home, 'hermes-agent', 'scripts')
|
||||
fs.mkdirSync(scriptsDir, { recursive: true })
|
||||
const scriptPath = path.join(scriptsDir, SCRIPT_NAME)
|
||||
fs.writeFileSync(scriptPath, '#!/bin/sh\necho hi\n')
|
||||
|
||||
assert.equal(installedAgentInstallScript(home), scriptPath)
|
||||
assert.equal(installedAgentInstallScript(null), null, 'null home -> null')
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveInstallScript prefers a cached script without touching the network', async () => {
|
||||
const home = mkTmpHome()
|
||||
try {
|
||||
const commit = 'a'.repeat(40)
|
||||
const cached = cachedScriptPath(home, commit)
|
||||
fs.mkdirSync(path.dirname(cached), { recursive: true })
|
||||
fs.writeFileSync(cached, '#!/bin/sh\necho cached\n')
|
||||
|
||||
const logs = []
|
||||
const result = await resolveInstallScript({
|
||||
installStamp: { commit },
|
||||
sourceRepoRoot: null,
|
||||
hermesHome: home,
|
||||
emit: ev => logs.push(ev)
|
||||
})
|
||||
|
||||
assert.equal(result.source, 'cache')
|
||||
assert.equal(result.path, cached)
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveInstallScript falls back to the installed agent checkout on a 404', async () => {
|
||||
const home = mkTmpHome()
|
||||
try {
|
||||
const commit = 'a'.repeat(40)
|
||||
// Seed the installed agent checkout so the fallback has something to resolve.
|
||||
const scriptsDir = path.join(home, 'hermes-agent', 'scripts')
|
||||
fs.mkdirSync(scriptsDir, { recursive: true })
|
||||
const installed = path.join(scriptsDir, SCRIPT_NAME)
|
||||
fs.writeFileSync(installed, '#!/bin/sh\necho fallback\n')
|
||||
|
||||
const logs = []
|
||||
const result = await resolveInstallScript({
|
||||
installStamp: { commit },
|
||||
sourceRepoRoot: null,
|
||||
hermesHome: home,
|
||||
emit: ev => logs.push(ev),
|
||||
// Simulate GitHub returning a 404 for the pinned commit.
|
||||
_download: async () => {
|
||||
throw new Error('Failed to download install.sh: HTTP 404')
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(result.source, 'installed-agent')
|
||||
// It should have copied the installer into the bootstrap cache.
|
||||
assert.equal(result.path, cachedScriptPath(home, commit))
|
||||
assert.ok(fs.existsSync(result.path), 'fallback script copied into cache')
|
||||
assert.ok(
|
||||
logs.some(ev => /falling back to installed agent/.test(ev.line || '')),
|
||||
'emits a fallback log line'
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveInstallScript rethrows when the 404 fallback is unavailable', async () => {
|
||||
const home = mkTmpHome()
|
||||
try {
|
||||
const commit = 'a'.repeat(40)
|
||||
// No installed agent checkout seeded -> nothing to fall back to.
|
||||
await assert.rejects(
|
||||
resolveInstallScript({
|
||||
installStamp: { commit },
|
||||
sourceRepoRoot: null,
|
||||
hermesHome: home,
|
||||
emit: () => {},
|
||||
_download: async () => {
|
||||
throw new Error('Failed to download install.sh: HTTP 404')
|
||||
}
|
||||
}),
|
||||
/HTTP 404|Failed to download/
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
232
apps/desktop/electron/desktop-uninstall.cjs
Normal file
232
apps/desktop/electron/desktop-uninstall.cjs
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* desktop-uninstall.cjs
|
||||
*
|
||||
* Pure, electron-free helpers for the desktop Chat GUI uninstaller. These map
|
||||
* the three user-facing uninstall modes to the `hermes uninstall` CLI flags,
|
||||
* resolve the running app bundle/exe so a detached cleanup script can remove
|
||||
* it after the app quits, and build that cleanup script for each OS.
|
||||
*
|
||||
* Kept standalone (no `require('electron')`) so it can be unit-tested with
|
||||
* `node --test` — same pattern as connection-config.cjs / backend-probes.cjs.
|
||||
* main.cjs requires these and wires them into the electron-coupled IPC layer.
|
||||
*
|
||||
* The three modes mirror the CLI's options exactly:
|
||||
* - 'gui' → remove ONLY the Chat GUI, keep the agent + all user data.
|
||||
* `hermes uninstall --gui --yes`
|
||||
* - 'lite' → remove the GUI + agent code, KEEP user data (config / sessions
|
||||
* / .env) for a future reinstall. `hermes uninstall --yes`
|
||||
* - 'full' → remove everything: GUI + agent + all user data.
|
||||
* `hermes uninstall --full --yes`
|
||||
*
|
||||
* Why a detached cleanup script: 'lite'/'full' delete the very venv the
|
||||
* `hermes` command runs from, and every mode may need to delete the running
|
||||
* app bundle (locked on macOS/Windows while the process is alive). So we hand
|
||||
* the work to a detached child that waits for this app's PID to exit, runs the
|
||||
* Python uninstall, then removes the app bundle — then the app quits. Same
|
||||
* shape as the self-update swap-and-relaunch flow already in main.cjs.
|
||||
*/
|
||||
|
||||
const path = require('node:path')
|
||||
|
||||
const UNINSTALL_MODES = ['gui', 'lite', 'full']
|
||||
|
||||
/**
|
||||
* Map an uninstall mode to the `python -m hermes_cli.uninstall` argv (after the
|
||||
* python executable). Uses the dedicated lightweight module entrypoint (not
|
||||
* `hermes_cli.main`) so it can run under a system Python OUTSIDE the venv that
|
||||
* lite/full delete — see the Finding-3 note in buildWindowsCleanupScript.
|
||||
* Throws on an unknown mode so a typo can't silently become a full wipe.
|
||||
*/
|
||||
function uninstallArgsForMode(mode) {
|
||||
if (!UNINSTALL_MODES.includes(mode)) {
|
||||
throw new Error(`Unknown uninstall mode: ${mode}`)
|
||||
}
|
||||
return ['-m', 'hermes_cli.uninstall', '--mode', mode]
|
||||
}
|
||||
|
||||
/** True when `mode` removes the agent (lite/full), false for gui-only. */
|
||||
function modeRemovesAgent(mode) {
|
||||
return mode === 'lite' || mode === 'full'
|
||||
}
|
||||
|
||||
/** True when `mode` removes user data (full only). */
|
||||
function modeRemovesUserData(mode) {
|
||||
return mode === 'full'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the on-disk app bundle/dir to remove for the running desktop app,
|
||||
* given the path to the running executable (`process.execPath`) and platform.
|
||||
*
|
||||
* macOS: …/Hermes.app/Contents/MacOS/Hermes → …/Hermes.app
|
||||
* Windows: …\Hermes\Hermes.exe → …\Hermes (install dir)
|
||||
* Linux: AppImage → the APPIMAGE env path; unpacked → the *-unpacked dir
|
||||
*
|
||||
* Returns null when we can't confidently identify a removable bundle (e.g.
|
||||
* running from a dev checkout, or a system-package install we must not rmtree).
|
||||
*/
|
||||
function resolveRemovableAppPath(execPath, platform, env = {}) {
|
||||
const exe = String(execPath || '')
|
||||
if (!exe) return null
|
||||
|
||||
// Use the path flavor that matches the TARGET platform, not the host running
|
||||
// this code — so the Windows branch parses backslash paths correctly even
|
||||
// when these pure helpers are unit-tested on Linux/macOS CI.
|
||||
const p = platform === 'win32' ? path.win32 : path.posix
|
||||
|
||||
if (platform === 'darwin') {
|
||||
// …/Hermes.app/Contents/MacOS/Hermes → strip 3 segments to the .app
|
||||
const macOsDir = p.dirname(exe) // …/Contents/MacOS
|
||||
const contents = p.dirname(macOsDir) // …/Contents
|
||||
const appBundle = p.dirname(contents) // …/Hermes.app
|
||||
if (appBundle.endsWith('.app')) return appBundle
|
||||
return null
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
// NSIS per-user installs Hermes.exe directly in the install dir.
|
||||
const dir = p.dirname(exe)
|
||||
if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) return dir
|
||||
return null
|
||||
}
|
||||
|
||||
// Linux: an AppImage exposes its own path via the APPIMAGE env var.
|
||||
if (env.APPIMAGE) return env.APPIMAGE
|
||||
// Unpacked electron-builder tree: …/linux-unpacked/hermes
|
||||
const dir = p.dirname(exe)
|
||||
if (/-unpacked$/.test(dir)) return dir
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Should we even try to remove the running app bundle from a cleanup script?
|
||||
* Only when packaged AND we resolved a concrete removable path. Dev runs
|
||||
* (electron from node_modules) and system-package installs return null above
|
||||
* and are left to the OS package manager.
|
||||
*/
|
||||
function shouldRemoveAppBundle(isPackaged, appPath) {
|
||||
return Boolean(isPackaged) && Boolean(appPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a POSIX cleanup shell script (macOS / Linux). It:
|
||||
* 1. waits (bounded ~30s) for the desktop PID to exit (venv/bundle unlock),
|
||||
* 2. runs the Python uninstall module with the mode,
|
||||
* 3. removes the app bundle if one was resolved.
|
||||
*
|
||||
* `pythonExe` should be a Python OUTSIDE the venv for lite/full (the venv is
|
||||
* being deleted); `pythonPath` is prepended to PYTHONPATH so `import hermes_cli`
|
||||
* resolves from the agent source. `q()` single-quote-escapes for the shell
|
||||
* (closes-escapes-reopens any embedded apostrophe), defending against spaces.
|
||||
*/
|
||||
function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
|
||||
const q = s => `'${String(s).replace(/'/g, `'\\''`)}'`
|
||||
const lines = [
|
||||
'#!/bin/bash',
|
||||
'set -u',
|
||||
'# Wait (up to ~30s) for the desktop process to exit so the venv python',
|
||||
'# and the app bundle are no longer in use.',
|
||||
`pid=${Number(desktopPid) || 0}`,
|
||||
'if [ "$pid" -gt 0 ]; then',
|
||||
' for _ in $(seq 1 60); do',
|
||||
' kill -0 "$pid" 2>/dev/null || break',
|
||||
' sleep 0.5',
|
||||
' done',
|
||||
'fi',
|
||||
`export HERMES_HOME=${q(hermesHome)}`
|
||||
]
|
||||
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`
|
||||
)
|
||||
if (appPath) {
|
||||
lines.push(`rm -rf ${q(appPath)} || true`)
|
||||
}
|
||||
// Self-delete the script.
|
||||
lines.push('rm -f "$0" 2>/dev/null || true')
|
||||
lines.push('')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Windows cleanup batch script. Same three steps, cmd.exe flavored.
|
||||
*
|
||||
* Finding 3 (venv self-deletion): for lite/full the agent uninstall rmtree's
|
||||
* the venv that contains `python.exe`. A running .exe is mandatory-locked on
|
||||
* Windows, so running the uninstall from the venv's OWN python half-fails. The
|
||||
* desktop passes a system Python (findSystemPython) as `pythonExe` for those
|
||||
* modes + `pythonPath`=agentRoot so `import hermes_cli` resolves from source
|
||||
* while the venv is torn down. gui-only doesn't touch the venv, so it can use
|
||||
* either interpreter.
|
||||
*
|
||||
* Wait-loop: bounded (matches POSIX's ~30s cap) so a never-exiting / mismatched
|
||||
* PID can't wedge the cleanup forever. The `/FI "PID eq"` filter is an EXACT
|
||||
* match, so no redundant `| find` (which would substring-match 99→990).
|
||||
*
|
||||
* 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 }) {
|
||||
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
|
||||
// a problem, but Hermes install paths don't use them.
|
||||
const q = s => `"${String(s).replace(/"/g, '')}"`
|
||||
const lines = [
|
||||
'@echo off',
|
||||
'setlocal enableextensions',
|
||||
`set "HERMES_HOME=${String(hermesHome).replace(/"/g, '')}"`,
|
||||
`set "PID=${pid}"`
|
||||
]
|
||||
if (pythonPath) {
|
||||
lines.push(`set "PYTHONPATH=${String(pythonPath).replace(/"/g, '')};%PYTHONPATH%"`)
|
||||
}
|
||||
lines.push(
|
||||
'set /a waited=0',
|
||||
':waitloop',
|
||||
'rem /FI "PID eq %PID%" is an EXACT filter — tasklist outputs the one task',
|
||||
'rem row for that PID, or "INFO: No tasks..." otherwise. /NH drops the',
|
||||
'rem header; findstr matches the PID as a whole space-delimited token so',
|
||||
'rem PID 99 cannot match 990 (the substring trap of a bare `find`).',
|
||||
'tasklist /NH /FI "PID eq %PID%" 2>nul | findstr /r /c:" %PID% " >nul',
|
||||
'if %ERRORLEVEL% neq 0 goto waited_done',
|
||||
'set /a waited+=1',
|
||||
'if %waited% geq 60 goto waited_done',
|
||||
'timeout /t 1 /nobreak >nul',
|
||||
'goto waitloop',
|
||||
':waited_done',
|
||||
`cd /d ${q(agentRoot)}`,
|
||||
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')}`
|
||||
)
|
||||
if (appPath) {
|
||||
lines.push(
|
||||
'set /a tries=0',
|
||||
':rmloop',
|
||||
`if not exist ${q(appPath)} goto rmdone`,
|
||||
`rmdir /s /q ${q(appPath)} >nul 2>&1`,
|
||||
`if not exist ${q(appPath)} goto rmdone`,
|
||||
'set /a tries+=1',
|
||||
'if %tries% geq 10 goto rmdone',
|
||||
'timeout /t 1 /nobreak >nul',
|
||||
'goto rmloop',
|
||||
':rmdone'
|
||||
)
|
||||
}
|
||||
lines.push('del "%~f0"')
|
||||
lines.push('')
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
UNINSTALL_MODES,
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
modeRemovesAgent,
|
||||
modeRemovesUserData,
|
||||
resolveRemovableAppPath,
|
||||
shouldRemoveAppBundle,
|
||||
uninstallArgsForMode
|
||||
}
|
||||
246
apps/desktop/electron/desktop-uninstall.test.cjs
Normal file
246
apps/desktop/electron/desktop-uninstall.test.cjs
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Tests for electron/desktop-uninstall.cjs.
|
||||
*
|
||||
* Run with: node --test electron/desktop-uninstall.test.cjs
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* These are the pure helpers behind the desktop Chat GUI uninstaller: the
|
||||
* mode → CLI-flag mapping, the running-app-bundle resolution per OS, and the
|
||||
* cleanup-script builders (POSIX + Windows).
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
UNINSTALL_MODES,
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
modeRemovesAgent,
|
||||
modeRemovesUserData,
|
||||
resolveRemovableAppPath,
|
||||
shouldRemoveAppBundle,
|
||||
uninstallArgsForMode
|
||||
} = require('./desktop-uninstall.cjs')
|
||||
|
||||
// --- uninstallArgsForMode ---
|
||||
|
||||
test('uninstallArgsForMode maps each mode to the module-runner argv', () => {
|
||||
assert.deepEqual(uninstallArgsForMode('gui'), ['-m', 'hermes_cli.uninstall', '--mode', 'gui'])
|
||||
assert.deepEqual(uninstallArgsForMode('lite'), ['-m', 'hermes_cli.uninstall', '--mode', 'lite'])
|
||||
assert.deepEqual(uninstallArgsForMode('full'), ['-m', 'hermes_cli.uninstall', '--mode', 'full'])
|
||||
})
|
||||
|
||||
test('uninstallArgsForMode throws on an unknown mode (no silent full wipe)', () => {
|
||||
assert.throws(() => uninstallArgsForMode('nuke'), /Unknown uninstall mode/)
|
||||
assert.throws(() => uninstallArgsForMode(''), /Unknown uninstall mode/)
|
||||
})
|
||||
|
||||
test('UNINSTALL_MODES lists exactly the three supported modes', () => {
|
||||
assert.deepEqual([...UNINSTALL_MODES].sort(), ['full', 'gui', 'lite'])
|
||||
})
|
||||
|
||||
// --- modeRemovesAgent / modeRemovesUserData ---
|
||||
|
||||
test('mode predicates classify what each mode removes', () => {
|
||||
assert.equal(modeRemovesAgent('gui'), false)
|
||||
assert.equal(modeRemovesAgent('lite'), true)
|
||||
assert.equal(modeRemovesAgent('full'), true)
|
||||
|
||||
assert.equal(modeRemovesUserData('gui'), false)
|
||||
assert.equal(modeRemovesUserData('lite'), false)
|
||||
assert.equal(modeRemovesUserData('full'), true)
|
||||
})
|
||||
|
||||
// --- resolveRemovableAppPath ---
|
||||
|
||||
test('resolveRemovableAppPath finds the .app bundle on macOS', () => {
|
||||
assert.equal(
|
||||
resolveRemovableAppPath('/Applications/Hermes.app/Contents/MacOS/Hermes', 'darwin'),
|
||||
'/Applications/Hermes.app'
|
||||
)
|
||||
assert.equal(
|
||||
resolveRemovableAppPath('/Users/x/Applications/Hermes.app/Contents/MacOS/Hermes', 'darwin'),
|
||||
'/Users/x/Applications/Hermes.app'
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveRemovableAppPath: dev-run .app resolves (safety is shouldRemoveAppBundle, not null)', () => {
|
||||
// A dev run from node_modules' Electron DOES resolve to a .app — the real
|
||||
// dev-run safety gate is shouldRemoveAppBundle(isPackaged=false,...), not a
|
||||
// null return here. This test documents that contract.
|
||||
assert.equal(
|
||||
resolveRemovableAppPath('/repo/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron', 'darwin'),
|
||||
'/repo/node_modules/electron/dist/Electron.app'
|
||||
)
|
||||
assert.equal(shouldRemoveAppBundle(false, '/repo/node_modules/electron/dist/Electron.app'), false)
|
||||
// A bare path with no .app ancestor → null.
|
||||
assert.equal(resolveRemovableAppPath('/usr/bin/electron', 'darwin'), null)
|
||||
})
|
||||
|
||||
test('resolveRemovableAppPath finds the install dir on Windows', () => {
|
||||
assert.equal(
|
||||
resolveRemovableAppPath('C:\\Users\\x\\AppData\\Local\\Programs\\Hermes\\Hermes.exe', 'win32'),
|
||||
'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes'
|
||||
)
|
||||
assert.equal(
|
||||
resolveRemovableAppPath('C:\\Users\\x\\AppData\\Local\\hermes-desktop\\Hermes.exe', 'win32'),
|
||||
'C:\\Users\\x\\AppData\\Local\\hermes-desktop'
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveRemovableAppPath returns null for an unrecognized Windows dir', () => {
|
||||
assert.equal(resolveRemovableAppPath('C:\\Temp\\foo\\Hermes.exe', 'win32'), null)
|
||||
})
|
||||
|
||||
test('resolveRemovableAppPath uses APPIMAGE on Linux when set', () => {
|
||||
assert.equal(
|
||||
resolveRemovableAppPath('/tmp/.mount_HermesXXXX/hermes', 'linux', { APPIMAGE: '/home/x/Apps/Hermes.AppImage' }),
|
||||
'/home/x/Apps/Hermes.AppImage'
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveRemovableAppPath finds the unpacked dir on Linux', () => {
|
||||
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)
|
||||
})
|
||||
|
||||
test('resolveRemovableAppPath returns null for an empty exe path', () => {
|
||||
assert.equal(resolveRemovableAppPath('', 'darwin'), null)
|
||||
assert.equal(resolveRemovableAppPath(null, 'win32'), null)
|
||||
})
|
||||
|
||||
// --- shouldRemoveAppBundle ---
|
||||
|
||||
test('shouldRemoveAppBundle requires packaged AND a resolved path', () => {
|
||||
assert.equal(shouldRemoveAppBundle(true, '/Applications/Hermes.app'), true)
|
||||
assert.equal(shouldRemoveAppBundle(false, '/Applications/Hermes.app'), false)
|
||||
assert.equal(shouldRemoveAppBundle(true, null), false)
|
||||
assert.equal(shouldRemoveAppBundle(false, null), false)
|
||||
})
|
||||
|
||||
// --- buildPosixCleanupScript ---
|
||||
|
||||
test('buildPosixCleanupScript waits for the PID, runs the uninstall module, removes bundle', () => {
|
||||
const script = buildPosixCleanupScript({
|
||||
desktopPid: 4321,
|
||||
pythonExe: '/home/x/.hermes/hermes-agent/venv/bin/python',
|
||||
pythonPath: null,
|
||||
agentRoot: '/home/x/.hermes/hermes-agent',
|
||||
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
|
||||
appPath: '/opt/hermes/linux-unpacked',
|
||||
hermesHome: '/home/x/.hermes'
|
||||
})
|
||||
assert.match(script, /^#!\/bin\/bash/)
|
||||
assert.match(script, /pid=4321/)
|
||||
assert.match(script, /kill -0 "\$pid"/)
|
||||
// bounded wait (~30s), not unbounded
|
||||
assert.match(script, /seq 1 60/)
|
||||
assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'gui'/)
|
||||
assert.match(script, /rm -rf '\/opt\/hermes\/linux-unpacked'/)
|
||||
assert.match(script, /export HERMES_HOME='\/home\/x\/\.hermes'/)
|
||||
})
|
||||
|
||||
test('buildPosixCleanupScript exports PYTHONPATH when pythonPath is set (lite/full)', () => {
|
||||
const script = buildPosixCleanupScript({
|
||||
desktopPid: 1,
|
||||
pythonExe: '/usr/bin/python3',
|
||||
pythonPath: '/home/x/.hermes/hermes-agent',
|
||||
agentRoot: '/home/x/.hermes/hermes-agent',
|
||||
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'full'],
|
||||
appPath: null,
|
||||
hermesHome: '/home/x/.hermes'
|
||||
})
|
||||
// System python + source on PYTHONPATH so import hermes_cli works while the
|
||||
// venv is torn down.
|
||||
assert.match(script, /export PYTHONPATH='\/home\/x\/\.hermes\/hermes-agent'/)
|
||||
assert.match(script, /'\/usr\/bin\/python3' '-m' 'hermes_cli\.uninstall' '--mode' 'full'/)
|
||||
})
|
||||
|
||||
test('buildPosixCleanupScript omits PYTHONPATH when pythonPath is null (gui)', () => {
|
||||
const script = buildPosixCleanupScript({
|
||||
desktopPid: 1,
|
||||
pythonExe: '/p/python',
|
||||
pythonPath: null,
|
||||
agentRoot: '/a',
|
||||
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
|
||||
appPath: null,
|
||||
hermesHome: '/h'
|
||||
})
|
||||
assert.doesNotMatch(script, /export PYTHONPATH/)
|
||||
})
|
||||
|
||||
test('buildPosixCleanupScript omits the bundle rm when appPath is null', () => {
|
||||
const script = buildPosixCleanupScript({
|
||||
desktopPid: 1,
|
||||
pythonExe: '/p/python',
|
||||
pythonPath: null,
|
||||
agentRoot: '/a',
|
||||
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'lite'],
|
||||
appPath: null,
|
||||
hermesHome: '/h'
|
||||
})
|
||||
assert.doesNotMatch(script, /rm -rf '\//)
|
||||
// Still runs the uninstall.
|
||||
assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'lite'/)
|
||||
})
|
||||
|
||||
test('buildPosixCleanupScript single-quote-escapes paths with apostrophes', () => {
|
||||
const script = buildPosixCleanupScript({
|
||||
desktopPid: 1,
|
||||
pythonExe: "/home/o'brien/python",
|
||||
pythonPath: null,
|
||||
agentRoot: '/a',
|
||||
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
|
||||
appPath: null,
|
||||
hermesHome: '/h'
|
||||
})
|
||||
// The apostrophe is closed-escaped-reopened so the shell sees the literal.
|
||||
assert.match(script, /'\/home\/o'\\''brien\/python'/)
|
||||
})
|
||||
|
||||
// --- buildWindowsCleanupScript ---
|
||||
|
||||
test('buildWindowsCleanupScript waits (bounded) for PID, runs uninstall, rmdir bundle', () => {
|
||||
const script = buildWindowsCleanupScript({
|
||||
desktopPid: 9988,
|
||||
pythonExe: 'C:\\Python313\\python.exe',
|
||||
pythonPath: 'C:\\hermes',
|
||||
agentRoot: 'C:\\hermes',
|
||||
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'full'],
|
||||
appPath: 'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes',
|
||||
hermesHome: 'C:\\Users\\x\\AppData\\Local\\hermes'
|
||||
})
|
||||
assert.match(script, /@echo off/)
|
||||
assert.match(script, /set "PID=9988"/)
|
||||
// PYTHONPATH set so a system python can import hermes_cli from source.
|
||||
assert.match(script, /set "PYTHONPATH=C:\\hermes;%PYTHONPATH%"/)
|
||||
assert.match(script, /"C:\\Python313\\python.exe" "-m" "hermes_cli\.uninstall" "--mode" "full"/)
|
||||
// Bounded wait-loop (no infinite loop), whole-token PID match (no substring).
|
||||
assert.match(script, /if %waited% geq 60 goto waited_done/)
|
||||
assert.match(script, /findstr \/r \/c:" %PID% "/)
|
||||
assert.doesNotMatch(script, /find "%PID%"/) // the old substring-prone form is gone
|
||||
// Removal is a retry loop (Windows releases dir handles lazily).
|
||||
assert.match(script, /:rmloop/)
|
||||
assert.match(script, /rmdir \/s \/q "C:\\Users\\x\\AppData\\Local\\Programs\\Hermes" >nul 2>&1/)
|
||||
assert.match(script, /if %tries% geq 10 goto rmdone/)
|
||||
assert.match(script, /del "%~f0"/)
|
||||
})
|
||||
|
||||
test('buildWindowsCleanupScript omits PYTHONPATH + rmdir when not needed (gui, no bundle)', () => {
|
||||
const script = buildWindowsCleanupScript({
|
||||
desktopPid: 2,
|
||||
pythonExe: 'C:\\h\\venv\\Scripts\\python.exe',
|
||||
pythonPath: null,
|
||||
agentRoot: 'C:\\h',
|
||||
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
|
||||
appPath: null,
|
||||
hermesHome: 'C:\\h'
|
||||
})
|
||||
assert.doesNotMatch(script, /rmdir/)
|
||||
assert.doesNotMatch(script, /set "PYTHONPATH=/)
|
||||
})
|
||||
@@ -29,6 +29,15 @@ const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const {
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
modeRemovesAgent,
|
||||
modeRemovesUserData,
|
||||
resolveRemovableAppPath,
|
||||
shouldRemoveAppBundle,
|
||||
uninstallArgsForMode
|
||||
} = require('./desktop-uninstall.cjs')
|
||||
const {
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
@@ -247,6 +256,25 @@ const DEFAULT_UPDATE_BRANCH = 'main'
|
||||
const DESKTOP_LOG_PATH = path.join(HERMES_HOME, 'logs', 'desktop.log')
|
||||
const DESKTOP_LOG_FLUSH_MS = 120
|
||||
const DESKTOP_LOG_BUFFER_MAX_CHARS = 64 * 1024
|
||||
// Bound desktop.log on disk. It is an append-only forensic log, so a boot loop
|
||||
// (version-skew crash -> backend exits instantly -> renderer keeps hitting
|
||||
// Retry) appends the full bootstrap transcript every attempt and grows without
|
||||
// bound — we have seen it reach ~326 GB and exhaust the disk, which then breaks
|
||||
// update/install (no room for git/venv/npm temp files).
|
||||
//
|
||||
// Mirror the Python logs (hermes_logging.py RotatingFileHandler, maxBytes x
|
||||
// backupCount): cascade live -> .1 -> .2 -> .3, drop the oldest. Steady-state
|
||||
// stays bounded at ~(backupCount + 1) x cap however hard the app loops.
|
||||
//
|
||||
// Bounding alone never RECLAIMS an already-huge file: a plain rotation just
|
||||
// renames the monster to .1 and strands it for a cycle a healthy app may never
|
||||
// reach. A multi-GB boot-loop transcript has no diagnostic value, so anything
|
||||
// past the discard ceiling is deleted outright — the updated app self-heals a
|
||||
// disk a stale build filled, on the next launch.
|
||||
const DESKTOP_LOG_MAX_BYTES = 10 * 1024 * 1024
|
||||
const DESKTOP_LOG_BACKUP_COUNT = 3
|
||||
const DESKTOP_LOG_DISCARD_BYTES = DESKTOP_LOG_MAX_BYTES * 4
|
||||
const desktopLogBackupPath = n => `${DESKTOP_LOG_PATH}.${n}`
|
||||
const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1'
|
||||
const BOOT_FAKE_STEP_MS = (() => {
|
||||
const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10)
|
||||
@@ -534,6 +562,59 @@ let bootProgressState = {
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
// Pure planner: ordered fs ops to bound a live log of `size`. [] = nothing.
|
||||
// Each step is ['rm', path] or ['mv', src, dst]; executed best-effort so a
|
||||
// missing chain link never aborts the rest.
|
||||
function planDesktopLogRotation(size) {
|
||||
if (size < DESKTOP_LOG_MAX_BYTES) return []
|
||||
const backups = n => Array.from({ length: n }, (_, i) => desktopLogBackupPath(i + 1))
|
||||
// Pathological boot-loop log: reclaim live + every backup outright.
|
||||
if (size > DESKTOP_LOG_DISCARD_BYTES) {
|
||||
return [DESKTOP_LOG_PATH, ...backups(DESKTOP_LOG_BACKUP_COUNT)].map(p => ['rm', p])
|
||||
}
|
||||
// Cascade: drop oldest, shift each up, live -> .1.
|
||||
const ops = [['rm', desktopLogBackupPath(DESKTOP_LOG_BACKUP_COUNT)]]
|
||||
for (let i = DESKTOP_LOG_BACKUP_COUNT - 1; i >= 1; i--) {
|
||||
ops.push(['mv', desktopLogBackupPath(i), desktopLogBackupPath(i + 1)])
|
||||
}
|
||||
ops.push(['mv', DESKTOP_LOG_PATH, desktopLogBackupPath(1)])
|
||||
return ops
|
||||
}
|
||||
|
||||
function rotateDesktopLogIfNeededSync() {
|
||||
let size
|
||||
try {
|
||||
size = fs.statSync(DESKTOP_LOG_PATH).size
|
||||
} catch {
|
||||
return // No live file yet — the append (re)creates it.
|
||||
}
|
||||
for (const [op, src, dst] of planDesktopLogRotation(size)) {
|
||||
try {
|
||||
if (op === 'rm') fs.rmSync(src, { force: true })
|
||||
else fs.renameSync(src, dst)
|
||||
} catch {
|
||||
// Best-effort — logging must never block startup/shutdown.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateDesktopLogIfNeededAsync() {
|
||||
let size
|
||||
try {
|
||||
size = (await fs.promises.stat(DESKTOP_LOG_PATH)).size
|
||||
} catch {
|
||||
return // No live file yet — the append (re)creates it.
|
||||
}
|
||||
for (const [op, src, dst] of planDesktopLogRotation(size)) {
|
||||
try {
|
||||
if (op === 'rm') await fs.promises.rm(src, { force: true })
|
||||
else await fs.promises.rename(src, dst)
|
||||
} catch {
|
||||
// Best-effort — logging must never crash the shell.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function flushDesktopLogBufferSync() {
|
||||
if (!desktopLogBuffer) return
|
||||
const chunk = desktopLogBuffer
|
||||
@@ -541,6 +622,7 @@ function flushDesktopLogBufferSync() {
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
|
||||
rotateDesktopLogIfNeededSync()
|
||||
fs.appendFileSync(DESKTOP_LOG_PATH, chunk)
|
||||
} catch {
|
||||
// Logging must never block app startup/shutdown.
|
||||
@@ -555,6 +637,7 @@ function flushDesktopLogBufferAsync() {
|
||||
desktopLogFlushPromise = desktopLogFlushPromise
|
||||
.then(async () => {
|
||||
await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
|
||||
await rotateDesktopLogIfNeededAsync()
|
||||
await fs.promises.appendFile(DESKTOP_LOG_PATH, chunk)
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -1414,6 +1497,20 @@ function forceKillProcessTree(pid) {
|
||||
// aggressively SIGKILL-ing the backend here would be an untested behavior change
|
||||
// for no benefit. So we no-op off Windows and leave that path exactly as it was.
|
||||
async function releaseBackendLockForUpdate(updateRoot) {
|
||||
return releaseBackendLock(updateRoot, 'updates')
|
||||
}
|
||||
|
||||
// Shared backend teardown + venv-shim unlock wait. Used by BOTH the self-update
|
||||
// hand-off and the desktop uninstaller — they have the identical Windows
|
||||
// problem: the desktop's backend (and the grandchildren IT spawned — a hermes
|
||||
// REPL, a pty terminal, the gateway) keep `hermes.exe` and other files in the
|
||||
// venv mandatory-locked, so any in-place replace/delete of the install tree
|
||||
// races a live handle and half-fails (#37532). We tree-kill every backend PID
|
||||
// the desktop owns, then poll the shim until it's genuinely writable.
|
||||
//
|
||||
// `tag` only flavors the log lines. No-op off Windows (POSIX has no mandatory
|
||||
// locks — the before-quit SIGTERM + the cleanup script's own PID-wait suffice).
|
||||
async function releaseBackendLock(updateRoot, tag) {
|
||||
if (!IS_WINDOWS) return { unlocked: true }
|
||||
|
||||
// Collect every backend PID the desktop owns: primary window backend + pool.
|
||||
@@ -1438,14 +1535,12 @@ async function releaseBackendLockForUpdate(updateRoot) {
|
||||
const deadlineMs = Date.now() + 15000
|
||||
while (Date.now() < deadlineMs) {
|
||||
if (!isShimLocked(shim)) {
|
||||
rememberLog('[updates] venv shim unlocked; safe to hand off the update')
|
||||
rememberLog(`[${tag}] venv shim unlocked; safe to proceed`)
|
||||
return { unlocked: true }
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
}
|
||||
// Timed out: the updater's own wait_for_venv_free + force-kill is the second
|
||||
// line of defense, and we pass --force so the guard won't dead-end. Log it.
|
||||
rememberLog('[updates] venv shim still locked after 15s; handing off anyway (updater will force)')
|
||||
rememberLog(`[${tag}] venv shim still locked after 15s; proceeding anyway (force)`)
|
||||
return { unlocked: false }
|
||||
}
|
||||
|
||||
@@ -4274,6 +4369,9 @@ async function spawnPoolBackend(profile, entry) {
|
||||
HERMES_HOME,
|
||||
...backend.env,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// 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
|
||||
},
|
||||
shell: backend.shell,
|
||||
@@ -4415,6 +4513,9 @@ async function startHermes() {
|
||||
HERMES_HOME,
|
||||
...backend.env,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// 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
|
||||
},
|
||||
shell: backend.shell,
|
||||
@@ -5399,6 +5500,199 @@ ipcMain.handle('hermes:version', async () => ({
|
||||
hermesRoot: resolveUpdateRoot()
|
||||
}))
|
||||
|
||||
// ===========================================================================
|
||||
// Uninstall — remove the Chat GUI (and optionally the agent / user data).
|
||||
// ===========================================================================
|
||||
//
|
||||
// The renderer's About → Danger Zone surfaces three options that mirror the
|
||||
// CLI exactly: GUI only, Lite (keep user data), Full. We ask the agent to do
|
||||
// the actual removal via `hermes uninstall …` so the cross-platform PATH /
|
||||
// registry / service / node-symlink cleanup all lives in one place
|
||||
// (hermes_cli/uninstall.py + hermes_cli/gui_uninstall.py).
|
||||
//
|
||||
// getUninstallSummary() shells out to `--gui-summary` (a fast, no-side-effect
|
||||
// JSON probe) so the UI can gate options on what's actually installed — and
|
||||
// detect a missing agent (a future "lite client" that ships without the
|
||||
// bundled agent), hiding the agent/full options when there's nothing to remove.
|
||||
|
||||
function uninstallVenvPython() {
|
||||
return getVenvPython(VENV_ROOT)
|
||||
}
|
||||
|
||||
async function getUninstallSummary() {
|
||||
const py = uninstallVenvPython()
|
||||
const agentRoot = ACTIVE_HERMES_ROOT
|
||||
// Fast JS-side fallback used when the agent venv is gone (lite client) or the
|
||||
// probe fails — the renderer still needs *something* to render options from.
|
||||
const fallback = () => ({
|
||||
hermes_home: HERMES_HOME,
|
||||
agent_installed: isHermesSourceRoot(agentRoot) && fileExists(py),
|
||||
gui_installed: true,
|
||||
source_built_artifacts: [],
|
||||
packaged_app_paths: [],
|
||||
userdata_dir: app.getPath('userData'),
|
||||
userdata_exists: true,
|
||||
platform: process.platform,
|
||||
probe: 'fallback'
|
||||
})
|
||||
|
||||
if (!fileExists(py)) {
|
||||
return fallback()
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let stdout = ''
|
||||
let settled = false
|
||||
const done = value => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(value)
|
||||
}
|
||||
try {
|
||||
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], {
|
||||
cwd: agentRoot,
|
||||
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
child.stdout.on('data', chunk => {
|
||||
stdout += chunk.toString()
|
||||
})
|
||||
child.on('error', () => done(fallback()))
|
||||
child.on('exit', code => {
|
||||
if (code !== 0) return done(fallback())
|
||||
try {
|
||||
const line = stdout.trim().split('\n').filter(Boolean).pop() || '{}'
|
||||
const parsed = JSON.parse(line)
|
||||
// The app bundle the renderer would be removing on *this* machine,
|
||||
// resolved from the running exe (the Python probe only knows the
|
||||
// standard locations, not where THIS build actually runs from).
|
||||
parsed.running_app_path = resolveRemovableAppPath(process.execPath, process.platform, process.env)
|
||||
done(parsed)
|
||||
} catch {
|
||||
done(fallback())
|
||||
}
|
||||
})
|
||||
setTimeout(() => done(fallback()), 8000)
|
||||
} catch {
|
||||
done(fallback())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function runDesktopUninstall(mode) {
|
||||
let uninstallArgs
|
||||
try {
|
||||
uninstallArgs = uninstallArgsForMode(mode)
|
||||
} catch (error) {
|
||||
return { ok: false, error: 'invalid-mode', message: error.message }
|
||||
}
|
||||
|
||||
const venvPy = uninstallVenvPython()
|
||||
if (!fileExists(venvPy)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'agent-missing',
|
||||
message: `Can't run the uninstaller: no Hermes agent venv at ${VENV_ROOT}.`
|
||||
}
|
||||
}
|
||||
|
||||
// Interpreter choice (Finding 3): lite/full rmtree the venv that holds the
|
||||
// running python.exe. On Windows a running .exe is mandatory-locked, so the
|
||||
// rmtree must NOT be driven by the venv's own interpreter — use a system
|
||||
// Python with PYTHONPATH=<agentRoot> so `import hermes_cli` resolves from
|
||||
// source while the venv is torn down. gui-only doesn't touch the venv, so the
|
||||
// venv python is fine there. If no system Python exists (the Windows edge
|
||||
// case), fall back to the venv python — gui-only is unaffected; lite/full may
|
||||
// leave venv remnants the user can delete, which we log.
|
||||
let py = venvPy
|
||||
let pythonPath = null
|
||||
if (modeRemovesAgent(mode)) {
|
||||
const sysPy = findSystemPython()
|
||||
if (sysPy) {
|
||||
py = sysPy
|
||||
pythonPath = ACTIVE_HERMES_ROOT
|
||||
} else if (IS_WINDOWS) {
|
||||
rememberLog(
|
||||
'[uninstall] no system Python found for lite/full on Windows; falling back ' +
|
||||
'to the venv python — venv files locked by the running interpreter may ' +
|
||||
'remain and need manual deletion.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const appPath = resolveRemovableAppPath(process.execPath, process.platform, process.env)
|
||||
const removeBundle = shouldRemoveAppBundle(IS_PACKAGED, appPath) ? appPath : null
|
||||
|
||||
// CRITICAL (Windows): tear down every backend the desktop owns and wait for
|
||||
// the venv shim to unlock BEFORE the cleanup script runs. lite/full delete
|
||||
// the venv, and even gui-only removes the install tree's GUI artifacts — a
|
||||
// live backend grandchild (gateway / pty / REPL) holding a mandatory file
|
||||
// lock would make the script's rmdir half-fail (#37532 for the update path).
|
||||
// Reuses the incident-hardened update teardown; no-op on macOS/Linux.
|
||||
try {
|
||||
await releaseBackendLock(ACTIVE_HERMES_ROOT, 'uninstall')
|
||||
} catch (error) {
|
||||
rememberLog(`[uninstall] backend teardown errored (continuing): ${error.message}`)
|
||||
}
|
||||
|
||||
const scriptArgs = {
|
||||
desktopPid: process.pid,
|
||||
pythonExe: py,
|
||||
pythonPath,
|
||||
agentRoot: ACTIVE_HERMES_ROOT,
|
||||
uninstallArgs,
|
||||
appPath: removeBundle,
|
||||
hermesHome: HERMES_HOME
|
||||
}
|
||||
|
||||
let scriptPath
|
||||
let runner
|
||||
let runnerArgs
|
||||
try {
|
||||
if (IS_WINDOWS) {
|
||||
scriptPath = path.join(app.getPath('temp'), `hermes-uninstall-${Date.now()}.cmd`)
|
||||
fs.writeFileSync(scriptPath, buildWindowsCleanupScript(scriptArgs))
|
||||
runner = process.env.ComSpec || 'cmd.exe'
|
||||
runnerArgs = ['/c', scriptPath]
|
||||
} else {
|
||||
scriptPath = path.join(app.getPath('temp'), `hermes-uninstall-${Date.now()}.sh`)
|
||||
fs.writeFileSync(scriptPath, buildPosixCleanupScript(scriptArgs), { mode: 0o755 })
|
||||
runner = '/bin/bash'
|
||||
runnerArgs = [scriptPath]
|
||||
}
|
||||
} catch (error) {
|
||||
return { ok: false, error: 'script-write-failed', message: error.message }
|
||||
}
|
||||
|
||||
try {
|
||||
const child = spawn(runner, runnerArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
child.unref()
|
||||
} catch (error) {
|
||||
return { ok: false, error: 'spawn-failed', message: error.message }
|
||||
}
|
||||
|
||||
rememberLog(
|
||||
`[uninstall] launched detached cleanup (${mode}): ${scriptPath} ` +
|
||||
`(removesAgent=${modeRemovesAgent(mode)} removesUserData=${modeRemovesUserData(mode)} bundle=${removeBundle || 'none'})`
|
||||
)
|
||||
|
||||
// Give the renderer a beat to show its "uninstalling…" state, then quit so
|
||||
// the venv python shim + app bundle unlock and the cleanup script can run.
|
||||
setTimeout(() => app.quit(), 800)
|
||||
return { ok: true, mode, willRemoveAppBundle: Boolean(removeBundle), scriptPath }
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary())
|
||||
ipcMain.handle('hermes:uninstall:run', async (_event, payload) => {
|
||||
const mode = payload && typeof payload === 'object' ? payload.mode : payload
|
||||
return runDesktopUninstall(String(mode || ''))
|
||||
})
|
||||
|
||||
|
||||
app.whenReady().then(() => {
|
||||
if (IS_MAC) {
|
||||
Menu.setApplicationMenu(buildApplicationMenu())
|
||||
|
||||
@@ -117,6 +117,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
return () => ipcRenderer.removeListener('hermes:bootstrap:event', listener)
|
||||
},
|
||||
getVersion: () => ipcRenderer.invoke('hermes:version'),
|
||||
uninstall: {
|
||||
summary: () => ipcRenderer.invoke('hermes:uninstall:summary'),
|
||||
run: mode => ipcRenderer.invoke('hermes:uninstall:run', { mode })
|
||||
},
|
||||
updates: {
|
||||
check: () => ipcRenderer.invoke('hermes:updates:check'),
|
||||
apply: opts => ipcRenderer.invoke('hermes:updates:apply', opts),
|
||||
|
||||
@@ -35,7 +35,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-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs",
|
||||
"type-check": "tsc -b",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
||||
BIN
apps/desktop/public/nous-girl.jpg
Normal file
BIN
apps/desktop/public/nous-girl.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -5,7 +5,7 @@ import { useI18n } from '@/i18n'
|
||||
import { COMPLETION_DRAWER_CLASS } from './completion-drawer'
|
||||
|
||||
const COMMON_COMMAND_KEYS = ['/help', '/clear', '/resume', '/details', '/copy', '/quit']
|
||||
const HOTKEY_KEYS = ['@', '/', '?', 'Enter', 'Cmd/Ctrl+K', 'Cmd/Ctrl+L', 'Esc', '↑ / ↓']
|
||||
const HOTKEY_KEYS = ['@', '/', '?', 'Enter', 'Cmd/Ctrl+Shift+K', 'Cmd/Ctrl+/', 'Esc', '↑ / ↓']
|
||||
|
||||
export function HelpHint() {
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -1496,11 +1496,10 @@ export function ChatBar({
|
||||
<div className="relative w-full rounded-[inherit]">
|
||||
<div
|
||||
className={cn(
|
||||
'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))] shadow-composer transition-[border-color,box-shadow] duration-200 ease-out',
|
||||
'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',
|
||||
COMPOSER_DROP_FADE_CLASS,
|
||||
'group-focus-within/composer:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)] group-focus-within/composer:shadow-composer-focus',
|
||||
'group-focus-within/composer:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
|
||||
'group-has-data-[state=open]/composer:border-t-transparent',
|
||||
'group-has-data-[state=open]/composer:shadow-[0_0.0625rem_0_0.0625rem_color-mix(in_srgb,var(--dt-composer-ring)_calc(35%*var(--composer-ring-strength)),transparent),0_0.5rem_1.5rem_color-mix(in_srgb,var(--shadow-ink)_6%,transparent)]',
|
||||
dragActive && COMPOSER_DROP_ACTIVE_CLASS
|
||||
)}
|
||||
data-slot="composer-surface"
|
||||
@@ -1593,7 +1592,7 @@ export function ChatBarFallback() {
|
||||
)}
|
||||
data-slot="composer-root"
|
||||
>
|
||||
<div className="composer-fallback-surface relative isolate h-(--composer-fallback-height) w-full rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] shadow-composer">
|
||||
<div className="composer-fallback-surface relative isolate h-(--composer-fallback-height) w-full rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))]">
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
|
||||
@@ -30,13 +30,13 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-t-2xl border border-b-0 border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] pt-0.5 pb-1">
|
||||
<div className="rounded-t-2xl border border-b-0 border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] pt-0.5 pb-1 mx-1">
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 px-2 py-0.5 text-left text-[0.72rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90"
|
||||
className="flex w-full items-center gap-1.5 px-2 text-left text-[0.6rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90"
|
||||
onClick={() => setCollapsed(open => !open)}
|
||||
type="button"
|
||||
>
|
||||
<DisclosureCaret className="shrink-0" open={!collapsed} size="0.875rem" />
|
||||
<DisclosureCaret className="shrink-0" open={!collapsed} size="1em" />
|
||||
<span className="truncate">{c.queued(entries.length)}</span>
|
||||
</button>
|
||||
|
||||
@@ -64,11 +64,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
|
||||
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p>
|
||||
{(attachmentsCount > 0 || isEditing) && (
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
|
||||
{attachmentsCount > 0 && (
|
||||
<span>
|
||||
{c.attachments(attachmentsCount)}
|
||||
</span>
|
||||
)}
|
||||
{attachmentsCount > 0 && <span>{c.attachments(attachmentsCount)}</span>}
|
||||
{isEditing && (
|
||||
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
|
||||
{c.editingInComposer}
|
||||
|
||||
@@ -38,17 +38,9 @@ export function UrlDialog({
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md gap-5">
|
||||
<DialogHeader className="flex-row items-center gap-3 sm:items-center">
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid size-9 shrink-0 place-items-center rounded-xl bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15"
|
||||
>
|
||||
<Globe className="size-4" />
|
||||
</span>
|
||||
<div className="grid gap-0.5 text-left">
|
||||
<DialogTitle>{c.attachUrlTitle}</DialogTitle>
|
||||
<DialogDescription>{c.attachUrlDesc}</DialogDescription>
|
||||
</div>
|
||||
<DialogHeader>
|
||||
<DialogTitle icon={Globe}>{c.attachUrlTitle}</DialogTitle>
|
||||
<DialogDescription>{c.attachUrlDesc}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
|
||||
325
apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx
Normal file
325
apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getCronJobRuns, type SessionInfo } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $selectedStoredSessionId } from '@/store/session'
|
||||
import type { CronJob } from '@/types/hermes'
|
||||
|
||||
import { jobState, jobTitle, STATE_DOT } from '../../cron/job-state'
|
||||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||
|
||||
const INACTIVE_STATES = new Set(['completed', 'disabled', 'error', 'paused'])
|
||||
|
||||
// Recent runs shown in the inline quick-peek — enough to glance at history
|
||||
// without turning the sidebar into the full Cron page.
|
||||
const PEEK_RUN_LIMIT = 5
|
||||
|
||||
// Runs are written by the background scheduler tick (no UI signal), so poll the
|
||||
// open peek so a freshly-fired run shows up within a few seconds.
|
||||
const PEEK_POLL_INTERVAL_MS = 8000
|
||||
|
||||
const relativeFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' })
|
||||
|
||||
// Localized "in 5 min" / "2 hr ago" without hand-rolled strings — picks the
|
||||
// coarsest sensible unit so a daily job reads "in 14 hr", not "in 840 min".
|
||||
function relativeTime(targetMs: number, nowMs: number): string {
|
||||
const diff = targetMs - nowMs
|
||||
const abs = Math.abs(diff)
|
||||
const sign = diff < 0 ? -1 : 1
|
||||
|
||||
if (abs < 60_000) {return relativeFmt.format(sign * Math.round(abs / 1000), 'second')}
|
||||
|
||||
if (abs < 3_600_000) {return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute')}
|
||||
|
||||
if (abs < 86_400_000) {return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour')}
|
||||
|
||||
return relativeFmt.format(sign * Math.round(abs / 86_400_000), 'day')
|
||||
}
|
||||
|
||||
function nextRunMs(job: CronJob): null | number {
|
||||
if (!job.next_run_at) {return null}
|
||||
|
||||
const ms = Date.parse(job.next_run_at)
|
||||
|
||||
return Number.isNaN(ms) ? null : ms
|
||||
}
|
||||
|
||||
// Runs all belong to the same job, so the run name just repeats the job name —
|
||||
// the timestamp is what tells them apart. Compact (no year, no seconds) for the
|
||||
// narrow sidebar.
|
||||
function formatRunTime(seconds?: null | number): string {
|
||||
if (!seconds) {return '—'}
|
||||
|
||||
const date = new Date(seconds * 1000)
|
||||
|
||||
return Number.isNaN(date.valueOf())
|
||||
? '—'
|
||||
: date.toLocaleString(undefined, { day: 'numeric', hour: 'numeric', minute: '2-digit', month: 'short' })
|
||||
}
|
||||
|
||||
interface SidebarCronJobsSectionProps {
|
||||
jobs: CronJob[]
|
||||
label: string
|
||||
max?: number
|
||||
// Open a run session's chat (1 click to output).
|
||||
onOpenRun: (sessionId: string) => void
|
||||
// Open the full Cron page focused on this job (manage / full history).
|
||||
onManageJob: (jobId: string) => void
|
||||
// Fire the job now.
|
||||
onTriggerJob: (jobId: string) => void
|
||||
onToggle: () => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function SidebarCronJobsSection({
|
||||
jobs,
|
||||
label,
|
||||
max = 50,
|
||||
onManageJob,
|
||||
onOpenRun,
|
||||
onTriggerJob,
|
||||
onToggle,
|
||||
open
|
||||
}: SidebarCronJobsSectionProps) {
|
||||
const [nowMs, setNowMs] = useState(() => Date.now())
|
||||
// Single-open inline peek so the section stays scannable.
|
||||
const [peekJobId, setPeekJobId] = useState<null | string>(null)
|
||||
|
||||
// One clock for the whole section (rows are pure) so the countdowns tick
|
||||
// without re-rendering the rest of the sidebar. Only runs while expanded.
|
||||
useEffect(() => {
|
||||
if (!open) {return}
|
||||
|
||||
const id = window.setInterval(() => setNowMs(Date.now()), 1000)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [open])
|
||||
|
||||
// Upcoming first (soonest next run), jobs with no next run sink to the bottom,
|
||||
// then alphabetical for stability.
|
||||
const sorted = useMemo(() => {
|
||||
return [...jobs].sort((a, b) => {
|
||||
const an = nextRunMs(a)
|
||||
const bn = nextRunMs(b)
|
||||
|
||||
if (an !== null && bn !== null && an !== bn) {return an - bn}
|
||||
|
||||
if (an === null && bn !== null) {return 1}
|
||||
|
||||
if (an !== null && bn === null) {return -1}
|
||||
|
||||
return jobTitle(a).localeCompare(jobTitle(b))
|
||||
})
|
||||
}, [jobs])
|
||||
|
||||
const shown = sorted.slice(0, max)
|
||||
// When capped, signal "50+" rather than implying the list is complete.
|
||||
const countLabel = jobs.length > max ? `${max}+` : String(jobs.length)
|
||||
|
||||
return (
|
||||
<SidebarGroup className="shrink-0 p-0 pb-1">
|
||||
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
|
||||
<button
|
||||
className="group/section-label flex w-fit items-center gap-1 bg-transparent text-left leading-none"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
<SidebarPanelLabel>{label}</SidebarPanelLabel>
|
||||
<span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{countLabel}</span>
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/section-label:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<SidebarGroupContent className="flex max-h-72 shrink-0 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75">
|
||||
{shown.map(job => (
|
||||
<CronJobSidebarRow
|
||||
expanded={peekJobId === job.id}
|
||||
job={job}
|
||||
key={job.id}
|
||||
nowMs={nowMs}
|
||||
onManage={() => onManageJob(job.id)}
|
||||
onOpenRun={onOpenRun}
|
||||
onTogglePeek={() => setPeekJobId(prev => (prev === job.id ? null : job.id))}
|
||||
onTrigger={() => onTriggerJob(job.id)}
|
||||
/>
|
||||
))}
|
||||
</SidebarGroupContent>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function CronJobSidebarRow({
|
||||
expanded,
|
||||
job,
|
||||
nowMs,
|
||||
onManage,
|
||||
onOpenRun,
|
||||
onTogglePeek,
|
||||
onTrigger
|
||||
}: {
|
||||
expanded: boolean
|
||||
job: CronJob
|
||||
nowMs: number
|
||||
onManage: () => void
|
||||
onOpenRun: (sessionId: string) => void
|
||||
onTogglePeek: () => void
|
||||
onTrigger: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.cron
|
||||
const state = jobState(job)
|
||||
const next = nextRunMs(job)
|
||||
const label = jobTitle(job)
|
||||
|
||||
const meta = INACTIVE_STATES.has(state)
|
||||
? (c.states[state] ?? state)
|
||||
: next !== null
|
||||
? relativeTime(next, nowMs)
|
||||
: '—'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="group/cron relative grid min-h-[1.625rem] grid-cols-[minmax(0,1fr)_auto] items-center rounded-md hover:bg-(--chrome-action-hover)">
|
||||
{/* Lead with the dot in the same w-3.5 cell + pl-2 the session rows use
|
||||
so the cron dots line up with the sessions above; the caret sits next
|
||||
to the label (matching the other sidebar disclosures) and the whole
|
||||
label area toggles the run peek. */}
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? c.hideRuns : c.showRuns}
|
||||
className="flex min-w-0 items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
onClick={onTogglePeek}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
<span className="grid w-3.5 shrink-0 place-items-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'size-1 rounded-full',
|
||||
STATE_DOT[state] ?? 'bg-(--ui-text-quaternary)',
|
||||
state === 'running' && 'size-1.5 animate-pulse'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[0.8125rem] text-(--ui-text-secondary) group-hover/cron:text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<DisclosureCaret
|
||||
className={cn(
|
||||
'shrink-0 text-(--ui-text-tertiary) transition',
|
||||
expanded ? 'opacity-100' : 'opacity-0 group-hover/cron:opacity-100'
|
||||
)}
|
||||
open={expanded}
|
||||
/>
|
||||
</button>
|
||||
{/* Trailing cluster: countdown by default, quick actions on hover. */}
|
||||
<div className="flex items-center gap-0.5 justify-self-end pr-1">
|
||||
<span className="text-[0.6875rem] text-(--ui-text-tertiary) tabular-nums group-hover/cron:hidden">
|
||||
{meta}
|
||||
</span>
|
||||
<div className="hidden items-center gap-0.5 group-hover/cron:flex">
|
||||
<Tip label={c.triggerNow}>
|
||||
<button
|
||||
aria-label={c.triggerNow}
|
||||
className="grid size-5 place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
onClick={onTrigger}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="zap" size="0.75rem" />
|
||||
</button>
|
||||
</Tip>
|
||||
<Tip label={c.manage}>
|
||||
<button
|
||||
aria-label={c.manage}
|
||||
className="grid size-5 place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
onClick={onManage}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="watch" size="0.75rem" />
|
||||
</button>
|
||||
</Tip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && <CronJobSidebarRuns jobId={job.id} onOpenRun={onOpenRun} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CronJobSidebarRuns({
|
||||
jobId,
|
||||
onOpenRun
|
||||
}: {
|
||||
jobId: string
|
||||
onOpenRun: (sessionId: string) => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.cron
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
const [runs, setRuns] = useState<null | SessionInfo[]>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = () =>
|
||||
getCronJobRuns(jobId, PEEK_RUN_LIMIT)
|
||||
.then(result => {
|
||||
if (!cancelled) {setRuns(result)}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {setRuns(prev => prev ?? [])}
|
||||
})
|
||||
|
||||
void load()
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible') {void load()}
|
||||
}, PEEK_POLL_INTERVAL_MS)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(intervalId)
|
||||
}
|
||||
}, [jobId])
|
||||
|
||||
return (
|
||||
<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 />
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="py-1 pl-1 text-[0.6875rem] text-(--ui-text-tertiary)">{c.noRuns}</div>
|
||||
) : (
|
||||
<>
|
||||
{runs.map(run => (
|
||||
<button
|
||||
className={cn(
|
||||
'truncate rounded-md px-1.5 py-0.5 text-left text-[0.6875rem] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40',
|
||||
run.id === selectedSessionId
|
||||
? 'bg-(--ui-row-active-background) text-foreground'
|
||||
: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
key={run.id}
|
||||
onClick={() => onOpenRun(run.id)}
|
||||
type="button"
|
||||
>
|
||||
{formatRunTime(run.last_active || run.started_at)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
@@ -40,16 +40,20 @@ import { useI18n } from '@/i18n'
|
||||
import { profileColor } from '@/lib/profile-color'
|
||||
import { sessionMatchesSearch } from '@/lib/session-search'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $cronJobs } from '@/store/cron'
|
||||
import {
|
||||
$panesFlipped,
|
||||
$pinnedSessionIds,
|
||||
$sidebarAgentsGrouped,
|
||||
$sidebarCronOpen,
|
||||
$sidebarOpen,
|
||||
$sidebarPinsOpen,
|
||||
$sidebarRecentsOpen,
|
||||
pinSession,
|
||||
reorderPinnedSession,
|
||||
SESSION_SEARCH_FOCUS_EVENT,
|
||||
setSidebarAgentsGrouped,
|
||||
setSidebarCronOpen,
|
||||
setSidebarPinsOpen,
|
||||
setSidebarRecentsOpen,
|
||||
SIDEBAR_SESSIONS_PAGE_SIZE,
|
||||
@@ -64,6 +68,7 @@ import {
|
||||
normalizeProfileKey
|
||||
} from '@/store/profile'
|
||||
import {
|
||||
$cronSessions,
|
||||
$selectedStoredSessionId,
|
||||
$sessionProfileTotals,
|
||||
$sessions,
|
||||
@@ -77,6 +82,7 @@ import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '..
|
||||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||
import type { SidebarNavItem } from '../../types'
|
||||
|
||||
import { SidebarCronJobsSection } from './cron-jobs-section'
|
||||
import { ProfileRail } from './profile-switcher'
|
||||
import { SidebarSessionRow } from './session-row'
|
||||
import { VirtualSessionList } from './virtual-session-list'
|
||||
@@ -222,6 +228,8 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
onArchiveSession: (sessionId: string) => void
|
||||
onNewSessionInWorkspace: (path: null | string) => void
|
||||
onManageCronJob: (jobId: string) => void
|
||||
onTriggerCronJob: (jobId: string) => void
|
||||
}
|
||||
|
||||
export function ChatSidebar({
|
||||
@@ -232,7 +240,9 @@ export function ChatSidebar({
|
||||
onResumeSession,
|
||||
onDeleteSession,
|
||||
onArchiveSession,
|
||||
onNewSessionInWorkspace
|
||||
onNewSessionInWorkspace,
|
||||
onManageCronJob,
|
||||
onTriggerCronJob
|
||||
}: ChatSidebarProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
@@ -242,8 +252,11 @@ export function ChatSidebar({
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const pinsOpen = useStore($sidebarPinsOpen)
|
||||
const agentsOpen = useStore($sidebarRecentsOpen)
|
||||
const cronOpen = useStore($sidebarCronOpen)
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
const sessions = useStore($sessions)
|
||||
const cronSessions = useStore($cronSessions)
|
||||
const cronJobs = useStore($cronJobs)
|
||||
const sessionsLoading = useStore($sessionsLoading)
|
||||
const sessionsTotal = useStore($sessionsTotal)
|
||||
const sessionProfileTotals = useStore($sessionProfileTotals)
|
||||
@@ -263,8 +276,18 @@ export function ChatSidebar({
|
||||
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
|
||||
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
|
||||
const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const trimmedQuery = searchQuery.trim()
|
||||
|
||||
// Hotkey (session.focusSearch) → focus the field once it's mounted.
|
||||
useEffect(() => {
|
||||
const onFocus = () => searchInputRef.current?.focus({ preventScroll: true })
|
||||
|
||||
window.addEventListener(SESSION_SEARCH_FOCUS_EVENT, onFocus)
|
||||
|
||||
return () => window.removeEventListener(SESSION_SEARCH_FOCUS_EVENT, onFocus)
|
||||
}, [])
|
||||
|
||||
// Flash the ⌘N hint full-opacity (no transition) for the press, so hitting
|
||||
// the shortcut visibly pings its affordance in the sidebar.
|
||||
useEffect(() => {
|
||||
@@ -312,7 +335,10 @@ export function ChatSidebar({
|
||||
const sessionByAnyId = useMemo(() => {
|
||||
const map = new Map<string, SessionInfo>()
|
||||
|
||||
for (const s of visibleSessions) {
|
||||
// Cron sessions are listed separately but can still be pinned, so index
|
||||
// them too — otherwise a pinned cron job can't resolve into the Pinned
|
||||
// section. Recents take precedence on id collisions (set last).
|
||||
for (const s of [...cronSessions, ...visibleSessions]) {
|
||||
map.set(s.id, s)
|
||||
|
||||
if (s._lineage_root_id && !map.has(s._lineage_root_id)) {
|
||||
@@ -321,7 +347,7 @@ export function ChatSidebar({
|
||||
}
|
||||
|
||||
return map
|
||||
}, [visibleSessions])
|
||||
}, [visibleSessions, cronSessions])
|
||||
|
||||
const pinnedSessions = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
@@ -471,7 +497,9 @@ export function ChatSidebar({
|
||||
])
|
||||
|
||||
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
|
||||
|
||||
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
|
||||
|
||||
// Pagination is scope-aware. In "All profiles" mode it tracks the global
|
||||
// unified set. When scoped to one profile it must compare that profile's own
|
||||
// loaded rows against that profile's total — otherwise a huge default profile
|
||||
@@ -621,6 +649,7 @@ export function ChatSidebar({
|
||||
<div className="shrink-0 px-2 pb-1 pt-1">
|
||||
<SearchField
|
||||
aria-label={s.searchAria}
|
||||
inputRef={searchInputRef}
|
||||
onChange={setSearchQuery}
|
||||
placeholder={s.searchPlaceholder}
|
||||
value={searchQuery}
|
||||
@@ -747,6 +776,18 @@ export function ChatSidebar({
|
||||
/>
|
||||
)}
|
||||
|
||||
{sidebarOpen && !trimmedQuery && cronJobs.length > 0 && (
|
||||
<SidebarCronJobsSection
|
||||
jobs={cronJobs}
|
||||
label={s.cronJobs}
|
||||
onManageJob={onManageCronJob}
|
||||
onOpenRun={onResumeSession}
|
||||
onToggle={() => setSidebarCronOpen(!cronOpen)}
|
||||
onTriggerJob={onTriggerCronJob}
|
||||
open={cronOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sidebarOpen && !showSessionSections && <div className="min-h-0 flex-1" />}
|
||||
|
||||
{sidebarOpen && (
|
||||
|
||||
@@ -34,6 +34,7 @@ import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
$profileColors,
|
||||
$profileCreateRequest,
|
||||
$profileOrder,
|
||||
$profiles,
|
||||
$profileScope,
|
||||
@@ -178,6 +179,20 @@ export function ProfileRail() {
|
||||
void refreshActiveProfile()
|
||||
}, [])
|
||||
|
||||
// Open the create dialog when the `profile.create` hotkey fires (the dialog
|
||||
// state lives here, so the global keybind bumps a request atom we watch).
|
||||
const createRequest = useStore($profileCreateRequest)
|
||||
const lastCreateRef = useRef(createRequest)
|
||||
|
||||
useEffect(() => {
|
||||
if (createRequest === lastCreateRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastCreateRef.current = createRequest
|
||||
setCreateOpen(true)
|
||||
}, [createRequest])
|
||||
|
||||
return (
|
||||
<div aria-label="Profiles" className="flex items-center gap-0.5" role="tablist">
|
||||
{/* One button toggles default ↔ all: home face when scoped to a profile,
|
||||
@@ -199,7 +214,12 @@ export function ProfileRail() {
|
||||
|
||||
{/* Single-profile: the active default's home icon next to the create +. */}
|
||||
{!multiProfile && defaultProfile && (
|
||||
<ProfilePill active glyph="home" label={defaultProfile.name} onSelect={() => selectProfile(defaultProfile.name)} />
|
||||
<ProfilePill
|
||||
active
|
||||
glyph="home"
|
||||
label={defaultProfile.name}
|
||||
onSelect={() => selectProfile(defaultProfile.name)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,114 +0,0 @@
|
||||
import type * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
|
||||
interface CronJobActions {
|
||||
busy?: boolean
|
||||
isPaused: boolean
|
||||
title: string
|
||||
onDelete: () => void
|
||||
onEdit: () => void
|
||||
onPauseResume: () => void
|
||||
onTrigger: () => void
|
||||
}
|
||||
|
||||
interface CronJobActionsMenuProps
|
||||
extends CronJobActions, Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function CronJobActionsMenu({
|
||||
align = 'end',
|
||||
busy = false,
|
||||
children,
|
||||
isPaused,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onPauseResume,
|
||||
onTrigger,
|
||||
sideOffset = 6,
|
||||
title
|
||||
}: CronJobActionsMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.cron
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
aria-label={c.actionsFor(title)}
|
||||
className="w-44"
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
disabled={busy}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onPauseResume()
|
||||
}}
|
||||
>
|
||||
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
|
||||
<span>{isPaused ? c.resumeTitle : c.pauseTitle}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={busy}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onTrigger()
|
||||
}}
|
||||
>
|
||||
<Codicon name="zap" size="0.875rem" />
|
||||
<span>{c.triggerNow}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onEdit()
|
||||
}}
|
||||
>
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
<span>{c.edit}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
triggerHaptic('warning')
|
||||
onDelete()
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
<span>{t.common.delete}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
interface CronJobActionsTriggerProps extends Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'> {
|
||||
title: string
|
||||
}
|
||||
|
||||
export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={t.cron.actionsFor(title)}
|
||||
className={className}
|
||||
size="icon-sm"
|
||||
title={t.cron.actionsTitle}
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
<Codicon className="text-muted-foreground" name="ellipsis" size="0.875rem" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -13,29 +14,33 @@ import {
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
createCronJob,
|
||||
type CronJob,
|
||||
deleteCronJob,
|
||||
getCronJobRuns,
|
||||
getCronJobs,
|
||||
pauseCronJob,
|
||||
resumeCronJob,
|
||||
type SessionInfo,
|
||||
triggerCronJob,
|
||||
updateCronJob
|
||||
} from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertTriangle, Clock } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $cronFocusJobId, $cronJobs, setCronFocusJobId, setCronJobs, updateCronJobs } from '@/store/cron'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { OverlayMain, OverlayNewButton, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
import { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu'
|
||||
import { jobState, jobTitle, STATE_DOT } from './job-state'
|
||||
|
||||
const DEFAULT_DELIVER = 'local'
|
||||
|
||||
@@ -80,28 +85,6 @@ function jobPrompt(job: CronJob): string {
|
||||
return asText(job.prompt)
|
||||
}
|
||||
|
||||
function jobTitle(job: CronJob): string {
|
||||
const name = jobName(job)
|
||||
|
||||
if (name) {
|
||||
return name
|
||||
}
|
||||
|
||||
const prompt = jobPrompt(job)
|
||||
|
||||
if (prompt) {
|
||||
return truncate(prompt, 60)
|
||||
}
|
||||
|
||||
const script = asText(job.script)
|
||||
|
||||
if (script) {
|
||||
return truncate(script, 60)
|
||||
}
|
||||
|
||||
return job.id || 'Cron job'
|
||||
}
|
||||
|
||||
function jobScheduleDisplay(job: CronJob): string {
|
||||
return asText(job.schedule_display) || asText(job.schedule?.display) || asText(job.schedule?.expr) || '—'
|
||||
}
|
||||
@@ -110,10 +93,6 @@ function jobScheduleExpr(job: CronJob): string {
|
||||
return asText(job.schedule?.expr) || asText(job.schedule_display) || ''
|
||||
}
|
||||
|
||||
function jobState(job: CronJob): string {
|
||||
return asText(job.state) || (job.enabled === false ? 'disabled' : 'scheduled')
|
||||
}
|
||||
|
||||
function jobDeliver(job: CronJob): string {
|
||||
return asText(job.deliver) || DEFAULT_DELIVER
|
||||
}
|
||||
@@ -261,31 +240,38 @@ function matchesQuery(job: CronJob, q: string): boolean {
|
||||
|
||||
interface CronViewProps extends React.ComponentProps<'section'> {
|
||||
onClose: () => void
|
||||
onOpenSession?: (sessionId: string) => void
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
}
|
||||
|
||||
export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: CronViewProps) {
|
||||
export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setStatusbarItemGroup }: CronViewProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.cron
|
||||
const [jobs, setJobs] = useState<CronJob[] | null>(null)
|
||||
// Source of truth is the shared atom (also fed by the controller poll), so the
|
||||
// sidebar and this overlay never drift — a delete here clears the sidebar row
|
||||
// immediately. `loading` only gates the first paint before the atom is filled.
|
||||
const jobs = useStore($cronJobs)
|
||||
const [loading, setLoading] = useState(jobs.length === 0)
|
||||
const [query, setQuery] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [busyJobId, setBusyJobId] = useState<null | string>(null)
|
||||
// Master/detail: the job whose schedule + run history fill the right pane.
|
||||
const [selectedJobId, setSelectedJobId] = useState<null | string>(null)
|
||||
// Set when a job is opened from the sidebar so we scroll it into view once the
|
||||
// row exists. Cleared after the scroll fires.
|
||||
const pendingScrollRef = useRef<null | string>(null)
|
||||
const focusJobId = useStore($cronFocusJobId)
|
||||
|
||||
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
|
||||
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const result = await getCronJobs()
|
||||
setJobs(result)
|
||||
setCronJobs(await getCronJobs())
|
||||
} catch (err) {
|
||||
notifyError(err, c.failedLoad)
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
setLoading(false)
|
||||
}
|
||||
}, [c])
|
||||
|
||||
@@ -295,16 +281,47 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
const visibleJobs = useMemo(() => {
|
||||
if (!jobs) {
|
||||
return []
|
||||
// Sidebar → "open this job": resolve the focus id (or name) to a job, select
|
||||
// it, queue a scroll, then clear the one-shot focus so re-opening cron
|
||||
// normally doesn't re-trigger it.
|
||||
useEffect(() => {
|
||||
if (!focusJobId) {return}
|
||||
|
||||
const match = jobs.find(job => job.id === focusJobId || jobName(job) === focusJobId)
|
||||
|
||||
if (match) {
|
||||
setSelectedJobId(match.id)
|
||||
pendingScrollRef.current = match.id
|
||||
}
|
||||
|
||||
return jobs.filter(job => matchesQuery(job, query.trim())).sort((a, b) => jobTitle(a).localeCompare(jobTitle(b)))
|
||||
}, [jobs, query])
|
||||
setCronFocusJobId(null)
|
||||
}, [focusJobId, jobs])
|
||||
|
||||
const enabledCount = jobs?.filter(job => job.enabled).length ?? 0
|
||||
const totalCount = jobs?.length ?? 0
|
||||
const visibleJobs = useMemo(
|
||||
() => jobs.filter(job => matchesQuery(job, query.trim())).sort((a, b) => jobTitle(a).localeCompare(jobTitle(b))),
|
||||
[jobs, query]
|
||||
)
|
||||
|
||||
// Detail always reflects a concrete job: the explicitly selected one, else the
|
||||
// first visible row, so the right pane is never empty while jobs exist.
|
||||
const selectedJob = useMemo(
|
||||
() => visibleJobs.find(job => job.id === selectedJobId) ?? visibleJobs[0] ?? null,
|
||||
[visibleJobs, selectedJobId]
|
||||
)
|
||||
|
||||
// Scroll a sidebar-opened job into view once its list row is mounted.
|
||||
useEffect(() => {
|
||||
const target = pendingScrollRef.current
|
||||
|
||||
if (!target || selectedJob?.id !== target) {return}
|
||||
|
||||
pendingScrollRef.current = null
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelector(`[data-cron-row="${CSS.escape(target)}"]`)?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
}, [selectedJob])
|
||||
|
||||
const totalCount = jobs.length
|
||||
|
||||
async function handlePauseResume(job: CronJob) {
|
||||
setBusyJobId(job.id)
|
||||
@@ -312,7 +329,7 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
|
||||
try {
|
||||
const isPaused = jobState(job) === 'paused'
|
||||
const updated = isPaused ? await resumeCronJob(job.id) : await pauseCronJob(job.id)
|
||||
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
|
||||
updateCronJobs(rows => rows.map(row => (row.id === job.id ? updated : row)))
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: isPaused ? c.resumed : c.paused,
|
||||
@@ -330,7 +347,7 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
|
||||
|
||||
try {
|
||||
const updated = await triggerCronJob(job.id)
|
||||
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
|
||||
updateCronJobs(rows => rows.map(row => (row.id === job.id ? updated : row)))
|
||||
notify({ kind: 'success', title: c.triggered, message: truncate(jobTitle(job), 60) })
|
||||
} catch (err) {
|
||||
notifyError(err, c.failedTrigger)
|
||||
@@ -348,7 +365,7 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
|
||||
|
||||
try {
|
||||
await deleteCronJob(pendingDelete.id)
|
||||
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
|
||||
updateCronJobs(rows => rows.filter(row => row.id !== pendingDelete.id))
|
||||
notify({ kind: 'success', title: c.deleted, message: truncate(jobTitle(pendingDelete), 60) })
|
||||
setPendingDelete(null)
|
||||
} catch (err) {
|
||||
@@ -367,7 +384,7 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
|
||||
deliver: values.deliver || DEFAULT_DELIVER
|
||||
})
|
||||
|
||||
setJobs(current => (current ? [...current, created] : [created]))
|
||||
updateCronJobs(rows => [...rows, created])
|
||||
notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) })
|
||||
} else if (editor.mode === 'edit') {
|
||||
const updated = await updateCronJob(editor.job.id, {
|
||||
@@ -377,7 +394,7 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
|
||||
deliver: values.deliver
|
||||
})
|
||||
|
||||
setJobs(current => (current ? current.map(row => (row.id === updated.id ? updated : row)) : current))
|
||||
updateCronJobs(rows => rows.map(row => (row.id === updated.id ? updated : row)))
|
||||
notify({ kind: 'success', title: c.updated, message: truncate(jobTitle(updated), 60) })
|
||||
}
|
||||
|
||||
@@ -386,71 +403,62 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
|
||||
|
||||
return (
|
||||
<OverlayView closeLabel={c.close} onClose={onClose}>
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
onSearchChange={setQuery}
|
||||
searchPlaceholder={c.search}
|
||||
searchTrailingAction={
|
||||
<Button
|
||||
aria-label={refreshing ? c.refreshing : c.refresh}
|
||||
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refresh()}
|
||||
size="icon-xs"
|
||||
title={refreshing ? c.refreshing : c.refresh}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
|
||||
</Button>
|
||||
}
|
||||
searchValue={query}
|
||||
>
|
||||
{!jobs ? (
|
||||
<PageLoader label={c.loading} />
|
||||
) : visibleJobs.length === 0 ? (
|
||||
// Empty state owns the primary "create" CTA — we used to also have
|
||||
// one in the filters bar but it was redundant. Only show the button
|
||||
// when there are zero jobs total; the search-empty case ("No
|
||||
// matches") just asks the user to broaden their query.
|
||||
<EmptyState
|
||||
actionLabel={totalCount === 0 ? c.createFirst : undefined}
|
||||
description={totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}
|
||||
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
|
||||
title={totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
{/* Inline header replaces the old top-bar "New cron" button. We
|
||||
still need a single, always-visible affordance to add a job
|
||||
when the list is non-empty (rows themselves only expose
|
||||
edit/pause/trigger/delete). */}
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
|
||||
{c.active(enabledCount, totalCount)}
|
||||
</span>
|
||||
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
|
||||
<Codicon name="add" />
|
||||
{c.newCron}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobRow
|
||||
busy={busyJobId === job.id}
|
||||
c={c}
|
||||
job={job}
|
||||
key={job.id}
|
||||
onDelete={() => setPendingDelete(job)}
|
||||
onEdit={() => setEditor({ mode: 'edit', job })}
|
||||
onPauseResume={() => void handlePauseResume(job)}
|
||||
onTrigger={() => void handleTrigger(job)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
{loading && jobs.length === 0 ? (
|
||||
<PageLoader label={c.loading} />
|
||||
) : (
|
||||
<OverlaySplitLayout>
|
||||
<OverlaySidebar>
|
||||
<OverlayNewButton label={c.newCron} onClick={() => setEditor({ mode: 'create' })} />
|
||||
{totalCount > 0 && (
|
||||
<SearchField
|
||||
aria-label={c.search}
|
||||
containerClassName="mb-1 w-full px-2"
|
||||
onChange={setQuery}
|
||||
placeholder={c.search}
|
||||
value={query}
|
||||
/>
|
||||
)}
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobListRow
|
||||
active={selectedJob?.id === job.id}
|
||||
c={c}
|
||||
job={job}
|
||||
key={job.id}
|
||||
onSelect={() => setSelectedJobId(job.id)}
|
||||
/>
|
||||
))}
|
||||
{visibleJobs.length === 0 && (
|
||||
<p className="px-2 py-4 text-center text-xs text-muted-foreground">
|
||||
{totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch}
|
||||
</p>
|
||||
)}
|
||||
</OverlaySidebar>
|
||||
|
||||
<OverlayMain className="px-0">
|
||||
{selectedJob ? (
|
||||
<CronJobDetail
|
||||
busy={busyJobId === selectedJob.id}
|
||||
c={c}
|
||||
job={selectedJob}
|
||||
onDelete={() => setPendingDelete(selectedJob)}
|
||||
onEdit={() => setEditor({ mode: 'edit', job: selectedJob })}
|
||||
onOpenSession={onOpenSession}
|
||||
onPauseResume={() => void handlePauseResume(selectedJob)}
|
||||
onTrigger={() => void handleTrigger(selectedJob)}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
|
||||
<div>
|
||||
<Clock className="mx-auto size-6 text-muted-foreground/60" />
|
||||
<p className="mt-3">{totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
)}
|
||||
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
<DialogContent className="max-w-md">
|
||||
@@ -476,17 +484,52 @@ export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGrou
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageSearchShell>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
function CronJobRow({
|
||||
function CronJobListRow({
|
||||
active,
|
||||
c,
|
||||
job,
|
||||
onSelect
|
||||
}: {
|
||||
active: boolean
|
||||
c: Translations['cron']
|
||||
job: CronJob
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const state = jobState(job)
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors',
|
||||
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
|
||||
)}
|
||||
data-cron-row={job.id}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex w-full items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn('size-1.5 shrink-0 rounded-full', STATE_DOT[state] ?? 'bg-muted-foreground')}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{jobTitle(job)}</span>
|
||||
</span>
|
||||
<span className="truncate pl-3.5 text-[0.66rem] text-muted-foreground">{jobScheduleDisplay(job)}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CronJobDetail({
|
||||
busy,
|
||||
c,
|
||||
job,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onOpenSession,
|
||||
onPauseResume,
|
||||
onTrigger
|
||||
}: {
|
||||
@@ -495,71 +538,172 @@ function CronJobRow({
|
||||
job: CronJob
|
||||
onDelete: () => void
|
||||
onEdit: () => void
|
||||
onOpenSession?: (sessionId: string) => void
|
||||
onPauseResume: () => void
|
||||
onTrigger: () => void
|
||||
}) {
|
||||
const state = jobState(job)
|
||||
const isPaused = state === 'paused'
|
||||
const hasName = Boolean(jobName(job))
|
||||
const prompt = jobPrompt(job)
|
||||
const deliver = jobDeliver(job)
|
||||
const prompt = jobPrompt(job)
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 px-3 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
|
||||
<button
|
||||
className="min-w-0 cursor-pointer rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
onClick={onEdit}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{jobTitle(job)}</span>
|
||||
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</StatePill>
|
||||
{deliver && deliver !== DEFAULT_DELIVER && (
|
||||
<StatePill tone="muted">{c.deliveryLabels[deliver] ?? deliver}</StatePill>
|
||||
)}
|
||||
</div>
|
||||
{hasName && prompt && <p className="mt-1 truncate text-xs text-muted-foreground">{truncate(prompt, 120)}</p>}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-[0.68rem] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1 font-mono">
|
||||
<Clock className="size-3" />
|
||||
{jobScheduleDisplay(job)}
|
||||
</span>
|
||||
<span>
|
||||
{c.last} {formatTime(job.last_run_at)}
|
||||
</span>
|
||||
<span>
|
||||
{c.next} {formatTime(job.next_run_at)}
|
||||
</span>
|
||||
</div>
|
||||
{job.last_error && (
|
||||
<p className="mt-1 inline-flex items-start gap-1 text-[0.68rem] text-destructive">
|
||||
<AlertTriangle className="mt-px size-3 shrink-0" />
|
||||
<span className="line-clamp-2">{job.last_error}</span>
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-2xl space-y-6 px-6 py-6">
|
||||
<header className="space-y-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-xl font-semibold tracking-tight">{jobTitle(job)}</h3>
|
||||
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</StatePill>
|
||||
{deliver && deliver !== DEFAULT_DELIVER && (
|
||||
<StatePill tone="muted">{c.deliveryLabels[deliver] ?? deliver}</StatePill>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[0.7rem] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{jobScheduleDisplay(job)}
|
||||
</span>
|
||||
<span>
|
||||
{c.last} {formatTime(job.last_run_at)}
|
||||
</span>
|
||||
<span>
|
||||
{c.next} {formatTime(job.next_run_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button disabled={busy} onClick={onPauseResume} size="sm" variant="outline">
|
||||
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
|
||||
{isPaused ? c.resumeTitle : c.pauseTitle}
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={onTrigger} size="sm" variant="outline">
|
||||
<Codicon name="zap" size="0.875rem" />
|
||||
{c.triggerNow}
|
||||
</Button>
|
||||
<Button onClick={onEdit} size="sm" variant="outline">
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
{c.edit}
|
||||
</Button>
|
||||
<Button
|
||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center">
|
||||
<CronJobActionsMenu
|
||||
busy={busy}
|
||||
isPaused={isPaused}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onPauseResume={onPauseResume}
|
||||
onTrigger={onTrigger}
|
||||
title={jobTitle(job)}
|
||||
>
|
||||
<CronJobActionsTrigger
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={event => event.stopPropagation()}
|
||||
title={jobTitle(job)}
|
||||
/>
|
||||
</CronJobActionsMenu>
|
||||
{prompt && <p className="line-clamp-3 text-xs text-muted-foreground">{prompt}</p>}
|
||||
{job.last_error && (
|
||||
<p className="inline-flex items-start gap-1 text-[0.7rem] text-destructive">
|
||||
<AlertTriangle className="mt-px size-3 shrink-0" />
|
||||
<span className="line-clamp-2">{job.last_error}</span>
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<CronJobRuns c={c} jobId={job.id} onOpenSession={onOpenSession} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatRunTime(seconds?: null | number): string {
|
||||
if (!seconds) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
const date = new Date(seconds * 1000)
|
||||
|
||||
return Number.isNaN(date.valueOf()) ? '—' : date.toLocaleString()
|
||||
}
|
||||
|
||||
// Runs are produced by the background scheduler tick (no UI signal), so poll
|
||||
// while the panel is open + on tab re-focus so a fired run shows up within a few
|
||||
// seconds instead of waiting for a reload.
|
||||
const RUNS_POLL_INTERVAL_MS = 8000
|
||||
|
||||
function CronJobRuns({
|
||||
c,
|
||||
jobId,
|
||||
onOpenSession
|
||||
}: {
|
||||
c: Translations['cron']
|
||||
jobId: string
|
||||
onOpenSession?: (sessionId: string) => void
|
||||
}) {
|
||||
const [runs, setRuns] = useState<null | SessionInfo[]>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = () =>
|
||||
getCronJobRuns(jobId)
|
||||
.then(result => {
|
||||
if (!cancelled) {setRuns(result)}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {setRuns(prev => prev ?? [])}
|
||||
})
|
||||
|
||||
void load()
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible') {void load()}
|
||||
}, RUNS_POLL_INTERVAL_MS)
|
||||
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') {void load()}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(intervalId)
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
}
|
||||
}, [jobId])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[0.62rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{c.runHistory}
|
||||
{runs && runs.length > 0 ? ` · ${runs.length}` : ''}
|
||||
</div>
|
||||
{runs === null ? (
|
||||
<div className="flex items-center gap-1.5 py-1 text-xs text-muted-foreground">
|
||||
<Codicon name="loading" size="0.75rem" spinning />
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="py-1 text-xs text-muted-foreground">{c.noRuns}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-px">
|
||||
{runs.map(run => (
|
||||
<button
|
||||
className="flex items-center justify-between gap-3 rounded-md px-2 py-1 text-left text-xs hover:bg-(--chrome-action-hover) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
key={run.id}
|
||||
onClick={() => onOpenSession?.(run.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate text-foreground">{run.title?.trim() || run.preview?.trim() || run.id}</span>
|
||||
<span className="shrink-0 text-[0.62rem] text-muted-foreground tabular-nums">
|
||||
{formatRunTime(run.last_active || run.started_at)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) {
|
||||
return (
|
||||
<span
|
||||
@@ -570,33 +714,6 @@ function StatePill({ children, tone }: { children: string; tone: keyof typeof PI
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
actionLabel,
|
||||
description,
|
||||
onAction,
|
||||
title
|
||||
}: {
|
||||
actionLabel?: string
|
||||
description: string
|
||||
onAction?: () => void
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<div className="grid h-full place-items-center px-6 py-12 text-center">
|
||||
<div className="max-w-sm space-y-2">
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
{actionLabel && onAction && (
|
||||
<Button className="mt-2" onClick={onAction} size="sm">
|
||||
<Codicon name="add" />
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CronEditorDialog({
|
||||
editor,
|
||||
onClose,
|
||||
@@ -753,7 +870,7 @@ function CronEditorDialog({
|
||||
<FieldHint>{c.customHint}</FieldHint>
|
||||
</Field>
|
||||
) : (
|
||||
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2">
|
||||
<div className="rounded-md bg-(--ui-bg-quinary) px-3 py-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||
<span className="font-medium text-foreground">{scheduleHint}</span>
|
||||
<span className="font-mono text-muted-foreground">{schedule}</span>
|
||||
@@ -762,7 +879,7 @@ function CronEditorDialog({
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<div className="flex items-start gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
|
||||
29
apps/desktop/src/app/cron/job-state.ts
Normal file
29
apps/desktop/src/app/cron/job-state.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { CronJob } from '@/types/hermes'
|
||||
|
||||
// Status-pip color per cron job state. Single source for the sidebar section and
|
||||
// the Cron page so the two never drift. (Animation/size live at the call site.)
|
||||
export const STATE_DOT: Record<string, string> = {
|
||||
completed: 'bg-(--ui-text-quaternary)',
|
||||
disabled: 'bg-(--ui-text-quaternary)',
|
||||
enabled: 'bg-primary',
|
||||
error: 'bg-destructive',
|
||||
paused: 'bg-amber-500',
|
||||
running: 'bg-primary',
|
||||
scheduled: 'bg-primary'
|
||||
}
|
||||
|
||||
// Effective state: explicit state wins; otherwise infer from the enabled flag.
|
||||
export function jobState(job: CronJob): string {
|
||||
const state = typeof job.state === 'string' ? job.state.trim() : ''
|
||||
|
||||
return state || (job.enabled === false ? 'disabled' : 'scheduled')
|
||||
}
|
||||
|
||||
// Human label for a job: name → first 60 of prompt → first 60 of script → id.
|
||||
// One source for the sidebar row and the Cron page so the two never drift.
|
||||
export function jobTitle(job: CronJob): string {
|
||||
const pick = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
|
||||
const clip = (v: string) => (v.length > 60 ? `${v.slice(0, 60)}…` : v)
|
||||
|
||||
return pick(job.name) || clip(pick(job.prompt)) || clip(pick(job.script)) || job.id || 'Cron job'
|
||||
}
|
||||
@@ -11,9 +11,9 @@ import { Pane, PaneMain } from '@/components/pane-shell'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||
import { getSessionMessages, listAllProfileSessions, type SessionInfo } from '../hermes'
|
||||
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
|
||||
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
|
||||
import { toggleCommandPalette } from '../store/command-palette'
|
||||
import { setCronFocusJobId, setCronJobs } from '../store/cron'
|
||||
import {
|
||||
$panesFlipped,
|
||||
$pinnedSessionIds,
|
||||
@@ -29,7 +29,14 @@ import {
|
||||
unpinSession
|
||||
} from '../store/layout'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
|
||||
import { $activeGatewayProfile, $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
$freshSessionRequest,
|
||||
$profileScope,
|
||||
ALL_PROFILES,
|
||||
normalizeProfileKey,
|
||||
refreshActiveProfile
|
||||
} from '../store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentCwd,
|
||||
@@ -38,10 +45,12 @@ import {
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
$workingSessionIds,
|
||||
CRON_SECTION_LIMIT,
|
||||
mergeSessionPage,
|
||||
sessionPinId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setCronSessions,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentModel,
|
||||
@@ -66,12 +75,13 @@ import { ChatSidebar } from './chat/sidebar'
|
||||
import { CommandPalette } from './command-palette'
|
||||
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
|
||||
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
|
||||
import { useKeybinds } from './hooks/use-keybinds'
|
||||
import { ModelPickerOverlay } from './model-picker-overlay'
|
||||
import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
||||
import { RightSidebarPane } from './right-sidebar'
|
||||
import { $terminalTakeover } from './right-sidebar/store'
|
||||
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
||||
import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
|
||||
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
|
||||
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
|
||||
import { useCwdActions } from './session/hooks/use-cwd-actions'
|
||||
import { useHermesConfig } from './session/hooks/use-hermes-config'
|
||||
@@ -101,6 +111,21 @@ const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).P
|
||||
const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView }))
|
||||
const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView }))
|
||||
|
||||
// Latest cron-job sessions surfaced in the collapsed "Cron jobs" section. The
|
||||
// Cron sessions are written by a background scheduler tick (the desktop
|
||||
// backend), so no user action signals the UI. Poll the bounded cron list on
|
||||
// this cadence while the app is open + visible so new runs surface promptly
|
||||
// instead of waiting for the next user-triggered refreshSessions().
|
||||
const CRON_POLL_INTERVAL_MS = 30_000
|
||||
|
||||
// Cheap signature compare so the poll only swaps the atom (and re-renders the
|
||||
// sidebar) when the visible cron rows actually changed.
|
||||
function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
|
||||
if (a.length !== b.length) {return false}
|
||||
|
||||
return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title)
|
||||
}
|
||||
|
||||
// Rows a session refresh must preserve even if the aggregator omits them:
|
||||
// in-flight first turns (message_count 0), pinned rows aged off the page, and
|
||||
// the actively-viewed chat (its "working" flag clears a beat before the
|
||||
@@ -139,6 +164,7 @@ export function DesktopController() {
|
||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const profileScope = useStore($profileScope)
|
||||
|
||||
const routedSessionId = routeSessionId(location.pathname)
|
||||
const routeToken = `${location.pathname}:${location.search}:${location.hash}`
|
||||
@@ -224,30 +250,35 @@ export function DesktopController() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Global chrome shortcuts (plain Cmd/Ctrl, no alt/shift): Cmd+K / Cmd+P →
|
||||
// command palette (the composer's "drain next queued" moved to Cmd+Shift+K),
|
||||
// Cmd+. → command center (sessions / system / usage).
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) {
|
||||
return
|
||||
}
|
||||
// Cron-job sessions as their own list (latest N). Independent of the recents
|
||||
// page so the two never compete for slots. Cheap + bounded. Kept (even though
|
||||
// the sidebar now lists cron *jobs*, not run sessions) so a pinned cron run
|
||||
// still resolves into the Pinned section via sessionByAnyId.
|
||||
const refreshCronSessions = useCallback(async () => {
|
||||
try {
|
||||
const { sessions } = await listAllProfileSessions(CRON_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', {
|
||||
source: 'cron'
|
||||
})
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
|
||||
if (key === 'k' || key === 'p') {
|
||||
event.preventDefault()
|
||||
toggleCommandPalette()
|
||||
} else if (key === '.') {
|
||||
event.preventDefault()
|
||||
toggleCommandCenter()
|
||||
}
|
||||
setCronSessions(prev => (sameCronSignature(prev, sessions) ? prev : sessions))
|
||||
} catch {
|
||||
// Non-fatal: the cron section just stays empty/stale.
|
||||
}
|
||||
}, [])
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
// Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created
|
||||
// synchronously (agent tool call or the cron UI), so refreshing here right
|
||||
// after an agent turn surfaces a new job immediately; the interval poll keeps
|
||||
// next-run/state fresh as the scheduler advances them.
|
||||
const refreshCronJobs = useCallback(async () => {
|
||||
try {
|
||||
const jobs = await getCronJobs()
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [toggleCommandCenter])
|
||||
setCronJobs(jobs)
|
||||
} catch {
|
||||
// Non-fatal: the cron section just keeps its last-known jobs.
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshSessions = useCallback(async () => {
|
||||
const requestId = refreshSessionsRequestRef.current + 1
|
||||
@@ -256,13 +287,22 @@ export function DesktopController() {
|
||||
|
||||
try {
|
||||
const limit = $sessionsLimit.get()
|
||||
|
||||
// Require at least one message so abandoned/empty "Untitled" drafts (one
|
||||
// was created per TUI/desktop launch before the lazy-create fix) don't
|
||||
// clutter the sidebar.
|
||||
// Unified cross-profile list (served read-only off each profile's
|
||||
// state.db; no per-profile backend is spawned). Single-profile users get
|
||||
// the same rows tagged profile="default".
|
||||
const result = await listAllProfileSessions(limit, 1)
|
||||
// the same rows tagged profile="default". Cron sessions are excluded here
|
||||
// and fetched separately (refreshCronSessions) so the scheduler's
|
||||
// always-newest rows can't consume the recents page budget.
|
||||
// Scope the fetch to the active profile (not always 'all') so a profile
|
||||
// with few recent sessions isn't windowed out of the cross-profile
|
||||
// recency page — the empty-history-on-profile-switch bug.
|
||||
const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
|
||||
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
|
||||
excludeSources: ['cron']
|
||||
})
|
||||
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep()))
|
||||
@@ -274,7 +314,10 @@ export function DesktopController() {
|
||||
setSessionsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
void refreshCronSessions()
|
||||
void refreshCronJobs()
|
||||
}, [profileScope, refreshCronSessions, refreshCronJobs])
|
||||
|
||||
const loadMoreSessions = useCallback(() => {
|
||||
bumpSessionsLimit()
|
||||
@@ -287,7 +330,11 @@ export function DesktopController() {
|
||||
const key = normalizeProfileKey(profile)
|
||||
const inKey = (s: SessionInfo) => normalizeProfileKey(s.profile) === key
|
||||
const loaded = $sessions.get().filter(inKey).length
|
||||
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key)
|
||||
|
||||
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, {
|
||||
excludeSources: ['cron']
|
||||
})
|
||||
|
||||
const keep = sessionsToKeep(key)
|
||||
|
||||
setSessions(prev => [...prev.filter(s => !inKey(s)), ...mergeSessionPage(prev.filter(inKey), result.sessions, keep)])
|
||||
@@ -457,40 +504,13 @@ export function DesktopController() {
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null
|
||||
|
||||
const editing =
|
||||
target?.isContentEditable ||
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement
|
||||
|
||||
if (event.defaultPrevented || event.repeat || event.altKey || event.code !== 'KeyN') {
|
||||
return
|
||||
}
|
||||
|
||||
// Two accelerators for "new session":
|
||||
// - Cmd/Ctrl+N (browser-like, works while typing in any input)
|
||||
// - Shift+N (single-key, only when no input is focused)
|
||||
const accelerator = event.metaKey || event.ctrlKey
|
||||
const singleKey = !accelerator && !editing && event.shiftKey
|
||||
|
||||
if (!accelerator && !singleKey) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
startFreshSessionDraft()
|
||||
// Briefly light up the sidebar's ⌘N hint so the shortcut is discoverable.
|
||||
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [startFreshSessionDraft])
|
||||
// Single global listener for every rebindable hotkey (incl. profile switching)
|
||||
// plus the on-screen keybind editor's capture mode.
|
||||
useKeybinds({
|
||||
startFreshSession: startFreshSessionDraft,
|
||||
toggleCommandCenter,
|
||||
toggleSelectedPin
|
||||
})
|
||||
|
||||
// A profile switch/create drops to a fresh new-session draft so the previously
|
||||
// open session doesn't bleed across contexts. Skip the initial value.
|
||||
@@ -612,6 +632,25 @@ export function DesktopController() {
|
||||
}
|
||||
}, [gatewayState, refreshCurrentModel, refreshSessions])
|
||||
|
||||
// Keep the cron jobs section live without a user action: the scheduler ticks
|
||||
// in the background (advancing next-run/state and creating runs), so poll the
|
||||
// job list on an interval (and on tab re-focus) while connected.
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open') {return}
|
||||
|
||||
const tick = () => {
|
||||
if (document.visibilityState === 'visible') {void refreshCronJobs()}
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(tick, CRON_POLL_INTERVAL_MS)
|
||||
document.addEventListener('visibilitychange', tick)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId)
|
||||
document.removeEventListener('visibilitychange', tick)
|
||||
}
|
||||
}, [gatewayState, refreshCronJobs])
|
||||
|
||||
useRouteResume({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
@@ -652,9 +691,18 @@ export function DesktopController() {
|
||||
onDeleteSession={sessionId => void removeSession(sessionId)}
|
||||
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
|
||||
onLoadMoreSessions={loadMoreSessions}
|
||||
onManageCronJob={jobId => {
|
||||
setCronFocusJobId(jobId)
|
||||
navigate(CRON_ROUTE)
|
||||
}}
|
||||
onNavigate={selectSidebarItem}
|
||||
onNewSessionInWorkspace={startSessionInWorkspace}
|
||||
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
onTriggerCronJob={jobId => {
|
||||
void triggerCronJob(jobId)
|
||||
.then(() => refreshCronJobs())
|
||||
.catch(() => undefined)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -721,7 +769,10 @@ export function DesktopController() {
|
||||
|
||||
{cronOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<CronView onClose={closeOverlayToPreviousRoute} />
|
||||
<CronView
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
|
||||
186
apps/desktop/src/app/hooks/use-keybinds.ts
Normal file
186
apps/desktop/src/app/hooks/use-keybinds.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { setRightSidebarTab } from '@/app/right-sidebar/store'
|
||||
import { PROFILE_SLOT_COUNT } from '@/lib/keybinds/actions'
|
||||
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
|
||||
import { toggleCommandPalette } from '@/store/command-palette'
|
||||
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
|
||||
import {
|
||||
requestSessionSearchFocus,
|
||||
setFileBrowserOpen,
|
||||
toggleFileBrowserOpen,
|
||||
togglePanesFlipped,
|
||||
toggleSidebarOpen
|
||||
} from '@/store/layout'
|
||||
import {
|
||||
cycleProfile,
|
||||
requestProfileCreate,
|
||||
switchProfileToSlot,
|
||||
switchToDefaultProfile,
|
||||
toggleShowAllProfiles
|
||||
} from '@/store/profile'
|
||||
import { $activeSessionId, $sessions, setModelPickerOpen } from '@/store/session'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { requestComposerFocus } from '../chat/composer/focus'
|
||||
import {
|
||||
AGENTS_ROUTE,
|
||||
ARTIFACTS_ROUTE,
|
||||
CRON_ROUTE,
|
||||
MESSAGING_ROUTE,
|
||||
PROFILES_ROUTE,
|
||||
sessionRoute,
|
||||
SETTINGS_ROUTE,
|
||||
SKILLS_ROUTE
|
||||
} from '../routes'
|
||||
|
||||
export interface KeybindRuntimeDeps {
|
||||
/** Open/close the command center overlay (sessions / system / usage). */
|
||||
toggleCommandCenter: () => void
|
||||
/** Drop to a fresh new-session draft. */
|
||||
startFreshSession: () => void
|
||||
/** Pin/unpin the active session. */
|
||||
toggleSelectedPin: () => void
|
||||
}
|
||||
|
||||
type HandlerMap = Record<string, () => void>
|
||||
|
||||
// Mount once near the top of the app. Owns the single global keydown listener
|
||||
// for every rebindable hotkey: it runs the matched action, or — while capture
|
||||
// mode is active (edit overlay / panel rebind) — records the pressed combo.
|
||||
export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
const navigate = useNavigate()
|
||||
const { resolvedMode, setMode } = useTheme()
|
||||
|
||||
// Keep the latest closures without re-subscribing the listener.
|
||||
const handlersRef = useRef<HandlerMap>({})
|
||||
|
||||
const profileSwitchHandlers: HandlerMap = {}
|
||||
|
||||
for (let slot = 1; slot <= PROFILE_SLOT_COUNT; slot += 1) {
|
||||
profileSwitchHandlers[`profile.switch.${slot}`] = () => switchProfileToSlot(slot)
|
||||
}
|
||||
|
||||
// Move to the adjacent session in recency order, wrapping at the ends.
|
||||
const cycleSession = (direction: 1 | -1) => {
|
||||
const sessions = $sessions.get()
|
||||
|
||||
if (sessions.length < 2) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = sessions.findIndex(session => session.id === $activeSessionId.get())
|
||||
const start = current === -1 ? (direction === 1 ? -1 : 0) : current
|
||||
const next = sessions[(start + direction + sessions.length) % sessions.length]
|
||||
|
||||
if (next) {
|
||||
navigate(sessionRoute(next.id))
|
||||
}
|
||||
}
|
||||
|
||||
const showRightSidebarTab = (tab: 'files' | 'terminal') => {
|
||||
setFileBrowserOpen(true)
|
||||
setRightSidebarTab(tab)
|
||||
}
|
||||
|
||||
handlersRef.current = {
|
||||
'keybinds.openPanel': toggleKeybindPanel,
|
||||
|
||||
'composer.focus': () => requestComposerFocus('main'),
|
||||
'composer.modelPicker': () => setModelPickerOpen(true),
|
||||
|
||||
'nav.commandPalette': toggleCommandPalette,
|
||||
'nav.commandCenter': deps.toggleCommandCenter,
|
||||
'nav.settings': () => navigate(SETTINGS_ROUTE),
|
||||
'nav.profiles': () => navigate(PROFILES_ROUTE),
|
||||
'nav.skills': () => navigate(SKILLS_ROUTE),
|
||||
'nav.messaging': () => navigate(MESSAGING_ROUTE),
|
||||
'nav.artifacts': () => navigate(ARTIFACTS_ROUTE),
|
||||
'nav.cron': () => navigate(CRON_ROUTE),
|
||||
'nav.agents': () => navigate(AGENTS_ROUTE),
|
||||
|
||||
'session.new': () => {
|
||||
deps.startFreshSession()
|
||||
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
|
||||
},
|
||||
'session.next': () => cycleSession(1),
|
||||
'session.prev': () => cycleSession(-1),
|
||||
'session.focusSearch': requestSessionSearchFocus,
|
||||
'session.togglePin': deps.toggleSelectedPin,
|
||||
|
||||
'view.toggleSidebar': toggleSidebarOpen,
|
||||
'view.toggleRightSidebar': toggleFileBrowserOpen,
|
||||
'view.showFiles': () => showRightSidebarTab('files'),
|
||||
'view.showTerminal': () => showRightSidebarTab('terminal'),
|
||||
'view.flipPanes': togglePanesFlipped,
|
||||
|
||||
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
|
||||
|
||||
'profile.default': switchToDefaultProfile,
|
||||
...profileSwitchHandlers,
|
||||
'profile.next': () => cycleProfile(1),
|
||||
'profile.prev': () => cycleProfile(-1),
|
||||
'profile.toggleAll': toggleShowAllProfiles,
|
||||
'profile.create': requestProfileCreate
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
// Capture mode: the next real key becomes the binding. Swallow everything
|
||||
// so e.g. ⌘K rebinds instead of opening the palette.
|
||||
const capturing = $capture.get()
|
||||
|
||||
if (capturing) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
endCapture()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const combo = comboFromEvent(event)
|
||||
|
||||
if (!combo) {
|
||||
return
|
||||
}
|
||||
|
||||
setBinding(capturing, [combo])
|
||||
endCapture()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const combo = comboFromEvent(event)
|
||||
|
||||
if (!combo) {
|
||||
return
|
||||
}
|
||||
|
||||
const actionId = $comboIndex.get().get(combo)
|
||||
|
||||
if (!actionId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditableTarget(event.target) && !comboAllowedInInput(combo)) {
|
||||
return
|
||||
}
|
||||
|
||||
const handler = handlersRef.current[actionId]
|
||||
|
||||
if (!handler) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
handler()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
}, [])
|
||||
}
|
||||
@@ -449,7 +449,7 @@ function PlatformDetail({
|
||||
{hiddenCount > 0 && (
|
||||
<section>
|
||||
<button
|
||||
className="flex w-full items-center justify-between gap-2 rounded-lg px-1 py-1 text-left text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground hover:text-foreground"
|
||||
className="flex w-full items-center justify-between gap-2 py-0.5 text-left text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => setShowAdvanced(value => !value)}
|
||||
type="button"
|
||||
>
|
||||
@@ -477,17 +477,13 @@ function PlatformDetail({
|
||||
|
||||
<footer className="bg-(--ui-chat-surface-background) px-5 py-2.5">
|
||||
<div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2">
|
||||
<label className="flex shrink-0 items-center gap-2 rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2.5 py-1.5 text-[length:var(--conversation-text-font-size)]">
|
||||
<Switch
|
||||
aria-label={platform.enabled ? m.disableAria(platform.name) : m.enableAria(platform.name)}
|
||||
checked={platform.enabled}
|
||||
disabled={saving === `enabled:${platform.id}`}
|
||||
onCheckedChange={onToggle}
|
||||
/>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{platform.enabled ? m.enabled : m.disabled}
|
||||
</span>
|
||||
</label>
|
||||
<Switch
|
||||
aria-label={platform.enabled ? m.disableAria(platform.name) : m.enableAria(platform.name)}
|
||||
checked={platform.enabled}
|
||||
disabled={saving === `enabled:${platform.id}`}
|
||||
onCheckedChange={onToggle}
|
||||
size="xs"
|
||||
/>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{hasEdits && <span className="text-xs text-muted-foreground">{m.unsavedChanges}</span>}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { RefObject } from 'react'
|
||||
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface OverlaySearchInputProps {
|
||||
containerClassName?: string
|
||||
@@ -12,6 +11,7 @@ interface OverlaySearchInputProps {
|
||||
value: string
|
||||
}
|
||||
|
||||
// Borderless underline search — matches the tools/skills page (PageSearchShell).
|
||||
export function OverlaySearchInput({
|
||||
containerClassName,
|
||||
inputRef,
|
||||
@@ -22,11 +22,7 @@ export function OverlaySearchInput({
|
||||
}: OverlaySearchInputProps) {
|
||||
return (
|
||||
<SearchField
|
||||
containerClassName={cn(
|
||||
'rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2 shadow-sm focus-within:border-(--ui-stroke-secondary)',
|
||||
containerClassName
|
||||
)}
|
||||
inputClassName="h-8 text-[0.8125rem]"
|
||||
containerClassName={containerClassName}
|
||||
inputRef={inputRef}
|
||||
loading={loading}
|
||||
onChange={onChange}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -73,6 +75,31 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Boxless "+ New …" action that tops an OverlaySidebar list (profiles, cron, …).
|
||||
// The text variant underlines on hover, which also strokes the icon glyph — so
|
||||
// we keep the button itself underline-free and underline only the label span.
|
||||
export function OverlayNewButton({
|
||||
icon = 'add',
|
||||
label,
|
||||
onClick
|
||||
}: {
|
||||
icon?: string
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
className="group mb-1 w-full justify-start gap-2 text-muted-foreground hover:bg-transparent hover:text-foreground"
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={icon} />
|
||||
<span className="underline-offset-4 group-hover:underline">{label}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) {
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -30,10 +29,8 @@ import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { OverlayMain, OverlayNewButton, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
import { titlebarHeaderBaseClass } from '../shell/titlebar'
|
||||
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
|
||||
|
||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
||||
|
||||
@@ -41,30 +38,20 @@ function isValidProfileName(name: string): boolean {
|
||||
return PROFILE_NAME_RE.test(name.trim())
|
||||
}
|
||||
|
||||
interface ProfilesViewProps extends React.ComponentProps<'section'> {
|
||||
interface ProfilesViewProps {
|
||||
onClose: () => void
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}
|
||||
|
||||
export function ProfilesView({
|
||||
onClose,
|
||||
setStatusbarItemGroup: _setStatusbarItemGroup,
|
||||
setTitlebarToolGroup,
|
||||
...props
|
||||
}: ProfilesViewProps) {
|
||||
export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const [profiles, setProfiles] = useState<null | ProfileInfo[]>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [selectedName, setSelectedName] = useState<null | string>(null)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const { profiles: list } = await getProfiles()
|
||||
setProfiles(list)
|
||||
@@ -77,8 +64,6 @@ export function ProfilesView({
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, p.failedLoad)
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [p])
|
||||
|
||||
@@ -88,24 +73,6 @@ export function ProfilesView({
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
useEffect(() => {
|
||||
if (!setTitlebarToolGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
setTitlebarToolGroup('profiles', [
|
||||
{
|
||||
disabled: refreshing,
|
||||
icon: <Codicon name="refresh" spinning={refreshing} />,
|
||||
id: 'refresh-profiles',
|
||||
label: refreshing ? p.refreshing : p.refresh,
|
||||
onSelect: () => void refresh()
|
||||
}
|
||||
])
|
||||
|
||||
return () => setTitlebarToolGroup('profiles', [])
|
||||
}, [p, refresh, refreshing, setTitlebarToolGroup])
|
||||
|
||||
const selected = useMemo(() => {
|
||||
if (!profiles) {
|
||||
return null
|
||||
@@ -172,64 +139,46 @@ export function ProfilesView({
|
||||
|
||||
return (
|
||||
<OverlayView closeLabel={p.close} onClose={onClose}>
|
||||
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background">
|
||||
<header className={titlebarHeaderBaseClass}>
|
||||
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">{p.title}</h2>
|
||||
<span className="pointer-events-auto text-xs text-muted-foreground">
|
||||
{profiles ? p.count(profiles.length) : ''}
|
||||
</span>
|
||||
</header>
|
||||
{!profiles ? (
|
||||
<PageLoader label={p.loading} />
|
||||
) : (
|
||||
<OverlaySplitLayout>
|
||||
<OverlaySidebar>
|
||||
<OverlayNewButton label={p.newProfile} onClick={() => setCreateOpen(true)} />
|
||||
{profiles.map(profile => (
|
||||
<ProfileRow
|
||||
active={selected?.name === profile.name}
|
||||
key={profile.name}
|
||||
onSelect={() => setSelectedName(profile.name)}
|
||||
profile={profile}
|
||||
/>
|
||||
))}
|
||||
{profiles.length === 0 && (
|
||||
<p className="px-2 py-4 text-center text-xs text-muted-foreground">{p.noProfiles}</p>
|
||||
)}
|
||||
</OverlaySidebar>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85">
|
||||
{!profiles ? (
|
||||
<PageLoader label={p.loading} />
|
||||
) : (
|
||||
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[16rem_minmax(0,1fr)]">
|
||||
<aside className="flex min-h-0 flex-col overflow-hidden border-b border-border/50 lg:border-b-0 lg:border-r">
|
||||
<div className="border-b border-border/40 p-2">
|
||||
<Button className="w-full" onClick={() => setCreateOpen(true)} size="sm">
|
||||
<Codicon name="add" />
|
||||
{p.newProfile}
|
||||
</Button>
|
||||
<OverlayMain className="px-0">
|
||||
{selected ? (
|
||||
<ProfileDetail
|
||||
key={selected.name}
|
||||
onDelete={() => setPendingDelete(selected)}
|
||||
onRename={newName => handleRename(selected.name, newName)}
|
||||
profile={selected}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
|
||||
<div>
|
||||
<Users className="mx-auto size-6 text-muted-foreground/60" />
|
||||
<p className="mt-3">{p.selectPrompt}</p>
|
||||
</div>
|
||||
<ul className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
|
||||
{profiles.map(profile => (
|
||||
<li key={profile.name}>
|
||||
<ProfileRow
|
||||
active={selected?.name === profile.name}
|
||||
onSelect={() => setSelectedName(profile.name)}
|
||||
profile={profile}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{profiles.length === 0 && (
|
||||
<li className="px-2 py-4 text-center text-xs text-muted-foreground">{p.noProfiles}</li>
|
||||
)}
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
)}
|
||||
|
||||
<main className="min-h-0 overflow-hidden">
|
||||
{selected ? (
|
||||
<ProfileDetail
|
||||
key={selected.name}
|
||||
onDelete={() => setPendingDelete(selected)}
|
||||
onRename={newName => handleRename(selected.name, newName)}
|
||||
profile={selected}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
|
||||
<div>
|
||||
<Users className="mx-auto size-6 text-muted-foreground/60" />
|
||||
<p className="mt-3">{p.selectPrompt}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CreateProfileDialog
|
||||
<CreateProfileDialog
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)}
|
||||
open={createOpen}
|
||||
@@ -261,7 +210,6 @@ export function ProfilesView({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
@@ -273,7 +221,7 @@ function ProfileRow({ active, onSelect, profile }: { active: boolean; onSelect:
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full flex-col items-start gap-1 rounded-lg px-2.5 py-2 text-left transition-colors',
|
||||
'flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors',
|
||||
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
@@ -368,7 +316,7 @@ function ProfileDetail({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="grid gap-2 rounded-lg border border-border/40 bg-background/70 px-3 py-3 text-xs sm:grid-cols-2">
|
||||
<dl className="grid gap-2 text-xs sm:grid-cols-2">
|
||||
<DetailRow label={p.modelLabel}>
|
||||
{profile.model ? (
|
||||
<>
|
||||
@@ -475,9 +423,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid h-44 place-items-center rounded-md border border-border/40 bg-background/60 text-xs text-muted-foreground">
|
||||
{p.loadingSoul}
|
||||
</div>
|
||||
<PageLoader className="min-h-44" label={p.loadingSoul} />
|
||||
) : (
|
||||
<Textarea
|
||||
className="min-h-72 font-mono text-xs leading-5"
|
||||
|
||||
@@ -410,6 +410,10 @@ export function useMessageStream({
|
||||
phase: 'running' | 'complete',
|
||||
sourceEventType?: string
|
||||
) => {
|
||||
// Text deltas flush on a timer but tool events apply now; flush first so
|
||||
// a tool part can't jump ahead of the text that preceded it.
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
if (!nativeSubagentSessionsRef.current.has(sessionId)) {
|
||||
for (const subagentPayload of delegateTaskPayloads(payload, phase, sourceEventType)) {
|
||||
upsertSubagent(
|
||||
@@ -428,7 +432,7 @@ export function useMessageStream({
|
||||
{ pending: m => phase !== 'complete' || (m.pending ?? false) }
|
||||
)
|
||||
},
|
||||
[mutateStream]
|
||||
[flushQueuedDeltas, mutateStream]
|
||||
)
|
||||
|
||||
const completeAssistantMessage = useCallback(
|
||||
@@ -447,7 +451,8 @@ export function useMessageStream({
|
||||
busy: false,
|
||||
needsInput: false,
|
||||
pendingBranchGroup: null,
|
||||
streamId: null
|
||||
streamId: null,
|
||||
turnStartedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,7 +542,8 @@ export function useMessageStream({
|
||||
pendingBranchGroup: null,
|
||||
awaitingResponse: false,
|
||||
busy: false,
|
||||
needsInput: false
|
||||
needsInput: false,
|
||||
turnStartedAt: null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -595,7 +601,8 @@ export function useMessageStream({
|
||||
sawAssistantPayload: true,
|
||||
awaitingResponse: false,
|
||||
busy: false,
|
||||
needsInput: false
|
||||
needsInput: false,
|
||||
turnStartedAt: null
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -679,7 +686,8 @@ export function useMessageStream({
|
||||
if (busy) {
|
||||
return {
|
||||
...state,
|
||||
busy
|
||||
busy,
|
||||
turnStartedAt: state.turnStartedAt ?? Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -692,7 +700,8 @@ export function useMessageStream({
|
||||
awaitingResponse: false,
|
||||
busy,
|
||||
pendingBranchGroup: null,
|
||||
streamId: null
|
||||
streamId: null,
|
||||
turnStartedAt: null
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -731,7 +740,8 @@ export function useMessageStream({
|
||||
busy: true,
|
||||
awaitingResponse: true,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: false
|
||||
interrupted: false,
|
||||
turnStartedAt: Date.now()
|
||||
}))
|
||||
|
||||
if (isActiveEvent) {
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
|
||||
import { type MutableRefObject, useCallback } from 'react'
|
||||
|
||||
import { getProfiles, transcribeAudio } from '@/hermes'
|
||||
import { type Translations, translateNow, useI18n } from '@/i18n'
|
||||
import { appendTextPart, branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||
import { translateNow, type Translations, useI18n } from '@/i18n'
|
||||
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||
import {
|
||||
attachmentDisplayText,
|
||||
parseCommandDispatch,
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
type CommandsCatalogLike,
|
||||
desktopSlashUnavailableMessage,
|
||||
filterDesktopCommandsCatalog,
|
||||
isDesktopSlashCommand
|
||||
isDesktopSlashCommand,
|
||||
isModelPickerCommand
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setMessages,
|
||||
setModelPickerOpen,
|
||||
setSessions,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
@@ -159,6 +161,7 @@ export function usePromptActions({
|
||||
}: PromptActionsOptions) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.desktop
|
||||
|
||||
const appendSessionTextMessage = useCallback(
|
||||
(sessionId: string, role: ChatMessage['role'], text: string) => {
|
||||
const body = text.trim()
|
||||
@@ -454,6 +457,49 @@ export function usePromptActions({
|
||||
return
|
||||
}
|
||||
|
||||
// /model opens the desktop model picker overlay — the same full
|
||||
// provider+model picker reachable from the status-bar model button —
|
||||
// instead of the headless prompt_toolkit modal the slash worker can't
|
||||
// render. With explicit args (`/model <name> [--provider ...]`) run the
|
||||
// switch directly through slash.exec so power users can still type it.
|
||||
if (isModelPickerCommand(`/${normalizedName}`)) {
|
||||
if (!arg.trim()) {
|
||||
setModelPickerOpen(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const sid = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
|
||||
|
||||
if (!sid) {
|
||||
notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestGateway<SlashExecResponse>('slash.exec', {
|
||||
session_id: sid,
|
||||
command: command.replace(/^\/+/, '')
|
||||
})
|
||||
|
||||
const body = result?.output || `/${name}: model switched`
|
||||
appendSessionTextMessage(
|
||||
sid,
|
||||
'system',
|
||||
recordInput ? slashStatusText(command, body) : body
|
||||
)
|
||||
} catch (err) {
|
||||
appendSessionTextMessage(
|
||||
sid,
|
||||
'system',
|
||||
`error: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizedName === 'skin' && !sessionHint && !activeSessionIdRef.current) {
|
||||
notify({ kind: 'success', message: handleSkinCommand(arg) })
|
||||
|
||||
@@ -547,6 +593,7 @@ export function usePromptActions({
|
||||
session_id: sessionId,
|
||||
title: arg
|
||||
})
|
||||
|
||||
const finalTitle = (result?.title || arg).trim()
|
||||
const queued = result?.pending === true
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $turnStartedAt, setTurnStartedAt } from '@/store/session'
|
||||
|
||||
import { useSessionStateCache } from './use-session-state-cache'
|
||||
|
||||
type Cache = ReturnType<typeof useSessionStateCache>
|
||||
|
||||
interface HarnessProps {
|
||||
activeSessionId: string | null
|
||||
onReady: (cache: Cache) => void
|
||||
selectedStoredSessionId: string | null
|
||||
}
|
||||
|
||||
function Harness({ activeSessionId, onReady, selectedStoredSessionId }: HarnessProps) {
|
||||
const busyRef: MutableRefObject<boolean> = { current: false }
|
||||
const cache = useSessionStateCache({
|
||||
activeSessionId,
|
||||
busyRef,
|
||||
selectedStoredSessionId,
|
||||
setAwaitingResponse: () => undefined,
|
||||
setBusy: () => undefined,
|
||||
setMessages: () => undefined
|
||||
})
|
||||
|
||||
onReady(cache)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
describe('useSessionStateCache — per-session turn timer', () => {
|
||||
beforeEach(() => {
|
||||
// The view-sync flush runs on a real rAF in the browser path; in jsdom we
|
||||
// want it synchronous so the global mirror is observable immediately. The
|
||||
// hook closes over `window.requestAnimationFrame`, so stub that exact ref.
|
||||
// Return null (not a handle) so the hook's `viewSyncRafRef.current = rAF(...)`
|
||||
// assignment doesn't overwrite the null the synchronous callback just set —
|
||||
// otherwise the ref reads truthy and the NEXT sync is suppressed (a real
|
||||
// browser returns a handle but runs the callback async, so this race is a
|
||||
// test-only artifact of firing synchronously).
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
|
||||
cb(0)
|
||||
|
||||
return null as unknown as number
|
||||
})
|
||||
setTurnStartedAt(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
setTurnStartedAt(null)
|
||||
})
|
||||
|
||||
it("keeps a background session's running turn clock and never mirrors it to the view", () => {
|
||||
let cache!: Cache
|
||||
// Active session is "fg-runtime"; the turn starts on the BACKGROUND session.
|
||||
render(
|
||||
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
|
||||
)
|
||||
|
||||
const startedAt = 1_700_000_000_000
|
||||
|
||||
act(() => {
|
||||
cache.updateSessionState(
|
||||
'bg-runtime',
|
||||
state => ({ ...state, busy: true, turnStartedAt: startedAt }),
|
||||
'bg-stored'
|
||||
)
|
||||
})
|
||||
|
||||
// The background session's own cache entry holds the clock...
|
||||
expect(cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')?.turnStartedAt).toBe(startedAt)
|
||||
// ...but the global atom (statusbar timer) is untouched — a background turn
|
||||
// must not drive the foreground timer.
|
||||
expect($turnStartedAt.get()).toBeNull()
|
||||
})
|
||||
|
||||
it("mirrors the focused session's turn clock into the global atom on view-sync", () => {
|
||||
let cache!: Cache
|
||||
render(<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />)
|
||||
|
||||
const startedAt = 1_700_000_111_000
|
||||
|
||||
// A turn on the ACTIVE session stages into the view; the flush mirrors its
|
||||
// turnStartedAt into the global atom the statusbar reads.
|
||||
act(() => {
|
||||
cache.updateSessionState(
|
||||
'fg-runtime',
|
||||
state => ({ ...state, busy: true, turnStartedAt: startedAt }),
|
||||
'fg-stored'
|
||||
)
|
||||
})
|
||||
|
||||
expect($turnStartedAt.get()).toBe(startedAt)
|
||||
})
|
||||
|
||||
it('clears the global clock when the focused turn ends', () => {
|
||||
let cache!: Cache
|
||||
render(<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />)
|
||||
|
||||
act(() => {
|
||||
cache.updateSessionState(
|
||||
'fg-runtime',
|
||||
state => ({ ...state, busy: true, turnStartedAt: 1_700_000_222_000 }),
|
||||
'fg-stored'
|
||||
)
|
||||
})
|
||||
expect($turnStartedAt.get()).toBe(1_700_000_222_000)
|
||||
|
||||
act(() => {
|
||||
cache.updateSessionState('fg-runtime', state => ({ ...state, busy: false, turnStartedAt: null }))
|
||||
})
|
||||
expect($turnStartedAt.get()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking } from '@/store/session'
|
||||
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking, setTurnStartedAt } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
@@ -92,6 +92,10 @@ export function useSessionStateCache({
|
||||
setBusy(pending.state.busy)
|
||||
setMutableRef(busyRef, pending.state.busy)
|
||||
setAwaitingResponse(pending.state.awaitingResponse)
|
||||
// Mirror the focused session's per-session turn clock into the global
|
||||
// atom the statusbar timer reads. Keeps a backgrounded turn's elapsed
|
||||
// time intact on focus instead of zeroing it (the "timer restarts" bug).
|
||||
setTurnStartedAt(pending.state.turnStartedAt)
|
||||
}, [busyRef, setAwaitingResponse, setBusy, setMessages])
|
||||
|
||||
const syncSessionStateToView = useCallback(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { BrandMark } from '@/components/brand-mark'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
} from '@/store/updates'
|
||||
|
||||
import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||
import { UninstallSection } from './uninstall-section'
|
||||
|
||||
const RELEASE_NOTES_URL = 'https://github.com/NousResearch/hermes-agent/releases'
|
||||
|
||||
@@ -92,9 +94,7 @@ export function AboutSettings() {
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="flex flex-col items-center gap-3 pt-6 pb-2 text-center">
|
||||
<span className="flex size-16 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<Sparkles className="size-8" />
|
||||
</span>
|
||||
<BrandMark className="size-16" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">{a.heading}</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
@@ -168,6 +168,8 @@ export function AboutSettings() {
|
||||
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
|
||||
title={a.automaticUpdates}
|
||||
/>
|
||||
|
||||
<UninstallSection />
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Palette } from '@/lib/icons'
|
||||
@@ -10,7 +11,7 @@ import { useTheme } from '@/themes/context'
|
||||
import { BUILTIN_THEMES } from '@/themes/presets'
|
||||
|
||||
import { MODE_OPTIONS } from './constants'
|
||||
import { Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||
|
||||
function ThemePreview({ name }: { name: string }) {
|
||||
const t = BUILTIN_THEMES[name]
|
||||
@@ -56,169 +57,105 @@ export function AppearanceSettings() {
|
||||
const { t, isSavingLocale } = useI18n()
|
||||
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
|
||||
const toolViewMode = useStore($toolViewMode)
|
||||
const activeTheme = availableThemes.find(theme => theme.name === themeName)
|
||||
const a = t.settings.appearance
|
||||
|
||||
const modeOptions = MODE_OPTIONS.map(({ id, icon }) => ({ icon, id, label: t.settings.modeOptions[id].label }))
|
||||
|
||||
const toolOptions = [
|
||||
{ id: 'product', label: a.product },
|
||||
{ id: 'technical', label: a.technical }
|
||||
] as const
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<SectionHeading icon={Palette} title={a.title} />
|
||||
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{a.intro}
|
||||
</p>
|
||||
<div>
|
||||
<SectionHeading icon={Palette} title={a.title} />
|
||||
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{a.intro}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 divide-y divide-(--ui-stroke-tertiary)">
|
||||
<ListRow
|
||||
action={<LanguageSwitcher />}
|
||||
description={isSavingLocale ? t.language.saving : t.language.description}
|
||||
title={t.language.label}
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('crisp')
|
||||
setMode(id)
|
||||
}}
|
||||
options={modeOptions}
|
||||
value={mode}
|
||||
/>
|
||||
}
|
||||
description={a.colorModeDesc}
|
||||
title={a.colorMode}
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
below={
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{availableThemes.map(theme => {
|
||||
const active = themeName === theme.name
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
key={theme.name}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
setTheme(theme.name)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ThemePreview name={theme.name} />
|
||||
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{theme.description}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
description={a.themeDesc}
|
||||
title={a.themeTitle}
|
||||
wide
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('selection')
|
||||
setToolViewMode(id)
|
||||
}}
|
||||
options={toolOptions}
|
||||
value={toolViewMode}
|
||||
/>
|
||||
}
|
||||
description={a.toolViewDesc}
|
||||
title={a.toolViewTitle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{t.language.label}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{t.language.description}</div>
|
||||
{isSavingLocale && <div className="mt-1 text-xs text-muted-foreground">{t.language.saving}</div>}
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{a.colorMode}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{a.colorModeDesc}</div>
|
||||
</div>
|
||||
<Pill>{t.settings.modeOptions[mode].label}</Pill>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{MODE_OPTIONS.map(({ id, icon: Icon }) => {
|
||||
const active = mode === id
|
||||
const copy = t.settings.modeOptions[id]
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
key={id}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
setMode(id)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="flex size-9 items-center justify-center rounded-lg bg-muted text-foreground transition group-hover:bg-background">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
{active && (
|
||||
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-[length:var(--conversation-text-font-size)] font-medium">{copy.label}</div>
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{copy.description}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{a.toolViewTitle}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{a.toolViewDesc}</div>
|
||||
</div>
|
||||
<Pill>{toolViewMode === 'technical' ? a.technical : a.product}</Pill>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{(
|
||||
[
|
||||
{ id: 'product', label: a.product, description: a.productDesc },
|
||||
{ id: 'technical', label: a.technical, description: a.technicalDesc }
|
||||
] as const
|
||||
).map(option => {
|
||||
const active = toolViewMode === option.id
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
key={option.id}
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
setToolViewMode(option.id)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{option.label}</div>
|
||||
{active && (
|
||||
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{option.description}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{a.themeTitle}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{a.themeDesc}</div>
|
||||
</div>
|
||||
{activeTheme && <Pill>{activeTheme.label}</Pill>}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{availableThemes.map(theme => {
|
||||
const active = themeName === theme.name
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
key={theme.name}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
setTheme(theme.name)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ThemePreview name={theme.name} />
|
||||
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{theme.description}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
|
||||
@@ -96,7 +96,7 @@ export function KeyField({
|
||||
/>
|
||||
{dirty && (
|
||||
<Button className="h-8 shrink-0" disabled={busy} onClick={() => void onSave(varKey)} size="sm">
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Save />}
|
||||
{busy ? <Loader2 className="animate-spin" /> : <Save />}
|
||||
{busy ? t.settings.credentials.saving : t.common.save}
|
||||
</Button>
|
||||
)}
|
||||
@@ -106,9 +106,10 @@ export function KeyField({
|
||||
{info.is_set && (
|
||||
<>
|
||||
<Button
|
||||
className="h-auto px-0 py-0 text-[0.6875rem] text-destructive hover:text-destructive"
|
||||
className="text-[0.6875rem] text-destructive hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void onClear(varKey)}
|
||||
size="inline"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
|
||||
@@ -535,13 +535,13 @@ export function GatewaySettings() {
|
||||
<Check className="size-3" /> {g.signedIn}
|
||||
</Pill>
|
||||
<Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline">
|
||||
{signingIn ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{signingIn ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.signOut}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
|
||||
{signingIn ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
|
||||
{signingIn ? <Loader2 className="animate-spin" /> : <LogIn />}
|
||||
{isPasswordProvider ? g.signIn : g.signInWith(providerLabel)}
|
||||
</Button>
|
||||
)
|
||||
@@ -591,14 +591,14 @@ export function GatewaySettings() {
|
||||
size="sm"
|
||||
variant="text"
|
||||
>
|
||||
{testing ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{testing ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.testRemote}
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
|
||||
{g.saveForRestart}
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
|
||||
{saving ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{saving ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.saveAndReconnect}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -607,7 +607,7 @@ export function GatewaySettings() {
|
||||
<ListRow
|
||||
action={
|
||||
<Button onClick={() => void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong">
|
||||
<FileText className="size-4" />
|
||||
<FileText />
|
||||
{g.openLogs}
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -1,28 +1,52 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Radix Select calls scrollIntoView on its items when the content opens; jsdom
|
||||
// doesn't implement it (nor hasPointerCapture / releasePointerCapture), so stub
|
||||
// them to let the dropdown open in tests.
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
Element.prototype.hasPointerCapture = vi.fn(() => false)
|
||||
Element.prototype.releasePointerCapture = vi.fn()
|
||||
})
|
||||
|
||||
const getGlobalModelInfo = vi.fn()
|
||||
const getGlobalModelOptions = vi.fn()
|
||||
const getAuxiliaryModels = vi.fn()
|
||||
const setModelAssignment = vi.fn()
|
||||
const getRecommendedDefaultModel = vi.fn()
|
||||
const setEnvVar = vi.fn()
|
||||
const startManualProviderOAuth = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getGlobalModelInfo: () => getGlobalModelInfo(),
|
||||
getGlobalModelOptions: () => getGlobalModelOptions(),
|
||||
getAuxiliaryModels: () => getAuxiliaryModels(),
|
||||
setModelAssignment: (body: unknown) => setModelAssignment(body)
|
||||
setModelAssignment: (body: unknown) => setModelAssignment(body),
|
||||
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
|
||||
setEnvVar: (key: string, value: string) => setEnvVar(key, value)
|
||||
}))
|
||||
|
||||
vi.mock('@/store/onboarding', () => ({
|
||||
startManualProviderOAuth: (slug: string) => startManualProviderOAuth(slug)
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
|
||||
getGlobalModelOptions.mockResolvedValue({
|
||||
providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'] }]
|
||||
providers: [
|
||||
{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'], authenticated: true },
|
||||
// An unconfigured api_key provider — surfaced by the full-universe payload.
|
||||
{ name: 'DeepSeek', slug: 'deepseek', models: [], authenticated: false, auth_type: 'api_key', key_env: 'DEEPSEEK_API_KEY' }
|
||||
]
|
||||
})
|
||||
getAuxiliaryModels.mockResolvedValue({
|
||||
main: { provider: 'nous', model: 'hermes-4' },
|
||||
tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }]
|
||||
})
|
||||
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
|
||||
getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null })
|
||||
setEnvVar.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -37,14 +61,43 @@ async function renderModelSettings() {
|
||||
}
|
||||
|
||||
describe('ModelSettings', () => {
|
||||
it('loads and shows the current main model', async () => {
|
||||
it('loads the current main model and lists the full provider universe', async () => {
|
||||
await renderModelSettings()
|
||||
|
||||
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalled())
|
||||
// The current model is loaded into the main-slot selectors (provider name
|
||||
// + model id), not a standalone label.
|
||||
expect(await screen.findByText('Nous')).toBeTruthy()
|
||||
expect(screen.getByText('hermes-4')).toBeTruthy()
|
||||
await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled())
|
||||
|
||||
// Open the provider Select — every provider from the full payload should be
|
||||
// listed, including the unconfigured one with its "set up" hint.
|
||||
const triggers = await screen.findAllByRole('combobox')
|
||||
fireEvent.click(triggers[0])
|
||||
|
||||
// "Nous" shows in both the trigger and the open list; the unconfigured
|
||||
// provider + its setup hint are the unique signal of the full universe.
|
||||
expect((await screen.findAllByText('Nous')).length).toBeGreaterThan(0)
|
||||
expect(await screen.findByText(/DeepSeek/)).toBeTruthy()
|
||||
expect(await screen.findByText(/set up/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('activates an unconfigured api_key provider inline by saving its key', async () => {
|
||||
await renderModelSettings()
|
||||
|
||||
await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled())
|
||||
|
||||
// Open the provider Select and pick the unconfigured provider.
|
||||
const triggers = screen.getAllByRole('combobox')
|
||||
fireEvent.click(triggers[0])
|
||||
const deepseekOption = await screen.findByText(/DeepSeek/)
|
||||
fireEvent.click(deepseekOption)
|
||||
|
||||
// The inline key input appears for an api_key provider that needs setup.
|
||||
const keyInput = await screen.findByPlaceholderText(/Paste DEEPSEEK_API_KEY/)
|
||||
fireEvent.change(keyInput, { target: { value: 'sk-test-123' } })
|
||||
|
||||
const activate = await screen.findByRole('button', { name: /Activate/ })
|
||||
fireEvent.click(activate)
|
||||
|
||||
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123'))
|
||||
})
|
||||
|
||||
it('renders the auxiliary task rows', async () => {
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes'
|
||||
import {
|
||||
getAuxiliaryModels,
|
||||
getGlobalModelInfo,
|
||||
getGlobalModelOptions,
|
||||
getRecommendedDefaultModel,
|
||||
setEnvVar,
|
||||
setModelAssignment
|
||||
} from '@/hermes'
|
||||
import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { startManualProviderOAuth } from '@/store/onboarding'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
|
||||
|
||||
// A provider row is "ready" to pick a model from when it reports models. The
|
||||
// backend now surfaces the full `hermes model` universe (every canonical
|
||||
// provider), so unconfigured providers come back with `authenticated:false`
|
||||
// and an empty `models` list — those need a setup step before a model exists.
|
||||
function isProviderReady(p?: ModelOptionProvider): boolean {
|
||||
return !!p && (p.authenticated !== false || (p.models?.length ?? 0) > 0)
|
||||
}
|
||||
|
||||
// Mirrors `_AUX_TASK_SLOTS` in hermes_cli/web_server.py. Friendly labels and
|
||||
// hints make the assignments readable; raw task keys (vision, mcp, …) are
|
||||
// opaque to most users.
|
||||
@@ -86,6 +103,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
// Aux slots reported stale by the backend immediately after a main-model
|
||||
// switch (provider differs from the new main). Cleared on next switch/reset.
|
||||
const [switchStaleAux, setSwitchStaleAux] = useState<StaleAuxAssignment[]>([])
|
||||
// Inline API-key entry for picking an unconfigured `api_key` provider in
|
||||
// place — mirrors the onboarding ApiKeyForm but scoped to the model picker.
|
||||
const [apiKeyDraft, setApiKeyDraft] = useState('')
|
||||
const [activating, setActivating] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -116,11 +137,24 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
|
||||
const providerOptions = providers.length ? providers : NO_PROVIDERS
|
||||
|
||||
const selectedProviderModels = useMemo(
|
||||
() => providers.find(provider => provider.slug === selectedProvider)?.models ?? [],
|
||||
const selectedProviderRow = useMemo(
|
||||
() => providers.find(provider => provider.slug === selectedProvider),
|
||||
[providers, selectedProvider]
|
||||
)
|
||||
|
||||
const selectedProviderModels = selectedProviderRow?.models ?? []
|
||||
|
||||
// An unconfigured provider was picked: no credentials yet, so there are no
|
||||
// models to choose. `api_key` providers can be activated inline (paste key);
|
||||
// OAuth / external flows hand off to the onboarding sign-in.
|
||||
const needsSetup = !!selectedProvider && !isProviderReady(selectedProviderRow)
|
||||
const setupIsApiKey = needsSetup && selectedProviderRow?.auth_type === 'api_key' && !!selectedProviderRow?.key_env
|
||||
|
||||
// Clear any half-typed key when switching provider so it can't leak across.
|
||||
useEffect(() => {
|
||||
setApiKeyDraft('')
|
||||
}, [selectedProvider])
|
||||
|
||||
const auxDraftProviderModels = useMemo(
|
||||
() => providers.find(provider => provider.slug === auxDraft.provider)?.models ?? [],
|
||||
[auxDraft.provider, providers]
|
||||
@@ -133,17 +167,70 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
// "I pinned aux months ago and forgot, now it bills a dead provider" case.
|
||||
const persistentStaleAux = useMemo<StaleAuxAssignment[]>(() => {
|
||||
const mainProvider = (mainModel?.provider ?? '').toLowerCase()
|
||||
|
||||
if (!mainProvider || !auxiliary) {
|
||||
return []
|
||||
}
|
||||
|
||||
return auxiliary.tasks
|
||||
.filter(entry => {
|
||||
const p = (entry.provider ?? '').toLowerCase()
|
||||
|
||||
return p && p !== 'auto' && p !== mainProvider
|
||||
})
|
||||
.map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model }))
|
||||
}, [auxiliary, mainModel])
|
||||
|
||||
// Paste an API key for the selected `api_key` provider, persist it, then
|
||||
// refresh so the now-authenticated provider's models populate. Auto-selects
|
||||
// the recommended default model so the user can Apply in one more click.
|
||||
const activateApiKeyProvider = useCallback(async () => {
|
||||
const keyEnv = selectedProviderRow?.key_env
|
||||
const slug = selectedProviderRow?.slug
|
||||
|
||||
if (!keyEnv || !slug || !apiKeyDraft.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
setActivating(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await setEnvVar(keyEnv, apiKeyDraft.trim())
|
||||
setApiKeyDraft('')
|
||||
|
||||
// Pick a sensible default for the freshly-activated provider (mirrors
|
||||
// `hermes model` curation). Best-effort — fall through to the refreshed
|
||||
// model list if it fails.
|
||||
let nextModel = ''
|
||||
|
||||
try {
|
||||
const rec = await getRecommendedDefaultModel(slug)
|
||||
nextModel = rec.model || ''
|
||||
} catch {
|
||||
nextModel = ''
|
||||
}
|
||||
|
||||
const options = await getGlobalModelOptions()
|
||||
setProviders(options.providers || [])
|
||||
const refreshedRow = options.providers?.find(p => p.slug === slug)
|
||||
const fallbackModel = refreshedRow?.models?.[0] ?? ''
|
||||
setSelectedModel(nextModel || fallbackModel)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setActivating(false)
|
||||
}
|
||||
}, [apiKeyDraft, selectedProviderRow])
|
||||
|
||||
// OAuth / external providers can't be activated with a pasted key — hand off
|
||||
// to the shared onboarding flow scoped to this provider's real sign-in.
|
||||
const startProviderSetup = useCallback(() => {
|
||||
if (selectedProviderRow?.slug) {
|
||||
startManualProviderOAuth(selectedProviderRow.slug)
|
||||
}
|
||||
}, [selectedProviderRow])
|
||||
|
||||
const applyMainModel = useCallback(async () => {
|
||||
if (!selectedProvider || !selectedModel) {
|
||||
return
|
||||
@@ -271,27 +358,68 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select onValueChange={setSelectedModel} value={selectedModel}>
|
||||
<SelectTrigger className={cn('min-w-60', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder={m.model} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(selectedProviderModels.length ? selectedProviderModels : []).map(model => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
disabled={!selectedProvider || !selectedModel || applying}
|
||||
onClick={() => void applyMainModel()}
|
||||
size="sm"
|
||||
>
|
||||
{applying && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{applying ? m.applying : t.common.apply}
|
||||
</Button>
|
||||
{needsSetup ? (
|
||||
setupIsApiKey ? (
|
||||
<>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
className={cn('min-w-60 flex-1', CONTROL_TEXT)}
|
||||
onChange={event => setApiKeyDraft(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
void activateApiKeyProvider()
|
||||
}
|
||||
}}
|
||||
placeholder={`Paste ${selectedProviderRow?.key_env ?? 'API key'}`}
|
||||
type="password"
|
||||
value={apiKeyDraft}
|
||||
/>
|
||||
<Button
|
||||
disabled={!apiKeyDraft.trim() || activating}
|
||||
onClick={() => void activateApiKeyProvider()}
|
||||
size="sm"
|
||||
>
|
||||
{activating && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{activating ? 'Activating...' : 'Activate'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={startProviderSetup} size="sm" variant="textStrong">
|
||||
Set up {selectedProviderRow?.name ?? 'provider'}
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<Select onValueChange={setSelectedModel} value={selectedModel}>
|
||||
<SelectTrigger className={cn('min-w-60', CONTROL_TEXT)}>
|
||||
<SelectValue placeholder={m.model} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(selectedProviderModels.length ? selectedProviderModels : []).map(model => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
disabled={!selectedProvider || !selectedModel || applying}
|
||||
onClick={() => void applyMainModel()}
|
||||
size="sm"
|
||||
>
|
||||
{applying && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{applying ? m.applying : t.common.apply}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{needsSetup && !setupIsApiKey && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{selectedProviderRow?.auth_type === 'api_key'
|
||||
? `${selectedProviderRow?.name} needs an API key — set it up to choose a model.`
|
||||
: `${selectedProviderRow?.name} signs in through your browser — Hermes runs the flow for you.`}
|
||||
</p>
|
||||
)}
|
||||
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
|
||||
{switchStaleAux.length > 0 && (
|
||||
<div className="mt-2">
|
||||
|
||||
@@ -111,8 +111,9 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
|
||||
<SettingsCategoryHeading icon={KeyRound} title={p.connectAccount} />
|
||||
<Button
|
||||
className="h-auto px-0 py-0 text-[length:var(--conversation-caption-font-size)]"
|
||||
className="text-[length:var(--conversation-caption-font-size)]"
|
||||
onClick={onWantApiKey}
|
||||
size="inline"
|
||||
type="button"
|
||||
variant="textStrong"
|
||||
>
|
||||
@@ -143,8 +144,9 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
|
||||
)}
|
||||
{collapsible && (
|
||||
<Button
|
||||
className="h-auto px-0 py-1 text-[length:var(--conversation-caption-font-size)]"
|
||||
className="py-1 text-[length:var(--conversation-caption-font-size)]"
|
||||
onClick={() => setShowAll(v => !v)}
|
||||
size="inline"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
|
||||
@@ -8,13 +8,17 @@ const selectToolsetProvider = vi.fn()
|
||||
const setEnvVar = vi.fn()
|
||||
const deleteEnvVar = vi.fn()
|
||||
const revealEnvVar = vi.fn()
|
||||
const runToolsetPostSetup = vi.fn()
|
||||
const getActionStatus = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getToolsetConfig: (name: string) => getToolsetConfig(name),
|
||||
selectToolsetProvider: (name: string, provider: string) => selectToolsetProvider(name, provider),
|
||||
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
|
||||
deleteEnvVar: (key: string) => deleteEnvVar(key),
|
||||
revealEnvVar: (key: string) => revealEnvVar(key)
|
||||
revealEnvVar: (key: string) => revealEnvVar(key),
|
||||
runToolsetPostSetup: (name: string, key: string) => runToolsetPostSetup(name, key),
|
||||
getActionStatus: (name: string, lines?: number) => getActionStatus(name, lines)
|
||||
}))
|
||||
|
||||
vi.mock('@/store/notifications', () => ({
|
||||
@@ -22,6 +26,10 @@ vi.mock('@/store/notifications', () => ({
|
||||
notifyError: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store/activity', () => ({
|
||||
upsertDesktopActionTask: vi.fn()
|
||||
}))
|
||||
|
||||
function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
|
||||
return {
|
||||
name: 'tts',
|
||||
@@ -152,4 +160,130 @@ describe('ToolsetConfigPanel', () => {
|
||||
// No provider selection was triggered — this is purely reflecting state.
|
||||
expect(selectToolsetProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs a provider post-setup install hook and tails its log', async () => {
|
||||
// A browser-style toolset whose active provider declares a post_setup hook.
|
||||
getToolsetConfig.mockResolvedValue(
|
||||
config({
|
||||
name: 'browser',
|
||||
active_provider: 'Camofox',
|
||||
providers: [
|
||||
{
|
||||
name: 'Camofox',
|
||||
badge: 'local',
|
||||
tag: 'Stealth local browser',
|
||||
env_vars: [],
|
||||
post_setup: 'camofox',
|
||||
requires_nous_auth: false,
|
||||
is_active: true
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
runToolsetPostSetup.mockResolvedValue({ ok: true, pid: 4321, name: 'tools-post-setup', key: 'camofox' })
|
||||
// First poll: still running; second poll: finished cleanly.
|
||||
getActionStatus
|
||||
.mockResolvedValueOnce({
|
||||
exit_code: null,
|
||||
lines: ['Installing Camofox browser server...'],
|
||||
name: 'tools-post-setup',
|
||||
pid: 4321,
|
||||
running: true
|
||||
})
|
||||
.mockResolvedValue({
|
||||
exit_code: 0,
|
||||
lines: ['Installing Camofox browser server...', "Post-setup 'camofox' complete"],
|
||||
name: 'tools-post-setup',
|
||||
pid: 4321,
|
||||
running: false
|
||||
})
|
||||
|
||||
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
|
||||
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Run setup/ }))
|
||||
|
||||
await waitFor(() => expect(runToolsetPostSetup).toHaveBeenCalledWith('browser', 'camofox'))
|
||||
// The install log is tailed inline. The first poll fires after a 1200ms
|
||||
// delay (mirrors command-center's poll cadence), so allow >1200ms here.
|
||||
await waitFor(() => expect(getActionStatus).toHaveBeenCalledWith('tools-post-setup', 300), {
|
||||
timeout: 4000
|
||||
})
|
||||
})
|
||||
|
||||
it('does not poll when the spawn endpoint reports ok:false', async () => {
|
||||
getToolsetConfig.mockResolvedValue(
|
||||
config({
|
||||
name: 'browser',
|
||||
active_provider: 'Camofox',
|
||||
providers: [
|
||||
{
|
||||
name: 'Camofox',
|
||||
badge: 'local',
|
||||
tag: 'Stealth local browser',
|
||||
env_vars: [],
|
||||
post_setup: 'camofox',
|
||||
requires_nous_auth: false,
|
||||
is_active: true
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
// Spawn failed server-side — must NOT proceed to poll a non-existent action.
|
||||
runToolsetPostSetup.mockResolvedValue({ ok: false, pid: 0, name: 'tools-post-setup' })
|
||||
|
||||
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
|
||||
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Run setup/ }))
|
||||
|
||||
await waitFor(() => expect(runToolsetPostSetup).toHaveBeenCalledWith('browser', 'camofox'))
|
||||
// Give the would-be first poll delay (1200ms) time to NOT fire.
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
expect(getActionStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a non-zero exit code from the setup process', async () => {
|
||||
getToolsetConfig.mockResolvedValue(
|
||||
config({
|
||||
name: 'browser',
|
||||
active_provider: 'Camofox',
|
||||
providers: [
|
||||
{
|
||||
name: 'Camofox',
|
||||
badge: 'local',
|
||||
tag: 'Stealth local browser',
|
||||
env_vars: [],
|
||||
post_setup: 'camofox',
|
||||
requires_nous_auth: false,
|
||||
is_active: true
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
runToolsetPostSetup.mockResolvedValue({ ok: true, pid: 4321, name: 'tools-post-setup', key: 'camofox' })
|
||||
// Action finished but failed (non-zero exit).
|
||||
getActionStatus.mockResolvedValue({
|
||||
exit_code: 1,
|
||||
lines: ['Installing...', 'npm ERR! install failed'],
|
||||
name: 'tools-post-setup',
|
||||
pid: 4321,
|
||||
running: false
|
||||
})
|
||||
|
||||
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
|
||||
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Run setup/ }))
|
||||
|
||||
// The failing install log is still tailed and shown; exit_code:1 routes to
|
||||
// the error notify branch (asserted via the poll completing on a non-zero
|
||||
// status without throwing).
|
||||
await waitFor(() => expect(getActionStatus).toHaveBeenCalledWith('tools-post-setup', 300), {
|
||||
timeout: 4000
|
||||
})
|
||||
await waitFor(() => expect(screen.getByText(/npm ERR! install failed/)).toBeTruthy(), {
|
||||
timeout: 4000
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes'
|
||||
import {
|
||||
deleteEnvVar,
|
||||
getActionStatus,
|
||||
getToolsetConfig,
|
||||
revealEnvVar,
|
||||
runToolsetPostSetup,
|
||||
selectToolsetProvider,
|
||||
setEnvVar
|
||||
} from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Check, Loader2, Save } from '@/lib/icons'
|
||||
import { Check, Loader2, Save, Terminal } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { upsertDesktopActionTask } from '@/store/activity'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { ToolEnvVar, ToolProvider, ToolsetConfig } from '@/types/hermes'
|
||||
import type { ActionStatusResponse, ToolEnvVar, ToolProvider, ToolsetConfig } from '@/types/hermes'
|
||||
|
||||
import { EnvVarActionsMenu, EnvVarActionsTrigger } from './env-var-actions-menu'
|
||||
import { Pill } from './primitives'
|
||||
@@ -157,6 +166,120 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
||||
)
|
||||
}
|
||||
|
||||
interface PostSetupRunnerProps {
|
||||
toolset: string
|
||||
/** The provider's post_setup hook key (e.g. "camofox", "ddgs"). */
|
||||
postSetupKey: string
|
||||
/** Refresh the parent config after the install finishes (a backend may now
|
||||
* report itself configured). */
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a provider's post-setup install hook (npm / pip / binary) via the
|
||||
* `/api/tools/toolsets/{name}/post-setup` spawn-action and tails the resulting
|
||||
* log inline — the GUI equivalent of the install step `hermes tools` runs
|
||||
* after you pick a backend that needs extra dependencies.
|
||||
*/
|
||||
function PostSetupRunner({ toolset, postSetupKey, onComplete }: PostSetupRunnerProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.settings.toolsets
|
||||
const [running, setRunning] = useState(false)
|
||||
const [status, setStatus] = useState<ActionStatusResponse | null>(null)
|
||||
// Guard against overlapping polls / state updates after unmount.
|
||||
const activeRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
activeRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const run = useCallback(async () => {
|
||||
setRunning(true)
|
||||
setStatus(null)
|
||||
activeRef.current = true
|
||||
|
||||
try {
|
||||
const started = await runToolsetPostSetup(toolset, postSetupKey)
|
||||
|
||||
// The spawn endpoint reports ok:false if it couldn't launch the action
|
||||
// (e.g. unknown key, server-side spawn failure). Don't poll a status
|
||||
// that will never exist — surface the failure and stop.
|
||||
if (!started.ok) {
|
||||
notifyError(new Error('spawn failed'), copy.postSetupFailed(postSetupKey))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let last: ActionStatusResponse | null = null
|
||||
|
||||
// Mirror command-center's runSystemAction poll loop: poll the action log
|
||||
// until it exits (or we hit the attempt ceiling), feeding the global
|
||||
// activity rail as we go.
|
||||
for (let attempt = 0; attempt < 150 && activeRef.current; attempt += 1) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 1200))
|
||||
|
||||
if (!activeRef.current) {
|
||||
break
|
||||
}
|
||||
|
||||
const polled = await getActionStatus(started.name, 300)
|
||||
last = polled
|
||||
setStatus(polled)
|
||||
upsertDesktopActionTask(polled)
|
||||
|
||||
if (!polled.running) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (activeRef.current) {
|
||||
const ok = last?.exit_code === 0
|
||||
|
||||
notify(
|
||||
ok
|
||||
? {
|
||||
kind: 'success',
|
||||
title: copy.postSetupCompleteTitle,
|
||||
message: copy.postSetupCompleteMessage(postSetupKey)
|
||||
}
|
||||
: { kind: 'error', title: copy.postSetupErrorTitle, message: copy.postSetupErrorMessage(postSetupKey) }
|
||||
)
|
||||
onComplete?.()
|
||||
}
|
||||
} catch (err) {
|
||||
if (activeRef.current) {
|
||||
notifyError(err, copy.postSetupFailed(postSetupKey))
|
||||
}
|
||||
} finally {
|
||||
if (activeRef.current) {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
}, [toolset, postSetupKey, onComplete, copy])
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 rounded-lg bg-background/55 p-2.5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[0.72rem] text-muted-foreground">{copy.postSetupHint(postSetupKey)}</p>
|
||||
</div>
|
||||
<Button disabled={running} onClick={() => void run()} size="sm">
|
||||
{running ? <Loader2 className="size-3.5 animate-spin" /> : <Terminal className="size-3.5" />}
|
||||
{running ? copy.postSetupRunning : copy.postSetupRun}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{status && (status.lines.length > 0 || status.running) && (
|
||||
<pre className="max-h-48 overflow-y-auto rounded-md bg-background px-2.5 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground whitespace-pre-wrap">
|
||||
{status.lines.length > 0 ? status.lines.join('\n') : copy.postSetupStarting}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfigPanelProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.settings.toolsets
|
||||
@@ -310,9 +433,11 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
|
||||
))
|
||||
)}
|
||||
{provider.post_setup && (
|
||||
<p className="text-[0.72rem] text-muted-foreground">
|
||||
{copy.postSetup(provider.post_setup)}
|
||||
</p>
|
||||
<PostSetupRunner
|
||||
onComplete={() => void refresh()}
|
||||
postSetupKey={provider.post_setup}
|
||||
toolset={toolset}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
185
apps/desktop/src/app/settings/uninstall-section.tsx
Normal file
185
apps/desktop/src/app/settings/uninstall-section.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle, Loader2, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DesktopUninstallMode, DesktopUninstallSummary } from '@/global'
|
||||
|
||||
import { SectionHeading } from './primitives'
|
||||
|
||||
interface ModeOption {
|
||||
mode: DesktopUninstallMode
|
||||
title: string
|
||||
description: string
|
||||
/** Shown in the confirm step so people know exactly what disappears. */
|
||||
consequence: string
|
||||
/** True when the option removes the Python agent (hidden if no agent). */
|
||||
needsAgent: boolean
|
||||
}
|
||||
|
||||
const OPTIONS: ModeOption[] = [
|
||||
{
|
||||
mode: 'gui',
|
||||
title: 'Uninstall Chat GUI only',
|
||||
description: 'Remove this desktop app. The Hermes agent, your config, and chats all stay.',
|
||||
consequence: 'the desktop Chat GUI (this app and its data)',
|
||||
needsAgent: false
|
||||
},
|
||||
{
|
||||
mode: 'lite',
|
||||
title: 'Uninstall GUI + agent, keep my data',
|
||||
description: 'Remove the app and the Hermes agent, but keep config, chats, and secrets for a future reinstall.',
|
||||
consequence: 'the Chat GUI and the Hermes agent (config, chats, and secrets are kept)',
|
||||
needsAgent: true
|
||||
},
|
||||
{
|
||||
mode: 'full',
|
||||
title: 'Uninstall everything',
|
||||
description: 'Remove the app, the agent, and all user data — config, chats, scheduled jobs, secrets, logs.',
|
||||
consequence: 'EVERYTHING — the Chat GUI, the Hermes agent, and all of your config, chats, secrets, and logs',
|
||||
// full removes the agent (and user data), so it's an agent-removing option:
|
||||
// hide it on a lite client with no local agent, same as lite. A lite client
|
||||
// connecting to a remote backend has no local agent OR local user data the
|
||||
// GUI installed, so gui-only is the correct (and only) option there.
|
||||
needsAgent: true
|
||||
}
|
||||
]
|
||||
|
||||
export function UninstallSection() {
|
||||
const [summary, setSummary] = useState<DesktopUninstallSummary | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [pending, setPending] = useState<DesktopUninstallMode | null>(null)
|
||||
const [running, setRunning] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
const bridge = window.hermesDesktop?.uninstall
|
||||
if (!bridge) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
void bridge
|
||||
.summary()
|
||||
.then(result => {
|
||||
if (alive) {
|
||||
setSummary(result)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Non-fatal — we degrade to offering the GUI-only option.
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) {
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const bridge = window.hermesDesktop?.uninstall
|
||||
if (!bridge) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Gate the agent-removing options on whether an agent is actually present.
|
||||
// A future lite client that ships without the bundled agent shows GUI-only.
|
||||
const agentInstalled = summary?.agent_installed ?? false
|
||||
const visibleOptions = OPTIONS.filter(opt => agentInstalled || !opt.needsAgent)
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
setRunning(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await bridge.run(pending)
|
||||
if (!result.ok) {
|
||||
setError(result.message || result.error || 'Uninstall could not start.')
|
||||
setRunning(false)
|
||||
setPending(null)
|
||||
}
|
||||
// On success the app quits shortly; keep the spinner up until it does.
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setRunning(false)
|
||||
setPending(null)
|
||||
}
|
||||
}
|
||||
|
||||
const pendingOption = OPTIONS.find(opt => opt.mode === pending) ?? null
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-8 w-full max-w-2xl">
|
||||
<SectionHeading icon={AlertTriangle} title="Danger zone" />
|
||||
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Checking what's installed…
|
||||
</div>
|
||||
) : pendingOption ? (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-destructive">Confirm uninstall</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
This removes {pendingOption.consequence}. This can't be undone.
|
||||
</p>
|
||||
{summary?.running_app_path && (
|
||||
<p className="mt-1 font-mono text-[0.68rem] text-muted-foreground/60">
|
||||
App: {summary.running_app_path}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
disabled={running}
|
||||
onClick={() => void handleConfirm()}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
{running && <Loader2 className="size-3 animate-spin" />}
|
||||
{running ? 'Uninstalling…' : 'Yes, uninstall'}
|
||||
</Button>
|
||||
<Button disabled={running} onClick={() => setPending(null)} size="sm" variant="text">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">Uninstall Hermes</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose how much to remove. The app closes to finish the job; reopen the installer any time to come back.
|
||||
</p>
|
||||
<div className="mt-1 flex flex-col gap-2">
|
||||
{visibleOptions.map(opt => (
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border border-border/60 bg-background/40 px-3 py-2.5 text-left transition',
|
||||
'hover:border-destructive/40 hover:bg-destructive/5'
|
||||
)}
|
||||
key={opt.mode}
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
setPending(opt.mode)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-foreground">{opt.title}</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">{opt.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { $paneWidthOverride } from '@/store/panes'
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { KeybindPanel } from './keybind-panel'
|
||||
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
|
||||
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
|
||||
import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
|
||||
@@ -155,6 +156,9 @@ export function AppShell({
|
||||
|
||||
{overlays}
|
||||
|
||||
{/* Keybind map dialog (titlebar ⌨ button / ⌘/). */}
|
||||
<KeybindPanel />
|
||||
|
||||
{/* Mounted at the shell root (after overlays) so success/error toasts
|
||||
surface above every route and overlay — not just the chat view. */}
|
||||
<NotificationStack />
|
||||
|
||||
@@ -111,13 +111,15 @@ export function GatewayMenuPanel({
|
||||
</Tip>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
className="mt-1.5 text-[0.66rem] font-medium text-muted-foreground hover:text-foreground"
|
||||
<Button
|
||||
className="-ml-2 mt-1.5 font-medium text-muted-foreground"
|
||||
onClick={onOpenSystem}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{copy.viewAllLogs}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
220
apps/desktop/src/app/shell/keybind-panel.tsx
Normal file
220
apps/desktop/src/app/shell/keybind-panel.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
KEYBIND_ACTIONS,
|
||||
KEYBIND_CATEGORIES,
|
||||
KEYBIND_PANEL_ACTION,
|
||||
KEYBIND_READONLY,
|
||||
type KeybindActionMeta,
|
||||
type KeybindReadonly
|
||||
} from '@/lib/keybinds/actions'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { arraysEqual } from '@/lib/storage'
|
||||
import {
|
||||
$bindings,
|
||||
$capture,
|
||||
$keybindPanelOpen,
|
||||
beginCapture,
|
||||
closeKeybindPanel,
|
||||
conflictsFor,
|
||||
endCapture,
|
||||
resetAllBindings,
|
||||
resetBinding
|
||||
} from '@/store/keybinds'
|
||||
|
||||
// The full hotkey map. Quiet popover, click a row's chip to rebind.
|
||||
export function KeybindPanel() {
|
||||
const { t } = useI18n()
|
||||
const open = useStore($keybindPanelOpen)
|
||||
const bindings = useStore($bindings)
|
||||
const k = t.keybinds
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
|
||||
|
||||
const openCombo = bindings[KEYBIND_PANEL_ACTION]?.[0]
|
||||
|
||||
const toggleCategory = (category: string) =>
|
||||
setCollapsed(prev => {
|
||||
const next = new Set(prev)
|
||||
|
||||
if (next.has(category)) {
|
||||
next.delete(category)
|
||||
} else {
|
||||
next.add(category)
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root onOpenChange={next => !next && closeKeybindPanel()} open={open}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/25 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
|
||||
<DialogPrimitive.Content
|
||||
aria-describedby={undefined}
|
||||
className="fixed left-1/2 top-[9vh] z-[210] flex max-h-[82vh] w-[min(38rem,calc(100vw-2rem))] -translate-x-1/2 flex-col overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3 border-b border-(--ui-stroke-tertiary) px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<DialogPrimitive.Title className="text-sm font-semibold text-foreground">{k.title}</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="mt-0.5 text-[0.72rem] text-muted-foreground">
|
||||
{k.subtitle(openCombo ? formatCombo(openCombo) : '')}
|
||||
</DialogPrimitive.Description>
|
||||
</div>
|
||||
<HeaderButton icon="discard" label={k.resetAll} onClick={resetAllBindings} />
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5">
|
||||
{KEYBIND_CATEGORIES.map(category => {
|
||||
const actions = KEYBIND_ACTIONS.filter(
|
||||
action => action.category === category && action.id !== KEYBIND_PANEL_ACTION
|
||||
)
|
||||
|
||||
const readonly = KEYBIND_READONLY.filter(shortcut => shortcut.category === category)
|
||||
|
||||
if (actions.length === 0 && readonly.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sectionOpen = !collapsed.has(category)
|
||||
|
||||
return (
|
||||
<section key={category}>
|
||||
<CategoryHeader
|
||||
label={k.categories[category] ?? category}
|
||||
onToggle={() => toggleCategory(category)}
|
||||
open={sectionOpen}
|
||||
/>
|
||||
{sectionOpen && actions.map(action => <KeybindRow action={action} key={action.id} />)}
|
||||
{sectionOpen && readonly.map(shortcut => <ReadonlyRow key={shortcut.id} shortcut={shortcut} />)}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsible category header — chevron fades in on hover, rotates when open
|
||||
// (matches the sessions sidebar section pattern).
|
||||
function CategoryHeader({ label, onToggle, open }: { label: string; onToggle: () => void; open: boolean }) {
|
||||
return (
|
||||
<button
|
||||
className="group/kbd-cat flex w-fit items-center gap-1 px-2.5 pb-1 pt-3 text-left leading-none"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
<span className="text-[0.64rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">{label}</span>
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/kbd-cat:opacity-100"
|
||||
open={open}
|
||||
size="0.6875rem"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function HeaderButton({ icon, label, onClick }: { icon: string; label: string; onClick: () => void }) {
|
||||
return (
|
||||
<Button className="shrink-0 text-[0.72rem]" onClick={onClick} size="xs" variant="text">
|
||||
<Codicon name={icon} size="0.8125rem" />
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function KeybindRow({ action }: { action: KeybindActionMeta }) {
|
||||
const { t } = useI18n()
|
||||
const k = t.keybinds
|
||||
const bindings = useStore($bindings)
|
||||
const capture = useStore($capture)
|
||||
|
||||
const combos = bindings[action.id] ?? []
|
||||
const capturing = capture === action.id
|
||||
const label = k.actions[action.id] ?? action.id
|
||||
const isDefault = arraysEqual(combos, [...action.defaults])
|
||||
|
||||
const conflict = combos
|
||||
.flatMap(combo => conflictsFor(action.id, combo).map(other => k.actions[other] ?? other))
|
||||
.find(Boolean)
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-2.5 rounded-lg px-2.5 py-1 transition-colors hover:bg-(--chrome-action-hover)">
|
||||
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/90">{label}</span>
|
||||
|
||||
{conflict && (
|
||||
<span className="flex size-4 items-center justify-center text-amber-500/90" title={k.conflictWith(conflict)}>
|
||||
<Codicon name="warning" size="0.8125rem" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Click the caps to rebind — the on-screen editor does the same thing. */}
|
||||
<button
|
||||
aria-label={k.rebind}
|
||||
className="flex shrink-0 items-center gap-1 rounded-lg outline-none"
|
||||
onClick={() => (capturing ? endCapture() : beginCapture(action.id))}
|
||||
title={k.rebind}
|
||||
type="button"
|
||||
>
|
||||
{capturing ? (
|
||||
<span className="kbd-cap kbd-capturing">{k.pressKey}</span>
|
||||
) : combos.length > 0 ? (
|
||||
combos.map(combo => (
|
||||
<span className="kbd-cap" key={combo}>
|
||||
{formatCombo(combo)}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="kbd-cap kbd-cap--ghost">{k.set}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Reset only shows once a binding diverges from its default; the spacer
|
||||
holds the column otherwise so rows stay aligned. */}
|
||||
{isDefault ? (
|
||||
<span aria-hidden className="size-6 shrink-0" />
|
||||
) : (
|
||||
<button
|
||||
aria-label={k.reset}
|
||||
className="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground/70 opacity-0 transition-all hover:bg-(--ui-control-active-background) hover:text-foreground group-hover:opacity-100"
|
||||
onClick={() => resetBinding(action.id)}
|
||||
title={k.reset}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="discard" size="0.8125rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Fixed shortcut: same layout as KeybindRow but the caps aren't interactive and
|
||||
// the trailing reset slot stays empty (spacer keeps the columns aligned).
|
||||
function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
|
||||
const { t } = useI18n()
|
||||
const k = t.keybinds
|
||||
const label = k.actions[shortcut.id] ?? shortcut.id
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-lg px-2.5 py-1">
|
||||
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/75">{label}</span>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{shortcut.keys.map(key => (
|
||||
<span className="kbd-cap" key={key}>
|
||||
{formatCombo(key)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span aria-hidden className="size-6 shrink-0" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
|
||||
import { toggleKeybindPanel } from '@/store/keybinds'
|
||||
import {
|
||||
$fileBrowserOpen,
|
||||
$panesFlipped,
|
||||
@@ -116,6 +117,15 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics,
|
||||
onSelect: toggleHaptics
|
||||
},
|
||||
{
|
||||
icon: <Codicon name="keyboard" />,
|
||||
id: 'keybinds',
|
||||
label: t.titlebar.openKeybinds,
|
||||
onSelect: () => {
|
||||
triggerHaptic('open')
|
||||
toggleKeybindPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: <Codicon name="settings-gear" />,
|
||||
id: 'settings',
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PAGE_INSET_X } from '../layout-constants'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers'
|
||||
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
|
||||
@@ -191,32 +192,22 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
filters={
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
|
||||
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
|
||||
{t.skills.tabSkills}
|
||||
mode === 'skills' && categories.length > 0 ? (
|
||||
<>
|
||||
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
|
||||
{t.skills.all} <TextTabMeta>{totalSkills}</TextTabMeta>
|
||||
</TextTab>
|
||||
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
|
||||
{t.skills.tabToolsets}
|
||||
</TextTab>
|
||||
</div>
|
||||
{mode === 'skills' && categories.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-x-2 gap-y-1">
|
||||
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
|
||||
{t.skills.all} <TextTabMeta>{totalSkills}</TextTabMeta>
|
||||
{categories.map(category => (
|
||||
<TextTab
|
||||
active={activeCategory === category.key}
|
||||
key={category.key}
|
||||
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
|
||||
>
|
||||
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
|
||||
</TextTab>
|
||||
{categories.map(category => (
|
||||
<TextTab
|
||||
active={activeCategory === category.key}
|
||||
key={category.key}
|
||||
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
|
||||
>
|
||||
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
|
||||
</TextTab>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
onSearchChange={setQuery}
|
||||
searchHidden={mode === 'skills' ? (skills?.length ?? 0) === 0 : (toolsets?.length ?? 0) === 0}
|
||||
@@ -236,21 +227,33 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
</Button>
|
||||
}
|
||||
searchValue={query}
|
||||
tabs={
|
||||
<>
|
||||
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
|
||||
{t.skills.tabSkills}
|
||||
</TextTab>
|
||||
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
|
||||
{t.skills.tabToolsets}
|
||||
</TextTab>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{!skills || !toolsets ? (
|
||||
<PageLoader label={t.skills.loading} />
|
||||
) : mode === 'skills' ? (
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
|
||||
{visibleSkills.length === 0 ? (
|
||||
<EmptyState description={t.skills.noSkillsDesc} title={t.skills.noSkillsTitle} />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{skillGroups.map(([category, list]) => (
|
||||
<div className="space-y-1.5" key={category}>
|
||||
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{prettyName(category)}
|
||||
</div>
|
||||
<div className="divide-y divide-(--ui-stroke-quaternary)">
|
||||
{activeCategory === null && (
|
||||
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{prettyName(category)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{list.map(skill => (
|
||||
<div
|
||||
className="grid gap-3 px-0 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
|
||||
@@ -276,7 +279,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
|
||||
{visibleToolsets.length === 0 ? (
|
||||
<EmptyState description={t.skills.noToolsetsDesc} title={t.skills.noToolsetsTitle} />
|
||||
) : (
|
||||
@@ -284,7 +287,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t.skills.toolsetsEnabled(enabledToolsets, toolsets.length)}
|
||||
</div>
|
||||
<div className="divide-y divide-(--ui-stroke-quaternary)">
|
||||
<div>
|
||||
{visibleToolsets.map(toolset => {
|
||||
const tools = toolNames(toolset)
|
||||
const label = toolsetDisplayLabel(toolset)
|
||||
|
||||
@@ -91,4 +91,9 @@ export interface ClientSessionState {
|
||||
/** A blocking clarify prompt is waiting on the user for this session. Drives
|
||||
* the sidebar "needs input" indicator; cleared when the turn resumes/ends. */
|
||||
needsInput: boolean
|
||||
/** Epoch ms the current turn started, or null when idle. Per-session so a
|
||||
* background turn's elapsed timer keeps counting while another session is
|
||||
* focused, and switching sessions doesn't zero a still-running turn's clock.
|
||||
* The global $turnStartedAt mirrors whichever session is currently viewed. */
|
||||
turnStartedAt: number | null
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { BrandMark } from '@/components/brand-mark'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { writeClipboardText } from '@/components/ui/copy-button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
|
||||
import { ErrorState } from '@/components/ui/error-state'
|
||||
import { ErrorIcon, ErrorState } from '@/components/ui/error-state'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
|
||||
import { AlertCircle, Check, CheckCircle2, Copy, Loader2, Sparkles, Terminal } from '@/lib/icons'
|
||||
import { AlertCircle, Check, CheckCircle2, Copy, Terminal } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$updateApply,
|
||||
@@ -119,7 +121,7 @@ function IdleView({
|
||||
|
||||
if (!status && checking) {
|
||||
return (
|
||||
<CenteredStatus icon={<Loader2 className="size-6 animate-spin text-primary" />} title={u.checking} />
|
||||
<CenteredStatus icon={<Loader className="size-12" label={u.checking} type="lemniscate-bloom" />} title={u.checking} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -131,7 +133,7 @@ function IdleView({
|
||||
{u.tryAgain}
|
||||
</Button>
|
||||
}
|
||||
icon={<AlertCircle className="size-6 text-muted-foreground" />}
|
||||
icon={<ErrorIcon />}
|
||||
title={u.checkFailedTitle}
|
||||
/>
|
||||
)
|
||||
@@ -156,7 +158,7 @@ function IdleView({
|
||||
</Button>
|
||||
}
|
||||
body={u.connectionRetry}
|
||||
icon={<AlertCircle className="size-6 text-muted-foreground" />}
|
||||
icon={<ErrorIcon />}
|
||||
title={u.checkFailedTitle}
|
||||
/>
|
||||
)
|
||||
@@ -179,9 +181,7 @@ function IdleView({
|
||||
return (
|
||||
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<span className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<Sparkles className="size-7" />
|
||||
</span>
|
||||
<BrandMark className="size-16" />
|
||||
|
||||
<DialogTitle className="text-center text-xl">{u.availableTitle}</DialogTitle>
|
||||
<DialogDescription className="text-center text-sm">
|
||||
@@ -209,13 +209,9 @@ function IdleView({
|
||||
<Button className="font-semibold" onClick={onInstall} size="lg">
|
||||
{u.updateNow}
|
||||
</Button>
|
||||
<button
|
||||
className="text-center text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={onLater}
|
||||
type="button"
|
||||
>
|
||||
<Button className="font-medium" onClick={onLater} type="button" variant="text">
|
||||
{u.maybeLater}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{remaining > 0 && (
|
||||
@@ -242,9 +238,7 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
|
||||
return (
|
||||
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<span className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<Terminal className="size-7" />
|
||||
</span>
|
||||
<Terminal className="size-8 text-primary" />
|
||||
|
||||
<DialogTitle className="text-center text-xl">{u.manualTitle}</DialogTitle>
|
||||
<DialogDescription className="text-center text-sm">
|
||||
@@ -280,7 +274,7 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
|
||||
{u.manualPickedUp}
|
||||
</p>
|
||||
|
||||
<Button className="font-semibold" onClick={onDone} size="lg" variant="outline">
|
||||
<Button className="font-semibold" onClick={onDone} size="lg" variant="secondary">
|
||||
{u.done}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -300,9 +294,7 @@ function ApplyingView({ apply }: { apply: UpdateApplyState }) {
|
||||
return (
|
||||
<div className="grid gap-5 px-6 pb-6 pt-7">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<span className="relative flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<Loader2 className="size-7 animate-spin" />
|
||||
</span>
|
||||
<Loader className="size-16" label={label} type="lemniscate-bloom" />
|
||||
|
||||
<DialogTitle className="text-center text-xl">{label}</DialogTitle>
|
||||
<DialogDescription className="text-center text-sm">
|
||||
@@ -365,7 +357,7 @@ function CenteredStatus({
|
||||
return (
|
||||
<div className="grid gap-4 px-6 pb-6 pt-8 pr-8">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<span className="flex size-14 items-center justify-center rounded-2xl bg-muted/40">{icon}</span>
|
||||
{icon}
|
||||
|
||||
<DialogTitle className="text-center text-lg">{title}</DialogTitle>
|
||||
{body && <DialogDescription className="text-center text-sm">{body}</DialogDescription>}
|
||||
|
||||
@@ -163,14 +163,14 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative mb-3 mt-2 grid gap-2 rounded-[0.5rem] border border-border/70 bg-card/40 px-3 py-2.5 text-sm shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]"
|
||||
className="relative mb-3 mt-2 grid gap-6 rounded-[0.5rem] border border-border/70 bg-card/40 px-3 py-2.5 text-sm shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]"
|
||||
data-slot="clarify-inline"
|
||||
>
|
||||
<span aria-hidden className="arc-border" />
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid size-6 shrink-0 place-items-center rounded-md bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15"
|
||||
className="mt-px grid size-6 shrink-0 place-items-center rounded-md bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15"
|
||||
>
|
||||
<HelpCircle className="size-3.5" />
|
||||
</span>
|
||||
@@ -264,14 +264,16 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
|
||||
{!typing && hasChoices && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
className="bg-transparent text-[0.6875rem] text-muted-foreground/70 underline-offset-4 hover:text-foreground hover:underline disabled:cursor-not-allowed disabled:opacity-50"
|
||||
<Button
|
||||
className="-mr-2"
|
||||
disabled={!ready || submitting}
|
||||
onClick={() => void respond('')}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
{copy.skip}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -172,4 +172,33 @@ describe('preprocessMarkdown', () => {
|
||||
'<https://www.getyourguide.com/en-gb/san-juan-puerto-rico-l355/san-juan-old-san-juan-sunset-cruise-with-drinks-transfer-t405191/>'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not swallow trailing emphasis asterisks into an autolinked url', () => {
|
||||
const input = '**PR opened: https://github.com/NousResearch/hermes-agent/pull/12345**'
|
||||
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
// The URL is autolinked WITHOUT the trailing `**` glued into the href,
|
||||
// and the bold emphasis run stays intact so it renders as bold + a link.
|
||||
expect(output).toContain('<https://github.com/NousResearch/hermes-agent/pull/12345>')
|
||||
expect(output).not.toContain('pull/12345**>')
|
||||
expect(output).not.toContain('12345*')
|
||||
})
|
||||
|
||||
it('stops an autolinked url at mid-string bold markers', () => {
|
||||
const input = 'See https://github.com/foo/bar**bold** for details.'
|
||||
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output).toContain('<https://github.com/foo/bar>')
|
||||
expect(output).toContain('**bold**')
|
||||
})
|
||||
|
||||
it('keeps underscores and tildes inside autolinked url paths', () => {
|
||||
const input = 'Docs at https://example.com/a_b/c~d/page'
|
||||
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output).toContain('<https://example.com/a_b/c~d/page>')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type SyntaxHighlighterProps
|
||||
} from '@assistant-ui/react-streamdown'
|
||||
import { code } from '@streamdown/code'
|
||||
import { type ComponentProps, memo, type ReactNode, useDeferredValue, useEffect, useMemo, useState } from 'react'
|
||||
import { type ComponentProps, memo, type ReactNode, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
|
||||
@@ -224,6 +224,88 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>)
|
||||
)
|
||||
}
|
||||
|
||||
// Steady character-reveal for streaming text: decouples visible cadence from
|
||||
// bursty arrival so text flows instead of popping (cf. assistant-ui's useSmooth,
|
||||
// reimplemented for a tunable rate). Proportional drain — each frame reveals a
|
||||
// slice of the backlog so the reveal converges within ~REVEAL_DRAIN_MS whatever
|
||||
// the size; the per-frame cap stops a huge dump rendering as one slab. The loop
|
||||
// is gated on backlog, not isRunning, so a stream that completes mid-reveal
|
||||
// keeps draining its tail instead of snapping.
|
||||
const REVEAL_DRAIN_MS = 500
|
||||
const REVEAL_MAX_CHARS_PER_FRAME = 30
|
||||
|
||||
function useSmoothReveal(text: string, isRunning: boolean): string {
|
||||
const [displayed, setDisplayed] = useState(isRunning ? '' : text)
|
||||
const targetRef = useRef(text)
|
||||
const shownRef = useRef(displayed)
|
||||
const frameRef = useRef<number | null>(null)
|
||||
const lastTickRef = useRef(0)
|
||||
|
||||
shownRef.current = displayed
|
||||
targetRef.current = text
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
// Non-extending change (regenerate / branch / history swap): restart from
|
||||
// empty while streaming, else snap to the replacement.
|
||||
if (!text.startsWith(shownRef.current)) {
|
||||
shownRef.current = isRunning ? '' : text
|
||||
setDisplayed(shownRef.current)
|
||||
}
|
||||
|
||||
if (shownRef.current.length >= text.length || frameRef.current !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
lastTickRef.current = performance.now()
|
||||
|
||||
const tick = () => {
|
||||
const now = performance.now()
|
||||
const dt = now - lastTickRef.current
|
||||
lastTickRef.current = now
|
||||
|
||||
const remaining = targetRef.current.length - shownRef.current.length
|
||||
const add = Math.min(remaining, REVEAL_MAX_CHARS_PER_FRAME, Math.max(1, Math.ceil((remaining * dt) / REVEAL_DRAIN_MS)))
|
||||
shownRef.current = targetRef.current.slice(0, shownRef.current.length + add)
|
||||
setDisplayed(shownRef.current)
|
||||
|
||||
frameRef.current = shownRef.current.length < targetRef.current.length ? requestAnimationFrame(tick) : null
|
||||
}
|
||||
|
||||
frameRef.current = requestAnimationFrame(tick)
|
||||
}, [text, isRunning])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (frameRef.current !== null && typeof window !== 'undefined') {
|
||||
cancelAnimationFrame(frameRef.current)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return displayed
|
||||
}
|
||||
|
||||
// Re-publish the part context with a smooth character-reveal, above
|
||||
// DeferStreamingText so the reveal feeds the deferred markdown pipeline. Status
|
||||
// stays running while revealing so the caret persists past the underlying part
|
||||
// settling.
|
||||
function SmoothStreamingText({ children }: { children: ReactNode }) {
|
||||
const { text, status } = useMessagePartText()
|
||||
const isRunning = status.type === 'running'
|
||||
const revealed = useSmoothReveal(text, isRunning)
|
||||
|
||||
return (
|
||||
<TextMessagePartProvider isRunning={isRunning || revealed !== text} text={revealed}>
|
||||
{children}
|
||||
</TextMessagePartProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-publish the active message-part context with React's `useDeferredValue`
|
||||
* applied to the streaming text and status. The outer wrapper still re-renders
|
||||
@@ -280,7 +362,7 @@ const MARKDOWN_CONTAINER_CLASS_NAME = cn(
|
||||
'prose-a:break-words prose-p:[overflow-wrap:anywhere]',
|
||||
'prose-li:marker:text-muted-foreground/70',
|
||||
'prose-code:rounded-[0.25rem] prose-code:px-[0.1875rem] prose-code:py-px prose-code:font-mono prose-code:text-[0.9em] prose-code:font-normal prose-code:before:content-none prose-code:after:content-none',
|
||||
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-1'
|
||||
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-(--paragraph-gap)'
|
||||
)
|
||||
|
||||
function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) {
|
||||
@@ -308,12 +390,14 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
|
||||
<h4 className={cn('my-1 font-semibold', HEADING_SIZES.h4, className)} {...props} />
|
||||
),
|
||||
p: ({ className, ...props }: ComponentProps<'p'>) => (
|
||||
<p className={cn('my-1 wrap-anywhere leading-(--dt-line-height)', className)} {...props} />
|
||||
// Vertical rhythm is owned by styles.css (`--paragraph-gap`), which
|
||||
// must out-specify Tailwind Typography's `prose` margins — so no
|
||||
// `my-*` here on purpose.
|
||||
<p className={cn('wrap-anywhere leading-(--dt-line-height)', className)} {...props} />
|
||||
),
|
||||
a: MarkdownLink,
|
||||
hr: ({ className, ...props }: ComponentProps<'hr'>) => (
|
||||
<hr className={cn('border-border', className)} {...props} />
|
||||
),
|
||||
// `---` as quiet spacing, not a heavy full-width rule.
|
||||
hr: (_props: ComponentProps<'hr'>) => <div aria-hidden className="my-3" />,
|
||||
blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
|
||||
<blockquote
|
||||
className={cn('border-l-2 border-border pl-3 text-muted-foreground italic', className)}
|
||||
@@ -391,18 +475,22 @@ interface MarkdownTextContentProps extends MarkdownTextSurfaceProps {
|
||||
export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: MarkdownTextContentProps) {
|
||||
return (
|
||||
<TextMessagePartProvider isRunning={isRunning} text={text}>
|
||||
<DeferStreamingText>
|
||||
<MarkdownTextSurface {...surfaceProps} />
|
||||
</DeferStreamingText>
|
||||
<SmoothStreamingText>
|
||||
<DeferStreamingText>
|
||||
<MarkdownTextSurface {...surfaceProps} />
|
||||
</DeferStreamingText>
|
||||
</SmoothStreamingText>
|
||||
</TextMessagePartProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const MarkdownTextImpl = () => {
|
||||
return (
|
||||
<DeferStreamingText>
|
||||
<MarkdownTextSurface />
|
||||
</DeferStreamingText>
|
||||
<SmoothStreamingText>
|
||||
<DeferStreamingText>
|
||||
<MarkdownTextSurface />
|
||||
</DeferStreamingText>
|
||||
</SmoothStreamingText>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -241,6 +241,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
>
|
||||
{hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
|
||||
<MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
|
||||
{messageStatus === 'running' && <StreamStallIndicator activity={`${content.length}:${messageText.length}`} />}
|
||||
{previewTargets.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{previewTargets.map(target => (
|
||||
@@ -293,6 +294,39 @@ const ResponseLoadingIndicator: FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
// Seconds of no visible output (text or part count) before a still-running turn
|
||||
// is treated as stalled and the thinking indicator returns at the tail.
|
||||
const STREAM_STALL_S = 2
|
||||
|
||||
// Tail "still thinking" indicator: the pre-first-token spinner goes away once
|
||||
// text flows, but if the stream then goes quiet mid-turn (tool think-time,
|
||||
// provider stall) nothing signals that work continues. Watch a per-render
|
||||
// activity signal; when it hasn't changed for STREAM_STALL_S, re-show the
|
||||
// dither + a timer counting from the last activity.
|
||||
const StreamStallIndicator: FC<{ activity: string }> = ({ activity }) => {
|
||||
const [stalled, setStalled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setStalled(false)
|
||||
const id = window.setTimeout(() => setStalled(true), STREAM_STALL_S * 1000)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}, [activity])
|
||||
|
||||
const elapsed = useElapsedSeconds(stalled)
|
||||
|
||||
if (!stalled) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusRow className="mt-1.5" data-slot="aui_stream-stall" label="Hermes is thinking">
|
||||
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
|
||||
const generatedImage = useGeneratedImageContext()
|
||||
const running = result === undefined
|
||||
@@ -441,6 +475,22 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
|
||||
.some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
|
||||
)
|
||||
|
||||
// A reasoning group with no actual text is pure noise — drop the whole
|
||||
// "Thinking" disclosure rather than leave an empty header eating a row. This
|
||||
// applies live too: encrypted/spinner-coerced reasoning (Opus reasoning max)
|
||||
// never carries visible text, and the bottom-of-thread loader already signals
|
||||
// "thinking", so an empty header is never wanted. Real reasoning surfaces the
|
||||
// instant its first token lands.
|
||||
const hasContent = useAuiState(s =>
|
||||
s.message.parts
|
||||
.slice(Math.max(0, startIndex), endIndex + 1)
|
||||
.some(p => p?.type === 'reasoning' && typeof p.text === 'string' && p.text.trim().length > 0)
|
||||
)
|
||||
|
||||
if (!hasContent) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ThinkingDisclosure messageRunning={messageRunning} pending={pending} timerKey={`reasoning:${messageId}`}>
|
||||
{children}
|
||||
@@ -456,7 +506,7 @@ const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ te
|
||||
return (
|
||||
<MarkdownTextContent
|
||||
containerClassName={cn(
|
||||
'text-xs leading-relaxed text-muted-foreground/85',
|
||||
'text-xs leading-snug text-muted-foreground/85',
|
||||
isRunning && 'shimmer text-muted-foreground/55'
|
||||
)}
|
||||
containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>}
|
||||
@@ -662,11 +712,11 @@ function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
// Shared "user bubble" base. Both the read-only message and the inline
|
||||
// edit composer render the same bubble surface (rounded glass card,
|
||||
// shadow-composer); they only differ in border weight, cursor, and
|
||||
// padding-right (the read-only view reserves room for the restore icon).
|
||||
// edit composer render the same bubble surface (rounded glass card);
|
||||
// they only differ in border weight, cursor, and padding-right (the
|
||||
// read-only view reserves room for the restore icon).
|
||||
const USER_BUBBLE_BASE_CLASS =
|
||||
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left shadow-composer'
|
||||
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left'
|
||||
|
||||
const USER_ACTION_ICON_BUTTON_CLASS =
|
||||
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import { cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
|
||||
@@ -8,12 +8,11 @@ import { $toolDisclosureStates } from '@/store/tool-view'
|
||||
|
||||
import { Thread } from './thread'
|
||||
|
||||
// Regression coverage for the "approval buried behind a collapsed tool group"
|
||||
// bug. When 2+ tools group into a collapsed "Tool actions · N steps" row, the
|
||||
// pending tool's inline ApprovalBar lives inside the group body — which is
|
||||
// `hidden` until expanded. A live approval must surface WITHOUT the user
|
||||
// expanding anything, so ToolGroupSlot force-opens its body while an approval
|
||||
// targeting one of its pending tools is in flight.
|
||||
// Regression coverage for the "approval must never be buried" bug. Tools now
|
||||
// render as a flat list (no collapsible "N steps" group), so a pending tool's
|
||||
// inline ApprovalBar is always in the visual flow — never inside a `hidden`
|
||||
// body. These assert the bar shows only when an approval is live and is never
|
||||
// trapped under a `hidden` ancestor.
|
||||
|
||||
const createdAt = new Date('2026-06-03T00:00:00.000Z')
|
||||
|
||||
@@ -71,8 +70,7 @@ stubOffsetDimension('offsetWidth', 'clientWidth', 800)
|
||||
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
|
||||
|
||||
// A running assistant message with two tools: a completed read_file plus a
|
||||
// pending terminal (no result). Two visible tools → ToolGroupSlot groups them
|
||||
// behind a collapsed "Tool actions · 2 steps" header.
|
||||
// pending terminal (no result), rendered as a flat two-row list.
|
||||
function groupedPendingMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-group-1',
|
||||
@@ -132,32 +130,28 @@ afterEach(() => {
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
describe('ToolGroupSlot approval surfacing', () => {
|
||||
it('hides the grouped pending tool body when there is no approval', async () => {
|
||||
describe('flat tool list approval surfacing', () => {
|
||||
it('renders no inline approval bar when there is no live approval', async () => {
|
||||
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
|
||||
|
||||
// Group header renders collapsed; the inline approval strip lives in the
|
||||
// hidden body, so with no live approval it must not render at all (the
|
||||
// ApprovalBar returns null when $approvalRequest is empty).
|
||||
// The pending terminal row mounts immediately, but its inline ApprovalBar
|
||||
// returns null while $approvalRequest is empty.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Tool actions/)).toBeTruthy()
|
||||
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
|
||||
})
|
||||
expect(container.querySelector('[data-slot="tool-approval-inline"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('force-opens the group body so the approval surfaces without expanding', async () => {
|
||||
it('surfaces the approval inline and never under a hidden ancestor', async () => {
|
||||
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
|
||||
|
||||
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
|
||||
|
||||
// Even though the group defaults collapsed, the live approval forces the
|
||||
// body open so the inline controls are visible (and reachable, not in a
|
||||
// hidden subtree) immediately.
|
||||
await waitFor(() => {
|
||||
const bar = container.querySelector('[data-slot="tool-approval-inline"]')
|
||||
expect(bar).not.toBeNull()
|
||||
// The forced-open group body must not be hidden — assert no ancestor
|
||||
// carries the `hidden` attribute that would keep the bar off-screen.
|
||||
// Flat rows live directly in the flow — nothing should ever wrap the bar
|
||||
// in a `hidden` subtree.
|
||||
expect(bar?.closest('[hidden]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,3 +33,34 @@ describe('buildToolView image handling', () => {
|
||||
expect(buildToolView(part({ result: { url } }), '').imageUrl).toBe(url)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildToolView terminal exit-code status', () => {
|
||||
const terminal = (result: Record<string, unknown>) =>
|
||||
buildToolView(part({ result, toolName: 'terminal' }), '')
|
||||
|
||||
// A non-zero exit code with real output is not a failure (grep no-match,
|
||||
// diff differences, piped commands surfacing the last stage's code, etc.) —
|
||||
// it should render as success so the card isn't painted red.
|
||||
it('treats non-zero exit with output as success', () => {
|
||||
expect(terminal({ exit_code: 7, output: 'node ... 5174 (LISTEN)' }).status).toBe('success')
|
||||
expect(terminal({ exit_code: 1, stdout: 'partial results' }).status).toBe('success')
|
||||
})
|
||||
|
||||
// No output + non-zero exit is a genuine failure worth flagging.
|
||||
it('treats non-zero exit with no output as error', () => {
|
||||
expect(terminal({ exit_code: 127, output: '' }).status).toBe('error')
|
||||
expect(terminal({ exit_code: 1 }).status).toBe('error')
|
||||
})
|
||||
|
||||
it('treats zero exit as success', () => {
|
||||
expect(terminal({ exit_code: 0, output: 'done' }).status).toBe('success')
|
||||
})
|
||||
|
||||
// Explicit error signals still win regardless of output presence.
|
||||
it('keeps explicit error signals red even with output', () => {
|
||||
expect(terminal({ error: 'boom', exit_code: 0, output: 'partial' }).status).toBe('error')
|
||||
expect(buildToolView(part({ isError: true, result: { output: 'x' }, toolName: 'terminal' }), '').status).toBe(
|
||||
'error'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -89,10 +89,13 @@ const TOOL_META: Record<string, ToolMeta> = {
|
||||
tone: 'browser'
|
||||
},
|
||||
browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: 'globe', tone: 'browser' },
|
||||
clarify: { done: 'Asked a question', pending: 'Asking a question', icon: 'question', tone: 'agent' },
|
||||
cronjob: { done: 'Cron job', pending: 'Scheduling cron job', icon: 'watch', tone: 'agent' },
|
||||
edit_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' },
|
||||
execute_code: { done: 'Ran code', pending: 'Running code', icon: 'terminal', tone: 'terminal' },
|
||||
image_generate: { done: 'Generated image', pending: 'Generating image', icon: 'file-media', tone: 'image' },
|
||||
list_files: { done: 'Listed files', pending: 'Listing files', icon: 'files', tone: 'file' },
|
||||
patch: { done: 'Patched file', pending: 'Patching file', icon: 'diff', tone: 'file' },
|
||||
read_file: { done: 'Read file', pending: 'Reading file', icon: 'file', tone: 'file' },
|
||||
search_files: { done: 'Searched files', pending: 'Searching files', icon: 'search', tone: 'file' },
|
||||
session_search_recall: {
|
||||
@@ -103,6 +106,7 @@ const TOOL_META: Record<string, ToolMeta> = {
|
||||
},
|
||||
terminal: { done: 'Ran command', pending: 'Running command', icon: 'terminal', tone: 'terminal' },
|
||||
todo: { done: 'Updated todos', pending: 'Updating todos', icon: 'tools', tone: 'agent' },
|
||||
vision_analyze: { done: 'Analyzed image', pending: 'Analyzing image', icon: 'eye', tone: 'image' },
|
||||
web_extract: { done: 'Read webpage', pending: 'Reading webpage', icon: 'globe', tone: 'web' },
|
||||
web_search: { done: 'Searched web', pending: 'Searching web', icon: 'search', tone: 'web' },
|
||||
write_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' }
|
||||
@@ -743,9 +747,20 @@ function toolErrorText(part: ToolPart, result: Record<string, unknown>): string
|
||||
return firstStringField(result, ['message', 'reason', 'detail']) || `Tool returned status "${result.status}".`
|
||||
}
|
||||
|
||||
// A non-zero exit code alone is a weak failure signal: grep returns 1 on
|
||||
// no-match, diff returns 1 on differences, piped commands surface the last
|
||||
// stage's code, etc. — all routinely produce useful output and aren't
|
||||
// failures. Only treat it as an error when the command produced no real
|
||||
// output to show; otherwise render the output normally (not red).
|
||||
const exit = numberValue(result.exit_code)
|
||||
|
||||
return exit !== null && exit !== 0 ? `Command failed with exit code ${exit}.` : ''
|
||||
if (exit !== null && exit !== 0) {
|
||||
const hasOutput = Boolean(firstStringField(result, ['output', 'stdout', 'stderr'])?.trim())
|
||||
|
||||
return hasOutput ? '' : `Command failed with exit code ${exit}.`
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function toolStatus(part: ToolPart, resultRecord: Record<string, unknown>): ToolStatus {
|
||||
@@ -885,6 +900,80 @@ function fallbackDetailText(args: unknown, result: unknown): string {
|
||||
return formatToolResultSummary(args) || minimalValueSummary(args)
|
||||
}
|
||||
|
||||
function cronScalar(value: unknown): string {
|
||||
if (typeof value === 'string') return value.trim()
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatCronTime(iso: string): string {
|
||||
const ts = Date.parse(iso)
|
||||
|
||||
if (Number.isNaN(ts)) return iso
|
||||
|
||||
return new Date(ts).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function cronjobSubtitle(
|
||||
argsRecord: Record<string, unknown>,
|
||||
resultRecord: Record<string, unknown>
|
||||
): string {
|
||||
const jobs = Array.isArray(resultRecord.jobs) ? resultRecord.jobs : null
|
||||
|
||||
if (jobs) {
|
||||
return jobs.length ? `${jobs.length} cron job${jobs.length === 1 ? '' : 's'}` : 'No cron jobs'
|
||||
}
|
||||
|
||||
const message = firstStringField(resultRecord, ['message'])
|
||||
|
||||
if (message) return message
|
||||
|
||||
const action = firstStringField(argsRecord, ['action']) || 'manage'
|
||||
const name = firstStringField(resultRecord, ['name']) || firstStringField(argsRecord, ['name', 'job_id'])
|
||||
const label = `${action[0]?.toUpperCase() ?? ''}${action.slice(1)}`
|
||||
|
||||
return name ? `${label} ${name}` : `Cron ${action}`
|
||||
}
|
||||
|
||||
function cronjobDetail(
|
||||
argsRecord: Record<string, unknown>,
|
||||
resultRecord: Record<string, unknown>
|
||||
): string {
|
||||
const jobs = Array.isArray(resultRecord.jobs) ? resultRecord.jobs : null
|
||||
|
||||
if (jobs) {
|
||||
if (!jobs.length) return 'No cron jobs scheduled'
|
||||
|
||||
return jobs
|
||||
.slice(0, 20)
|
||||
.map(job => {
|
||||
const row = isRecord(job) ? job : {}
|
||||
const name = firstStringField(row, ['name', 'id']) || 'job'
|
||||
const sched = firstStringField(row, ['schedule_display', 'schedule'])
|
||||
|
||||
return sched ? `- ${name} · ${sched}` : `- ${name}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
const nextRun = cronScalar(resultRecord.next_run_at)
|
||||
const rows: [string, string][] = [
|
||||
['Schedule', cronScalar(resultRecord.schedule)],
|
||||
['Repeat', cronScalar(resultRecord.repeat)],
|
||||
['Delivery', cronScalar(resultRecord.deliver)],
|
||||
['Next run', nextRun ? formatCronTime(nextRun) : '']
|
||||
]
|
||||
const lines = rows.filter(([, value]) => value).map(([key, value]) => `${key}: ${value}`)
|
||||
|
||||
return lines.length ? lines.join('\n') : fallbackDetailText(argsRecord, resultRecord)
|
||||
}
|
||||
|
||||
function toolSubtitle(
|
||||
part: ToolPart,
|
||||
argsRecord: Record<string, unknown>,
|
||||
@@ -978,6 +1067,10 @@ function toolSubtitle(
|
||||
return url ? hostnameOf(url) : 'Fetched webpage'
|
||||
}
|
||||
|
||||
if (toolName === 'cronjob') {
|
||||
return cronjobSubtitle(argsRecord, resultRecord)
|
||||
}
|
||||
|
||||
return (
|
||||
compactPreview(formatToolResultSummary(part.result), 120) ||
|
||||
compactPreview(resultRecord, 120) ||
|
||||
@@ -1078,6 +1171,10 @@ function toolDetailText(
|
||||
.replace(/\bDuration\s+S\s*:/gi, 'Duration:')
|
||||
}
|
||||
|
||||
if (part.toolName === 'cronjob') {
|
||||
return cronjobDetail(argsRecord, resultRecord)
|
||||
}
|
||||
|
||||
return fallbackDetailText(argsRecord, resultRecord)
|
||||
}
|
||||
|
||||
@@ -1269,124 +1366,3 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
|
||||
tone: meta.tone
|
||||
}
|
||||
}
|
||||
|
||||
function isToolPart(part: unknown): part is ToolPart {
|
||||
if (!part || typeof part !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const row = part as Record<string, unknown>
|
||||
|
||||
return row.type === 'tool-call' && typeof row.toolName === 'string'
|
||||
}
|
||||
|
||||
export function groupToolParts(content: unknown): ToolPart[][] {
|
||||
if (!Array.isArray(content)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const groups: ToolPart[][] = []
|
||||
let current: ToolPart[] = []
|
||||
|
||||
for (const part of content) {
|
||||
// todo parts render in their own hoisted panel; skip from grouped tools.
|
||||
if (isToolPart(part) && part.toolName !== 'todo') {
|
||||
current.push(part)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (current.length) {
|
||||
groups.push(current)
|
||||
current = []
|
||||
}
|
||||
}
|
||||
|
||||
if (current.length) {
|
||||
groups.push(current)
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
export function groupStatus(parts: ToolPart[]): ToolStatus {
|
||||
if (parts.some(p => p.result === undefined)) {
|
||||
return 'running'
|
||||
}
|
||||
|
||||
const statuses = parts.map(part => toolStatus(part, parseMaybeObject(part.result)))
|
||||
const hasError = statuses.includes('error')
|
||||
|
||||
if (!hasError) {
|
||||
return 'success'
|
||||
}
|
||||
|
||||
return statuses.at(-1) === 'success' ? 'warning' : 'error'
|
||||
}
|
||||
|
||||
export function groupTitle(parts: ToolPart[]): string {
|
||||
const prefix = PREFIX_META.find(p => parts.every(part => part.toolName.startsWith(p.prefix)))
|
||||
const verb = prefix?.verb || 'Tool'
|
||||
|
||||
return `${verb} actions · ${parts.length} steps`
|
||||
}
|
||||
|
||||
export function groupPreviewTargets(parts: ToolPart[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const targets: string[] = []
|
||||
|
||||
for (const part of parts) {
|
||||
const view = buildToolView(part, inlineDiffFromResult(part.result))
|
||||
const target = view.previewTarget
|
||||
|
||||
if (target && isPreviewableTarget(target) && !seen.has(target)) {
|
||||
seen.add(target)
|
||||
targets.push(target)
|
||||
}
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
export function groupFailedStepCount(parts: ToolPart[]): number {
|
||||
return parts.filter(part => toolStatus(part, parseMaybeObject(part.result)) === 'error').length
|
||||
}
|
||||
|
||||
export function groupTotalDurationLabel(parts: ToolPart[]): string {
|
||||
const seconds = parts.reduce((sum, part) => {
|
||||
const value = numberValue(parseMaybeObject(part.result).duration_s)
|
||||
|
||||
return sum + (value && value > 0 ? value : 0)
|
||||
}, 0)
|
||||
|
||||
if (!seconds) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return formatDurationSeconds(seconds)
|
||||
}
|
||||
|
||||
export function groupTailSubtitle(parts: ToolPart[]): string {
|
||||
const tail = parts.at(-1)
|
||||
|
||||
return tail ? buildToolView(tail, '').subtitle : ''
|
||||
}
|
||||
|
||||
export function groupCopyText(parts: ToolPart[]): string {
|
||||
return parts
|
||||
.map(part => {
|
||||
const view = buildToolView(part, '')
|
||||
const lines = [view.title]
|
||||
|
||||
if (view.subtitle && view.subtitle !== view.title) {
|
||||
lines.push(view.subtitle)
|
||||
}
|
||||
|
||||
if (view.detail && view.detail !== view.subtitle) {
|
||||
lines.push(view.detail)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
})
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/shallow'
|
||||
|
||||
import { AnsiText } from '@/components/assistant-ui/ansi-text'
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
@@ -22,20 +21,13 @@ import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } f
|
||||
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $approvalRequest } from '@/store/prompts'
|
||||
import { $toolInlineDiffs } from '@/store/tool-diffs'
|
||||
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
|
||||
|
||||
import { APPROVAL_TOOLS, PendingToolApproval } from './tool-approval'
|
||||
import { PendingToolApproval } from './tool-approval'
|
||||
import {
|
||||
groupCopyText as buildGroupCopyText,
|
||||
buildToolView,
|
||||
cleanVisibleText,
|
||||
groupFailedStepCount,
|
||||
groupPreviewTargets,
|
||||
groupStatus,
|
||||
groupTitle,
|
||||
groupTotalDurationLabel,
|
||||
inlineDiffFromResult,
|
||||
isPreviewableTarget,
|
||||
looksRedundant,
|
||||
@@ -48,14 +40,10 @@ import {
|
||||
type ToolStatus
|
||||
} from './tool-fallback-model'
|
||||
|
||||
// Tool names that ChainToolFallback intercepts and renders as something
|
||||
// other than a ToolEntry — they don't count toward "is this a group of
|
||||
// tool calls?" because they have no visible tool block.
|
||||
const SPECIAL_TOOL_NAMES = new Set(['todo', 'image_generate', 'clarify'])
|
||||
|
||||
// `true` when the current ToolEntry is being rendered inside a group
|
||||
// wrapper. Lets ToolEntry suppress per-row chrome (timer / preview) that
|
||||
// the group already shows.
|
||||
// `true` when a ToolEntry is rendered inside an embedding wrapper that owns
|
||||
// the per-row chrome (timer / preview). The flat ToolGroupSlot sets this
|
||||
// false, so every row currently owns its own chrome; kept as a seam for any
|
||||
// future embedding surface.
|
||||
const ToolEmbedContext = createContext(false)
|
||||
|
||||
// Shared header chrome for tool rows. Both the single-tool DisclosureRow
|
||||
@@ -283,6 +271,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
const hasExpandableContent = Boolean(
|
||||
(view.previewTarget && isPreviewableTarget(view.previewTarget)) ||
|
||||
view.imageUrl ||
|
||||
view.inlineDiff ||
|
||||
showDetail ||
|
||||
hasSearchHits ||
|
||||
toolViewMode === 'technical'
|
||||
@@ -423,155 +412,42 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{view.inlineDiff && <DiffLines text={view.inlineDiff} />}
|
||||
{open && view.inlineDiff && <DiffLines text={view.inlineDiff} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Always-present wrapper around the consecutive tool-call range that
|
||||
* `MessagePrimitive.Parts` already grouped for us. Renders a header +
|
||||
* collapsible body when there are 2+ visible tools; otherwise it's a
|
||||
* transparent passthrough that just owns the entry animation for the
|
||||
* single ToolEntry inside.
|
||||
* Flat, Cursor-style tool list. assistant-ui hands us a *range* of
|
||||
* consecutive tool-call parts, but how that range is sliced is unstable: a
|
||||
* live stream interleaves narration/reasoning between calls (many tiny
|
||||
* ranges), while the settled message reconstructs every tool_call back-to-back
|
||||
* (one big range). Rendering a "Tool actions · N steps" group off that range
|
||||
* therefore reshuffled the whole turn the instant it settled.
|
||||
*
|
||||
* Crucially, the wrapper element is the SAME `<div>` regardless of
|
||||
* group size — only the optional header element appears/disappears.
|
||||
* That preserves React identity for the inner `MessagePartByIndex`
|
||||
* children when the 1→2 transition happens, so existing tool blocks
|
||||
* never remount when a new tool joins them mid-stream.
|
||||
*
|
||||
* The previous design (per-tool ToolFallback computing its own group
|
||||
* lookup and conditionally returning either `<ToolEntry>` or
|
||||
* `<ToolGroup>`) flipped the React element type at the 1→2 transition
|
||||
* and tore down the existing tool entirely, which is what showed up as
|
||||
* "the previous tool's animation resets every time a new tool arrives."
|
||||
* So we never group: each tool is a standalone row, and the wrapper just lays
|
||||
* its children out on the tight `--tool-row-gap` rhythm. One range or ten,
|
||||
* fragmented or consecutive, the result is pixel-identical — a tight, stable
|
||||
* stack. The wrapper stays a single `<div>` of stable identity so children
|
||||
* never remount as the range grows mid-stream. `ToolEmbedContext` is false so
|
||||
* every row owns its own chrome (timer / preview / copy / inline approval).
|
||||
*/
|
||||
export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex: number }>> = ({
|
||||
children,
|
||||
endIndex,
|
||||
startIndex
|
||||
}) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.tool
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const messageRunning = useAuiState(selectMessageRunning)
|
||||
|
||||
// Pull the visible tool parts in this range. `useShallow` makes this
|
||||
// re-render only when the actual part references change (assistant-ui
|
||||
// gives stable refs for unchanged parts), not on every text/reasoning
|
||||
// delta elsewhere in the message.
|
||||
const visibleParts = useAuiState(
|
||||
useShallow((s: { message: { parts: readonly unknown[] } }) =>
|
||||
s.message.parts.slice(startIndex, endIndex + 1).filter((p): p is ToolPart => {
|
||||
if (!p || typeof p !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const row = p as { toolName?: unknown; type?: unknown }
|
||||
|
||||
return row.type === 'tool-call' && typeof row.toolName === 'string' && !SPECIAL_TOOL_NAMES.has(row.toolName)
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const isGroup = visibleParts.length > 1
|
||||
const isRunning = messageRunning && visibleParts.some(p => p.result === undefined)
|
||||
// Stable across the group's lifetime (start index doesn't shift when
|
||||
// tools append to the end), so user-driven open/close persists across
|
||||
// streaming.
|
||||
const disclosureId = `tool-group:${messageId}:${startIndex}`
|
||||
const userOpen = useDisclosureOpen(disclosureId)
|
||||
|
||||
// A live approval request must NEVER be buried inside a collapsed group —
|
||||
// the user has to be able to act on it without first expanding "Tool
|
||||
// actions · N steps". When an approval is in flight and this group hosts
|
||||
// the pending approval-eligible tool that raised it (terminal /
|
||||
// execute_code with no result yet — see tool-approval.tsx for why the
|
||||
// single pending row IS the one that raised it), force the body open so
|
||||
// the inline ApprovalBar surfaces. The user can still collapse the group
|
||||
// again once the approval resolves.
|
||||
const approvalRequest = useStore($approvalRequest)
|
||||
|
||||
const hostsLiveApproval =
|
||||
approvalRequest !== null &&
|
||||
messageRunning &&
|
||||
visibleParts.some(p => p.result === undefined && APPROVAL_TOOLS.has(p.toolName))
|
||||
|
||||
const open = userOpen || hostsLiveApproval
|
||||
const enterRef = useEnterAnimation(messageRunning, disclosureId)
|
||||
|
||||
const status = groupStatus(visibleParts)
|
||||
const displayStatus = !isRunning && status === 'running' ? 'success' : status
|
||||
const failedStepCount = useMemo(() => groupFailedStepCount(visibleParts), [visibleParts])
|
||||
const totalDurationLabel = useMemo(() => groupTotalDurationLabel(visibleParts), [visibleParts])
|
||||
|
||||
const statusSummary =
|
||||
displayStatus === 'running' || failedStepCount === 0
|
||||
? ''
|
||||
: displayStatus === 'warning'
|
||||
? failedStepCount === 1
|
||||
? copy.recoveredOne
|
||||
: copy.recoveredMany(failedStepCount)
|
||||
: failedStepCount === 1
|
||||
? copy.failedOne
|
||||
: copy.failedMany(failedStepCount)
|
||||
|
||||
const groupCopyText = useMemo(() => buildGroupCopyText(visibleParts), [visibleParts])
|
||||
const previewTargets = useMemo(() => groupPreviewTargets(visibleParts), [visibleParts])
|
||||
const enterRef = useEnterAnimation(messageRunning, `tool-group:${messageId}:${startIndex}`)
|
||||
|
||||
return (
|
||||
<ToolEmbedContext.Provider value={isGroup}>
|
||||
<div className="min-w-0 max-w-full overflow-hidden" data-slot="tool-block" ref={enterRef}>
|
||||
{isGroup && (
|
||||
<DisclosureRow
|
||||
key="header"
|
||||
onToggle={() => setToolDisclosureOpen(disclosureId, !open)}
|
||||
open={open}
|
||||
trailing={
|
||||
!isRunning && groupCopyText ? (
|
||||
<CopyButton appearance="tool-row" label={copy.copyActivity} stopPropagation text={groupCopyText} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<ToolGlyph copy={copy} status={displayStatus === 'success' ? undefined : displayStatus} />
|
||||
<FadeText
|
||||
className={cn(
|
||||
TOOL_HEADER_TITLE_CLASS,
|
||||
displayStatus === 'error' && 'text-destructive',
|
||||
displayStatus === 'warning' && 'text-amber-700 dark:text-amber-300'
|
||||
)}
|
||||
>
|
||||
{groupTitle(visibleParts)}
|
||||
</FadeText>
|
||||
{totalDurationLabel && <span className={TOOL_HEADER_DURATION_CLASS}>{totalDurationLabel}</span>}
|
||||
</span>
|
||||
{statusSummary && (
|
||||
<FadeText
|
||||
className={cn(
|
||||
TOOL_HEADER_SUBTITLE_CLASS,
|
||||
displayStatus === 'warning' ? 'text-amber-700/80 dark:text-amber-300/85' : 'text-destructive/85'
|
||||
)}
|
||||
>
|
||||
{statusSummary}
|
||||
</FadeText>
|
||||
)}
|
||||
</DisclosureRow>
|
||||
)}
|
||||
{isGroup && previewTargets.length > 0 && (
|
||||
<div className="mt-2 grid w-full min-w-0 max-w-full gap-2 overflow-hidden pr-2 pl-3">
|
||||
{previewTargets.map(target => (
|
||||
<PreviewAttachment key={target} source="tool-result" target={target} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Body is always rendered so children stay mounted across collapse/
|
||||
expand and across the 1→2 group transition. `hidden` removes it
|
||||
from a11y/visual flow without unmounting React subtree. */}
|
||||
<div className={cn(isGroup && 'mt-0.5 w-full overflow-hidden pr-2 pl-3')} hidden={isGroup && !open} key="body">
|
||||
{children}
|
||||
</div>
|
||||
<ToolEmbedContext.Provider value={false}>
|
||||
<div
|
||||
className="grid min-w-0 max-w-full gap-(--tool-row-gap) overflow-hidden"
|
||||
data-slot="tool-block"
|
||||
ref={enterRef}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ToolEmbedContext.Provider>
|
||||
)
|
||||
|
||||
@@ -2,9 +2,11 @@ import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ErrorIcon } from '@/components/ui/error-state'
|
||||
import { LogView } from '@/components/ui/log-view'
|
||||
import type { DesktopConnectionConfig } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertTriangle, FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons'
|
||||
import { FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons'
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $desktopOnboarding } from '@/store/onboarding'
|
||||
@@ -172,11 +174,9 @@ export function BootFailureOverlay() {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
|
||||
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
|
||||
<div className="flex items-start gap-3 border-b border-(--ui-stroke-tertiary) px-5 py-4">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10 text-destructive">
|
||||
<AlertTriangle className="size-5" />
|
||||
</div>
|
||||
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous">
|
||||
<div className="flex items-start gap-3 px-5 py-4">
|
||||
<ErrorIcon className="mt-0.5" size="1.25rem" />
|
||||
<div>
|
||||
<h2 className="text-[0.9375rem] font-semibold tracking-tight">
|
||||
{remoteReauth ? copy.remoteTitle : copy.title}
|
||||
@@ -196,27 +196,27 @@ export function BootFailureOverlay() {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{remoteReauth ? (
|
||||
<Button disabled={Boolean(busy)} onClick={() => void signInRemote()}>
|
||||
{busy === 'signin' ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
|
||||
{busy === 'signin' ? <Loader2 className="animate-spin" /> : <LogIn />}
|
||||
{label}
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled={Boolean(busy)} onClick={() => void retry()}>
|
||||
{busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
|
||||
{busy === 'retry' ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||
{copy.retry}
|
||||
</Button>
|
||||
)}
|
||||
{!remoteReauth ? (
|
||||
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline">
|
||||
{busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />}
|
||||
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="secondary">
|
||||
{busy === 'repair' ? <Loader2 className="animate-spin" /> : <Wrench />}
|
||||
{copy.repairInstall}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="outline">
|
||||
{busy === 'local' ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="secondary">
|
||||
{busy === 'local' ? <Loader2 className="animate-spin" /> : null}
|
||||
{copy.useLocalGateway}
|
||||
</Button>
|
||||
<Button onClick={openLogs} variant="ghost">
|
||||
<FileText className="size-4" />
|
||||
<FileText />
|
||||
{copy.openLogs}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -227,18 +227,16 @@ export function BootFailureOverlay() {
|
||||
|
||||
{logs.length > 0 ? (
|
||||
<div className="grid gap-2">
|
||||
<button
|
||||
className="self-start text-xs font-medium text-muted-foreground transition hover:text-foreground"
|
||||
<Button
|
||||
className="-ml-2 self-start font-medium"
|
||||
onClick={() => setShowLogs(v => !v)}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{showLogs ? copy.hideRecentLogs : copy.showRecentLogs}
|
||||
</button>
|
||||
{showLogs ? (
|
||||
<pre className="max-h-48 overflow-auto rounded-2xl border border-border bg-secondary/30 p-3 font-mono text-[0.7rem] leading-4 text-muted-foreground">
|
||||
{logs.slice(-40).join('')}
|
||||
</pre>
|
||||
) : null}
|
||||
</Button>
|
||||
{showLogs ? <LogView className="max-h-48">{logs.slice(-40).join('')}</LogView> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
19
apps/desktop/src/components/brand-mark.tsx
Normal file
19
apps/desktop/src/components/brand-mark.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
|
||||
|
||||
// Brand badge: nous-girl mark on a white tile, identical in light/dark.
|
||||
// Fills the tile (softly rounded); size via className (default size-14).
|
||||
export function BrandMark({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex size-14 shrink-0 items-center justify-center overflow-hidden rounded-md bg-white',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<img alt="" className="size-full object-contain" src={assetPath('nous-girl.jpg')} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export function DiffLines({ className, text, ...props }: DiffLinesProps) {
|
||||
return (
|
||||
<pre
|
||||
className={cn(
|
||||
'mt-2 max-h-96 max-w-full min-w-0 overflow-auto rounded-md border border-border/60 bg-muted/35 px-2.5 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground',
|
||||
'mt-1 mb-1.5 max-h-96 max-w-full min-w-0 overflow-auto rounded-md border border-border/60 bg-muted/35 px-2.5 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
data-slot="diff-lines"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { LogView } from '@/components/ui/log-view'
|
||||
import type {
|
||||
DesktopBootstrapEvent,
|
||||
DesktopBootstrapStageDescriptor,
|
||||
@@ -350,7 +352,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md">
|
||||
<div className="w-full max-w-xl rounded-xl border bg-card p-8 shadow-xl">
|
||||
<div className="w-full max-w-xl rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">{copy.oneTimeTitle}</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{copy.unsupportedDesc(platformLabel)}
|
||||
@@ -411,7 +413,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md p-4">
|
||||
<div className="flex w-full max-w-2xl max-h-[90vh] flex-col rounded-xl border bg-card shadow-xl">
|
||||
<div className="flex w-full max-w-2xl max-h-[90vh] flex-col rounded-xl border border-(--stroke-nous) bg-card shadow-nous">
|
||||
{/* Header -- always visible, never scrolls */}
|
||||
<div className="flex-shrink-0 p-8 pb-4">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
@@ -444,8 +446,8 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
)}
|
||||
|
||||
{totalCount === 0 && state.active && (
|
||||
<div className="mb-4 flex items-center gap-2 rounded-md border border-dashed bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<div className="mb-4 flex items-center gap-2.5 text-sm text-muted-foreground">
|
||||
<Loader className="size-5" type="lemniscate-bloom" />
|
||||
<span>{copy.fetchingManifest}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -474,53 +476,44 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
</ol>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-3">
|
||||
<button
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
<div className="pt-3">
|
||||
<Button
|
||||
className="-ml-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setLogOpen(v => !v)}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{logOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
<span>{logOpen ? copy.hideOutput : copy.showOutput}</span>
|
||||
<span className="ml-1 tabular-nums">
|
||||
({copy.lines(state.log.length)})
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
{logOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-2 overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed',
|
||||
failed ? 'max-h-96' : 'max-h-64'
|
||||
)}
|
||||
>
|
||||
<LogView className={cn('mt-2', failed ? 'max-h-96' : 'max-h-64')}>
|
||||
{state.log.length === 0 ? (
|
||||
<div className="text-muted-foreground">{copy.noOutput}</div>
|
||||
<div>{copy.noOutput}</div>
|
||||
) : (
|
||||
<>
|
||||
{state.log.map((entry, i) => (
|
||||
<div
|
||||
className={cn(
|
||||
'whitespace-pre-wrap break-words',
|
||||
entry.stream === 'stderr' && 'text-muted-foreground'
|
||||
)}
|
||||
key={i}
|
||||
>
|
||||
{entry.stage ? <span className="text-muted-foreground/70">[{entry.stage}] </span> : null}
|
||||
<div className={cn(entry.stream === 'stderr' && 'text-muted-foreground/70')} key={i}>
|
||||
{entry.stage ? <span className="text-muted-foreground/60">[{entry.stage}] </span> : null}
|
||||
<span>{entry.line}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</LogView>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active footer: let the user actually cancel a running install. */}
|
||||
{state.active && !failed && (
|
||||
<div className="flex-shrink-0 border-t bg-card p-4">
|
||||
<div className="flex-shrink-0 bg-card p-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
disabled={cancelling}
|
||||
@@ -545,7 +538,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
|
||||
{/* Footer -- always visible, never scrolls; only renders on failure */}
|
||||
{failed && (
|
||||
<div className="flex-shrink-0 border-t bg-card p-4">
|
||||
<div className="flex-shrink-0 bg-card p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{copy.transcriptSaved}{' '}
|
||||
|
||||
@@ -5,7 +5,9 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ModelPickerDialog } from '@/components/model-picker'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ErrorIcon } from '@/components/ui/error-state'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { getGlobalModelOptions } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
@@ -16,7 +18,6 @@ import {
|
||||
ExternalLink,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Terminal
|
||||
} from '@/lib/icons'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
@@ -30,6 +31,8 @@ import {
|
||||
confirmOnboardingModel,
|
||||
copyDeviceCode,
|
||||
copyExternalCommand,
|
||||
DEFAULT_MANUAL_ONBOARDING_REASON,
|
||||
DEFAULT_ONBOARDING_REASON,
|
||||
dismissFirstRunOnboarding,
|
||||
type OnboardingContext,
|
||||
type OnboardingFlow,
|
||||
@@ -43,7 +46,7 @@ import {
|
||||
startProviderOAuth,
|
||||
submitOnboardingCode
|
||||
} from '@/store/onboarding'
|
||||
import type { OAuthProvider } from '@/types/hermes'
|
||||
import type { ModelOptionProvider, OAuthProvider } from '@/types/hermes'
|
||||
|
||||
interface DesktopOnboardingOverlayProps {
|
||||
enabled: boolean
|
||||
@@ -95,6 +98,74 @@ const API_KEY_OPTIONS: ApiKeyOption[] = [
|
||||
}
|
||||
]
|
||||
|
||||
// Build the FULL API-key provider catalog from the backend model options so the
|
||||
// onboarding / Providers key form lists every `api_key` provider `hermes model`
|
||||
// knows about — not just the hand-curated five. Curated entries keep their
|
||||
// richer copy + placeholders and float to the top (recommended defaults); every
|
||||
// other api_key provider is appended with a generic "paste {KEY}" affordance.
|
||||
// OAuth / external providers are intentionally excluded here — they go through
|
||||
// the OAuth picker / sign-in flow, not a pasted key.
|
||||
function useApiKeyCatalog(): ApiKeyOption[] {
|
||||
const [rows, setRows] = useState<ModelOptionProvider[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
// Best-effort — on failure the curated defaults still render. Wrapped in
|
||||
// Promise.resolve().then so a synchronous throw (e.g. no desktop bridge in
|
||||
// tests) is funneled into the same .catch instead of escaping.
|
||||
void Promise.resolve()
|
||||
.then(() => getGlobalModelOptions())
|
||||
.then(res => {
|
||||
if (!cancelled) {
|
||||
setRows(res.providers ?? [])
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore — fall back to the curated API_KEY_OPTIONS only.
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return useMemo(() => {
|
||||
const curatedByEnv = new Map(API_KEY_OPTIONS.map(o => [o.envKey, o]))
|
||||
const derived: ApiKeyOption[] = []
|
||||
const seenEnv = new Set<string>(API_KEY_OPTIONS.map(o => o.envKey))
|
||||
|
||||
for (const row of rows) {
|
||||
// Only api_key providers can be activated with a pasted key. Skip OAuth /
|
||||
// external / managed flows and anything missing an env var to write to.
|
||||
if (row.auth_type && row.auth_type !== 'api_key') {
|
||||
continue
|
||||
}
|
||||
|
||||
const envKey = row.key_env
|
||||
|
||||
if (!envKey || seenEnv.has(envKey)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seenEnv.add(envKey)
|
||||
derived.push({
|
||||
id: row.slug,
|
||||
name: row.name,
|
||||
envKey,
|
||||
description: `Direct API access to ${row.name}.`,
|
||||
docsUrl: ''
|
||||
})
|
||||
}
|
||||
|
||||
// Curated first (recommended order), then the rest alphabetically so the
|
||||
// long tail is scannable.
|
||||
derived.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
return [...API_KEY_OPTIONS.filter(o => curatedByEnv.has(o.envKey)), ...derived]
|
||||
}, [rows])
|
||||
}
|
||||
|
||||
const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
|
||||
nous: { order: 0, title: 'Nous Portal' },
|
||||
'openai-codex': { order: 1, title: 'OpenAI OAuth (ChatGPT)' },
|
||||
@@ -115,6 +186,11 @@ const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
|
||||
export const sortProviders = (providers: OAuthProvider[]) =>
|
||||
[...providers].sort((a, b) => orderOf(a) - orderOf(b) || a.name.localeCompare(b.name))
|
||||
|
||||
// Exit choreography, mirroring the gateway "connecting" overlay's timing:
|
||||
// text-out (360ms: CONNECTED fades down, rest scrambles+fades) → hold (300ms)
|
||||
// → surface-out (520ms, held back by [transition-delay:660ms]). Finalize after.
|
||||
const ONBOARDING_EXIT_MS = 1180
|
||||
|
||||
export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway }: DesktopOnboardingOverlayProps) {
|
||||
const { t } = useI18n()
|
||||
const onboarding = useStore($desktopOnboarding)
|
||||
@@ -130,6 +206,29 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
|
||||
[]
|
||||
)
|
||||
|
||||
// Cinematic exit on "Begin": dissolve the panel + overlay (revealing the chat
|
||||
// behind), THEN finalize so the unmount lands after the fade — mirrors the
|
||||
// connecting overlay's exit choreography instead of cutting instantly.
|
||||
const [leaving, setLeaving] = useState(false)
|
||||
|
||||
const finalizeOnboarding = () => {
|
||||
if (leaving) {
|
||||
return
|
||||
}
|
||||
|
||||
const reduce =
|
||||
typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
if (reduce) {
|
||||
confirmOnboardingModel(ctx)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setLeaving(true)
|
||||
window.setTimeout(() => confirmOnboardingModel(ctx), ONBOARDING_EXIT_MS)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled || onboarding.requested) {
|
||||
void refreshOnboarding(ctx)
|
||||
@@ -183,18 +282,52 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
|
||||
}
|
||||
|
||||
const { flow } = onboarding
|
||||
// Show the launch reason only when it's a meaningful, caller-supplied prompt —
|
||||
// suppress the generic defaults (useless noise) and provider-setup errors
|
||||
// (those are surfaced by FlowPanel, not as a banner).
|
||||
const rawReason = onboarding.reason?.trim() || null
|
||||
const reason = rawReason && !isProviderSetupErrorMessage(rawReason) ? rawReason : null
|
||||
|
||||
const reason =
|
||||
rawReason &&
|
||||
!isProviderSetupErrorMessage(rawReason) &&
|
||||
rawReason !== DEFAULT_ONBOARDING_REASON &&
|
||||
rawReason !== DEFAULT_MANUAL_ONBOARDING_REASON
|
||||
? rawReason
|
||||
: null
|
||||
|
||||
// In manual mode the app is already configured, so the flow is "ready"
|
||||
// immediately — no runtime gate needed. Otherwise wait for the readiness
|
||||
// check (configured === false) before showing the picker.
|
||||
const ready = onboarding.manual || (enabled && onboarding.configured === false)
|
||||
const showPicker = flow.status === 'idle' || flow.status === 'success'
|
||||
// The final "you're in" screen drops the card chrome and floats centered on
|
||||
// the surface — same bare, cinematic treatment as the connecting overlay.
|
||||
const bare = ready && !showPicker && flow.status === 'confirming_model'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
|
||||
<div className="relative w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
|
||||
<Header />
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-1300 flex items-center justify-center bg-(--ui-chat-surface-background) p-6 transition-opacity duration-[520ms] ease-out',
|
||||
// On the bare confirm screen, hold the surface (text-out + hold) so the
|
||||
// per-element exit plays before it dissolves.
|
||||
bare && leaving ? '[transition-delay:660ms]' : '',
|
||||
leaving ? 'pointer-events-none opacity-0' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full max-w-[45rem] transition-all duration-500 ease-out',
|
||||
bare
|
||||
? ''
|
||||
: 'overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous',
|
||||
// Bare confirm screen orchestrates its own per-element exit; the
|
||||
// carded states use the simple lift/blur dissolve.
|
||||
leaving && !bare
|
||||
? '-translate-y-1 scale-[0.985] opacity-0 blur-[2px]'
|
||||
: 'translate-y-0 scale-100 opacity-100 blur-0'
|
||||
)}
|
||||
>
|
||||
{showPicker || !ready ? <Header /> : null}
|
||||
{onboarding.manual ? (
|
||||
<Button
|
||||
aria-label={t.common.close}
|
||||
@@ -208,16 +341,24 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
|
||||
) : null}
|
||||
<div className="grid gap-3 p-5">
|
||||
{reason ? <ReasonNotice reason={reason} /> : null}
|
||||
{ready ? showPicker ? <Picker ctx={ctx} /> : <FlowPanel ctx={ctx} flow={flow} /> : <Preparing boot={boot} />}
|
||||
{ready ? (
|
||||
showPicker ? (
|
||||
<Picker ctx={ctx} />
|
||||
) : (
|
||||
<FlowPanel ctx={ctx} flow={flow} leaving={leaving} onBegin={finalizeOnboarding} />
|
||||
)
|
||||
) : (
|
||||
<Preparing boot={boot} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// The launch reason is a prompt ("why am I seeing this"), not an error — real
|
||||
// provider-setup failures are filtered out upstream and surfaced by FlowPanel.
|
||||
// Keep it neutral so it never reads as a failure.
|
||||
// The launch reason is a prompt ("why am I seeing this"), not an error. Only
|
||||
// rendered for meaningful caller-supplied reasons (defaults are filtered out
|
||||
// upstream), so it never shows the generic "no provider configured" noise.
|
||||
function ReasonNotice({ reason }: { reason: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/40 px-4 py-3 text-sm text-muted-foreground">
|
||||
@@ -235,9 +376,7 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
|
||||
return (
|
||||
<div className="grid gap-3" role="status">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{installing
|
||||
? t.onboarding.preparingInstall
|
||||
: t.onboarding.starting}
|
||||
{installing ? t.onboarding.preparingInstall : t.onboarding.starting}
|
||||
</p>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
@@ -261,18 +400,9 @@ function Header() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<div className="border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) px-5 py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)">
|
||||
<Sparkles className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[0.9375rem] font-semibold tracking-tight">{t.onboarding.headerTitle}</h2>
|
||||
<p className="mt-1 max-w-xl text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
|
||||
{t.onboarding.headerDesc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-(--ui-chat-bubble-background) px-5 pt-5 pb-1">
|
||||
<h2 className="text-[0.9375rem] font-semibold tracking-tight">{t.onboarding.headerTitle}</h2>
|
||||
<p className="mt-1 max-w-xl text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">{t.onboarding.headerDesc}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -304,6 +434,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
||||
const [showAll, setShowAll] = useState(readShowAll)
|
||||
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
|
||||
const hasOauth = ordered.length > 0
|
||||
const apiKeyOptions = useApiKeyCatalog()
|
||||
|
||||
if (mode === 'apikey' || !hasOauth) {
|
||||
return (
|
||||
@@ -312,6 +443,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
||||
canGoBack={hasOauth}
|
||||
onBack={() => setOnboardingMode('oauth')}
|
||||
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
|
||||
options={apiKeyOptions}
|
||||
/>
|
||||
{manual ? null : (
|
||||
<div className="flex justify-center border-t border-(--ui-stroke-tertiary) pt-3">
|
||||
@@ -336,37 +468,43 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{featured ? <FeaturedProviderRow onSelect={select} provider={featured} /> : null}
|
||||
{showRest ? (
|
||||
<>
|
||||
{rest.map(p => (
|
||||
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
||||
))}
|
||||
<KeyProviderRow onClick={() => setOnboardingMode('apikey')} />
|
||||
</>
|
||||
) : null}
|
||||
<div className="grid max-h-[60dvh] gap-2 overflow-y-auto p-1">
|
||||
{featured ? <FeaturedProviderRow onSelect={select} provider={featured} /> : null}
|
||||
{showRest ? (
|
||||
<>
|
||||
{rest.map(p => (
|
||||
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
||||
))}
|
||||
<KeyProviderRow onClick={() => setOnboardingMode('apikey')} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{collapsible ? (
|
||||
<button
|
||||
className="flex items-center justify-center gap-1.5 pt-1 text-xs font-medium text-muted-foreground transition hover:text-foreground"
|
||||
<Button
|
||||
className="mt-1 self-center font-medium"
|
||||
onClick={() => setShowAll(persistShowAll(!showAll))}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{showAll ? t.onboarding.collapse : t.onboarding.otherProviders}
|
||||
<ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-3 pt-1">
|
||||
{/* First run only: let the user defer the choice and land in the app.
|
||||
In manual mode the overlay already has a close affordance, so the
|
||||
"choose later" escape would be redundant — hide it. */}
|
||||
{manual ? <span /> : <ChooseLaterLink />}
|
||||
<button
|
||||
className="text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
<Button
|
||||
className="-mr-2 font-medium"
|
||||
onClick={() => setOnboardingMode('apikey')}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{t.onboarding.haveApiKey}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -379,13 +517,15 @@ function ChooseLaterLink() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<button
|
||||
className="text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
<Button
|
||||
className="font-medium"
|
||||
onClick={() => dismissFirstRunOnboarding()}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{t.onboarding.chooseLater}
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -439,15 +579,14 @@ function ConnectedTag() {
|
||||
)
|
||||
}
|
||||
|
||||
const PROVIDER_ROW_CLASS =
|
||||
'group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)'
|
||||
|
||||
export function KeyProviderRow({ onClick }: { onClick: () => void }) {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<button
|
||||
className="group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<button className={PROVIDER_ROW_CLASS} onClick={onClick} type="button">
|
||||
<div className="min-w-0">
|
||||
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">OpenRouter</span>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.openRouterPitch}</p>
|
||||
@@ -469,11 +608,7 @@ export function ProviderRow({
|
||||
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
|
||||
|
||||
return (
|
||||
<button
|
||||
className="group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
|
||||
onClick={() => onSelect(provider)}
|
||||
type="button"
|
||||
>
|
||||
<button className={PROVIDER_ROW_CLASS} onClick={() => onSelect(provider)} type="button">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">
|
||||
@@ -481,9 +616,7 @@ export function ProviderRow({
|
||||
</span>
|
||||
{loggedIn ? <ConnectedTag /> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{t.onboarding.flowSubtitles[provider.flow]}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
|
||||
</div>
|
||||
<Trail className="size-4 text-muted-foreground transition group-hover:text-foreground" />
|
||||
</button>
|
||||
@@ -521,12 +654,12 @@ export function ApiKeyForm({
|
||||
// Providers page wiring its search into this grid). Keep the selection valid
|
||||
// by snapping back to the first remaining option when the current one drops.
|
||||
useEffect(() => {
|
||||
if (options.length > 0 && !options.some(o => o.id === option.id)) {
|
||||
if (options.length > 0 && !options.some(o => o.envKey === option.envKey)) {
|
||||
setOption(options[0])
|
||||
setValue('')
|
||||
setError(null)
|
||||
}
|
||||
}, [option.id, options])
|
||||
}, [option.envKey, options])
|
||||
// The catalog grid can be tall, leaving the entry field far below the fold.
|
||||
// On selection we scroll the field into view and focus it so it's always
|
||||
// obvious where to paste next.
|
||||
@@ -574,39 +707,35 @@ export function ApiKeyForm({
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{canGoBack ? (
|
||||
<button
|
||||
className="-mt-1 flex items-center gap-1 self-start text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
<Button
|
||||
className="-mt-1 self-start font-medium"
|
||||
onClick={onBack}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
{t.onboarding.backToSignIn}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="grid max-h-[42dvh] gap-2 overflow-y-auto p-1 sm:grid-cols-2">
|
||||
{options.map(o => (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-2xl border bg-background/60 p-3 text-left transition hover:bg-accent/50',
|
||||
option.id === o.id ? 'border-primary ring-2 ring-primary/20' : 'border-border'
|
||||
option.envKey === o.envKey ? 'border-primary ring-2 ring-primary/20' : 'border-transparent'
|
||||
)}
|
||||
key={o.id}
|
||||
key={o.envKey}
|
||||
onClick={() => pick(o)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{o.name}</span>
|
||||
{option.id === o.id ? (
|
||||
<Check className="size-4 text-primary" />
|
||||
) : isSet?.(o.envKey) ? (
|
||||
<Check className="size-3.5 text-muted-foreground" />
|
||||
) : null}
|
||||
{isSet?.(o.envKey) ? <Check className="size-3.5 text-muted-foreground" /> : null}
|
||||
</div>
|
||||
{(t.onboarding.apiKeyOptions[o.id]?.short ?? o.short) ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t.onboarding.apiKeyOptions[o.id]?.short ?? o.short}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t.onboarding.apiKeyOptions[o.id]?.short ?? o.short}</p>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
@@ -624,7 +753,8 @@ export function ApiKeyForm({
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && void submit()}
|
||||
placeholder={
|
||||
currentRedacted ?? (alreadySet ? t.onboarding.replaceCurrent : option.placeholder || t.onboarding.pasteApiKey)
|
||||
currentRedacted ??
|
||||
(alreadySet ? t.onboarding.replaceCurrent : option.placeholder || t.onboarding.pasteApiKey)
|
||||
}
|
||||
type={isLocal ? 'text' : 'password'}
|
||||
value={value}
|
||||
@@ -641,7 +771,7 @@ export function ApiKeyForm({
|
||||
) : null}
|
||||
</div>
|
||||
<Button disabled={!canSave || saving} onClick={() => void submit()}>
|
||||
{saving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
|
||||
{saving ? <Loader2 className="animate-spin" /> : <KeyRound />}
|
||||
{saving ? t.onboarding.connecting : alreadySet ? t.onboarding.update : t.common.connect}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -649,7 +779,17 @@ export function ApiKeyForm({
|
||||
)
|
||||
}
|
||||
|
||||
function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow }) {
|
||||
function FlowPanel({
|
||||
ctx,
|
||||
flow,
|
||||
leaving,
|
||||
onBegin
|
||||
}: {
|
||||
ctx: OnboardingContext
|
||||
flow: OnboardingFlow
|
||||
leaving: boolean
|
||||
onBegin: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const title = 'provider' in flow && flow.provider ? providerTitle(flow.provider) : ''
|
||||
|
||||
@@ -663,22 +803,20 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
|
||||
|
||||
if (flow.status === 'success') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
|
||||
<Check className="size-4" />
|
||||
{t.onboarding.connectedPicking(title)}
|
||||
</div>
|
||||
<DecodedLabel text={t.onboarding.connectedPicking(title)} />
|
||||
)
|
||||
}
|
||||
|
||||
if (flow.status === 'confirming_model') {
|
||||
return <ConfirmingModelPanel ctx={ctx} flow={flow} />
|
||||
return <ConfirmingModelPanel flow={flow} leaving={leaving} onBegin={onBegin} />
|
||||
}
|
||||
|
||||
if (flow.status === 'error') {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{flow.message || t.onboarding.signInFailed}
|
||||
<div className="flex items-center gap-1.5 text-sm text-destructive">
|
||||
<ErrorIcon className="shrink-0" size="0.875rem" />
|
||||
<span>{flow.message || t.onboarding.signInFailed}</span>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={cancelOnboardingFlow} variant="outline">
|
||||
@@ -717,9 +855,7 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
|
||||
if (flow.status === 'awaiting_browser') {
|
||||
return (
|
||||
<Step title={t.onboarding.signInWith(title)}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t.onboarding.autoBrowser(title)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t.onboarding.autoBrowser(title)}</p>
|
||||
<FlowFooter left={<DocsLink href={flow.start.auth_url}>{t.onboarding.reopenSignInPage}</DocsLink>}>
|
||||
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
@@ -734,18 +870,17 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
|
||||
if (flow.status === 'external_pending') {
|
||||
return (
|
||||
<Step title={t.onboarding.signInWith(title)}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t.onboarding.externalPending(title)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t.onboarding.externalPending(title)}</p>
|
||||
<CodeBlock copied={flow.copied} onCopy={() => void copyExternalCommand()} text={flow.provider.cli_command} />
|
||||
<FlowFooter
|
||||
left={flow.provider.docs_url ? <DocsLink href={flow.provider.docs_url}>{t.onboarding.docs(title)}</DocsLink> : null}
|
||||
left={
|
||||
flow.provider.docs_url ? (
|
||||
<DocsLink href={flow.provider.docs_url}>{t.onboarding.docs(title)}</DocsLink>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<CancelBtn />
|
||||
<Button onClick={() => void recheckExternalSignin(ctx)}>
|
||||
<Check className="size-4" />
|
||||
{t.onboarding.signedIn}
|
||||
</Button>
|
||||
<Button onClick={() => void recheckExternalSignin(ctx)}>{t.onboarding.signedIn}</Button>
|
||||
</FlowFooter>
|
||||
</Step>
|
||||
)
|
||||
@@ -758,7 +893,7 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
|
||||
return (
|
||||
<Step title={t.onboarding.signInWith(title)}>
|
||||
<p className="text-sm text-muted-foreground">{t.onboarding.deviceCodeOpened(title)}</p>
|
||||
<CodeBlock copied={flow.copied} large onCopy={() => void copyDeviceCode()} text={flow.start.user_code} />
|
||||
<DeviceCode code={flow.start.user_code} copied={flow.copied} onCopy={() => void copyDeviceCode()} />
|
||||
<FlowFooter left={<DocsLink href={flow.start.verification_url}>{t.onboarding.reopenVerification}</DocsLink>}>
|
||||
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
@@ -779,24 +914,53 @@ function Step({ children, title }: { children: React.ReactNode; title: string })
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({
|
||||
copied,
|
||||
large,
|
||||
onCopy,
|
||||
text
|
||||
}: {
|
||||
copied: boolean
|
||||
large?: boolean
|
||||
onCopy: () => void
|
||||
text: string
|
||||
}) {
|
||||
// Device-code display: OTP-style — each character in its own readonly cell.
|
||||
// The whole row is the copy button (no side button, no checkmark); on copy the
|
||||
// cells flash emerald for feedback. Dashes render as quiet separators.
|
||||
function DeviceCode({ code, copied, onCopy }: { code: string; copied: boolean; onCopy: () => void }) {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-secondary/30 px-4 py-3">
|
||||
<code className={cn('font-mono', large ? 'text-2xl tracking-[0.4em]' : 'text-sm')}>{text}</code>
|
||||
<button
|
||||
aria-label={t.onboarding.copy}
|
||||
className="group flex w-full items-center justify-center gap-1.5"
|
||||
onClick={onCopy}
|
||||
type="button"
|
||||
>
|
||||
{[...code].map((ch, i) =>
|
||||
ch === '-' || ch === ' ' ? (
|
||||
<span className="w-1.5 text-center text-lg text-muted-foreground" key={i}>
|
||||
–
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-10 items-center justify-center rounded-md border font-mono text-xl font-semibold uppercase transition-colors',
|
||||
copied
|
||||
? 'border-primary/50 text-primary'
|
||||
: 'border-(--stroke-nous) text-foreground group-hover:border-(--ui-stroke-secondary)'
|
||||
)}
|
||||
key={i}
|
||||
>
|
||||
{ch}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({ copied, onCopy, text }: { copied: boolean; onCopy: () => void; text: string }) {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-(--stroke-nous) px-3 py-2">
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-sm">
|
||||
<span className="mr-2 select-none text-muted-foreground">$</span>
|
||||
{text}
|
||||
</code>
|
||||
<Button onClick={onCopy} size="sm" variant="outline">
|
||||
{copied ? <Check className="size-4" /> : t.onboarding.copy}
|
||||
{copied ? t.common.copied : t.onboarding.copy}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -821,14 +985,184 @@ function CancelBtn({ size = 'default' }: { size?: 'default' | 'sm' }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ConfirmingModelPanel({
|
||||
ctx,
|
||||
flow
|
||||
// Borrowed from the gateway "connecting" overlay: a mono, letter-spaced label
|
||||
// that decodes left-to-right from scrambled glyphs into the real text, with a
|
||||
// blinking block cursor. Ties onboarding's success moment to that same motif.
|
||||
// Cuneiform glyphs (array, since each is a surrogate pair) for the scramble.
|
||||
// Hero "X CONNECTED" decode uses the SAME ascii map as the connecting overlay.
|
||||
const ASCII_GLYPHS = [...'/\\|-_=+<>~:*']
|
||||
const pickAscii = () => ASCII_GLYPHS[(Math.random() * ASCII_GLYPHS.length) | 0]
|
||||
// Cuneiform is reserved for the subtle "other text" (model name + BEGIN) easter egg.
|
||||
const SCRAMBLE_GLYPHS = [...'𒀀𒀁𒀂𒀅𒀊𒀖𒀜𒀭𒀲𒀸𒁀𒁉𒁒𒁕𒁹𒂊𒃻𒄆𒄴𒅀𒆍𒇽𒈨𒉡']
|
||||
const GLYPH_SET = new Set(SCRAMBLE_GLYPHS)
|
||||
const pickGlyph = () => SCRAMBLE_GLYPHS[(Math.random() * SCRAMBLE_GLYPHS.length) | 0]
|
||||
// How many trailing characters of each word scramble during decode-in.
|
||||
const DECODE_TAIL = 4
|
||||
|
||||
// Renders text where cuneiform scramble-glyphs are dropped to a smaller em-size
|
||||
// (resolved Latin chars stay full size) — keeps the easter-egg glyphs subtle.
|
||||
function GlyphText({ text }: { text: string }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from(text, (ch, i) =>
|
||||
GLYPH_SET.has(ch) ? (
|
||||
<span className="text-[0.62em]" key={i}>
|
||||
{ch}
|
||||
</span>
|
||||
) : (
|
||||
ch
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function useDecoded(text: string): string {
|
||||
const [out, setOut] = useState(text)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
||||
setOut(text)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Each WORD keeps its head static and only churns its tail (last few chars),
|
||||
// resolving left-to-right across all tails — same anchor-the-prefix trick the
|
||||
// connecting overlay uses ("CONN" static, "ECTING" churns), applied per word
|
||||
// so both the provider and "CONNECTED" decode and time stays constant.
|
||||
const chars = [...text]
|
||||
const scrambleable = chars.map(() => false)
|
||||
|
||||
for (let i = 0; i < chars.length; ) {
|
||||
if (!/[a-z0-9]/i.test(chars[i])) {
|
||||
i += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
let j = i
|
||||
|
||||
while (j < chars.length && /[a-z0-9]/i.test(chars[j])) {
|
||||
j += 1
|
||||
}
|
||||
|
||||
for (let k = Math.max(i, j - DECODE_TAIL); k < j; k += 1) {
|
||||
scrambleable[k] = true
|
||||
}
|
||||
|
||||
i = j
|
||||
}
|
||||
|
||||
const tailIndices = chars.map((_, idx) => idx).filter(idx => scrambleable[idx])
|
||||
let resolved = 0
|
||||
|
||||
const id = window.setInterval(() => {
|
||||
resolved += 0.5
|
||||
const settled = new Set(tailIndices.slice(0, Math.floor(resolved)))
|
||||
|
||||
setOut(chars.map((ch, idx) => (scrambleable[idx] && !settled.has(idx) ? pickAscii() : ch)).join(''))
|
||||
|
||||
if (Math.floor(resolved) >= tailIndices.length) {
|
||||
window.clearInterval(id)
|
||||
}
|
||||
}, 45)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [text])
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// Continuously scrambles alphanumeric chars while `active` (used on exit so the
|
||||
// model name / button decay into ascii noise as they fade).
|
||||
function useScramble(text: string, active: boolean): string {
|
||||
const [out, setOut] = useState(text)
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setOut(text)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const id = window.setInterval(() => {
|
||||
setOut(Array.from(text, ch => (/[a-z0-9]/i.test(ch) ? pickGlyph() : ch)).join(''))
|
||||
}, 45)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [text, active])
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function DecodedLabel({ leaving, text }: { leaving?: boolean; text: string }) {
|
||||
const decoded = useDecoded(text.toUpperCase())
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center font-mono text-xs font-semibold uppercase tracking-[0.28em] tabular-nums text-primary transition duration-[360ms] ease-out',
|
||||
leaving ? 'translate-y-2 opacity-0 saturate-0' : 'translate-y-0 opacity-100 saturate-100'
|
||||
)}
|
||||
>
|
||||
<GlyphText text={decoded} />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="dither ml-1.5 -mr-[0.875rem] inline-block size-2 shrink-0 -translate-y-px rounded-[1px] text-primary"
|
||||
style={{ animation: 'ob-decode-cursor 1s step-end infinite' }}
|
||||
/>
|
||||
<style>{'@keyframes ob-decode-cursor { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }'}</style>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Terminal-flavored CTA to match the connecting overlay's hacker aesthetic:
|
||||
// mono, uppercase, letter-spaced, wrapped in primary brackets that light up on
|
||||
// hover. The whole onboarding "you're in" moment leans into this motif.
|
||||
function HackeryButton({
|
||||
disabled,
|
||||
label,
|
||||
loading,
|
||||
onClick
|
||||
}: {
|
||||
disabled?: boolean
|
||||
label: React.ReactNode
|
||||
loading?: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group inline-flex items-center gap-2 rounded-md border border-(--stroke-nous) px-6 py-2.5',
|
||||
'font-mono text-xs font-semibold uppercase text-primary',
|
||||
'transition-all duration-150 hover:border-primary/60 hover:bg-primary/[0.06]',
|
||||
'disabled:pointer-events-none disabled:opacity-50'
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span className="text-primary/40 transition-colors group-hover:text-primary">[</span>
|
||||
{loading ? <Loader2 className="size-3 animate-spin" /> : null}
|
||||
<span className="-mr-[0.25em] pl-[0.25em] tracking-[0.25em]">{label}</span>
|
||||
<span className="text-primary/40 transition-colors group-hover:text-primary">]</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfirmingModelPanel({
|
||||
flow,
|
||||
leaving,
|
||||
onBegin
|
||||
}: {
|
||||
ctx: OnboardingContext
|
||||
flow: Extract<OnboardingFlow, { status: 'confirming_model' }>
|
||||
leaving: boolean
|
||||
onBegin: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const scrambledModel = useScramble(flow.currentModel, leaving)
|
||||
const scrambledBegin = useScramble(t.onboarding.startChatting, leaving)
|
||||
// Local state controls whether the model picker dialog is open.
|
||||
// We reuse the existing ModelPickerDialog component (the same picker
|
||||
// available from the chat shell) rather than building an inline
|
||||
@@ -851,46 +1185,61 @@ function ConfirmingModelPanel({
|
||||
const freeTier = providerRow?.free_tier
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
|
||||
<Check className="size-4 shrink-0" />
|
||||
<span>{t.onboarding.connectedProvider(flow.label)}</span>
|
||||
</div>
|
||||
<div className="grid place-items-center gap-7 py-6 text-center">
|
||||
<DecodedLabel leaving={leaving} text={t.onboarding.connectedProvider(flow.label)} />
|
||||
|
||||
<div className="grid gap-3 rounded-2xl border border-border bg-background/60 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">{t.onboarding.defaultModel}</p>
|
||||
{freeTier === true && (
|
||||
<span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||
{t.onboarding.freeTier}
|
||||
</span>
|
||||
)}
|
||||
{freeTier === false && (
|
||||
<span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
|
||||
{t.onboarding.pro}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 truncate font-mono text-sm">{flow.currentModel}</p>
|
||||
{price && (price.input || price.output) && (
|
||||
<p className="mt-1 font-mono text-xs text-muted-foreground">
|
||||
{price.free ? t.onboarding.free : t.onboarding.price(price.input || '?', price.output || '?')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button disabled={flow.saving} onClick={() => setPickerOpen(true)} size="sm" variant="outline">
|
||||
{t.onboarding.change}
|
||||
</Button>
|
||||
<div
|
||||
className={cn(
|
||||
'grid justify-items-center gap-1.5 transition duration-[360ms] ease-out',
|
||||
leaving ? 'opacity-0 saturate-0' : 'opacity-100 saturate-100'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[0.625rem] uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{t.onboarding.defaultModel}
|
||||
</span>
|
||||
{freeTier === true && (
|
||||
<span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||
{t.onboarding.freeTier}
|
||||
</span>
|
||||
)}
|
||||
{freeTier === false && (
|
||||
<span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
|
||||
{t.onboarding.pro}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="font-mono text-base">
|
||||
<GlyphText text={scrambledModel} />
|
||||
</p>
|
||||
{price && (price.input || price.output) && (
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{price.free ? t.onboarding.free : t.onboarding.price(price.input || '?', price.output || '?')}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="mt-0.5 text-xs"
|
||||
disabled={flow.saving}
|
||||
onClick={() => setPickerOpen(true)}
|
||||
size="inline"
|
||||
variant="text"
|
||||
>
|
||||
{t.onboarding.change}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button disabled={flow.saving} onClick={() => confirmOnboardingModel(ctx)}>
|
||||
{flow.saving ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}
|
||||
{t.onboarding.startChatting}
|
||||
</Button>
|
||||
<div
|
||||
className={cn(
|
||||
'transition duration-[360ms] ease-out',
|
||||
leaving ? 'opacity-0 saturate-0' : 'opacity-100 saturate-100'
|
||||
)}
|
||||
>
|
||||
<HackeryButton
|
||||
disabled={flow.saving}
|
||||
label={<GlyphText text={scrambledBegin} />}
|
||||
loading={flow.saving}
|
||||
onClick={onBegin}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
@@ -918,7 +1267,7 @@ function ConfirmingModelPanel({
|
||||
|
||||
function DocsLink({ children, href }: { children: React.ReactNode; href: string }) {
|
||||
return (
|
||||
<Button asChild size="xs" variant="ghost">
|
||||
<Button asChild size="xs" variant="text">
|
||||
<a href={href} rel="noreferrer" target="_blank">
|
||||
<ExternalLink className="size-3" />
|
||||
{children}
|
||||
@@ -929,8 +1278,8 @@ function DocsLink({ children, href }: { children: React.ReactNode; href: string
|
||||
|
||||
function Status({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<div className="flex items-center gap-2.5 py-1 text-sm text-muted-foreground" role="status">
|
||||
<Loader className="size-7" type="lemniscate-bloom" />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -128,7 +128,7 @@ export function ModelPickerDialog({
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
<DialogFooter className="flex-row items-center justify-between gap-3 border-t border-border bg-card p-3 sm:justify-between">
|
||||
<DialogFooter className="flex-row items-center justify-between gap-3 bg-card p-3 sm:justify-between">
|
||||
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={persistGlobal || !sessionId}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
@@ -135,16 +136,18 @@ export function ModelVisibilityDialog({
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<button
|
||||
className="text-xs text-(--ui-text-tertiary) transition-colors hover:text-foreground"
|
||||
<Button
|
||||
className="-ml-2 text-(--ui-text-tertiary)"
|
||||
onClick={() => {
|
||||
onOpenChange(false)
|
||||
onOpenProviders()
|
||||
}}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{copy.addProvider}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { useI18n } from '@/i18n'
|
||||
@@ -26,8 +27,7 @@ const tone: Record<NotificationKind, { icon: IconComponent; iconClass: string; v
|
||||
success: { icon: CheckCircle2, iconClass: 'text-primary', variant: 'success' }
|
||||
}
|
||||
|
||||
const STACK_SURFACE = 'pointer-events-auto border-border/80 bg-popover/95 shadow-lg shadow-black/5 backdrop-blur-md'
|
||||
const GHOST_BTN = 'bg-transparent text-muted-foreground hover:text-foreground'
|
||||
const STACK_SURFACE = 'pointer-events-auto border border-(--stroke-nous) bg-popover/95 shadow-nous backdrop-blur-md'
|
||||
|
||||
export function NotificationStack() {
|
||||
const notifications = useStore($notifications)
|
||||
@@ -83,12 +83,12 @@ export function NotificationStack() {
|
||||
{expanded && olderNotifications.map(n => <NotificationItem key={n.id} notification={n} />)}
|
||||
{overflowCount > 0 && (
|
||||
<div className={cn(STACK_SURFACE, 'flex min-h-8 items-center justify-between rounded-lg px-3 text-xs')}>
|
||||
<button className={cn(GHOST_BTN, 'font-medium')} onClick={() => setExpanded(v => !v)} type="button">
|
||||
<Button className="-ml-2 font-medium" onClick={() => setExpanded(v => !v)} size="xs" type="button" variant="text">
|
||||
{expanded ? copy.hide : copy.show} {copy.more(overflowCount)}
|
||||
</button>
|
||||
<button className={GHOST_BTN} onClick={clearNotifications} type="button">
|
||||
</Button>
|
||||
<Button className="-mr-2" onClick={clearNotifications} size="xs" type="button" variant="text">
|
||||
{copy.clearAll}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
@@ -117,27 +117,31 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
|
||||
<p className="m-0">{notification.message}</p>
|
||||
{hasDetail && <NotificationDetail detail={notification.detail || ''} />}
|
||||
{notification.action && (
|
||||
<button
|
||||
className="mt-1.5 inline-flex items-center rounded-md bg-primary/15 px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-primary/25"
|
||||
<Button
|
||||
className="mt-1.5 bg-primary/15 font-medium text-primary hover:bg-primary/25 hover:text-primary"
|
||||
onClick={() => {
|
||||
notification.action?.onClick()
|
||||
dismissNotification(notification.id)
|
||||
}}
|
||||
size="xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{notification.action.label}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
aria-label={copy.dismiss}
|
||||
className="col-start-3 -mr-1 grid size-6 place-items-center rounded-md bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
className="col-start-3 -mr-1 text-muted-foreground"
|
||||
onClick={() => dismissNotification(notification.id)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="0.875rem" />
|
||||
</button>
|
||||
</Button>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -149,7 +153,7 @@ function NotificationDetail({ detail }: { detail: string }) {
|
||||
return (
|
||||
<details className="mt-2 text-xs text-muted-foreground">
|
||||
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">{copy.details}</summary>
|
||||
<div className="mt-1 rounded-md border border-border/70 bg-background/65 p-2">
|
||||
<div className="mt-1 rounded-md bg-background/65 p-2">
|
||||
<pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed">
|
||||
{detail}
|
||||
</pre>
|
||||
|
||||
@@ -103,10 +103,7 @@ function SudoDialog() {
|
||||
<Dialog onOpenChange={onOpenChange} open>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Lock className="size-4 text-primary" />
|
||||
{copy.sudoTitle}
|
||||
</DialogTitle>
|
||||
<DialogTitle icon={Lock}>{copy.sudoTitle}</DialogTitle>
|
||||
<DialogDescription>{copy.sudoDesc}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -200,10 +197,7 @@ function SecretDialog() {
|
||||
<Dialog onOpenChange={onOpenChange} open>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<KeyRound className="size-4 text-primary" />
|
||||
{request.envVar || copy.secretTitle}
|
||||
</DialogTitle>
|
||||
<DialogTitle icon={KeyRound}>{request.envVar || copy.secretTitle}</DialogTitle>
|
||||
<DialogDescription>{request.prompt || copy.secretDesc}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ActionStatus({
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{state === 'saving' ? <Loader2 className="size-4 animate-spin" /> : state === 'done' ? <Check /> : idleIcon}
|
||||
{state === 'saving' ? <Loader2 className="animate-spin" /> : state === 'done' ? <Check /> : idleIcon}
|
||||
{state === 'saving' ? busy : state === 'done' ? done : idle}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -8,17 +8,20 @@ import { cn } from '@/lib/utils'
|
||||
// fixed heights — so they stay snug and scale with content. Only icon buttons
|
||||
// (inherently square) carry the shared 4px radius.
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-[2.5px] text-xs leading-4 font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-default disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-[2.5px] text-xs leading-4 font-medium whitespace-nowrap shadow-none transition-all duration-100 outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-default disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40',
|
||||
// Quiet action — transparent fill with a 1.5px inset ring (no layout-shifting border).
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
'bg-transparent text-(--ui-text-primary) shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--ui-stroke-secondary)_50%,transparent)] hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
|
||||
// Soft-fill action (the default "non-primary button" look).
|
||||
secondary:
|
||||
'bg-(--ui-bg-quaternary) text-(--ui-text-primary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
|
||||
ghost: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
|
||||
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline',
|
||||
// Boxless inline-text action (no bg/border). Quiet by default — reads as
|
||||
// muted label text, underlines on hover (e.g. "Cancel", "Clear").
|
||||
@@ -32,6 +35,10 @@ const buttonVariants = cva(
|
||||
xs: "gap-1 px-2 py-0.5 text-[0.6875rem] leading-4 has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: 'px-2.5 py-1 has-[>svg]:px-2',
|
||||
lg: 'px-5 py-2 text-sm leading-5 has-[>svg]:px-4',
|
||||
// Flush inline text action — no box padding/height. Pair with text/link
|
||||
// variants when the button must sit inline in a heading or sentence
|
||||
// (replaces ad-hoc `h-auto px-0 py-0` overrides).
|
||||
inline: 'h-auto gap-1 p-0 has-[>svg]:px-0',
|
||||
icon: 'size-9 rounded-[4px]',
|
||||
'icon-xs': "size-6 rounded-[4px] [&_svg:not([class*='size-'])]:size-3",
|
||||
'icon-sm': 'size-8 rounded-[4px]',
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
@@ -53,7 +53,7 @@ function DialogContent({
|
||||
// Cap height at 85vh and let long content scroll inside the dialog
|
||||
// instead of overflowing off-screen (long cron titles, tool detail
|
||||
// dumps, etc.). Individual dialogs can still override via className.
|
||||
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid max-h-[85vh] w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-md duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid max-h-[85vh] w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
data-slot="dialog-content"
|
||||
@@ -68,7 +68,7 @@ function DialogContent({
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="1rem" />
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">{t.common.close}</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
@@ -98,13 +98,30 @@ function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
function DialogTitle({
|
||||
className,
|
||||
icon: Icon,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title> & {
|
||||
// Pass a lucide icon to get the canonical dialog-header glyph: a plain
|
||||
// primary-tinted icon inline with the title (no bg chip / ring). This is the
|
||||
// single source of truth for dialog header icons — don't hand-roll wrappers.
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
className={cn('text-[0.9375rem] font-semibold tracking-tight text-foreground', className)}
|
||||
className={cn(
|
||||
'text-[0.9375rem] font-semibold tracking-tight text-foreground',
|
||||
Icon && 'flex items-center gap-2',
|
||||
className
|
||||
)}
|
||||
data-slot="dialog-title"
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{Icon ? <Icon className="size-4 shrink-0 text-primary" /> : null}
|
||||
{children}
|
||||
</DialogPrimitive.Title>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { AlertCircle } from '@/lib/icons'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// The single canonical error glyph (codicon's filled error mark). Use this
|
||||
// everywhere an error is surfaced (boundaries, dialogs, banners) so failures
|
||||
// read identically — one icon, one color, no background chip.
|
||||
export function ErrorIcon({ className, size = '1.75rem' }: { className?: string; size?: string }) {
|
||||
return <Codicon className={cn('text-destructive', className)} name="error" size={size} />
|
||||
}
|
||||
|
||||
export interface ErrorStateProps {
|
||||
/** Optional actions row/stack rendered below the copy. */
|
||||
children?: ReactNode
|
||||
@@ -13,18 +20,16 @@ export interface ErrorStateProps {
|
||||
title: ReactNode
|
||||
}
|
||||
|
||||
// Shared, presentation-only error layout: a destructive icon chip over a
|
||||
// centered title + body, with an optional actions stack. Used by both the
|
||||
// top-level React error boundary and the in-dialog update error so every
|
||||
// failure state reads the same. Title/description accept nodes so callers in a
|
||||
// Radix Dialog can pass DialogTitle/DialogDescription for accessibility.
|
||||
// Shared, presentation-only error layout: the canonical ErrorIcon (no bg chip)
|
||||
// over a centered title + body, with an optional actions stack. Used by the
|
||||
// React error boundary, the in-dialog update error, and the boot-failure banner
|
||||
// so every failure reads the same. Title/description accept nodes so Radix
|
||||
// Dialog callers can pass DialogTitle/DialogDescription for accessibility.
|
||||
export function ErrorState({ children, className, description, icon, title }: ErrorStateProps) {
|
||||
return (
|
||||
<div className={cn('grid gap-5', className)}>
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<span className="flex size-14 items-center justify-center rounded-2xl bg-destructive/10 text-destructive">
|
||||
{icon ?? <AlertCircle className="size-7" />}
|
||||
</span>
|
||||
{icon ?? <ErrorIcon />}
|
||||
|
||||
{typeof title === 'string' ? (
|
||||
<h2 className="text-center text-xl font-semibold tracking-tight">{title}</h2>
|
||||
|
||||
17
apps/desktop/src/components/ui/log-view.tsx
Normal file
17
apps/desktop/src/components/ui/log-view.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Shared raw-log viewer: no bg, hairline border, tight padding, small mono.
|
||||
// One style everywhere we surface logs. Pass a max-h-* via className.
|
||||
export function LogView({ className, ...props }: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-auto rounded-lg border border-(--ui-stroke-tertiary) px-2.5 py-1.5 font-mono text-[0.6875rem] leading-[1.5] whitespace-pre-wrap break-words text-(--ui-text-tertiary) [scrollbar-width:thin]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,6 @@ const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = '16rem'
|
||||
const SIDEBAR_WIDTH_MOBILE = '18rem'
|
||||
const SIDEBAR_WIDTH_ICON = '3rem'
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: 'expanded' | 'collapsed'
|
||||
@@ -87,19 +86,8 @@ function SidebarProvider({
|
||||
return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
// The sidebar toggle (Cmd/Ctrl+B by default) is owned by the keybind runtime
|
||||
// (`view.toggleSidebar`) so it appears in the hotkey map and is rebindable.
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
|
||||
28
apps/desktop/src/global.d.ts
vendored
28
apps/desktop/src/global.d.ts
vendored
@@ -81,6 +81,10 @@ declare global {
|
||||
setBranch: (name: string) => Promise<{ branch: string }>
|
||||
onProgress: (callback: (payload: DesktopUpdateProgress) => void) => () => void
|
||||
}
|
||||
uninstall: {
|
||||
summary: () => Promise<DesktopUninstallSummary>
|
||||
run: (mode: DesktopUninstallMode) => Promise<DesktopUninstallResult>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,6 +108,30 @@ export interface DesktopVersionInfo {
|
||||
hermesRoot: string
|
||||
}
|
||||
|
||||
export type DesktopUninstallMode = 'full' | 'gui' | 'lite'
|
||||
|
||||
export interface DesktopUninstallSummary {
|
||||
hermes_home: string
|
||||
agent_installed: boolean
|
||||
gui_installed: boolean
|
||||
source_built_artifacts: string[]
|
||||
packaged_app_paths: string[]
|
||||
userdata_dir: string
|
||||
userdata_exists: boolean
|
||||
platform: string
|
||||
running_app_path?: null | string
|
||||
probe?: string
|
||||
}
|
||||
|
||||
export interface DesktopUninstallResult {
|
||||
ok: boolean
|
||||
mode?: DesktopUninstallMode
|
||||
willRemoveAppBundle?: boolean
|
||||
scriptPath?: string
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface DesktopUpdateCommit {
|
||||
sha: string
|
||||
summary: string
|
||||
|
||||
49
apps/desktop/src/hermes.test.ts
Normal file
49
apps/desktop/src/hermes.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { listAllProfileSessions, listSessions } from './hermes'
|
||||
|
||||
const emptySessionsResponse = {
|
||||
limit: 0,
|
||||
offset: 0,
|
||||
sessions: [],
|
||||
total: 0
|
||||
}
|
||||
|
||||
describe('Hermes REST session helpers', () => {
|
||||
let api: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
api = vi.fn().mockResolvedValue(emptySessionsResponse)
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { api }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(window, 'hermesDesktop')
|
||||
})
|
||||
|
||||
it('uses a longer timeout for the single-profile session list', async () => {
|
||||
await listSessions(50, 1)
|
||||
|
||||
expect(api).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '/api/sessions?limit=50&offset=0&min_messages=1&archived=exclude&order=recent',
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a longer timeout for the all-profile session list', async () => {
|
||||
await listAllProfileSessions(50, 1)
|
||||
|
||||
expect(api).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '/api/profiles/sessions?limit=50&offset=0&min_messages=1&archived=exclude&order=recent&profile=all',
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
ProfileSetupCommand,
|
||||
ProfileSoul,
|
||||
ProfilesResponse,
|
||||
SessionInfo,
|
||||
SessionMessagesResponse,
|
||||
SessionSearchResponse,
|
||||
SkillInfo,
|
||||
@@ -41,6 +42,7 @@ import type {
|
||||
} from '@/types/hermes'
|
||||
|
||||
const DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS = 30_000
|
||||
const SESSION_LIST_REQUEST_TIMEOUT_MS = 60_000
|
||||
|
||||
export type {
|
||||
ActionResponse,
|
||||
@@ -135,7 +137,8 @@ export async function listSessions(
|
||||
order: 'created' | 'recent' = 'recent'
|
||||
): Promise<PaginatedSessions> {
|
||||
const result = await window.hermesDesktop.api<PaginatedSessions>({
|
||||
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}&order=${order}`
|
||||
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}&order=${order}`,
|
||||
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -149,17 +152,34 @@ export async function listSessions(
|
||||
// primary backend straight off each profile's state.db — no per-profile backend
|
||||
// is spawned. Single-profile users get the same rows as listSessions(), tagged
|
||||
// profile="default".
|
||||
// Source scoping lets callers split the unified list into independent slices:
|
||||
// recents pass `excludeSources: ['cron']`, the cron-jobs section passes
|
||||
// `source: 'cron'`. Without this a burst of (always-newest) cron sessions
|
||||
// consumes the whole recents page and starves real conversations.
|
||||
export interface SessionSourceFilter {
|
||||
source?: string
|
||||
excludeSources?: string[]
|
||||
}
|
||||
|
||||
export async function listAllProfileSessions(
|
||||
limit = 40,
|
||||
minMessages = 0,
|
||||
archived: 'exclude' | 'include' | 'only' = 'exclude',
|
||||
order: 'created' | 'recent' = 'recent',
|
||||
profile: 'all' | (string & {}) = 'all'
|
||||
profile: 'all' | (string & {}) = 'all',
|
||||
filter: SessionSourceFilter = {}
|
||||
): Promise<PaginatedSessions> {
|
||||
const sourceParam = filter.source ? `&source=${encodeURIComponent(filter.source)}` : ''
|
||||
|
||||
const excludeParam = filter.excludeSources?.length
|
||||
? `&exclude_sources=${encodeURIComponent(filter.excludeSources.join(','))}`
|
||||
: ''
|
||||
|
||||
const result = await window.hermesDesktop.api<PaginatedSessions>({
|
||||
path:
|
||||
`/api/profiles/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
|
||||
`&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}`
|
||||
`&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}${sourceParam}${excludeParam}`,
|
||||
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -443,6 +463,15 @@ export function selectToolsetProvider(
|
||||
})
|
||||
}
|
||||
|
||||
export function runToolsetPostSetup(name: string, key: string): Promise<ActionResponse & { key: string }> {
|
||||
return window.hermesDesktop.api<ActionResponse & { key: string }>({
|
||||
...profileScoped(),
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`,
|
||||
method: 'POST',
|
||||
body: { key }
|
||||
})
|
||||
}
|
||||
|
||||
export function getMessagingPlatforms(): Promise<MessagingPlatformsResponse> {
|
||||
return window.hermesDesktop.api<MessagingPlatformsResponse>({
|
||||
path: '/api/messaging/platforms'
|
||||
@@ -479,6 +508,14 @@ export function getCronJob(jobId: string): Promise<CronJob> {
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCronJobRuns(jobId: string, limit = 20): Promise<SessionInfo[]> {
|
||||
const { runs } = await window.hermesDesktop.api<{ runs: SessionInfo[] }>({
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}`
|
||||
})
|
||||
|
||||
return runs ?? []
|
||||
}
|
||||
|
||||
export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
|
||||
return window.hermesDesktop.api<CronJob>({
|
||||
path: '/api/cron/jobs',
|
||||
|
||||
@@ -145,7 +145,85 @@ export const en: Translations = {
|
||||
showRightSidebar: 'Show right sidebar',
|
||||
muteHaptics: 'Mute haptics',
|
||||
unmuteHaptics: 'Unmute haptics',
|
||||
openSettings: 'Open settings'
|
||||
openSettings: 'Open settings',
|
||||
openKeybinds: 'Keyboard shortcuts'
|
||||
},
|
||||
|
||||
keybinds: {
|
||||
title: 'Keyboard shortcuts',
|
||||
subtitle: open => `Click a shortcut to rebind it · ${open} reopens this panel.`,
|
||||
rebind: 'Rebind',
|
||||
reset: 'Reset to default',
|
||||
resetAll: 'Reset all',
|
||||
pressKey: 'Press a key…',
|
||||
set: 'set',
|
||||
conflictWith: label => `Also bound to “${label}”`,
|
||||
categories: {
|
||||
composer: 'Composer',
|
||||
profiles: 'Profiles',
|
||||
session: 'Session',
|
||||
navigation: 'Navigation',
|
||||
view: 'View'
|
||||
},
|
||||
actions: {
|
||||
'keybinds.openPanel': 'Open keyboard shortcuts',
|
||||
'nav.commandPalette': 'Open command palette',
|
||||
'nav.commandCenter': 'Open command center',
|
||||
'nav.settings': 'Open settings',
|
||||
'nav.profiles': 'Open profiles',
|
||||
'nav.skills': 'Open skills',
|
||||
'nav.messaging': 'Open messaging',
|
||||
'nav.artifacts': 'Open artifacts',
|
||||
'nav.cron': 'Open scheduled jobs',
|
||||
'nav.agents': 'Open agents',
|
||||
'session.new': 'New session',
|
||||
'session.next': 'Next session',
|
||||
'session.prev': 'Previous session',
|
||||
'session.focusSearch': 'Search sessions',
|
||||
'session.togglePin': 'Pin / unpin current session',
|
||||
'composer.focus': 'Focus composer',
|
||||
'composer.modelPicker': 'Open model picker',
|
||||
'view.toggleSidebar': 'Toggle sessions sidebar',
|
||||
'view.toggleRightSidebar': 'Toggle file browser',
|
||||
'view.showFiles': 'Show file browser',
|
||||
'view.showTerminal': 'Show terminal',
|
||||
'view.terminalSelection': 'Send terminal selection to composer',
|
||||
'view.closePreviewTab': 'Close preview tab',
|
||||
'view.flipPanes': 'Swap sidebar sides',
|
||||
'appearance.toggleMode': 'Toggle light / dark',
|
||||
'profile.default': 'Switch to default profile',
|
||||
'profile.switch.1': 'Switch to profile 1',
|
||||
'profile.switch.2': 'Switch to profile 2',
|
||||
'profile.switch.3': 'Switch to profile 3',
|
||||
'profile.switch.4': 'Switch to profile 4',
|
||||
'profile.switch.5': 'Switch to profile 5',
|
||||
'profile.switch.6': 'Switch to profile 6',
|
||||
'profile.switch.7': 'Switch to profile 7',
|
||||
'profile.switch.8': 'Switch to profile 8',
|
||||
'profile.switch.9': 'Switch to profile 9',
|
||||
'profile.switch.10': 'Switch to profile 10',
|
||||
'profile.switch.11': 'Switch to profile 11',
|
||||
'profile.switch.12': 'Switch to profile 12',
|
||||
'profile.switch.13': 'Switch to profile 13',
|
||||
'profile.switch.14': 'Switch to profile 14',
|
||||
'profile.switch.15': 'Switch to profile 15',
|
||||
'profile.switch.16': 'Switch to profile 16',
|
||||
'profile.switch.17': 'Switch to profile 17',
|
||||
'profile.switch.18': 'Switch to profile 18',
|
||||
'profile.next': 'Next profile',
|
||||
'profile.prev': 'Previous profile',
|
||||
'profile.toggleAll': 'Toggle all-profiles view',
|
||||
'profile.create': 'Create profile',
|
||||
'composer.send': 'Send message',
|
||||
'composer.newline': 'Insert newline',
|
||||
'composer.steer': 'Steer the running turn',
|
||||
'composer.sendQueued': 'Send next queued turn',
|
||||
'composer.mention': 'Reference files, folders, URLs',
|
||||
'composer.slash': 'Slash command palette',
|
||||
'composer.help': 'Quick help',
|
||||
'composer.history': 'Cycle popover / history',
|
||||
'composer.cancel': 'Close popover · cancel run'
|
||||
}
|
||||
},
|
||||
|
||||
language: {
|
||||
@@ -244,8 +322,7 @@ export const en: Translations = {
|
||||
minAgo: count => `${count} min ago`,
|
||||
hoursAgo: count => `${count} hours ago`,
|
||||
daysAgo: count => `${count} days ago`
|
||||
}
|
||||
,
|
||||
},
|
||||
config: {
|
||||
none: 'None',
|
||||
noneParen: '(none)',
|
||||
@@ -292,7 +369,8 @@ export const en: Translations = {
|
||||
appliesTo: 'Applies to',
|
||||
allProfiles: 'All profiles',
|
||||
defaultConnection: 'Default connection for every profile that has no override of its own.',
|
||||
profileConnection: profile => `Connection used only when “${profile}” is the active profile. Set it to Local to inherit the default.`,
|
||||
profileConnection: profile =>
|
||||
`Connection used only when “${profile}” is the active profile. Set it to Local to inherit the default.`,
|
||||
envOverrideTitle: 'Environment variables are controlling this desktop session.',
|
||||
envOverrideDesc:
|
||||
'Unset HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN to use the saved setting below.',
|
||||
@@ -316,8 +394,7 @@ export const en: Translations = {
|
||||
authNeedsPassword: 'This gateway uses a username and password. Sign in to authorize this desktop app.',
|
||||
authNeedsOauth: provider => `This gateway uses OAuth. Sign in with ${provider} to authorize this desktop app.`,
|
||||
tokenTitle: 'Session token',
|
||||
tokenDesc:
|
||||
'The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token.',
|
||||
tokenDesc: 'The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token.',
|
||||
existingToken: value => `Existing token ${value}`,
|
||||
savedToken: 'saved',
|
||||
pasteSessionToken: 'Paste session token',
|
||||
@@ -408,7 +485,8 @@ export const en: Translations = {
|
||||
providers: {
|
||||
connectAccount: 'Connect an account',
|
||||
haveApiKey: 'Have an API key instead?',
|
||||
intro: 'Sign in with a subscription — no API key to copy. Hermes runs the browser sign-in for you, right here in the app.',
|
||||
intro:
|
||||
'Sign in with a subscription — no API key to copy. Hermes runs the browser sign-in for you, right here in the app.',
|
||||
connected: 'Connected',
|
||||
collapse: 'Collapse',
|
||||
connectAnother: 'Connect another provider',
|
||||
@@ -464,7 +542,16 @@ export const en: Translations = {
|
||||
ready: 'Ready',
|
||||
nousIncluded: 'Included with a Nous subscription — sign in to Nous Portal to activate.',
|
||||
noApiKeyRequired: 'No API key required.',
|
||||
postSetup: step => `This provider needs an extra setup step (${step}). Run it from the CLI with hermes tools for now.`
|
||||
postSetupHint: step =>
|
||||
`This backend needs a one-time install (${step}). Runs on this machine — may take a few minutes.`,
|
||||
postSetupRun: 'Run setup',
|
||||
postSetupRunning: 'Installing…',
|
||||
postSetupStarting: 'Starting…',
|
||||
postSetupCompleteTitle: 'Setup complete',
|
||||
postSetupCompleteMessage: step => `${step} installed.`,
|
||||
postSetupErrorTitle: 'Setup finished with errors',
|
||||
postSetupErrorMessage: step => `Check the ${step} log.`,
|
||||
postSetupFailed: step => `Failed to run ${step} setup`
|
||||
}
|
||||
},
|
||||
|
||||
@@ -665,7 +752,10 @@ export const en: Translations = {
|
||||
label: 'Bot token',
|
||||
help: 'Create an application in the Discord Developer Portal, add a bot, then paste its token.'
|
||||
},
|
||||
DISCORD_ALLOWED_USERS: { label: 'Allowed Discord user IDs', help: 'Recommended. Comma-separated Discord user IDs.' },
|
||||
DISCORD_ALLOWED_USERS: {
|
||||
label: 'Allowed Discord user IDs',
|
||||
help: 'Recommended. Comma-separated Discord user IDs.'
|
||||
},
|
||||
DISCORD_REPLY_TO_MODE: { label: 'Reply style', help: 'first, all, or off.' },
|
||||
DISCORD_ALLOW_ALL_USERS: {
|
||||
label: 'Allow all Discord users',
|
||||
@@ -679,7 +769,10 @@ export const en: Translations = {
|
||||
label: 'Home channel name',
|
||||
help: 'Display name for the home channel in logs and status output.'
|
||||
},
|
||||
BLUEBUBBLES_ALLOW_ALL_USERS: { label: 'Allow all iMessage users', help: 'When true, skip the BlueBubbles allowlist.' },
|
||||
BLUEBUBBLES_ALLOW_ALL_USERS: {
|
||||
label: 'Allow all iMessage users',
|
||||
help: 'When true, skip the BlueBubbles allowlist.'
|
||||
},
|
||||
MATTERMOST_ALLOW_ALL_USERS: { label: 'Allow all Mattermost users' },
|
||||
MATTERMOST_HOME_CHANNEL: { label: 'Home channel' },
|
||||
QQ_ALLOW_ALL_USERS: { label: 'Allow all QQ users' },
|
||||
@@ -698,7 +791,10 @@ export const en: Translations = {
|
||||
SLACK_ALLOWED_USERS: { label: 'Allowed Slack user IDs', help: 'Recommended. Comma-separated Slack user IDs.' },
|
||||
MATTERMOST_URL: { label: 'Server URL', placeholder: 'https://mattermost.example.com' },
|
||||
MATTERMOST_TOKEN: { label: 'Bot token' },
|
||||
MATTERMOST_ALLOWED_USERS: { label: 'Allowed user IDs', help: 'Recommended. Comma-separated Mattermost user IDs.' },
|
||||
MATTERMOST_ALLOWED_USERS: {
|
||||
label: 'Allowed user IDs',
|
||||
help: 'Recommended. Comma-separated Mattermost user IDs.'
|
||||
},
|
||||
MATRIX_HOMESERVER: { label: 'Homeserver URL', placeholder: 'https://matrix.org' },
|
||||
MATRIX_ACCESS_TOKEN: { label: 'Access token' },
|
||||
MATRIX_USER_ID: { label: 'Bot user ID', placeholder: '@hermes:example.org' },
|
||||
@@ -801,8 +897,6 @@ export const en: Translations = {
|
||||
cron: {
|
||||
close: 'Close cron',
|
||||
search: 'Search cron jobs...',
|
||||
refresh: 'Refresh cron jobs',
|
||||
refreshing: 'Refreshing cron jobs',
|
||||
loading: 'Loading cron jobs...',
|
||||
states: {
|
||||
enabled: 'enabled',
|
||||
@@ -855,9 +949,7 @@ export const en: Translations = {
|
||||
monthlyOnDayAt: (dayOfMonth, time) => `Monthly on day ${dayOfMonth} at ${time}`,
|
||||
topOfHour: 'At the top of every hour',
|
||||
everyHourAt: minute => `Every hour at :${minute}`,
|
||||
active: (enabled, total) => `${enabled}/${total} active`,
|
||||
newCron: 'New cron',
|
||||
createFirst: 'Create first cron',
|
||||
emptyDescNew:
|
||||
'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.',
|
||||
emptyDescSearch: 'Try a broader search query.',
|
||||
@@ -865,6 +957,11 @@ export const en: Translations = {
|
||||
emptyTitleSearch: 'No matches',
|
||||
last: 'Last:',
|
||||
next: 'Next:',
|
||||
noRuns: 'No runs yet',
|
||||
manage: 'Manage',
|
||||
showRuns: 'Show runs',
|
||||
hideRuns: 'Hide runs',
|
||||
runHistory: 'Run history',
|
||||
actionsFor: title => `Actions for ${title}`,
|
||||
actionsTitle: 'Cron job actions',
|
||||
resume: 'Resume cron',
|
||||
@@ -956,12 +1053,13 @@ export const en: Translations = {
|
||||
results: 'Results',
|
||||
pinned: 'Pinned',
|
||||
sessions: 'Sessions',
|
||||
cronJobs: 'Cron jobs',
|
||||
groupAriaGrouped: 'Show sessions as a single list',
|
||||
groupAriaUngrouped: 'Group sessions by workspace',
|
||||
groupTitleGrouped: 'Ungroup sessions',
|
||||
groupTitleUngrouped: 'Group by workspace',
|
||||
allPinned: 'Everything here is pinned. Unpin a chat to show it in recents.',
|
||||
shiftClickHint: 'Shift-click a chat to pin · drag to reorder',
|
||||
shiftClickHint: 'Shift-click a chat to pin',
|
||||
noWorkspace: 'No workspace',
|
||||
newSessionIn: label => `New session in ${label}`,
|
||||
reorderWorkspace: label => `Reorder workspace ${label}`,
|
||||
@@ -1057,8 +1155,8 @@ export const en: Translations = {
|
||||
'/': 'slash command palette',
|
||||
'?': 'this quick help (delete to dismiss)',
|
||||
Enter: 'send · Shift+Enter for newline',
|
||||
'Cmd/Ctrl+K': 'send next queued turn',
|
||||
'Cmd/Ctrl+L': 'redraw',
|
||||
'Cmd/Ctrl+Shift+K': 'send next queued turn',
|
||||
'Cmd/Ctrl+/': 'all keyboard shortcuts',
|
||||
Esc: 'close popover · cancel run',
|
||||
'↑ / ↓': 'cycle popover / history'
|
||||
},
|
||||
@@ -1212,7 +1310,10 @@ export const en: Translations = {
|
||||
featuredPitch: 'One subscription, 300+ frontier models — the recommended way to run Hermes',
|
||||
openRouterPitch: 'One key, hundreds of models — a solid default',
|
||||
apiKeyOptions: {
|
||||
openrouter: { short: 'one key, many models', description: 'Hosts hundreds of models behind a single key. Good default for new installs.' },
|
||||
openrouter: {
|
||||
short: 'one key, many models',
|
||||
description: 'Hosts hundreds of models behind a single key. Good default for new installs.'
|
||||
},
|
||||
openai: { short: 'GPT-class models', description: 'Direct access to OpenAI models.' },
|
||||
gemini: { short: 'Gemini models', description: 'Direct access to Google Gemini models.' },
|
||||
xai: { short: 'Grok models', description: 'Direct access to xAI Grok models.' },
|
||||
@@ -1236,7 +1337,7 @@ export const en: Translations = {
|
||||
},
|
||||
startingSignIn: provider => `Starting sign-in for ${provider}...`,
|
||||
verifyingCode: provider => `Verifying your code with ${provider}...`,
|
||||
connectedProvider: provider => `${provider} connected.`,
|
||||
connectedProvider: provider => `${provider} connected`,
|
||||
connectedPicking: provider => `${provider} connected. Picking a default model...`,
|
||||
signInFailed: 'Sign-in failed. Try again.',
|
||||
pickDifferentProvider: 'Pick a different provider',
|
||||
@@ -1262,7 +1363,7 @@ export const en: Translations = {
|
||||
free: 'Free',
|
||||
price: (input, output) => `${input} in / ${output} out per Mtok`,
|
||||
change: 'Change',
|
||||
startChatting: 'Start chatting',
|
||||
startChatting: 'Begin',
|
||||
docs: provider => `${provider} docs`
|
||||
},
|
||||
|
||||
@@ -1459,8 +1560,7 @@ export const en: Translations = {
|
||||
showConsole: 'Show preview console',
|
||||
hideDevTools: 'Hide preview DevTools',
|
||||
openDevTools: 'Open preview DevTools',
|
||||
finishedRestarting: message =>
|
||||
`Hermes finished restarting the preview server${message ? `: ${message}` : ''}`,
|
||||
finishedRestarting: message => `Hermes finished restarting the preview server${message ? `: ${message}` : ''}`,
|
||||
failedRestarting: message => `Server restart failed: ${message}`,
|
||||
unknownError: 'unknown error',
|
||||
restartedTitle: 'Preview server restarted',
|
||||
|
||||
@@ -637,8 +637,16 @@ export const ja = defineLocale({
|
||||
ready: '準備完了',
|
||||
nousIncluded: 'Nous サブスクリプションに含まれています。有効にするには Nous Portal にサインインしてください。',
|
||||
noApiKeyRequired: 'API キーは不要です。',
|
||||
postSetup: step =>
|
||||
`このプロバイダーは追加のセットアップ手順 (${step}) が必要です。今は CLI で hermes tools を実行してください。`
|
||||
postSetupHint: step =>
|
||||
`このバックエンドは一度だけインストールが必要です (${step})。このマシン上で実行され、数分かかる場合があります。`,
|
||||
postSetupRun: 'セットアップを実行',
|
||||
postSetupRunning: 'インストール中…',
|
||||
postSetupStarting: '開始中…',
|
||||
postSetupCompleteTitle: 'セットアップ完了',
|
||||
postSetupCompleteMessage: step => `${step} をインストールしました。`,
|
||||
postSetupErrorTitle: 'セットアップはエラーで終了しました',
|
||||
postSetupErrorMessage: step => `${step} のログを確認してください。`,
|
||||
postSetupFailed: step => `${step} のセットアップの実行に失敗しました`
|
||||
}
|
||||
},
|
||||
|
||||
@@ -991,8 +999,6 @@ export const ja = defineLocale({
|
||||
cron: {
|
||||
close: 'Cron を閉じる',
|
||||
search: 'Cron ジョブを検索...',
|
||||
refresh: 'Cron ジョブを更新',
|
||||
refreshing: 'Cron ジョブを更新中',
|
||||
loading: 'Cron ジョブを読み込み中...',
|
||||
states: {
|
||||
enabled: '有効',
|
||||
@@ -1045,9 +1051,7 @@ export const ja = defineLocale({
|
||||
monthlyOnDayAt: (dayOfMonth, time) => `毎月 ${dayOfMonth} 日 ${time} に`,
|
||||
topOfHour: '毎時 0 分',
|
||||
everyHourAt: minute => `毎時 :${minute} に`,
|
||||
active: (enabled, total) => `${enabled}/${total} 有効`,
|
||||
newCron: '新しい Cron',
|
||||
createFirst: '最初の Cron を作成',
|
||||
emptyDescNew:
|
||||
'Cron 式でプロンプトを実行するスケジュールを設定します。Hermes が実行して、選択した宛先に結果を送信します。',
|
||||
emptyDescSearch: '検索キーワードを広げてください。',
|
||||
@@ -1055,6 +1059,11 @@ export const ja = defineLocale({
|
||||
emptyTitleSearch: '一致なし',
|
||||
last: '前回',
|
||||
next: '次回',
|
||||
noRuns: 'まだ実行されていません',
|
||||
manage: '管理',
|
||||
showRuns: '実行履歴を表示',
|
||||
hideRuns: '実行履歴を隠す',
|
||||
runHistory: '実行履歴',
|
||||
actionsFor: title => `${title} のアクション`,
|
||||
actionsTitle: 'Cron ジョブのアクション',
|
||||
resume: '再開',
|
||||
@@ -1147,6 +1156,7 @@ export const ja = defineLocale({
|
||||
results: '結果',
|
||||
pinned: 'ピン留め',
|
||||
sessions: 'セッション',
|
||||
cronJobs: 'Cronジョブ',
|
||||
groupAriaGrouped: 'セッションを単一リストとして表示',
|
||||
groupAriaUngrouped: 'ワークスペースごとにセッションをグループ化',
|
||||
groupTitleGrouped: 'セッションのグループ化を解除',
|
||||
@@ -1430,7 +1440,7 @@ export const ja = defineLocale({
|
||||
},
|
||||
startingSignIn: provider => `${provider} のサインインを開始中...`,
|
||||
verifyingCode: provider => `${provider} でコードを確認中...`,
|
||||
connectedProvider: provider => `${provider} が接続されました。`,
|
||||
connectedProvider: provider => `${provider} が接続されました`,
|
||||
connectedPicking: provider => `${provider} が接続されました。デフォルトモデルを選択中...`,
|
||||
signInFailed: 'サインインに失敗しました。再試行してください。',
|
||||
pickDifferentProvider: '別のプロバイダーを選択',
|
||||
@@ -1456,7 +1466,7 @@ export const ja = defineLocale({
|
||||
free: '無料',
|
||||
price: (input, output) => `${input} 入力 / ${output} 出力 per Mtok`,
|
||||
change: '変更',
|
||||
startChatting: 'チャットを始める',
|
||||
startChatting: '始める',
|
||||
docs: provider => `${provider} ドキュメント`
|
||||
},
|
||||
|
||||
|
||||
@@ -157,6 +157,20 @@ export interface Translations {
|
||||
muteHaptics: string
|
||||
unmuteHaptics: string
|
||||
openSettings: string
|
||||
openKeybinds: string
|
||||
}
|
||||
|
||||
keybinds: {
|
||||
title: string
|
||||
subtitle: (open: string) => string
|
||||
rebind: string
|
||||
reset: string
|
||||
resetAll: string
|
||||
pressKey: string
|
||||
set: string
|
||||
conflictWith: (label: string) => string
|
||||
categories: Record<string, string>
|
||||
actions: Record<string, string>
|
||||
}
|
||||
|
||||
language: {
|
||||
@@ -436,7 +450,15 @@ export interface Translations {
|
||||
ready: string
|
||||
nousIncluded: string
|
||||
noApiKeyRequired: string
|
||||
postSetup: (step: string) => string
|
||||
postSetupHint: (step: string) => string
|
||||
postSetupRun: string
|
||||
postSetupRunning: string
|
||||
postSetupStarting: string
|
||||
postSetupCompleteTitle: string
|
||||
postSetupCompleteMessage: (step: string) => string
|
||||
postSetupErrorTitle: string
|
||||
postSetupErrorMessage: (step: string) => string
|
||||
postSetupFailed: (step: string) => string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,8 +699,6 @@ export interface Translations {
|
||||
cron: {
|
||||
close: string
|
||||
search: string
|
||||
refresh: string
|
||||
refreshing: string
|
||||
loading: string
|
||||
states: Record<string, string>
|
||||
deliveryLabels: Record<string, string>
|
||||
@@ -692,15 +712,18 @@ export interface Translations {
|
||||
monthlyOnDayAt: (dayOfMonth: string, time: string) => string
|
||||
topOfHour: string
|
||||
everyHourAt: (minute: string) => string
|
||||
active: (enabled: number, total: number) => string
|
||||
newCron: string
|
||||
createFirst: string
|
||||
emptyDescNew: string
|
||||
emptyDescSearch: string
|
||||
emptyTitleNew: string
|
||||
emptyTitleSearch: string
|
||||
last: string
|
||||
next: string
|
||||
noRuns: string
|
||||
manage: string
|
||||
showRuns: string
|
||||
hideRuns: string
|
||||
runHistory: string
|
||||
actionsFor: (title: string) => string
|
||||
actionsTitle: string
|
||||
resume: string
|
||||
@@ -787,6 +810,7 @@ export interface Translations {
|
||||
results: string
|
||||
pinned: string
|
||||
sessions: string
|
||||
cronJobs: string
|
||||
groupAriaGrouped: string
|
||||
groupAriaUngrouped: string
|
||||
groupTitleGrouped: string
|
||||
|
||||
@@ -621,7 +621,15 @@ export const zhHant = defineLocale({
|
||||
ready: '就緒',
|
||||
nousIncluded: '包含在 Nous 訂閱中;登入 Nous Portal 即可啟用。',
|
||||
noApiKeyRequired: '不需要 API 金鑰。',
|
||||
postSetup: step => `此提供方需要額外設定步驟 (${step})。暫時請在 CLI 中執行 hermes tools。`
|
||||
postSetupHint: step => `此後端需要一次性安裝 (${step})。將在此機器上執行,可能需要幾分鐘。`,
|
||||
postSetupRun: '執行設定',
|
||||
postSetupRunning: '安裝中…',
|
||||
postSetupStarting: '啟動中…',
|
||||
postSetupCompleteTitle: '設定完成',
|
||||
postSetupCompleteMessage: step => `已安裝 ${step}。`,
|
||||
postSetupErrorTitle: '設定完成但有錯誤',
|
||||
postSetupErrorMessage: step => `請檢查 ${step} 日誌。`,
|
||||
postSetupFailed: step => `執行 ${step} 設定失敗`
|
||||
}
|
||||
},
|
||||
|
||||
@@ -958,8 +966,6 @@ export const zhHant = defineLocale({
|
||||
cron: {
|
||||
close: '關閉排程',
|
||||
search: '搜尋排程工作…',
|
||||
refresh: '重新整理排程工作',
|
||||
refreshing: '正在重新整理排程工作',
|
||||
loading: '正在載入排程工作…',
|
||||
states: {
|
||||
enabled: '已啟用',
|
||||
@@ -1012,9 +1018,7 @@ export const zhHant = defineLocale({
|
||||
monthlyOnDayAt: (dayOfMonth, time) => `每月 ${dayOfMonth} 日 ${time}`,
|
||||
topOfHour: '每個整點',
|
||||
everyHourAt: minute => `每小時的 :${minute}`,
|
||||
active: (enabled, total) => `${enabled}/${total} 個啟用`,
|
||||
newCron: '新排程工作',
|
||||
createFirst: '建立第一個排程工作',
|
||||
emptyDescNew:
|
||||
'按 cron 表達式排程一個提示詞。Hermes 會執行它,並將結果傳送至您選擇的目的地。',
|
||||
emptyDescSearch: '請嘗試更廣泛的搜尋詞。',
|
||||
@@ -1022,6 +1026,11 @@ export const zhHant = defineLocale({
|
||||
emptyTitleSearch: '無相符項目',
|
||||
last: '上次:',
|
||||
next: '下次:',
|
||||
noRuns: '尚無執行',
|
||||
manage: '管理',
|
||||
showRuns: '顯示執行記錄',
|
||||
hideRuns: '隱藏執行記錄',
|
||||
runHistory: '執行記錄',
|
||||
actionsFor: title => `${title} 的動作`,
|
||||
actionsTitle: '排程工作動作',
|
||||
resume: '繼續',
|
||||
@@ -1113,6 +1122,7 @@ export const zhHant = defineLocale({
|
||||
results: '結果',
|
||||
pinned: '已釘選',
|
||||
sessions: '工作階段',
|
||||
cronJobs: '排程任務',
|
||||
groupAriaGrouped: '以單一清單顯示工作階段',
|
||||
groupAriaUngrouped: '依工作區分組工作階段',
|
||||
groupTitleGrouped: '取消分組',
|
||||
@@ -1391,7 +1401,7 @@ export const zhHant = defineLocale({
|
||||
},
|
||||
startingSignIn: provider => `正在為 ${provider} 啟動登入...`,
|
||||
verifyingCode: provider => `正在透過 ${provider} 驗證您的代碼...`,
|
||||
connectedProvider: provider => `${provider} 已連線。`,
|
||||
connectedProvider: provider => `${provider} 已連線`,
|
||||
connectedPicking: provider => `${provider} 已連線。正在選擇預設模型...`,
|
||||
signInFailed: '登入失敗,請重試。',
|
||||
pickDifferentProvider: '選擇其他提供方',
|
||||
@@ -1417,7 +1427,7 @@ export const zhHant = defineLocale({
|
||||
free: '免費',
|
||||
price: (input, output) => `${input} 輸入 / ${output} 輸出 每 Mtok`,
|
||||
change: '變更',
|
||||
startChatting: '開始聊天',
|
||||
startChatting: '開始',
|
||||
docs: provider => `${provider} 文件`
|
||||
},
|
||||
|
||||
|
||||
@@ -141,7 +141,85 @@ export const zh: Translations = {
|
||||
showRightSidebar: '显示右侧栏',
|
||||
muteHaptics: '关闭触感反馈',
|
||||
unmuteHaptics: '开启触感反馈',
|
||||
openSettings: '打开设置'
|
||||
openSettings: '打开设置',
|
||||
openKeybinds: '键盘快捷键'
|
||||
},
|
||||
|
||||
keybinds: {
|
||||
title: '键盘快捷键',
|
||||
subtitle: open => `点击快捷键即可重新绑定 · ${open} 可重新打开此面板。`,
|
||||
rebind: '重新绑定',
|
||||
reset: '恢复默认',
|
||||
resetAll: '全部重置',
|
||||
pressKey: '请按下按键…',
|
||||
set: '设置',
|
||||
conflictWith: label => `已绑定到“${label}”`,
|
||||
categories: {
|
||||
composer: '输入框',
|
||||
profiles: '配置',
|
||||
session: '会话',
|
||||
navigation: '导航',
|
||||
view: '视图'
|
||||
},
|
||||
actions: {
|
||||
'keybinds.openPanel': '打开键盘快捷键',
|
||||
'nav.commandPalette': '打开命令面板',
|
||||
'nav.commandCenter': '打开命令中心',
|
||||
'nav.settings': '打开设置',
|
||||
'nav.profiles': '打开配置',
|
||||
'nav.skills': '打开技能',
|
||||
'nav.messaging': '打开消息',
|
||||
'nav.artifacts': '打开制品',
|
||||
'nav.cron': '打开定时任务',
|
||||
'nav.agents': '打开智能体',
|
||||
'session.new': '新建会话',
|
||||
'session.next': '下一个会话',
|
||||
'session.prev': '上一个会话',
|
||||
'session.focusSearch': '搜索会话',
|
||||
'session.togglePin': '固定/取消固定当前会话',
|
||||
'composer.focus': '聚焦输入框',
|
||||
'composer.modelPicker': '打开模型选择器',
|
||||
'view.toggleSidebar': '切换会话侧边栏',
|
||||
'view.toggleRightSidebar': '切换文件浏览器',
|
||||
'view.showFiles': '显示文件浏览器',
|
||||
'view.showTerminal': '显示终端',
|
||||
'view.terminalSelection': '将终端选区发送到输入框',
|
||||
'view.closePreviewTab': '关闭预览标签',
|
||||
'view.flipPanes': '交换侧边栏位置',
|
||||
'appearance.toggleMode': '切换浅色/深色',
|
||||
'profile.default': '切换到默认配置',
|
||||
'profile.switch.1': '切换到配置 1',
|
||||
'profile.switch.2': '切换到配置 2',
|
||||
'profile.switch.3': '切换到配置 3',
|
||||
'profile.switch.4': '切换到配置 4',
|
||||
'profile.switch.5': '切换到配置 5',
|
||||
'profile.switch.6': '切换到配置 6',
|
||||
'profile.switch.7': '切换到配置 7',
|
||||
'profile.switch.8': '切换到配置 8',
|
||||
'profile.switch.9': '切换到配置 9',
|
||||
'profile.switch.10': '切换到配置 10',
|
||||
'profile.switch.11': '切换到配置 11',
|
||||
'profile.switch.12': '切换到配置 12',
|
||||
'profile.switch.13': '切换到配置 13',
|
||||
'profile.switch.14': '切换到配置 14',
|
||||
'profile.switch.15': '切换到配置 15',
|
||||
'profile.switch.16': '切换到配置 16',
|
||||
'profile.switch.17': '切换到配置 17',
|
||||
'profile.switch.18': '切换到配置 18',
|
||||
'profile.next': '下一个配置',
|
||||
'profile.prev': '上一个配置',
|
||||
'profile.toggleAll': '切换全部配置视图',
|
||||
'profile.create': '创建配置',
|
||||
'composer.send': '发送消息',
|
||||
'composer.newline': '插入换行',
|
||||
'composer.steer': '引导正在运行的回合',
|
||||
'composer.sendQueued': '发送下一条排队消息',
|
||||
'composer.mention': '引用文件、文件夹、网址',
|
||||
'composer.slash': '斜杠命令面板',
|
||||
'composer.help': '快速帮助',
|
||||
'composer.history': '切换弹窗/历史',
|
||||
'composer.cancel': '关闭弹窗·取消运行'
|
||||
}
|
||||
},
|
||||
|
||||
language: {
|
||||
@@ -618,7 +696,15 @@ export const zh: Translations = {
|
||||
ready: '就绪',
|
||||
nousIncluded: '包含在 Nous 订阅中;登录 Nous Portal 即可激活。',
|
||||
noApiKeyRequired: '不需要 API 密钥。',
|
||||
postSetup: step => `此提供方需要额外设置步骤 (${step})。暂时请在 CLI 中运行 hermes tools。`
|
||||
postSetupHint: step => `此后端需要一次性安装 (${step})。将在此机器上执行,可能需要几分钟。`,
|
||||
postSetupRun: '运行设置',
|
||||
postSetupRunning: '安装中…',
|
||||
postSetupStarting: '启动中…',
|
||||
postSetupCompleteTitle: '设置完成',
|
||||
postSetupCompleteMessage: step => `已安装 ${step}。`,
|
||||
postSetupErrorTitle: '设置完成但有错误',
|
||||
postSetupErrorMessage: step => `请检查 ${step} 日志。`,
|
||||
postSetupFailed: step => `运行 ${step} 设置失败`
|
||||
}
|
||||
},
|
||||
|
||||
@@ -959,8 +1045,6 @@ export const zh: Translations = {
|
||||
cron: {
|
||||
close: '关闭定时任务',
|
||||
search: '搜索定时任务…',
|
||||
refresh: '刷新定时任务',
|
||||
refreshing: '正在刷新定时任务',
|
||||
loading: '正在加载定时任务…',
|
||||
states: {
|
||||
enabled: '已启用',
|
||||
@@ -1013,15 +1097,18 @@ export const zh: Translations = {
|
||||
monthlyOnDayAt: (dayOfMonth, time) => `每月 ${dayOfMonth} 日 ${time}`,
|
||||
topOfHour: '每个整点',
|
||||
everyHourAt: minute => `每小时的 :${minute}`,
|
||||
active: (enabled, total) => `${enabled}/${total} 个启用`,
|
||||
newCron: '新建定时任务',
|
||||
createFirst: '创建第一个定时任务',
|
||||
emptyDescNew: '按 cron 表达式排程一个提示词。Hermes 会运行它,并把结果发送到你选择的目的地。',
|
||||
emptyDescSearch: '尝试更宽泛的搜索词。',
|
||||
emptyTitleNew: '暂无排程任务',
|
||||
emptyTitleSearch: '无匹配项',
|
||||
last: '上次:',
|
||||
next: '下次:',
|
||||
noRuns: '尚无运行',
|
||||
manage: '管理',
|
||||
showRuns: '显示运行记录',
|
||||
hideRuns: '隐藏运行记录',
|
||||
runHistory: '运行记录',
|
||||
actionsFor: title => `${title} 的操作`,
|
||||
actionsTitle: '定时任务操作',
|
||||
resume: '恢复定时任务',
|
||||
@@ -1113,6 +1200,7 @@ export const zh: Translations = {
|
||||
results: '结果',
|
||||
pinned: '已置顶',
|
||||
sessions: '会话',
|
||||
cronJobs: '定时任务',
|
||||
groupAriaGrouped: '以单一列表显示会话',
|
||||
groupAriaUngrouped: '按工作区分组会话',
|
||||
groupTitleGrouped: '取消分组',
|
||||
@@ -1392,7 +1480,7 @@ export const zh: Translations = {
|
||||
},
|
||||
startingSignIn: provider => `正在为 ${provider} 启动登录...`,
|
||||
verifyingCode: provider => `正在通过 ${provider} 验证你的代码...`,
|
||||
connectedProvider: provider => `${provider} 已连接。`,
|
||||
connectedProvider: provider => `${provider} 已连接`,
|
||||
connectedPicking: provider => `${provider} 已连接。正在选择默认模型...`,
|
||||
signInFailed: '登录失败,请重试。',
|
||||
pickDifferentProvider: '选择其他提供方',
|
||||
@@ -1416,7 +1504,7 @@ export const zh: Translations = {
|
||||
free: '免费',
|
||||
price: (input, output) => `${input} 输入 / ${output} 输出每 Mtok`,
|
||||
change: '更改',
|
||||
startChatting: '开始对话',
|
||||
startChatting: '开始',
|
||||
docs: provider => `${provider} 文档`
|
||||
},
|
||||
|
||||
|
||||
@@ -46,7 +46,8 @@ export function createClientSessionState(
|
||||
sawAssistantPayload: false,
|
||||
pendingBranchGroup: null,
|
||||
interrupted: false,
|
||||
needsInput: false
|
||||
needsInput: false,
|
||||
turnStartedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
desktopSlashUnavailableMessage,
|
||||
filterDesktopCommandsCatalog,
|
||||
isDesktopSlashCommand,
|
||||
isDesktopSlashSuggestion
|
||||
isDesktopSlashSuggestion,
|
||||
isModelPickerCommand
|
||||
} from './desktop-slash-commands'
|
||||
|
||||
describe('desktop slash command curation', () => {
|
||||
@@ -115,4 +116,11 @@ describe('desktop slash command curation', () => {
|
||||
expect(desktopSlashUnavailableMessage('/skills')).toContain('desktop sidebar')
|
||||
expect(desktopSlashUnavailableMessage('/clear')).toContain('terminal interface')
|
||||
})
|
||||
|
||||
it('flags /model as a picker-owned command so the desktop opens the overlay', () => {
|
||||
expect(isModelPickerCommand('/model')).toBe(true)
|
||||
expect(isModelPickerCommand('/model sonnet')).toBe(true)
|
||||
expect(isModelPickerCommand('/new')).toBe(false)
|
||||
expect(isModelPickerCommand('/skills')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -183,6 +183,18 @@ export function isDesktopSlashSuggestion(command: string): boolean {
|
||||
return DESKTOP_COMMANDS.has(canonical) && !DESKTOP_ALIASES.has(normalized)
|
||||
}
|
||||
|
||||
/**
|
||||
* True for commands the desktop fulfils by opening the model picker overlay
|
||||
* (e.g. `/model`) rather than executing a slash command. The caller opens the
|
||||
* picker UI instead of printing the "uses the desktop model picker" notice.
|
||||
*/
|
||||
export function isModelPickerCommand(command: string): boolean {
|
||||
const normalized = normalizeCommand(command)
|
||||
const canonical = canonicalDesktopSlashCommand(normalized)
|
||||
|
||||
return PICKER_OWNED_COMMANDS.has(canonical)
|
||||
}
|
||||
|
||||
export function desktopSlashUnavailableMessage(command: string): string | null {
|
||||
const normalized = normalizeCommand(command)
|
||||
const canonical = canonicalDesktopSlashCommand(normalized)
|
||||
|
||||
125
apps/desktop/src/lib/keybinds/actions.ts
Normal file
125
apps/desktop/src/lib/keybinds/actions.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// The single source of truth for rebindable desktop hotkeys.
|
||||
//
|
||||
// Each entry is pure metadata: an id, a category, and the default combo(s).
|
||||
// Handlers are wired separately in `use-keybinds.ts` (they need React context
|
||||
// like navigate / theme); labels come from i18n (`t.keybinds.actions[id]`). To
|
||||
// add a hotkey, add a row here and a handler there — nothing else.
|
||||
|
||||
export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view'
|
||||
|
||||
// The self-referential opener — bound + dispatched like any action, but shown in
|
||||
// the panel subtitle (not as its own row).
|
||||
export const KEYBIND_PANEL_ACTION = 'keybinds.openPanel'
|
||||
|
||||
// `composer` is read-only; the rest are rebindable. `view` is the catch-all for
|
||||
// layout, appearance, and the panel-opener.
|
||||
export const KEYBIND_CATEGORIES: readonly KeybindCategory[] = [
|
||||
'composer',
|
||||
'profiles',
|
||||
'session',
|
||||
'navigation',
|
||||
'view'
|
||||
]
|
||||
|
||||
export interface KeybindActionMeta {
|
||||
id: string
|
||||
category: KeybindCategory
|
||||
/** Default combos. Empty = shipped unbound (user can assign one). */
|
||||
defaults: readonly string[]
|
||||
}
|
||||
|
||||
// Positional switch slots for *named* profiles: ⌘1…⌘9 for profiles 1-9, then
|
||||
// ⌘⌥1…⌘⌥9 for 10-18. The default profile gets the two-key mnemonic ⌘D (see
|
||||
// `profile.default`) — ⌘` is macOS-reserved (window cycling) and ⌘0 is reset-zoom.
|
||||
export const PROFILE_SLOT_COUNT = 18
|
||||
|
||||
function comboForSlot(slot: number): string {
|
||||
return slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}`
|
||||
}
|
||||
|
||||
const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => ({
|
||||
id: `profile.switch.${i + 1}`,
|
||||
category: 'profiles' as const,
|
||||
defaults: [comboForSlot(i + 1)]
|
||||
}))
|
||||
|
||||
export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
|
||||
// ── Composer ─────────────────────────────────────────────────────────────
|
||||
{ id: 'composer.focus', category: 'composer', defaults: [] },
|
||||
{ id: 'composer.modelPicker', category: 'composer', defaults: [] },
|
||||
|
||||
// ── Profiles ─────────────────────────────────────────────────────────────
|
||||
{ id: 'profile.default', category: 'profiles', defaults: ['mod+d'] },
|
||||
...PROFILE_SWITCH_ACTIONS,
|
||||
{ id: 'profile.next', category: 'profiles', defaults: ['mod+shift+]'] },
|
||||
{ id: 'profile.prev', category: 'profiles', defaults: ['mod+shift+['] },
|
||||
{ id: 'profile.toggleAll', category: 'profiles', defaults: ['mod+shift+0'] },
|
||||
{ id: 'profile.create', category: 'profiles', defaults: [] },
|
||||
|
||||
// ── Session ──────────────────────────────────────────────────────────────
|
||||
{ id: 'session.new', category: 'session', defaults: ['mod+n', 'shift+n'] },
|
||||
{ id: 'session.next', category: 'session', defaults: [] },
|
||||
{ id: 'session.prev', category: 'session', defaults: [] },
|
||||
{ id: 'session.focusSearch', category: 'session', defaults: ['mod+shift+f'] },
|
||||
{ id: 'session.togglePin', category: 'session', defaults: [] },
|
||||
|
||||
// ── Navigation ───────────────────────────────────────────────────────────
|
||||
{ id: 'nav.commandPalette', category: 'navigation', defaults: ['mod+k', 'mod+p'] },
|
||||
{ id: 'nav.commandCenter', category: 'navigation', defaults: ['mod+.'] },
|
||||
{ id: 'nav.settings', category: 'navigation', defaults: ['mod+,'] },
|
||||
{ id: 'nav.profiles', category: 'navigation', defaults: [] },
|
||||
{ id: 'nav.skills', category: 'navigation', defaults: [] },
|
||||
{ id: 'nav.messaging', category: 'navigation', defaults: [] },
|
||||
{ id: 'nav.artifacts', category: 'navigation', defaults: [] },
|
||||
{ id: 'nav.cron', category: 'navigation', defaults: [] },
|
||||
{ id: 'nav.agents', category: 'navigation', defaults: [] },
|
||||
|
||||
// ── View (layout + appearance + the shortcuts panel itself) ───────────────
|
||||
{ id: 'view.toggleSidebar', category: 'view', defaults: ['mod+b'] },
|
||||
{ id: 'view.toggleRightSidebar', category: 'view', defaults: ['mod+j'] },
|
||||
{ id: 'view.showFiles', category: 'view', defaults: [] },
|
||||
{ id: 'view.showTerminal', category: 'view', defaults: [] },
|
||||
// ⌘\ — the backslash reads like a mirror line flipping the layout.
|
||||
{ id: 'view.flipPanes', category: 'view', defaults: ['mod+\\'] },
|
||||
{ id: 'appearance.toggleMode', category: 'view', defaults: ['shift+x'] },
|
||||
{ id: 'keybinds.openPanel', category: 'view', defaults: ['mod+/'] }
|
||||
]
|
||||
|
||||
export const KEYBIND_ACTION_IDS: readonly string[] = KEYBIND_ACTIONS.map(action => action.id)
|
||||
|
||||
const ACTION_BY_ID = new Map(KEYBIND_ACTIONS.map(action => [action.id, action]))
|
||||
|
||||
export function keybindAction(id: string): KeybindActionMeta | undefined {
|
||||
return ACTION_BY_ID.get(id)
|
||||
}
|
||||
|
||||
export type KeybindBindings = Record<string, string[]>
|
||||
|
||||
export function defaultBindings(): KeybindBindings {
|
||||
return Object.fromEntries(KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults]]))
|
||||
}
|
||||
|
||||
// Fixed, non-rebindable shortcuts surfaced read-only in the panel so the map is
|
||||
// complete. `keys` are canonical tokens run through `formatCombo` for display
|
||||
// (single symbols like "@" / "/" pass through unchanged). Categories listed here
|
||||
// render after the rebindable ones.
|
||||
export interface KeybindReadonly {
|
||||
id: string
|
||||
category: KeybindCategory
|
||||
keys: readonly string[]
|
||||
}
|
||||
|
||||
export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
|
||||
{ id: 'composer.send', category: 'composer', keys: ['enter'] },
|
||||
{ id: 'composer.newline', category: 'composer', keys: ['shift+enter'] },
|
||||
{ id: 'composer.steer', category: 'composer', keys: ['mod+enter'] },
|
||||
{ id: 'composer.sendQueued', category: 'composer', keys: ['mod+shift+k'] },
|
||||
{ id: 'composer.mention', category: 'composer', keys: ['@'] },
|
||||
{ id: 'composer.slash', category: 'composer', keys: ['/'] },
|
||||
{ id: 'composer.help', category: 'composer', keys: ['?'] },
|
||||
{ id: 'composer.history', category: 'composer', keys: ['up', 'down'] },
|
||||
{ id: 'composer.cancel', category: 'composer', keys: ['escape'] },
|
||||
// Fixed, context-local shortcuts surfaced for discoverability.
|
||||
{ id: 'view.terminalSelection', category: 'view', keys: ['mod+l'] },
|
||||
{ id: 'view.closePreviewTab', category: 'view', keys: ['mod+w'] }
|
||||
]
|
||||
169
apps/desktop/src/lib/keybinds/combo.ts
Normal file
169
apps/desktop/src/lib/keybinds/combo.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
// Keybind combo normalization + display.
|
||||
//
|
||||
// A combo is a canonical lowercase string like "mod+k", "mod+shift+]", "shift+x",
|
||||
// or "r". `mod` is Cmd on macOS / Ctrl elsewhere, so a single binding works on
|
||||
// both. We derive the base key from `event.code` (not `event.key`) so Shift never
|
||||
// mutates it ("shift+/" stays "shift+/" instead of becoming "shift+?").
|
||||
|
||||
export const IS_MAC =
|
||||
typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
|
||||
|
||||
// event.code → canonical base token. Letters/digits map to their lowercase
|
||||
// character; everything else uses an explicit name so combos read cleanly.
|
||||
const CODE_TO_KEY: Record<string, string> = {
|
||||
Backquote: '`',
|
||||
Backslash: '\\',
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
Comma: ',',
|
||||
Equal: '=',
|
||||
Minus: '-',
|
||||
Period: '.',
|
||||
Quote: "'",
|
||||
Semicolon: ';',
|
||||
Slash: '/',
|
||||
Space: 'space',
|
||||
Enter: 'enter',
|
||||
Escape: 'escape',
|
||||
Backspace: 'backspace',
|
||||
Tab: 'tab',
|
||||
ArrowUp: 'up',
|
||||
ArrowDown: 'down',
|
||||
ArrowLeft: 'left',
|
||||
ArrowRight: 'right'
|
||||
}
|
||||
|
||||
const MODIFIER_CODES = new Set([
|
||||
'AltLeft',
|
||||
'AltRight',
|
||||
'ControlLeft',
|
||||
'ControlRight',
|
||||
'MetaLeft',
|
||||
'MetaRight',
|
||||
'ShiftLeft',
|
||||
'ShiftRight'
|
||||
])
|
||||
|
||||
function baseKeyFromCode(code: string): string | null {
|
||||
if (code.startsWith('Key')) {
|
||||
return code.slice(3).toLowerCase()
|
||||
}
|
||||
|
||||
if (code.startsWith('Digit')) {
|
||||
return code.slice(5)
|
||||
}
|
||||
|
||||
if (code.startsWith('Numpad')) {
|
||||
const rest = code.slice(6)
|
||||
|
||||
return /^[0-9]$/.test(rest) ? rest : null
|
||||
}
|
||||
|
||||
if (code.startsWith('F') && /^F\d{1,2}$/.test(code)) {
|
||||
return code.toLowerCase()
|
||||
}
|
||||
|
||||
return CODE_TO_KEY[code] ?? null
|
||||
}
|
||||
|
||||
// Returns the canonical combo for a keydown, or null while only modifiers are
|
||||
// held (so capture mode keeps waiting for a real key).
|
||||
export function comboFromEvent(event: KeyboardEvent): string | null {
|
||||
if (MODIFIER_CODES.has(event.code)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const base = baseKeyFromCode(event.code)
|
||||
|
||||
if (!base) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
parts.push('mod')
|
||||
}
|
||||
|
||||
if (event.altKey) {
|
||||
parts.push('alt')
|
||||
}
|
||||
|
||||
if (event.shiftKey) {
|
||||
parts.push('shift')
|
||||
}
|
||||
|
||||
parts.push(base)
|
||||
|
||||
return parts.join('+')
|
||||
}
|
||||
|
||||
const TOKEN_LABELS: Record<string, string> = {
|
||||
enter: '↵',
|
||||
escape: 'Esc',
|
||||
backspace: '⌫',
|
||||
tab: '⇥',
|
||||
space: 'Space',
|
||||
up: '↑',
|
||||
down: '↓',
|
||||
left: '←',
|
||||
right: '→'
|
||||
}
|
||||
|
||||
function labelForBase(base: string): string {
|
||||
if (TOKEN_LABELS[base]) {
|
||||
return TOKEN_LABELS[base]
|
||||
}
|
||||
|
||||
if (/^f\d{1,2}$/.test(base)) {
|
||||
return base.toUpperCase()
|
||||
}
|
||||
|
||||
return base.length === 1 ? base.toUpperCase() : base
|
||||
}
|
||||
|
||||
// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
|
||||
export function formatCombo(combo: string): string {
|
||||
const parts = combo.split('+')
|
||||
const base = parts.pop() ?? ''
|
||||
const mods = parts
|
||||
|
||||
const modLabels = mods.map(mod => {
|
||||
if (mod === 'mod') {
|
||||
return IS_MAC ? '⌘' : 'Ctrl'
|
||||
}
|
||||
|
||||
if (mod === 'alt') {
|
||||
return IS_MAC ? '⌥' : 'Alt'
|
||||
}
|
||||
|
||||
if (mod === 'shift') {
|
||||
return IS_MAC ? '⇧' : 'Shift'
|
||||
}
|
||||
|
||||
return mod
|
||||
})
|
||||
|
||||
const tokens = [...modLabels, labelForBase(base)]
|
||||
|
||||
return IS_MAC ? tokens.join('') : tokens.join('+')
|
||||
}
|
||||
|
||||
// True when focus is in a text-entry surface, so bare-key shortcuts don't fire
|
||||
// while the user is typing.
|
||||
export function isEditableTarget(target: EventTarget | null): boolean {
|
||||
const el = target as HTMLElement | null
|
||||
|
||||
return Boolean(
|
||||
el?.isContentEditable ||
|
||||
el instanceof HTMLInputElement ||
|
||||
el instanceof HTMLTextAreaElement ||
|
||||
el instanceof HTMLSelectElement
|
||||
)
|
||||
}
|
||||
|
||||
// Combos with a primary modifier (Cmd/Ctrl) are safe to fire even while typing
|
||||
// (e.g. ⌘K from the composer); bare/Shift-only combos are suppressed in inputs.
|
||||
export function comboAllowedInInput(combo: string): boolean {
|
||||
return combo.startsWith('mod+') || combo === 'mod'
|
||||
}
|
||||
@@ -8,7 +8,14 @@ const FENCE_LINE_RE = /^([ \t]*)(`{3,}|~{3,})([^\n]*)$/
|
||||
const EMPTY_FENCE_BLOCK_RE = /(^|\n)[ \t]*(?:`{3,}|~{3,})[^\n]*\n[ \t]*(?:`{3,}|~{3,})[ \t]*(?=\n|$)/g
|
||||
const CODE_FENCE_SPLIT_RE = /((?:```|~~~)[\s\S]*?(?:```|~~~))/g
|
||||
const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g
|
||||
const RAW_URL_RE = /https?:\/\/[^\s<>"'`]+[^\s<>"'`.,;:!?]/g
|
||||
// Bare-URL autolink matcher. The character classes EXCLUDE `*` so a URL that
|
||||
// abuts markdown emphasis with no separating space (e.g. `**label: https://x**`,
|
||||
// a very common LLM pattern) doesn't swallow the trailing `**` into the href.
|
||||
// `*` is never meaningful in a real URL path, and GFM's own autolink extension
|
||||
// likewise strips trailing emphasis/punctuation — so dropping it here is safe
|
||||
// and keeps the emphasis run intact. Other trailing punctuation is still peeled
|
||||
// off by the final `[^\s<>"'`*.,;:!?]` class.
|
||||
const RAW_URL_RE = /https?:\/\/[^\s<>"'`*]+[^\s<>"'`*.,;:!?]/g
|
||||
const LOCAL_PREVIEW_URL_RE = /(^|\s)https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?\/?[^\s<>"'`]*/gi
|
||||
const LOCAL_PREVIEW_ONLY_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?\/?$/i
|
||||
const URL_ONLY_LINE_RE = /^\s*https?:\/\/\S+\s*$/i
|
||||
|
||||
19
apps/desktop/src/store/cron.ts
Normal file
19
apps/desktop/src/store/cron.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import type { CronJob } from '@/types/hermes'
|
||||
|
||||
// Cron *jobs* (not run sessions) power the sidebar "Cron jobs" section. Listing
|
||||
// the job — schedule, state, live next-run countdown — makes the job the
|
||||
// first-class entity; its runs (sessions) resolve under it in the cron detail.
|
||||
export const $cronJobs = atom<CronJob[]>([])
|
||||
export const setCronJobs = (jobs: CronJob[]) => $cronJobs.set(jobs)
|
||||
|
||||
// In-place edit so the cron overlay's mutations (create/edit/delete/pause/…)
|
||||
// land in the same atom the sidebar renders — no stale list until the next poll.
|
||||
export const updateCronJobs = (fn: (jobs: CronJob[]) => CronJob[]) => $cronJobs.set(fn($cronJobs.get()))
|
||||
|
||||
// One-shot focus target: clicking "Manage" on a job sets this, then opens the
|
||||
// cron overlay, which reads it once to select + scroll to that job. Cleared
|
||||
// after consumption so re-opening cron normally doesn't re-focus a stale job.
|
||||
export const $cronFocusJobId = atom<null | string>(null)
|
||||
export const setCronFocusJobId = (id: null | string) => $cronFocusJobId.set(id)
|
||||
139
apps/desktop/src/store/keybinds.ts
Normal file
139
apps/desktop/src/store/keybinds.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import {
|
||||
defaultBindings,
|
||||
KEYBIND_ACTION_IDS,
|
||||
keybindAction,
|
||||
type KeybindBindings
|
||||
} from '@/lib/keybinds/actions'
|
||||
import { arraysEqual, persistString, storedString } from '@/lib/storage'
|
||||
|
||||
const STORAGE_KEY = 'hermes.desktop.keybinds'
|
||||
|
||||
// Defaults overlaid with the user's stored overrides. Unknown / stale action ids
|
||||
// are dropped; actions added in a later release pick up their shipped default.
|
||||
function loadBindings(): KeybindBindings {
|
||||
const base = defaultBindings()
|
||||
const raw = storedString(STORAGE_KEY)
|
||||
|
||||
if (!raw) {
|
||||
return base
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||||
|
||||
for (const id of KEYBIND_ACTION_IDS) {
|
||||
const value = parsed[id]
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
base[id] = value.filter((combo): combo is string => typeof combo === 'string')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Corrupt storage falls back to defaults.
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
// Persist only the actions whose combos differ from their shipped default, so
|
||||
// changing a default never gets shadowed by a stored snapshot.
|
||||
function persistBindings(bindings: KeybindBindings): void {
|
||||
const defaults = defaultBindings()
|
||||
const diff: KeybindBindings = {}
|
||||
|
||||
for (const id of KEYBIND_ACTION_IDS) {
|
||||
const current = bindings[id] ?? []
|
||||
|
||||
if (!arraysEqual(current, defaults[id] ?? [])) {
|
||||
diff[id] = current
|
||||
}
|
||||
}
|
||||
|
||||
persistString(STORAGE_KEY, JSON.stringify(diff))
|
||||
}
|
||||
|
||||
export const $bindings = atom<KeybindBindings>(loadBindings())
|
||||
|
||||
$bindings.subscribe(persistBindings)
|
||||
|
||||
// Reverse lookup combo → actionId for dispatch. First action wins on conflict;
|
||||
// the panel/edit overlay surface conflicts so users can resolve them.
|
||||
export const $comboIndex = computed($bindings, bindings => {
|
||||
const index = new Map<string, string>()
|
||||
|
||||
for (const id of KEYBIND_ACTION_IDS) {
|
||||
for (const combo of bindings[id] ?? []) {
|
||||
if (!index.has(combo)) {
|
||||
index.set(combo, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return index
|
||||
})
|
||||
|
||||
export function setBinding(actionId: string, combos: string[]): void {
|
||||
if (!keybindAction(actionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
$bindings.set({ ...$bindings.get(), [actionId]: [...combos] })
|
||||
}
|
||||
|
||||
export function resetBinding(actionId: string): void {
|
||||
const action = keybindAction(actionId)
|
||||
|
||||
if (!action) {
|
||||
return
|
||||
}
|
||||
|
||||
$bindings.set({ ...$bindings.get(), [actionId]: [...action.defaults] })
|
||||
}
|
||||
|
||||
export function resetAllBindings(): void {
|
||||
$bindings.set(defaultBindings())
|
||||
}
|
||||
|
||||
// Other actions that already use `combo` (excluding `actionId` itself).
|
||||
export function conflictsFor(actionId: string, combo: string): string[] {
|
||||
const bindings = $bindings.get()
|
||||
|
||||
return KEYBIND_ACTION_IDS.filter(id => id !== actionId && (bindings[id] ?? []).includes(combo))
|
||||
}
|
||||
|
||||
// ── Capture ─────────────────────────────────────────────────────────────────
|
||||
// `$capture` is the action currently listening for its next keypress (a panel
|
||||
// row armed for rebinding). Session-only — never persisted.
|
||||
|
||||
export const $capture = atom<string | null>(null)
|
||||
|
||||
export function beginCapture(actionId: string): void {
|
||||
$capture.set(actionId)
|
||||
}
|
||||
|
||||
export function endCapture(): void {
|
||||
$capture.set(null)
|
||||
}
|
||||
|
||||
// ── Panel ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const $keybindPanelOpen = atom(false)
|
||||
|
||||
export function openKeybindPanel(): void {
|
||||
$keybindPanelOpen.set(true)
|
||||
}
|
||||
|
||||
export function closeKeybindPanel(): void {
|
||||
$keybindPanelOpen.set(false)
|
||||
$capture.set(null)
|
||||
}
|
||||
|
||||
export function toggleKeybindPanel(): void {
|
||||
if ($keybindPanelOpen.get()) {
|
||||
closeKeybindPanel()
|
||||
} else {
|
||||
openKeybindPanel()
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,8 @@ import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride, t
|
||||
|
||||
export const SIDEBAR_DEFAULT_WIDTH = 237
|
||||
export const SIDEBAR_MAX_WIDTH = 360
|
||||
export const FILE_BROWSER_DEFAULT_WIDTH = '17rem'
|
||||
// Open at the same width as the sessions sidebar so the two rails match.
|
||||
export const FILE_BROWSER_DEFAULT_WIDTH = `${SIDEBAR_DEFAULT_WIDTH}px`
|
||||
export const FILE_BROWSER_MIN_WIDTH = '14rem'
|
||||
export const FILE_BROWSER_MAX_WIDTH = '20rem'
|
||||
|
||||
@@ -21,6 +22,7 @@ export const SIDEBAR_SESSIONS_PAGE_SIZE = 50
|
||||
|
||||
const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
|
||||
const SIDEBAR_AGENTS_GROUPED_STORAGE_KEY = 'hermes.desktop.agentsGroupedByWorkspace'
|
||||
const SIDEBAR_CRON_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarCronOpen'
|
||||
const PANES_FLIPPED_STORAGE_KEY = 'hermes.desktop.panesFlipped'
|
||||
|
||||
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
|
||||
@@ -53,6 +55,10 @@ export const $sidebarWidth: ReadableAtom<number> = computed($paneStates, states
|
||||
export const $pinnedSessionIds = atom(storedStringArray(SIDEBAR_PINNED_STORAGE_KEY))
|
||||
export const $sidebarPinsOpen = atom(true)
|
||||
export const $sidebarRecentsOpen = atom(true)
|
||||
// Cron-job sessions live in their own section below recents, collapsed by
|
||||
// default (it only renders at all when cron sessions exist) so the
|
||||
// scheduler's `[IMPORTANT: …]` first-message previews don't spam recents.
|
||||
export const $sidebarCronOpen = atom(storedBoolean(SIDEBAR_CRON_OPEN_STORAGE_KEY, false))
|
||||
export const $sidebarAgentsGrouped = atom(storedBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, false))
|
||||
// When true, the sessions sidebar moves to the right and the file browser +
|
||||
// preview rail move to the left — a mirror of the default layout.
|
||||
@@ -61,6 +67,7 @@ export const $isSidebarResizing = atom(false)
|
||||
export const $sessionsLimit = atom(SIDEBAR_SESSIONS_PAGE_SIZE)
|
||||
|
||||
$pinnedSessionIds.subscribe(ids => persistStringArray(SIDEBAR_PINNED_STORAGE_KEY, [...ids]))
|
||||
$sidebarCronOpen.subscribe(open => persistBoolean(SIDEBAR_CRON_OPEN_STORAGE_KEY, open))
|
||||
$sidebarAgentsGrouped.subscribe(grouped => persistBoolean(SIDEBAR_AGENTS_GROUPED_STORAGE_KEY, grouped))
|
||||
$panesFlipped.subscribe(flipped => persistBoolean(PANES_FLIPPED_STORAGE_KEY, flipped))
|
||||
|
||||
@@ -81,6 +88,22 @@ export function toggleFileBrowserOpen() {
|
||||
togglePane(FILE_BROWSER_PANE_ID)
|
||||
}
|
||||
|
||||
export function setFileBrowserOpen(open: boolean) {
|
||||
setPaneOpen(FILE_BROWSER_PANE_ID, open)
|
||||
}
|
||||
|
||||
// Hotkey → focus the sessions search field. Opens the sidebar first, then lets
|
||||
// the field (which only mounts when the sidebar is open) subscribe + focus.
|
||||
export const SESSION_SEARCH_FOCUS_EVENT = 'hermes:focus-session-search'
|
||||
|
||||
export function requestSessionSearchFocus() {
|
||||
setSidebarOpen(true)
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.setTimeout(() => window.dispatchEvent(new CustomEvent(SESSION_SEARCH_FOCUS_EVENT)), 0)
|
||||
}
|
||||
}
|
||||
|
||||
export function togglePanesFlipped() {
|
||||
$panesFlipped.set(!$panesFlipped.get())
|
||||
}
|
||||
@@ -97,6 +120,10 @@ export function setSidebarRecentsOpen(open: boolean) {
|
||||
$sidebarRecentsOpen.set(open)
|
||||
}
|
||||
|
||||
export function setSidebarCronOpen(open: boolean) {
|
||||
$sidebarCronOpen.set(open)
|
||||
}
|
||||
|
||||
export function setSidebarAgentsGrouped(grouped: boolean) {
|
||||
$sidebarAgentsGrouped.set(grouped)
|
||||
}
|
||||
|
||||
@@ -83,7 +83,8 @@ const CONFIGURED_CACHE_KEY = 'hermes-desktop-onboarded-v1'
|
||||
const SKIP_CACHE_KEY = 'hermes-onboarding-skipped-v1'
|
||||
const POLL_MS = 2000
|
||||
const COPY_FLASH_MS = 1500
|
||||
const DEFAULT_ONBOARDING_REASON = 'No inference provider is configured.'
|
||||
export const DEFAULT_ONBOARDING_REASON = 'No inference provider is configured.'
|
||||
export const DEFAULT_MANUAL_ONBOARDING_REASON = 'Add or switch inference provider.'
|
||||
|
||||
function readCachedConfigured(): boolean | null {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -387,7 +388,7 @@ export function requestDesktopOnboarding(reason = DEFAULT_ONBOARDING_REASON) {
|
||||
// onboarding flow (OAuth rows, API-key form, model-confirm) instead of
|
||||
// duplicating provider UI. Sets manual=true so the overlay shows the picker
|
||||
// even though configured===true, and refreshes the provider list.
|
||||
export function startManualOnboarding(reason: null | string = 'Add or switch inference provider.') {
|
||||
export function startManualOnboarding(reason: null | string = DEFAULT_MANUAL_ONBOARDING_REASON) {
|
||||
patch({
|
||||
manual: true,
|
||||
requested: true,
|
||||
@@ -857,7 +858,9 @@ export function confirmOnboardingModel(ctx: OnboardingContext) {
|
||||
return
|
||||
}
|
||||
|
||||
notifyReady(flow.label)
|
||||
// No success toast here: the confirm-model screen already showed "<provider>
|
||||
// connected." notifyReady is reserved for completion paths that SKIP this
|
||||
// screen (no-default fallthrough, local endpoint) so feedback isn't lost.
|
||||
completeDesktopOnboarding()
|
||||
ctx.onCompleted?.()
|
||||
}
|
||||
|
||||
@@ -288,6 +288,72 @@ export function setShowAllProfiles(value: boolean): void {
|
||||
$showAllProfiles.set(value)
|
||||
}
|
||||
|
||||
export function toggleShowAllProfiles(): void {
|
||||
$showAllProfiles.set(!$showAllProfiles.get())
|
||||
}
|
||||
|
||||
// ── Hotkey-driven profile switching ────────────────────────────────────────
|
||||
// Positional + relative navigation for the rail, used by the keybind runtime.
|
||||
// The ordered list is [default, ...named-in-rail-order]; switching is a no-op
|
||||
// when the slot is empty so unused ⌘N keys stay harmless.
|
||||
|
||||
function orderedProfileKeys(): string[] {
|
||||
const profiles = $profiles.get()
|
||||
|
||||
const named = sortByProfileOrder(
|
||||
profiles.filter(profile => !profile.is_default),
|
||||
$profileOrder.get()
|
||||
).map(profile => normalizeProfileKey(profile.name))
|
||||
|
||||
const hasDefault = profiles.some(profile => profile.is_default)
|
||||
|
||||
return hasDefault ? ['default', ...named] : named
|
||||
}
|
||||
|
||||
// Switch to the default (root ~/.hermes) profile — bound to ⌘1.
|
||||
export function switchToDefaultProfile(): void {
|
||||
const def = $profiles.get().find(profile => profile.is_default)
|
||||
|
||||
selectProfile(def ? def.name : 'default')
|
||||
}
|
||||
|
||||
// Switch to the Nth named (non-default) profile in rail order (1-based).
|
||||
export function switchProfileToSlot(slot: number): void {
|
||||
const named = sortByProfileOrder(
|
||||
$profiles.get().filter(profile => !profile.is_default),
|
||||
$profileOrder.get()
|
||||
)
|
||||
|
||||
const target = named[slot - 1]
|
||||
|
||||
if (target) {
|
||||
selectProfile(target.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Step to the next/previous profile in the rail, wrapping around.
|
||||
export function cycleProfile(direction: 1 | -1): void {
|
||||
const keys = orderedProfileKeys()
|
||||
|
||||
if (keys.length < 2) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = $showAllProfiles.get() ? -1 : keys.indexOf(normalizeProfileKey($activeGatewayProfile.get()))
|
||||
const start = current < 0 ? (direction === 1 ? -1 : 0) : current
|
||||
const next = (start + direction + keys.length) % keys.length
|
||||
|
||||
selectProfile(keys[next])
|
||||
}
|
||||
|
||||
// Bumped to ask the rail to open its "create profile" dialog (the dialog state
|
||||
// is local to the rail component; this lets a global hotkey trigger it).
|
||||
export const $profileCreateRequest = atom(0)
|
||||
|
||||
export function requestProfileCreate(): void {
|
||||
$profileCreateRequest.set($profileCreateRequest.get() + 1)
|
||||
}
|
||||
|
||||
// Keepalive ping for the active pool backend so the main-process idle reaper
|
||||
// (which can't see the direct renderer↔backend WS) spares it. No-op for the
|
||||
// primary/default backend, which is never pooled.
|
||||
|
||||
@@ -76,6 +76,15 @@ export const $connection = atom<HermesConnection | null>(null)
|
||||
export const $gatewayState = atom('idle')
|
||||
export const $sessions = atom<SessionInfo[]>([])
|
||||
export const $sessionsTotal = atom<number>(0)
|
||||
// Cron-job sessions (source === 'cron') are fetched as their own list so the
|
||||
// scheduler's always-newest sessions never crowd recents out of the page
|
||||
// budget. Powers the collapsed "Cron jobs" sidebar section.
|
||||
export const $cronSessions = atom<SessionInfo[]>([])
|
||||
// Max cron sessions fetched for the sidebar section (single bounded page). When
|
||||
// the fetch returns exactly this many rows we know more exist, so the section
|
||||
// badge renders "N+". Lives here so the controller (fetch) and sidebar (badge)
|
||||
// share one source of truth without a circular import.
|
||||
export const CRON_SECTION_LIMIT = 50
|
||||
// Listable conversation count per profile (children excluded), keyed by profile
|
||||
// name. Lets the sidebar scope its "Load more" footer to the active profile so a
|
||||
// huge default profile doesn't keep "Load more" visible while browsing a small
|
||||
@@ -119,6 +128,7 @@ export const setConnection = (next: Updater<HermesConnection | null>) => updateA
|
||||
export const setGatewayState = (next: Updater<string>) => updateAtom($gatewayState, next)
|
||||
export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($sessions, next)
|
||||
export const setSessionsTotal = (next: Updater<number>) => updateAtom($sessionsTotal, next)
|
||||
export const setCronSessions = (next: Updater<SessionInfo[]>) => updateAtom($cronSessions, next)
|
||||
export const setSessionProfileTotals = (next: Updater<Record<string, number>>) =>
|
||||
updateAtom($sessionProfileTotals, next)
|
||||
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user