mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 04:08:28 +08:00
Compare commits
133 Commits
dependabot
...
bb/coding-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d459868776 | ||
|
|
0401176c7a | ||
|
|
f31c950182 | ||
|
|
ffb53767bf | ||
|
|
3c163cb035 | ||
|
|
86643d84e9 | ||
|
|
bc9e33d66b | ||
|
|
38acced687 | ||
|
|
5bb7156949 | ||
|
|
3a5e36cfa5 | ||
|
|
aecdc75bb0 | ||
|
|
9e02b18828 | ||
|
|
fd68ae6331 | ||
|
|
e026fd88cd | ||
|
|
fd88d527af | ||
|
|
88bdb6b074 | ||
|
|
ded620b711 | ||
|
|
311e80809f | ||
|
|
ac9de2e80c | ||
|
|
40420a619b | ||
|
|
2e628ae971 | ||
|
|
30c7b787d1 | ||
|
|
03ba06ebfb | ||
|
|
e68fc4def2 | ||
|
|
e45dd2b0e7 | ||
|
|
e2ea648a08 | ||
|
|
75e29f97ee | ||
|
|
947f305f84 | ||
|
|
41ede96304 | ||
|
|
f15d2cb5e4 | ||
|
|
2b762c5364 | ||
|
|
75adf7d603 | ||
|
|
0776d1b19c | ||
|
|
d6e2c940e9 | ||
|
|
fb0250ef63 | ||
|
|
1e1ab31ad6 | ||
|
|
8c0f15478d | ||
|
|
712bf4d8e4 | ||
|
|
35a750eedd | ||
|
|
7402706c5e | ||
|
|
2059707fce | ||
|
|
40fbb0f3c6 | ||
|
|
e3313c50a7 | ||
|
|
72f556dfc4 | ||
|
|
58eb473baa | ||
|
|
f66a929a6b | ||
|
|
04d620d91f | ||
|
|
92be989291 | ||
|
|
343c54e35b | ||
|
|
b0a52d74ac | ||
|
|
5a22cd427d | ||
|
|
ca06715721 | ||
|
|
d50741af90 | ||
|
|
725290db63 | ||
|
|
e7bc6189cf | ||
|
|
6efc7eda57 | ||
|
|
de124800a2 | ||
|
|
f354323547 | ||
|
|
5446153c98 | ||
|
|
01c010e233 | ||
|
|
f99665f99a | ||
|
|
a6e47314f9 | ||
|
|
1c88360fed | ||
|
|
475ecea3d7 | ||
|
|
e8c3ac2f5c | ||
|
|
ec69c767ff | ||
|
|
2f523a4691 | ||
|
|
8a19884bf3 | ||
|
|
7ea37cd082 | ||
|
|
1927ff217e | ||
|
|
63727f32bf | ||
|
|
5c0a1fec0c | ||
|
|
96f0ddc6a9 | ||
|
|
51a2c07016 | ||
|
|
e223503b03 | ||
|
|
6fff744158 | ||
|
|
26a57467a8 | ||
|
|
cd188b814e | ||
|
|
d4787d3e2e | ||
|
|
0caa23788f | ||
|
|
9ba7e5b1b4 | ||
|
|
da4f407e51 | ||
|
|
39fee4f3bc | ||
|
|
d3b1e43005 | ||
|
|
c349eca823 | ||
|
|
b91c382035 | ||
|
|
1b89715e15 | ||
|
|
93228d5299 | ||
|
|
b4b9a93848 | ||
|
|
1971b10526 | ||
|
|
84710995ef | ||
|
|
9632609447 | ||
|
|
2d9ea0997f | ||
|
|
ee8aeea4ca | ||
|
|
3c73d1852e | ||
|
|
df848bd2da | ||
|
|
973decc050 | ||
|
|
9666305630 | ||
|
|
810e5864db | ||
|
|
ecac659d7d | ||
|
|
c711146ad4 | ||
|
|
a1cda2410b | ||
|
|
e02a6038a4 | ||
|
|
12ea7fc7e3 | ||
|
|
7fb8a6b5c5 | ||
|
|
1dca7c6207 | ||
|
|
214b7e070f | ||
|
|
6ee046a72f | ||
|
|
de26b17854 | ||
|
|
827f251426 | ||
|
|
432325933a | ||
|
|
0d9b7132ff | ||
|
|
a78c73f3aa | ||
|
|
4c544b633d | ||
|
|
60b6352fe5 | ||
|
|
e76d8bf5aa | ||
|
|
c5d199eada | ||
|
|
c930a49ce9 | ||
|
|
3aa24e2619 | ||
|
|
ba57ebec33 | ||
|
|
b98b645f87 | ||
|
|
f45d7dee7d | ||
|
|
1b302a0474 | ||
|
|
1d90b23982 | ||
|
|
ef65298103 | ||
|
|
50ba36dcab | ||
|
|
5fca754ee3 | ||
|
|
192020992d | ||
|
|
d833b1eff7 | ||
|
|
a1264e9967 | ||
|
|
0022e94d74 | ||
|
|
6038bfb66e | ||
|
|
047e7cf36f |
5
.github/workflows/tests.yml
vendored
5
.github/workflows/tests.yml
vendored
@@ -171,6 +171,11 @@ jobs:
|
||||
source .venv/bin/activate
|
||||
uv pip install -e ".[all,dev]"
|
||||
|
||||
- name: Packaged-wheel i18n smoke test
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
python -m pytest -m integration tests/test_wheel_locales_e2e.py -v
|
||||
|
||||
- name: Run e2e tests
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
|
||||
15
AGENTS.md
15
AGENTS.md
@@ -283,6 +283,21 @@ The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes
|
||||
|
||||
**Structured React UI around the TUI is allowed when it is not a second chat surface.** Sidebar widgets, inspectors, summaries, status panels, and similar supporting views (e.g. `ChatSidebar`, `ModelPickerDialog`, `ToolCall`) are fine when they complement the embedded TUI rather than replacing the transcript / composer / terminal. Keep their state independent of the PTY child's session and surface their failures non-destructively so the terminal pane keeps working unimpaired.
|
||||
|
||||
### Electron Desktop Chat App (`apps/desktop/`)
|
||||
|
||||
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`.
|
||||
|
||||
**Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline:
|
||||
|
||||
- **Backend already provides everything.** `tui_gateway/server.py` `commands.catalog` (empty-query list) and `complete.slash` (typed-query completions) both include built-in commands, user `quick_commands`, AND skill-derived commands (`scan_skill_commands()` / `get_skill_commands()`). The desktop app does not need a new RPC to see skills.
|
||||
- **The renderer curates via `apps/desktop/src/lib/desktop-slash-commands.ts`.** This is the load-bearing file. It holds `DESKTOP_COMMANDS` (the ~19 built-ins shown in the palette) plus block-lists for terminal-only / messaging-only / picker-owned / settings-owned / advanced commands that should NOT clutter the desktop popover.
|
||||
- `isDesktopSlashCommand(name)` — gates **execution**. Returns true for built-ins AND for any non-built-in (skill / quick command), so typed extension commands run.
|
||||
- `isDesktopSlashSuggestion(name)` — gates **discovery/completion**. Used by BOTH completion paths in `app/chat/composer/hooks/use-slash-completions.ts` (empty-query catalog filter + typed-query `complete.slash` filter) and by `filterDesktopCommandsCatalog`.
|
||||
- `isDesktopSlashExtensionCommand(name)` — true when the command is NOT a known Hermes built-in (i.e. a skill or user quick command). Both suggestion and catalog-filter paths allow extensions through so skill commands surface in the palette. (Added when fixing "skill commands missing from the desktop slash palette" — the curated allow-list was silently dropping every skill/quick command from completions even though they executed fine when typed.)
|
||||
- **Dispatch** lives in `app/session/hooks/use-prompt-actions.ts` (`runSlash`): built-ins that the desktop owns (`/skin`, `/help`, `/new`, …) are handled locally or via `commands.catalog`; everything else goes to `slash.exec`, falling back to `command.dispatch` (which the gateway resolves into skill / alias / exec directives). A skill command resolves to `{type: "skill", message}` and is submitted as a normal prompt.
|
||||
|
||||
**Rule:** the desktop slash palette's curation is about hiding noise (terminal-only / messaging-only built-ins), NOT about hiding user-activated extensions. Skill commands and `quick_commands` are extensions the backend surfaces — they belong in completions. If you tighten `desktop-slash-commands.ts`, keep `isDesktopSlashExtensionCommand` flowing into both the suggestion and catalog-filter paths. Tests: `apps/desktop/src/lib/desktop-slash-commands.test.ts` (run via the repo-root `vitest`, since `apps/desktop` resolves deps from the root workspace install).
|
||||
|
||||
---
|
||||
|
||||
## Adding New Tools
|
||||
|
||||
@@ -73,7 +73,7 @@ This isn't a quality bar — it's a coupling-and-maintenance decision. Memory pr
|
||||
|
||||
| Requirement | Notes |
|
||||
|-------------|-------|
|
||||
| **Git** | With `--recurse-submodules` support, and the `git-lfs` extension installed |
|
||||
| **Git** | With the `git-lfs` extension installed |
|
||||
| **Python 3.11+** | uv will install it if missing |
|
||||
| **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) |
|
||||
| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) |
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
||||
# hermes process, the dashboard, and per-profile gateways.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev procps git openssh-client docker-cli xz-utils && \
|
||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---------- s6-overlay install ----------
|
||||
@@ -157,10 +157,17 @@ RUN npm install --prefer-offline --no-audit && \
|
||||
# so Docker users can use these providers without requiring runtime
|
||||
# lazy-install access to PyPI (often blocked in containerized envs).
|
||||
#
|
||||
# The hindsight memory provider's client (hindsight-client) is baked in
|
||||
# for the same reason: it lazy-installs into /opt/hermes/.venv at first
|
||||
# use, which lives inside the (immutable) image layer rather than the
|
||||
# mounted /opt/data volume, so it is lost on every container recreate /
|
||||
# image update and recall/retain then fails with
|
||||
# `ModuleNotFoundError: No module named 'hindsight_client'` (#38128).
|
||||
#
|
||||
# The editable link is created after the source copy below.
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN touch ./README.md
|
||||
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity
|
||||
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight
|
||||
|
||||
# ---------- Source code ----------
|
||||
# .dockerignore excludes node_modules, so the installs above survive.
|
||||
@@ -178,13 +185,16 @@ RUN cd web && npm run build && \
|
||||
# hermes_cli/main.py succeeds (see #18800). /opt/hermes/web is build-time
|
||||
# only (HERMES_WEB_DIST points at hermes_cli/web_dist) and is intentionally
|
||||
# not chowned here.
|
||||
# /opt/hermes/gateway is runtime-writable: Python may create __pycache__ and
|
||||
# gateway state artifacts beneath the package after services drop privileges,
|
||||
# especially when the hermes UID is remapped at boot (#27221).
|
||||
# The .venv MUST remain hermes-writable so lazy_deps.py can install
|
||||
# remaining optional platform packages and future pin bumps at first use.
|
||||
# Without this, `uv pip install` fails with EACCES and adapters silently
|
||||
# fail to load. See tools/lazy_deps.py.
|
||||
USER root
|
||||
RUN chmod -R a+rX /opt/hermes && \
|
||||
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/node_modules
|
||||
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/gateway /opt/hermes/node_modules
|
||||
# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
|
||||
# the data volume. Each supervised service then drops to the hermes user via
|
||||
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
graft skills
|
||||
graft optional-skills
|
||||
graft locales
|
||||
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
|
||||
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs
|
||||
# built from the sdist (e.g. Homebrew, downstream packagers). package-data
|
||||
|
||||
@@ -94,7 +94,7 @@ One command from a fresh install:
|
||||
hermes setup --portal
|
||||
```
|
||||
|
||||
That logs you in via OAuth, sets Nous as your provider, and turns on the Tool Gateway. Check what's wired up any time with `hermes portal status`. Full details on the [Tool Gateway docs page](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway).
|
||||
That logs you in via OAuth, sets Nous as your provider, and turns on the Tool Gateway. Check what's wired up any time with `hermes portal info`. Full details on the [Tool Gateway docs page](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway).
|
||||
|
||||
You can still bring your own keys per-tool whenever you want — the gateway is per-backend, not all-or-nothing.
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ Hermes 始终允许你使用任意服务商,这点不会改变。但如果你
|
||||
hermes setup --portal
|
||||
```
|
||||
|
||||
它会通过 OAuth 登录、把 Nous 设为推理服务商,并启用 Tool Gateway。随时用 `hermes portal status` 查看路由状态。完整说明见 [Tool Gateway 文档](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway)。
|
||||
它会通过 OAuth 登录、把 Nous 设为推理服务商,并启用 Tool Gateway。随时用 `hermes portal info` 查看路由状态。完整说明见 [Tool Gateway 文档](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway)。
|
||||
|
||||
你随时可以按工具单独切回自己的 API Key — Gateway 是按工具粒度生效的,不是一刀切。
|
||||
|
||||
|
||||
@@ -47,6 +47,20 @@ def _ra():
|
||||
return run_agent
|
||||
|
||||
|
||||
AGENT_RUNTIME_POST_HOOK_TOOL_NAMES = frozenset(
|
||||
{"todo", "session_search", "memory", "clarify", "delegate_task"}
|
||||
)
|
||||
|
||||
|
||||
def agent_runtime_owns_post_tool_hook(agent: Any, function_name: str) -> bool:
|
||||
"""Return True when an agent-level tool path emits its own post hook."""
|
||||
if function_name in AGENT_RUNTIME_POST_HOOK_TOOL_NAMES:
|
||||
return True
|
||||
if getattr(agent, "_context_engine_tool_names", None) and function_name in agent._context_engine_tool_names:
|
||||
return True
|
||||
memory_manager = getattr(agent, "_memory_manager", None)
|
||||
return bool(memory_manager and memory_manager.has_tool(function_name))
|
||||
|
||||
|
||||
def convert_to_trajectory_format(agent, messages: List[Dict[str, Any]], user_query: str, completed: bool) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -1618,36 +1632,84 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
block_message = get_pre_tool_call_block_message(
|
||||
function_name, function_args, task_id=effective_task_id or "",
|
||||
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 "",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if block_message is not None:
|
||||
return json.dumps({"error": block_message}, ensure_ascii=False)
|
||||
result = json.dumps({"error": block_message}, ensure_ascii=False)
|
||||
try:
|
||||
from model_tools import _emit_post_tool_call_hook
|
||||
_emit_post_tool_call_hook(
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
result=result,
|
||||
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 "",
|
||||
status="blocked",
|
||||
error_type="plugin_block",
|
||||
error_message=block_message,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
tool_start_time = time.monotonic()
|
||||
|
||||
def _finish_agent_tool(result: Any) -> Any:
|
||||
try:
|
||||
from model_tools import _emit_post_tool_call_hook
|
||||
_emit_post_tool_call_hook(
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
result=result,
|
||||
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 "",
|
||||
duration_ms=int((time.monotonic() - tool_start_time) * 1000),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
if function_name == "todo":
|
||||
from tools.todo_tool import todo_tool as _todo_tool
|
||||
return _todo_tool(
|
||||
todos=function_args.get("todos"),
|
||||
merge=function_args.get("merge", False),
|
||||
store=agent._todo_store,
|
||||
return _finish_agent_tool(
|
||||
_todo_tool(
|
||||
todos=function_args.get("todos"),
|
||||
merge=function_args.get("merge", False),
|
||||
store=agent._todo_store,
|
||||
)
|
||||
)
|
||||
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 json.dumps({"success": False, "error": 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 _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,
|
||||
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,
|
||||
)
|
||||
)
|
||||
elif function_name == "memory":
|
||||
target = function_args.get("target", "memory")
|
||||
@@ -1673,23 +1735,27 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
return _finish_agent_tool(result)
|
||||
elif agent._memory_manager and agent._memory_manager.has_tool(function_name):
|
||||
return agent._memory_manager.handle_tool_call(function_name, function_args)
|
||||
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 _clarify_tool(
|
||||
question=function_args.get("question", ""),
|
||||
choices=function_args.get("choices"),
|
||||
callback=agent.clarify_callback,
|
||||
return _finish_agent_tool(
|
||||
_clarify_tool(
|
||||
question=function_args.get("question", ""),
|
||||
choices=function_args.get("choices"),
|
||||
callback=agent.clarify_callback,
|
||||
)
|
||||
)
|
||||
elif function_name == "delegate_task":
|
||||
return agent._dispatch_delegate_task(function_args)
|
||||
return _finish_agent_tool(agent._dispatch_delegate_task(function_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),
|
||||
|
||||
@@ -1296,7 +1296,7 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
|
||||
for internal_key in [k for k in api_msg if isinstance(k, str) and k.startswith("_")]:
|
||||
api_msg.pop(internal_key, None)
|
||||
if _needs_sanitize:
|
||||
agent._sanitize_tool_calls_for_strict_api(api_msg)
|
||||
agent._sanitize_tool_calls_for_strict_api(api_msg, model=agent.model)
|
||||
api_messages.append(api_msg)
|
||||
|
||||
effective_system = agent._cached_system_prompt or ""
|
||||
|
||||
@@ -435,6 +435,9 @@ def run_conversation(
|
||||
# state registry. Set BEFORE any tool dispatch so snapshots taken at
|
||||
# child-launch time see the parent's real id, not None.
|
||||
agent._current_task_id = effective_task_id
|
||||
turn_id = f"{agent.session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}"
|
||||
agent._current_turn_id = turn_id
|
||||
agent._current_api_request_id = ""
|
||||
|
||||
# Reset retry counters and iteration budget at the start of each turn
|
||||
# so subagent usage from a previous turn doesn't eat into the next one.
|
||||
@@ -702,6 +705,8 @@ def run_conversation(
|
||||
_pre_results = _invoke_hook(
|
||||
"pre_llm_call",
|
||||
session_id=agent.session_id,
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
user_message=original_user_message,
|
||||
conversation_history=list(messages),
|
||||
is_first_turn=(not bool(conversation_history)),
|
||||
@@ -977,7 +982,7 @@ def run_conversation(
|
||||
# Uses new dicts so the internal messages list retains the fields
|
||||
# for Codex Responses compatibility.
|
||||
if agent._should_sanitize_tool_calls():
|
||||
agent._sanitize_tool_calls_for_strict_api(api_msg)
|
||||
agent._sanitize_tool_calls_for_strict_api(api_msg, model=agent.model)
|
||||
# Keep 'reasoning_details' - OpenRouter uses this for multi-turn reasoning context
|
||||
# The signature field helps maintain reasoning continuity
|
||||
api_messages.append(api_msg)
|
||||
@@ -1153,6 +1158,8 @@ def run_conversation(
|
||||
finish_reason = "stop"
|
||||
response = None # Guard against UnboundLocalError if all retries fail
|
||||
api_kwargs = None # Guard against UnboundLocalError in except handler
|
||||
api_request_id = f"{turn_id}:api:{api_call_count}"
|
||||
agent._current_api_request_id = api_request_id
|
||||
|
||||
while retry_count < max_retries:
|
||||
# ── Nous Portal rate limit guard ──────────────────────
|
||||
@@ -1220,37 +1227,58 @@ def run_conversation(
|
||||
api_kwargs = agent._get_transport().preflight_kwargs(api_kwargs, allow_stream=False)
|
||||
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||
request_messages = api_kwargs.get("messages")
|
||||
if not isinstance(request_messages, list):
|
||||
request_messages = api_kwargs.get("input")
|
||||
if not isinstance(request_messages, list):
|
||||
request_messages = api_messages
|
||||
# Shallow-copy the outer list so plugins that retain the
|
||||
# reference for async snapshotting don't observe later
|
||||
# mutations of api_messages. The inner dicts are not
|
||||
# mutated by the agent loop, so a shallow copy is
|
||||
# sufficient; a deepcopy would walk every tool result
|
||||
# and base64 image on every API call.
|
||||
_invoke_hook(
|
||||
"pre_api_request",
|
||||
task_id=effective_task_id,
|
||||
session_id=agent.session_id or "",
|
||||
user_message=original_user_message,
|
||||
conversation_history=list(messages),
|
||||
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,
|
||||
request_messages=list(request_messages) if isinstance(request_messages, list) else [],
|
||||
message_count=len(api_messages),
|
||||
tool_count=len(agent.tools or []),
|
||||
approx_input_tokens=approx_tokens,
|
||||
request_char_count=total_chars,
|
||||
max_tokens=agent.max_tokens,
|
||||
from hermes_cli.plugins import (
|
||||
has_hook,
|
||||
invoke_hook as _invoke_hook,
|
||||
)
|
||||
if has_hook("pre_api_request"):
|
||||
request_messages = api_kwargs.get("messages")
|
||||
if not isinstance(request_messages, list):
|
||||
request_messages = api_kwargs.get("input")
|
||||
if not isinstance(request_messages, list):
|
||||
request_messages = api_messages
|
||||
# Shallow-copy the outer list so plugins that retain the
|
||||
# reference for async snapshotting don't observe later
|
||||
# mutations of api_messages. The inner dicts are not
|
||||
# mutated by the agent loop, so a shallow copy is
|
||||
# sufficient; a deepcopy would walk every tool result
|
||||
# and base64 image on every API call.
|
||||
#
|
||||
# The ``request_messages`` and ``conversation_history``
|
||||
# kwargs below are pre-existing raw passthroughs
|
||||
# consumed by the bundled langfuse plugin
|
||||
# (``plugins/observability/langfuse/__init__.py:_coerce_request_messages``).
|
||||
# They predate ``request`` and are intentionally NOT
|
||||
# sanitised — secrets are not expected here because
|
||||
# ``api_kwargs`` is the same object passed to the
|
||||
# provider client. New consumers should read the
|
||||
# sanitised view from ``request["body"]["messages"]``.
|
||||
_request_payload = agent._api_request_payload_for_hook(api_kwargs)
|
||||
_invoke_hook(
|
||||
"pre_api_request",
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
api_request_id=api_request_id,
|
||||
session_id=agent.session_id or "",
|
||||
user_message=original_user_message,
|
||||
conversation_history=list(messages),
|
||||
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,
|
||||
request_messages=list(request_messages)
|
||||
if isinstance(request_messages, list)
|
||||
else [],
|
||||
message_count=len(api_messages),
|
||||
tool_count=len(agent.tools or []),
|
||||
approx_input_tokens=approx_tokens,
|
||||
request_char_count=total_chars,
|
||||
max_tokens=agent.max_tokens,
|
||||
started_at=api_start_time,
|
||||
request=_request_payload,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1300,12 +1328,14 @@ def run_conversation(
|
||||
if isinstance(getattr(agent, "client", None), Mock):
|
||||
_use_streaming = False
|
||||
|
||||
if _use_streaming:
|
||||
response = agent._interruptible_streaming_api_call(
|
||||
api_kwargs, on_first_delta=_stop_spinner
|
||||
)
|
||||
else:
|
||||
response = agent._interruptible_api_call(api_kwargs)
|
||||
def _perform_api_call(next_api_kwargs):
|
||||
if _use_streaming:
|
||||
return agent._interruptible_streaming_api_call(
|
||||
next_api_kwargs, on_first_delta=_stop_spinner
|
||||
)
|
||||
return agent._interruptible_api_call(next_api_kwargs)
|
||||
|
||||
response = _perform_api_call(api_kwargs)
|
||||
|
||||
api_duration = time.time() - api_start_time
|
||||
|
||||
@@ -1406,6 +1436,21 @@ def run_conversation(
|
||||
error_details.append("response.choices is empty")
|
||||
|
||||
if response_invalid:
|
||||
agent._invoke_api_request_error_hook(
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
api_request_id=api_request_id,
|
||||
api_call_count=api_call_count,
|
||||
api_start_time=api_start_time,
|
||||
api_kwargs=api_kwargs,
|
||||
error_type="InvalidAPIResponse",
|
||||
error_message=", ".join(error_details) or "Invalid API response",
|
||||
status_code=getattr(getattr(response, "error", None), "code", None),
|
||||
retry_count=retry_count,
|
||||
max_retries=max_retries,
|
||||
retryable=True,
|
||||
reason="invalid_response",
|
||||
)
|
||||
# Stop spinner silently — retry status is now buffered
|
||||
# and only surfaced if every retry+fallback exhausts.
|
||||
if thinking_spinner:
|
||||
@@ -2278,6 +2323,21 @@ def run_conversation(
|
||||
classified.retryable, classified.should_compress,
|
||||
classified.should_rotate_credential, classified.should_fallback,
|
||||
)
|
||||
agent._invoke_api_request_error_hook(
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
api_request_id=api_request_id,
|
||||
api_call_count=api_call_count,
|
||||
api_start_time=api_start_time,
|
||||
api_kwargs=api_kwargs,
|
||||
error_type=type(api_error).__name__,
|
||||
error_message=str(api_error),
|
||||
status_code=status_code,
|
||||
retry_count=retry_count,
|
||||
max_retries=max_retries,
|
||||
retryable=classified.retryable,
|
||||
reason=classified.reason.value,
|
||||
)
|
||||
|
||||
if (
|
||||
classified.reason == FailoverReason.billing
|
||||
@@ -3195,7 +3255,7 @@ def run_conversation(
|
||||
else: # nous
|
||||
agent._vprint(f"{agent.log_prefix} 💡 Nous Portal OAuth token was rejected (HTTP 401). Your token may be", force=True)
|
||||
agent._vprint(f"{agent.log_prefix} expired, revoked, or your account may be out of credits. To fix:", force=True)
|
||||
agent._vprint(f"{agent.log_prefix} 1. Re-authenticate: hermes auth add nous --type oauth", force=True)
|
||||
agent._vprint(f"{agent.log_prefix} 1. Re-authenticate: hermes portal", force=True)
|
||||
agent._vprint(f"{agent.log_prefix} 2. Check your portal account: https://portal.nousresearch.com", force=True)
|
||||
# ``:free`` is OpenRouter slug syntax; Nous Portal will reject
|
||||
# the model name even after a successful re-auth.
|
||||
@@ -3378,6 +3438,12 @@ def run_conversation(
|
||||
"completed": False,
|
||||
"failed": True,
|
||||
"error": _final_summary,
|
||||
# Surface the classified reason so callers (notably the
|
||||
# kanban worker path in cli.py) can distinguish a
|
||||
# transient throttle from a real failure and choose a
|
||||
# different exit code. ``rate_limit`` / ``billing`` here
|
||||
# mean "quota wall, not a task error".
|
||||
"failure_reason": classified.reason.value,
|
||||
}
|
||||
|
||||
# For rate limits, respect the Retry-After header if present
|
||||
@@ -3501,29 +3567,44 @@ def run_conversation(
|
||||
assistant_message.content = str(raw)
|
||||
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||
_assistant_tool_calls = getattr(assistant_message, "tool_calls", None) or []
|
||||
_assistant_text = assistant_message.content or ""
|
||||
_invoke_hook(
|
||||
"post_api_request",
|
||||
task_id=effective_task_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_duration=api_duration,
|
||||
finish_reason=finish_reason,
|
||||
message_count=len(api_messages),
|
||||
response_model=getattr(response, "model", None),
|
||||
response=response,
|
||||
usage=agent._usage_summary_for_api_request_hook(response),
|
||||
assistant_message=assistant_message,
|
||||
assistant_content_chars=len(_assistant_text),
|
||||
assistant_tool_call_count=len(_assistant_tool_calls),
|
||||
from hermes_cli.plugins import (
|
||||
has_hook,
|
||||
invoke_hook as _invoke_hook,
|
||||
)
|
||||
if has_hook("post_api_request"):
|
||||
_assistant_tool_calls = (
|
||||
getattr(assistant_message, "tool_calls", None) or []
|
||||
)
|
||||
_assistant_text = assistant_message.content or ""
|
||||
_api_ended_at = api_start_time + api_duration
|
||||
_invoke_hook(
|
||||
"post_api_request",
|
||||
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_duration=api_duration,
|
||||
started_at=api_start_time,
|
||||
ended_at=_api_ended_at,
|
||||
finish_reason=finish_reason,
|
||||
message_count=len(api_messages),
|
||||
response_model=getattr(response, "model", None),
|
||||
response=agent._api_response_payload_for_hook(
|
||||
response,
|
||||
assistant_message,
|
||||
finish_reason=finish_reason,
|
||||
),
|
||||
usage=agent._usage_summary_for_api_request_hook(response),
|
||||
assistant_message=assistant_message,
|
||||
assistant_content_chars=len(_assistant_text),
|
||||
assistant_tool_call_count=len(_assistant_tool_calls),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -4617,6 +4698,8 @@ def run_conversation(
|
||||
_invoke_hook(
|
||||
"post_llm_call",
|
||||
session_id=agent.session_id,
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
user_message=original_user_message,
|
||||
assistant_response=final_response,
|
||||
conversation_history=list(messages),
|
||||
@@ -4736,6 +4819,8 @@ def run_conversation(
|
||||
_invoke_hook(
|
||||
"on_session_end",
|
||||
session_id=agent.session_id,
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
completed=completed,
|
||||
interrupted=interrupted,
|
||||
model=agent.model,
|
||||
|
||||
@@ -32,6 +32,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sysconfig
|
||||
import threading
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
@@ -87,11 +88,54 @@ _catalog_lock = threading.Lock()
|
||||
def _locales_dir() -> Path:
|
||||
"""Return the directory containing locale YAML files.
|
||||
|
||||
Lives next to the repo root so both the bundled install and editable
|
||||
checkouts find it without PYTHONPATH gymnastics.
|
||||
Resolution order, first existing wins:
|
||||
|
||||
1. ``HERMES_BUNDLED_LOCALES`` env var -- set by the Nix wrapper (or any
|
||||
sealed-packaging system) to point at the installed catalog directory.
|
||||
2. ``<repo-root>/locales`` -- source checkouts and ``pip install -e .``,
|
||||
where the working tree sits next to ``agent/``.
|
||||
3. ``<sysconfig data|purelib|platlib>/locales`` -- pip wheel installs.
|
||||
setuptools ``data-files`` extracts ``locales/*.yaml`` under the
|
||||
interpreter's ``data`` scheme; the other schemes are checked as a
|
||||
safety net for nonstandard layouts.
|
||||
|
||||
Falling through to the source-style path (even when missing) keeps
|
||||
``_load_catalog`` error messages informative -- it logs the path it
|
||||
looked at -- rather than raising.
|
||||
"""
|
||||
# agent/i18n.py -> agent/ -> repo root
|
||||
return Path(__file__).resolve().parent.parent / "locales"
|
||||
override = os.getenv("HERMES_BUNDLED_LOCALES", "").strip()
|
||||
if override:
|
||||
candidate = Path(override)
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
logger.warning(
|
||||
"HERMES_BUNDLED_LOCALES points to a non-directory path (%s); "
|
||||
"falling back to bundled/source locale resolution",
|
||||
override,
|
||||
)
|
||||
|
||||
# agent/i18n.py -> agent/ -> repo root (source checkout, editable install)
|
||||
source_dir = Path(__file__).resolve().parent.parent / "locales"
|
||||
if source_dir.is_dir():
|
||||
return source_dir
|
||||
|
||||
# pip wheel install: data-files lands under the interpreter data scheme.
|
||||
# ``data`` (== sys.prefix in a venv) is where setuptools data-files extract
|
||||
# and is checked first. ``purelib``/``platlib`` (site-packages) are a safety
|
||||
# net for nonstandard layouts. NOTE: this does NOT cover ``pip install
|
||||
# --user`` (user scheme, ~/.local/locales) or ``pip install --target`` --
|
||||
# both are out of scope; see the plan header.
|
||||
for scheme in ("data", "purelib", "platlib"):
|
||||
raw = sysconfig.get_path(scheme)
|
||||
if not raw:
|
||||
continue
|
||||
candidate = Path(raw) / "locales"
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
|
||||
# Last resort: return the source-style path so _load_catalog's catalog-missing
|
||||
# log (logger.debug "i18n catalog missing for %s at %s") stays informative.
|
||||
return source_dir
|
||||
|
||||
|
||||
def _normalize_lang(value: Any) -> str:
|
||||
|
||||
@@ -129,9 +129,14 @@ DEFAULT_AGENT_IDENTITY = (
|
||||
)
|
||||
|
||||
HERMES_AGENT_HELP_GUIDANCE = (
|
||||
"If the user asks about configuring, setting up, or using Hermes Agent "
|
||||
"itself, load the `hermes-agent` skill with skill_view(name='hermes-agent') "
|
||||
"before answering. Docs: https://hermes-agent.nousresearch.com/docs"
|
||||
"You run on Hermes Agent (by Nous Research). When the user needs help with "
|
||||
"Hermes itself — configuring, setting up, using, extending, or troubleshooting "
|
||||
"it — or when you need to understand your own features, tools, or capabilities, "
|
||||
"the documentation at https://hermes-agent.nousresearch.com/docs is your "
|
||||
"authoritative reference and always holds the latest, most up-to-date "
|
||||
"information. Load the `hermes-agent` skill with skill_view(name='hermes-agent') "
|
||||
"for additional guidance and proven workflows, but treat the docs as the source "
|
||||
"of truth when the two differ."
|
||||
)
|
||||
|
||||
MEMORY_GUIDANCE = (
|
||||
|
||||
@@ -19,7 +19,7 @@ import os
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.display import (
|
||||
KawaiiSpinner,
|
||||
@@ -58,6 +58,76 @@ def _ra():
|
||||
return run_agent
|
||||
|
||||
|
||||
def _emit_terminal_post_tool_call(
|
||||
agent,
|
||||
*,
|
||||
function_name: str,
|
||||
function_args: dict,
|
||||
result: Any,
|
||||
effective_task_id: str,
|
||||
tool_call_id: str,
|
||||
duration_ms: int = 0,
|
||||
status: str | None = None,
|
||||
error_type: str | None = None,
|
||||
error_message: str | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
from model_tools import _emit_post_tool_call_hook
|
||||
_emit_post_tool_call_hook(
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
result=result,
|
||||
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 "",
|
||||
duration_ms=duration_ms,
|
||||
status=status,
|
||||
error_type=error_type,
|
||||
error_message=error_message,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _cancelled_tool_result(reason: str = "user interrupt") -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"error": f"Tool execution cancelled by {reason}",
|
||||
"status": "cancelled",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def _emit_cancelled_terminal_post_tool_call(
|
||||
agent,
|
||||
*,
|
||||
function_name: str,
|
||||
function_args: dict,
|
||||
effective_task_id: str,
|
||||
tool_call_id: str,
|
||||
start_time: float,
|
||||
reason: str = "user interrupt",
|
||||
error_type: str = "keyboard_interrupt",
|
||||
) -> str:
|
||||
result = _cancelled_tool_result(reason)
|
||||
_emit_terminal_post_tool_call(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
result=result,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=tool_call_id,
|
||||
duration_ms=int((time.time() - start_time) * 1000),
|
||||
status="cancelled",
|
||||
error_type=error_type,
|
||||
error_message=f"Tool execution cancelled by {reason}",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _tool_search_scoped_names(agent) -> frozenset:
|
||||
"""Return the deferrable tool names the session may invoke via tool_call.
|
||||
|
||||
@@ -188,22 +258,61 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
if _ts_scope_block is not None:
|
||||
# Out-of-scope tool_call: reject before hooks/guardrails/dispatch.
|
||||
block_result = _ts_scope_block
|
||||
_emit_terminal_post_tool_call(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
result=block_result,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
status="blocked",
|
||||
error_type="tool_scope_block",
|
||||
error_message=_ts_scope_block,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
block_message = get_pre_tool_call_block_message(
|
||||
function_name, function_args, task_id=effective_task_id or "",
|
||||
function_name,
|
||||
function_args,
|
||||
task_id=effective_task_id or "",
|
||||
session_id=getattr(agent, "session_id", "") or "",
|
||||
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 "",
|
||||
)
|
||||
except Exception:
|
||||
block_message = None
|
||||
|
||||
if block_message is not None:
|
||||
block_result = json.dumps({"error": block_message}, ensure_ascii=False)
|
||||
_emit_terminal_post_tool_call(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
result=block_result,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
status="blocked",
|
||||
error_type="plugin_block",
|
||||
error_message=block_message,
|
||||
)
|
||||
else:
|
||||
guardrail_decision = agent._tool_guardrails.before_call(function_name, function_args)
|
||||
if not guardrail_decision.allows_execution:
|
||||
block_result = agent._guardrail_block_result(guardrail_decision)
|
||||
blocked_by_guardrail = True
|
||||
_emit_terminal_post_tool_call(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
result=block_result,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
status="blocked",
|
||||
error_type="guardrail_block",
|
||||
error_message=getattr(guardrail_decision, "message", None) or "Tool blocked by guardrail policy",
|
||||
)
|
||||
|
||||
# ── Checkpoint preflight (only for tools that will execute) ──
|
||||
if block_result is None:
|
||||
@@ -315,6 +424,23 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
messages=messages,
|
||||
pre_tool_block_checked=True,
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
try:
|
||||
agent.interrupt("keyboard interrupt")
|
||||
except Exception:
|
||||
pass
|
||||
result = _emit_cancelled_terminal_post_tool_call(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
start_time=start,
|
||||
)
|
||||
duration = time.time() - start
|
||||
logger.info("tool %s cancelled (%.2fs)", function_name, duration)
|
||||
results[index] = (function_name, function_args, result, duration, True, False)
|
||||
return
|
||||
except Exception as tool_error:
|
||||
result = f"Error executing tool '{function_name}': {tool_error}"
|
||||
logger.error("_invoke_tool raised for %s: %s", function_name, tool_error, exc_info=True)
|
||||
@@ -426,8 +552,30 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
# Tool was cancelled (interrupt) or thread didn't return
|
||||
if agent._interrupt_requested:
|
||||
function_result = f"[Tool execution cancelled — {name} was skipped due to user interrupt]"
|
||||
_emit_terminal_post_tool_call(
|
||||
agent,
|
||||
function_name=name,
|
||||
function_args=args,
|
||||
result=function_result,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tc, "id", "") or "",
|
||||
status="cancelled",
|
||||
error_type="keyboard_interrupt",
|
||||
error_message="Tool execution cancelled by user interrupt",
|
||||
)
|
||||
else:
|
||||
function_result = f"Error executing tool '{name}': thread did not return a result"
|
||||
_emit_terminal_post_tool_call(
|
||||
agent,
|
||||
function_name=name,
|
||||
function_args=args,
|
||||
result=function_result,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tc, "id", "") or "",
|
||||
status="error",
|
||||
error_type="thread_missing_result",
|
||||
error_message=function_result,
|
||||
)
|
||||
tool_duration = 0.0
|
||||
else:
|
||||
function_name, function_args, function_result, tool_duration, is_error, blocked = r
|
||||
@@ -592,13 +740,21 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
|
||||
# Check plugin hooks for a block directive before executing.
|
||||
_block_msg: Optional[str] = None
|
||||
_block_error_type = "plugin_block"
|
||||
if _ts_scope_block is not None:
|
||||
_block_msg = _ts_scope_block
|
||||
_block_error_type = "tool_scope_block"
|
||||
else:
|
||||
try:
|
||||
from hermes_cli.plugins import get_pre_tool_call_block_message
|
||||
_block_msg = get_pre_tool_call_block_message(
|
||||
function_name, function_args, task_id=effective_task_id or "",
|
||||
function_name,
|
||||
function_args,
|
||||
task_id=effective_task_id or "",
|
||||
session_id=getattr(agent, "session_id", "") or "",
|
||||
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 "",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -687,11 +843,33 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
# Tool blocked by plugin policy — return error without executing.
|
||||
function_result = json.dumps({"error": _block_msg}, ensure_ascii=False)
|
||||
tool_duration = 0.0
|
||||
_emit_terminal_post_tool_call(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
result=function_result,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
status="blocked",
|
||||
error_type=_block_error_type,
|
||||
error_message=_block_msg,
|
||||
)
|
||||
elif _guardrail_block_decision is not None:
|
||||
# Tool blocked by tool-loop guardrail — synthesize exactly one
|
||||
# tool result for the original tool_call_id without executing.
|
||||
function_result = agent._guardrail_block_result(_guardrail_block_decision)
|
||||
tool_duration = 0.0
|
||||
_emit_terminal_post_tool_call(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
result=function_result,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
status="blocked",
|
||||
error_type="guardrail_block",
|
||||
error_message=getattr(_guardrail_block_decision, "message", None) or "Tool blocked by guardrail policy",
|
||||
)
|
||||
elif function_name == "todo":
|
||||
from tools.todo_tool import todo_tool as _todo_tool
|
||||
function_result = _todo_tool(
|
||||
@@ -850,12 +1028,29 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
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),
|
||||
)
|
||||
_spinner_result = function_result
|
||||
except KeyboardInterrupt:
|
||||
function_result = _emit_cancelled_terminal_post_tool_call(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
start_time=tool_start_time,
|
||||
)
|
||||
_spinner_result = function_result
|
||||
try:
|
||||
agent.interrupt("keyboard interrupt")
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
except Exception as tool_error:
|
||||
function_result = f"Error executing tool '{function_name}': {tool_error}"
|
||||
logger.error("handle_function_call raised for %s: %s", function_name, tool_error, exc_info=True)
|
||||
@@ -872,11 +1067,27 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
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),
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
_emit_cancelled_terminal_post_tool_call(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
start_time=tool_start_time,
|
||||
)
|
||||
try:
|
||||
agent.interrupt("keyboard interrupt")
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
except Exception as tool_error:
|
||||
function_result = f"Error executing tool '{function_name}': {tool_error}"
|
||||
logger.error("handle_function_call raised for %s: %s", function_name, tool_error, exc_info=True)
|
||||
@@ -895,6 +1106,27 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
# Log tool errors to the persistent error log so [error] tags
|
||||
# in the UI always have a corresponding detailed entry on disk.
|
||||
_is_error_result, _ = _detect_tool_failure(function_name, function_result)
|
||||
# The agent-runtime tools above (todo, session_search, memory,
|
||||
# context-engine, memory-manager, clarify, delegate_task) are
|
||||
# dispatched inline — they never reach handle_function_call, so the
|
||||
# executor is the one that has to fire post_tool_call. For
|
||||
# registry-dispatched tools the else-branch above invoked
|
||||
# handle_function_call, which already fires the hook.
|
||||
from agent.agent_runtime_helpers import agent_runtime_owns_post_tool_hook
|
||||
_executor_must_emit_post_hook = (
|
||||
not _execution_blocked
|
||||
and agent_runtime_owns_post_tool_hook(agent, function_name)
|
||||
)
|
||||
if _executor_must_emit_post_hook:
|
||||
_emit_terminal_post_tool_call(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
result=function_result,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
duration_ms=int(tool_duration * 1000),
|
||||
)
|
||||
if not _execution_blocked:
|
||||
function_result = agent._append_guardrail_observation(
|
||||
function_name,
|
||||
|
||||
@@ -99,6 +99,22 @@ def _is_gemini_openai_compat_base_url(base_url: Any) -> bool:
|
||||
return normalized.endswith("/openai")
|
||||
|
||||
|
||||
def _model_consumes_thought_signature(model: Any) -> bool:
|
||||
"""True when the outgoing model is a Gemini family model that requires
|
||||
``extra_content`` (thought_signature) to be replayed on tool calls.
|
||||
|
||||
Gemini 3 thinking models attach ``extra_content`` to each tool call and
|
||||
reject subsequent requests with HTTP 400 if it is missing. Every other
|
||||
strict OpenAI-compatible provider (Fireworks, Mistral, ...) rejects the
|
||||
request with 400 if ``extra_content`` *is* present. So the field must be
|
||||
kept only when the target model is itself Gemini-family, and stripped
|
||||
otherwise — including when a non-Gemini model inherits stale Gemini
|
||||
``extra_content`` from earlier in a mixed-provider session.
|
||||
"""
|
||||
m = str(model or "").lower()
|
||||
return "gemini" in m or "gemma" in m
|
||||
|
||||
|
||||
class ChatCompletionsTransport(ProviderTransport):
|
||||
"""Transport for api_mode='chat_completions'.
|
||||
|
||||
@@ -119,6 +135,14 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
- Codex Responses API fields: ``codex_reasoning_items`` /
|
||||
``codex_message_items`` on the message, ``call_id`` /
|
||||
``response_item_id`` on ``tool_calls`` entries.
|
||||
- ``extra_content`` on ``tool_calls`` (Gemini thought_signature) —
|
||||
stripped unless the outgoing ``model`` is itself Gemini-family.
|
||||
Gemini 3 thinking models attach it for replay, but strict providers
|
||||
(Fireworks, Mistral) reject any payload containing it with
|
||||
``Extra inputs are not permitted, field: 'messages[N].tool_calls[M].extra_content'``.
|
||||
It must be kept for Gemini targets (replay required) and dropped for
|
||||
everyone else, including non-Gemini models that inherited stale
|
||||
Gemini ``extra_content`` earlier in a mixed-provider session.
|
||||
- ``tool_name`` on tool-result messages — written by
|
||||
``make_tool_result_message()`` for the SQLite FTS index, but not
|
||||
part of the Chat Completions schema. Strict providers (Fireworks,
|
||||
@@ -137,6 +161,9 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
``Extra inputs are not permitted, field: 'messages[N]._empty_recovery_synthetic'``,
|
||||
which then poisons every subsequent request in the session.
|
||||
"""
|
||||
strip_extra_content = not _model_consumes_thought_signature(
|
||||
kwargs.get("model")
|
||||
)
|
||||
needs_sanitize = False
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
@@ -155,7 +182,9 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
if isinstance(tool_calls, list):
|
||||
for tc in tool_calls:
|
||||
if isinstance(tc, dict) and (
|
||||
"call_id" in tc or "response_item_id" in tc
|
||||
"call_id" in tc
|
||||
or "response_item_id" in tc
|
||||
or (strip_extra_content and "extra_content" in tc)
|
||||
):
|
||||
needs_sanitize = True
|
||||
break
|
||||
@@ -183,6 +212,8 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
if isinstance(tc, dict):
|
||||
tc.pop("call_id", None)
|
||||
tc.pop("response_item_id", None)
|
||||
if strip_extra_content:
|
||||
tc.pop("extra_content", None)
|
||||
return sanitized
|
||||
|
||||
def convert_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
@@ -240,8 +271,10 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
anthropic_max_output: int | None
|
||||
extra_body_additions: dict | None
|
||||
"""
|
||||
# Codex sanitization: drop reasoning_items / call_id / response_item_id
|
||||
sanitized = self.convert_messages(messages)
|
||||
# Codex sanitization: drop reasoning_items / call_id / response_item_id.
|
||||
# Pass model so the Gemini thought_signature (extra_content) is kept for
|
||||
# Gemini targets and stripped for strict non-Gemini providers.
|
||||
sanitized = self.convert_messages(messages, model=model)
|
||||
|
||||
# ── Provider profile: single-path when present ──────────────────
|
||||
_profile = params.get("provider_profile")
|
||||
|
||||
@@ -21,7 +21,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
use crate::events::{BootstrapEvent, Manifest, StageState};
|
||||
use crate::events::{BootstrapEvent, LogStream, Manifest, StageState};
|
||||
use crate::install_script::{self, Pin, ScriptKind, ScriptSource};
|
||||
use crate::powershell::{self, StreamSink};
|
||||
use crate::AppState;
|
||||
@@ -179,9 +179,11 @@ pub async fn launch_hermes_desktop(
|
||||
|
||||
tracing::info!(?exe_path, "launching Hermes desktop");
|
||||
|
||||
// Detach from us — the installer is about to exit.
|
||||
let mut cmd = tokio::process::Command::new(&exe_path);
|
||||
cmd.current_dir(exe_path.parent().unwrap_or(&install_root));
|
||||
// Detach from us — the installer is about to exit. On macOS launch the
|
||||
// bundle through LaunchServices instead of exec'ing Contents/MacOS/Hermes
|
||||
// directly; this matches user double-click/open behavior and avoids cwd /
|
||||
// quarantine oddities after a self-update rebuild.
|
||||
let mut cmd = desktop_launch_command(&exe_path, &install_root);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
@@ -232,6 +234,24 @@ pub(crate) fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Opti
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_hermes_desktop_app(install_root: &std::path::Path) -> Option<PathBuf> {
|
||||
let exe = resolve_hermes_desktop_exe(install_root)?;
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// .../Hermes.app/Contents/MacOS/Hermes -> .../Hermes.app
|
||||
let app = exe.parent()?.parent()?.parent()?.to_path_buf();
|
||||
if app.extension().and_then(|e| e.to_str()) == Some("app") && app.is_dir() {
|
||||
return Some(app);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
return Some(exe);
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
None
|
||||
}
|
||||
|
||||
/// True when a prior install completed (bootstrap-complete marker present) AND a
|
||||
/// launchable desktop app exists on disk. Used by the installer's launcher fast
|
||||
/// path so a bare re-open just opens Hermes instead of re-running setup.
|
||||
@@ -247,8 +267,7 @@ pub(crate) fn spawn_installed_desktop(install_root: &std::path::Path) -> std::io
|
||||
let exe = resolve_hermes_desktop_exe(install_root).ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::NotFound, "no built Hermes desktop app")
|
||||
})?;
|
||||
let mut cmd = std::process::Command::new(&exe);
|
||||
cmd.current_dir(exe.parent().unwrap_or(install_root));
|
||||
let mut cmd = desktop_launch_command_std(&exe, install_root);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
@@ -261,6 +280,62 @@ pub(crate) fn spawn_installed_desktop(install_root: &std::path::Path) -> std::io
|
||||
cmd.spawn().map(|_child| ())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn open_macos_app_detached(app_bundle: &std::path::Path) -> std::io::Result<()> {
|
||||
let mut cmd = std::process::Command::new("/usr/bin/open");
|
||||
cmd.arg(app_bundle);
|
||||
cmd.current_dir(crate::paths::hermes_home());
|
||||
cmd.spawn().map(|_child| ())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn app_bundle_for_exe(exe: &std::path::Path) -> Option<PathBuf> {
|
||||
let app = exe.parent()?.parent()?.parent()?.to_path_buf();
|
||||
if app.extension().and_then(|e| e.to_str()) == Some("app") && app.is_dir() {
|
||||
Some(app)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn desktop_launch_command(
|
||||
exe_path: &std::path::Path,
|
||||
install_root: &std::path::Path,
|
||||
) -> tokio::process::Command {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(app_bundle) = app_bundle_for_exe(exe_path) {
|
||||
let mut cmd = tokio::process::Command::new("/usr/bin/open");
|
||||
cmd.arg(app_bundle);
|
||||
cmd.current_dir(crate::paths::hermes_home());
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
|
||||
let mut cmd = tokio::process::Command::new(exe_path);
|
||||
cmd.current_dir(exe_path.parent().unwrap_or(install_root));
|
||||
cmd
|
||||
}
|
||||
|
||||
fn desktop_launch_command_std(
|
||||
exe_path: &std::path::Path,
|
||||
install_root: &std::path::Path,
|
||||
) -> std::process::Command {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(app_bundle) = app_bundle_for_exe(exe_path) {
|
||||
let mut cmd = std::process::Command::new("/usr/bin/open");
|
||||
cmd.arg(app_bundle);
|
||||
cmd.current_dir(crate::paths::hermes_home());
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
|
||||
let mut cmd = std::process::Command::new(exe_path);
|
||||
cmd.current_dir(exe_path.parent().unwrap_or(install_root));
|
||||
cmd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bootstrap implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -291,6 +366,7 @@ async fn run_bootstrap(
|
||||
BootstrapEvent::Log {
|
||||
stage: None,
|
||||
line: line.to_string(),
|
||||
stream: LogStream::Stdout,
|
||||
},
|
||||
);
|
||||
// Bump to info-level so the line shows in bootstrap-installer.log
|
||||
@@ -625,6 +701,7 @@ async fn run_install_script(
|
||||
BootstrapEvent::Log {
|
||||
stage: stage_for_stdout.clone(),
|
||||
line: line.to_string(),
|
||||
stream: LogStream::Stdout,
|
||||
},
|
||||
);
|
||||
// Tee to the rolling installer log so we have a persistent
|
||||
@@ -643,7 +720,8 @@ async fn run_install_script(
|
||||
&app_for_stderr,
|
||||
BootstrapEvent::Log {
|
||||
stage: stage_for_stderr.clone(),
|
||||
line: format!("stderr: {line}"),
|
||||
line: line.to_string(),
|
||||
stream: LogStream::Stderr,
|
||||
},
|
||||
);
|
||||
// stderr-level lines get warn! so they're visually distinct
|
||||
@@ -739,3 +817,90 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
format!("{}...", &s[..max])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
use std::path::Path;
|
||||
|
||||
fn unique_tmp_dir(tag: &str) -> PathBuf {
|
||||
let base = std::env::temp_dir().join(format!(
|
||||
"hermes-bootstrap-test-{tag}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
base
|
||||
}
|
||||
|
||||
// Build a fake built-desktop release tree at the platform's expected path
|
||||
// and return (install_root, expected_app_bundle_or_exe).
|
||||
fn make_release_tree(install_root: &Path) -> PathBuf {
|
||||
let release = install_root.join("apps").join("desktop").join("release");
|
||||
if cfg!(target_os = "macos") {
|
||||
let macos_dir = release
|
||||
.join("mac-arm64")
|
||||
.join("Hermes.app")
|
||||
.join("Contents")
|
||||
.join("MacOS");
|
||||
std::fs::create_dir_all(&macos_dir).unwrap();
|
||||
std::fs::write(macos_dir.join("Hermes"), b"#!/bin/sh\n").unwrap();
|
||||
macos_dir.parent().unwrap().parent().unwrap().to_path_buf() // .../Hermes.app
|
||||
} else if cfg!(target_os = "windows") {
|
||||
let dir = release.join("win-unpacked");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let exe = dir.join("Hermes.exe");
|
||||
std::fs::write(&exe, b"stub").unwrap();
|
||||
exe
|
||||
} else {
|
||||
let dir = release.join("linux-unpacked");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let exe = dir.join("hermes");
|
||||
std::fs::write(&exe, b"stub").unwrap();
|
||||
exe
|
||||
}
|
||||
}
|
||||
|
||||
// The relaunch / install target is derived from the rebuilt desktop app.
|
||||
// On macOS this MUST resolve to the .app bundle (what `open` relaunches and
|
||||
// what the updater ditto's over /Applications/Hermes.app). A regression in
|
||||
// this derivation breaks the post-update auto-relaunch, so guard it.
|
||||
#[test]
|
||||
fn resolve_hermes_desktop_app_finds_built_bundle() {
|
||||
let root = unique_tmp_dir("app-ok");
|
||||
let expected = make_release_tree(&root);
|
||||
|
||||
let resolved = resolve_hermes_desktop_app(&root)
|
||||
.expect("should resolve the freshly-built desktop app");
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
assert_eq!(resolved, expected, "must resolve to the .app bundle");
|
||||
assert_eq!(
|
||||
resolved.extension().and_then(|e| e.to_str()),
|
||||
Some("app"),
|
||||
"relaunch target must be a .app bundle on macOS"
|
||||
);
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
assert_eq!(resolved, expected);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_hermes_desktop_app_is_none_without_a_build() {
|
||||
let root = unique_tmp_dir("app-none");
|
||||
// No release tree created.
|
||||
assert!(
|
||||
resolve_hermes_desktop_app(&root).is_none(),
|
||||
"no resolved app when nothing has been built"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,16 @@ pub enum StageState {
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Which pipe a raw log line came from. Reported as structured metadata so
|
||||
/// the UI can style stderr subtly rather than mislabeling it as an error:
|
||||
/// uv/pip/git/npm write normal progress to stderr by design.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LogStream {
|
||||
Stdout,
|
||||
Stderr,
|
||||
}
|
||||
|
||||
/// The single event channel `bootstrap` emits these. `type` discriminates.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
@@ -72,11 +82,14 @@ pub enum BootstrapEvent {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
},
|
||||
/// Raw stdout/stderr line from install.ps1 (or our wrapper).
|
||||
/// Raw stdout/stderr line from install.ps1 (or our wrapper). `stream`
|
||||
/// tells the UI which pipe it came from so stderr can be styled subtly
|
||||
/// instead of being mislabeled as an error.
|
||||
Log {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stage: Option<String>,
|
||||
line: String,
|
||||
stream: LogStream,
|
||||
},
|
||||
/// Sent once when all stages complete successfully.
|
||||
Complete {
|
||||
|
||||
@@ -45,6 +45,14 @@ pub async fn run_script(
|
||||
) -> Result<ScriptResult> {
|
||||
let mut cmd = build_command(script_path, args);
|
||||
|
||||
// The installer can be launched from a .app bundle that is later replaced
|
||||
// during self-update. Pin child scripts to a stable directory so bash/zsh
|
||||
// never starts from a deleted cwd and emits getcwd/job-working-directory
|
||||
// errors at the end of an otherwise successful install.
|
||||
if let Some(cwd) = stable_script_cwd(script_path, hermes_home_override) {
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
|
||||
if let Some(home) = hermes_home_override {
|
||||
cmd.env("HERMES_HOME", home);
|
||||
}
|
||||
@@ -146,6 +154,16 @@ pub async fn run_script(
|
||||
})
|
||||
}
|
||||
|
||||
fn stable_script_cwd<'a>(script_path: &'a Path, hermes_home_override: Option<&'a str>) -> Option<&'a Path> {
|
||||
if let Some(home) = hermes_home_override {
|
||||
let path = Path::new(home);
|
||||
if path.is_dir() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
script_path.parent().filter(|p| p.is_dir())
|
||||
}
|
||||
|
||||
async fn recv_cancel(rx: &mut Option<CancelRx>) {
|
||||
match rx {
|
||||
Some(r) => {
|
||||
@@ -264,4 +282,11 @@ info line
|
||||
assert!(parse_stage_result("just banner\n").is_none());
|
||||
assert!(parse_manifest("just banner\n").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_script_cwd_prefers_existing_hermes_home() {
|
||||
let script = Path::new("/tmp/install.sh");
|
||||
let cwd = stable_script_cwd(script, Some("/"));
|
||||
assert_eq!(cwd, Some(Path::new("/")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,11 @@
|
||||
//! the no-window creation flag — both already cfg-gated. Keep new logic
|
||||
//! OS-agnostic so the mac/linux port stays "fill in the paths".
|
||||
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
@@ -28,7 +31,7 @@ use tauri::{AppHandle, Emitter};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::events::{BootstrapEvent, StageInfo, StageState};
|
||||
use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};
|
||||
|
||||
/// `hermes update` exit code meaning "another hermes process is holding the
|
||||
/// venv shim open / dirty precondition" — see _cmd_update_impl in
|
||||
@@ -40,10 +43,48 @@ const UPDATE_EXIT_CONCURRENT: i32 = 2;
|
||||
const DESKTOP_EXIT_WAIT: Duration = Duration::from_secs(20);
|
||||
const DESKTOP_EXIT_POLL: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Guards against concurrent update runs. The frontend kicks `startUpdate()`
|
||||
/// from a mount effect, which can fire more than once (React strict-mode
|
||||
/// double-invokes effects in dev; a window reload or stray re-init can do it
|
||||
/// in prod). Two `run_update` tasks racing on `git stash` corrupt the working
|
||||
/// tree — one stashes the changes the other then can't find. Exactly one task
|
||||
/// may hold this flag at a time.
|
||||
static UPDATE_RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Frontend → Rust: kick off the update flow. Mirrors `start_bootstrap`'s
|
||||
/// fire-and-forget shape; progress arrives on the `bootstrap` event channel.
|
||||
#[tauri::command]
|
||||
pub async fn start_update(app: AppHandle) -> Result<(), String> {
|
||||
// Re-entrancy guard (see UPDATE_RUNNING). compare_exchange lets exactly one
|
||||
// caller flip false→true; any concurrent caller no-ops instead of spawning
|
||||
// a second racing update.
|
||||
if UPDATE_RUNNING
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
// Already running: re-emit the manifest so a duplicate startUpdate()
|
||||
// call (which resets the frontend store) can recover its stage list.
|
||||
let target_app = if cfg!(target_os = "macos") {
|
||||
target_app_from_args(std::env::args().skip(1))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut stages = vec![
|
||||
stage_info("update", "Updating Hermes"),
|
||||
stage_info("rebuild", "Rebuilding the desktop app"),
|
||||
];
|
||||
if cfg!(target_os = "macos") && target_app.is_some() {
|
||||
stages.push(stage_info("install", "Installing the updated app"));
|
||||
}
|
||||
emit(
|
||||
&app,
|
||||
BootstrapEvent::Manifest {
|
||||
stages,
|
||||
protocol_version: None,
|
||||
},
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = run_update(app.clone()).await {
|
||||
// run_update already emits a Failed event on the paths that matter;
|
||||
@@ -56,6 +97,7 @@ pub async fn start_update(app: AppHandle) -> Result<(), String> {
|
||||
},
|
||||
);
|
||||
}
|
||||
UPDATE_RUNNING.store(false, Ordering::SeqCst);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -63,6 +105,14 @@ pub async fn start_update(app: AppHandle) -> Result<(), String> {
|
||||
async fn run_update(app: AppHandle) -> Result<()> {
|
||||
let hermes_home = crate::paths::hermes_home();
|
||||
let install_root = hermes_home.join("hermes-agent");
|
||||
let update_branch = update_branch_from_args(std::env::args().skip(1))
|
||||
.or_else(|| option_env_string("BUILD_PIN_BRANCH"))
|
||||
.unwrap_or_else(|| "main".to_string());
|
||||
let target_app = if cfg!(target_os = "macos") {
|
||||
target_app_from_args(std::env::args().skip(1))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let hermes = resolve_hermes(&install_root).ok_or_else(|| {
|
||||
let msg = format!(
|
||||
@@ -81,13 +131,18 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
})?;
|
||||
|
||||
// Synthetic manifest so the existing progress UI renders our two stages.
|
||||
let mut stages = vec![
|
||||
stage_info("update", "Updating Hermes"),
|
||||
stage_info("rebuild", "Rebuilding the desktop app"),
|
||||
];
|
||||
if cfg!(target_os = "macos") && target_app.is_some() {
|
||||
stages.push(stage_info("install", "Installing the updated app"));
|
||||
}
|
||||
|
||||
emit(
|
||||
&app,
|
||||
BootstrapEvent::Manifest {
|
||||
stages: vec![
|
||||
stage_info("update", "Updating Hermes"),
|
||||
stage_info("rebuild", "Rebuilding the desktop app"),
|
||||
],
|
||||
stages,
|
||||
protocol_version: None,
|
||||
},
|
||||
);
|
||||
@@ -107,12 +162,17 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
// reports "already up to date" against the wrong branch. The desktop
|
||||
// detected the update against this same branch, so we must update against
|
||||
// it too.
|
||||
let pin_branch = option_env_string("BUILD_PIN_BRANCH");
|
||||
let mut update_args: Vec<&str> = vec!["update", "--yes", "--gateway"];
|
||||
if let Some(b) = pin_branch.as_deref() {
|
||||
update_args.push("--branch");
|
||||
update_args.push(b);
|
||||
}
|
||||
emit_log(
|
||||
&app,
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
&format!("[update] updating against branch {update_branch}"),
|
||||
);
|
||||
let child_env = update_child_env(&install_root);
|
||||
let mut update_args: Vec<String> =
|
||||
vec!["update".into(), "--yes".into(), "--gateway".into()];
|
||||
update_args.push("--branch".into());
|
||||
update_args.push(update_branch);
|
||||
|
||||
emit_stage(&app, "update", StageState::Running, None, None);
|
||||
let started = Instant::now();
|
||||
@@ -121,6 +181,7 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
&hermes,
|
||||
&update_args,
|
||||
&install_root,
|
||||
&child_env,
|
||||
Some("update"),
|
||||
)
|
||||
.await?;
|
||||
@@ -182,11 +243,13 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
// repo-root deps with --workspaces=false). This is the rebuild it skips.
|
||||
emit_stage(&app, "rebuild", StageState::Running, None, None);
|
||||
let started = Instant::now();
|
||||
let rebuild_args: Vec<String> = vec!["desktop".into(), "--build-only".into()];
|
||||
let rebuild = run_streamed(
|
||||
&app,
|
||||
&hermes,
|
||||
&["desktop", "--build-only"],
|
||||
&rebuild_args,
|
||||
&install_root,
|
||||
&child_env,
|
||||
Some("rebuild"),
|
||||
)
|
||||
.await?;
|
||||
@@ -217,6 +280,43 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
}
|
||||
emit_stage(&app, "rebuild", StageState::Succeeded, Some(rebuild_ms), None);
|
||||
|
||||
let launch_target = if let Some(target_app) = target_app {
|
||||
let started = Instant::now();
|
||||
emit_stage(&app, "install", StageState::Running, None, None);
|
||||
match install_macos_app_update(&app, &install_root, &target_app).await {
|
||||
Ok(installed_app) => {
|
||||
emit_stage(
|
||||
&app,
|
||||
"install",
|
||||
StageState::Succeeded,
|
||||
Some(started.elapsed().as_millis() as u64),
|
||||
None,
|
||||
);
|
||||
Some(installed_app)
|
||||
}
|
||||
Err(err) => {
|
||||
let msg = format!("{err:#}");
|
||||
emit_stage(
|
||||
&app,
|
||||
"install",
|
||||
StageState::Failed,
|
||||
Some(started.elapsed().as_millis() as u64),
|
||||
Some(msg.clone()),
|
||||
);
|
||||
emit(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some("install".into()),
|
||||
error: msg.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(msg));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// ---- done: signal complete, then launch the fresh desktop ------------
|
||||
emit(
|
||||
&app,
|
||||
@@ -226,10 +326,17 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
},
|
||||
);
|
||||
|
||||
// Reuse the same detached-launch + app.exit(0) used post-install.
|
||||
if let Err(err) =
|
||||
crate::bootstrap::launch_hermes_desktop(app.clone(), install_root.to_string_lossy().into_owned())
|
||||
.await
|
||||
if let Some(target_app) = launch_target {
|
||||
if let Err(err) = launch_macos_app_and_exit(&app, &target_app).await {
|
||||
emit_log(
|
||||
&app,
|
||||
None,
|
||||
LogStream::Stderr,
|
||||
&format!("[update] could not auto-launch desktop: {err}. Launch Hermes manually."),
|
||||
);
|
||||
}
|
||||
} else if let Err(err) =
|
||||
crate::bootstrap::launch_hermes_desktop(app.clone(), install_root.to_string_lossy().into_owned()).await
|
||||
{
|
||||
// Launch failed: don't hard-fail the update (it succeeded); surface a
|
||||
// log line so the success screen can still tell the user to launch
|
||||
@@ -237,6 +344,7 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
emit_log(
|
||||
&app,
|
||||
None,
|
||||
LogStream::Stdout,
|
||||
&format!("[update] could not auto-launch desktop: {err}. Launch Hermes manually."),
|
||||
);
|
||||
}
|
||||
@@ -251,7 +359,7 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
|
||||
let shim = venv_hermes(install_root);
|
||||
let deadline = Instant::now() + DESKTOP_EXIT_WAIT;
|
||||
|
||||
emit_log(app, Some("update"), "[update] waiting for Hermes to exit…");
|
||||
emit_log(app, Some("update"), LogStream::Stdout, "[update] waiting for Hermes to exit…");
|
||||
|
||||
loop {
|
||||
if !is_locked(&shim) {
|
||||
@@ -261,6 +369,7 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
|
||||
emit_log(
|
||||
app,
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
"[update] timed out waiting for Hermes to exit; proceeding anyway",
|
||||
);
|
||||
return;
|
||||
@@ -289,8 +398,9 @@ fn is_locked(path: &Path) -> bool {
|
||||
async fn run_streamed(
|
||||
app: &AppHandle,
|
||||
program: &Path,
|
||||
args: &[&str],
|
||||
args: &[String],
|
||||
cwd: &Path,
|
||||
envs: &[(String, OsString)],
|
||||
stage: Option<&str>,
|
||||
) -> Result<CmdResult> {
|
||||
let mut cmd = Command::new(program);
|
||||
@@ -299,6 +409,9 @@ async fn run_streamed(
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
for (key, value) in envs {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
@@ -320,22 +433,22 @@ async fn run_streamed(
|
||||
loop {
|
||||
tokio::select! {
|
||||
line = out.next_line() => match line {
|
||||
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), &l),
|
||||
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), LogStream::Stdout, &l),
|
||||
Ok(None) => break,
|
||||
Err(e) => { tracing::warn!("stdout read error: {e}"); break; }
|
||||
},
|
||||
line = err.next_line() => match line {
|
||||
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), &format!("stderr: {l}")),
|
||||
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), LogStream::Stderr, &l),
|
||||
Ok(None) => {}
|
||||
Err(e) => { tracing::warn!("stderr read error: {e}"); }
|
||||
},
|
||||
}
|
||||
}
|
||||
while let Ok(Some(l)) = out.next_line().await {
|
||||
emit_log(app, stage_owned.as_deref(), &l);
|
||||
emit_log(app, stage_owned.as_deref(), LogStream::Stdout, &l);
|
||||
}
|
||||
while let Ok(Some(l)) = err.next_line().await {
|
||||
emit_log(app, stage_owned.as_deref(), &format!("stderr: {l}"));
|
||||
emit_log(app, stage_owned.as_deref(), LogStream::Stderr, &l);
|
||||
}
|
||||
|
||||
let status = child.wait().await.map_err(|e| anyhow!("waiting for child: {e}"))?;
|
||||
@@ -378,6 +491,225 @@ fn resolve_hermes(install_root: &Path) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
fn update_child_env(install_root: &Path) -> Vec<(String, OsString)> {
|
||||
let hermes_home = crate::paths::hermes_home();
|
||||
let mut envs = vec![(
|
||||
"HERMES_HOME".to_string(),
|
||||
hermes_home.as_os_str().to_os_string(),
|
||||
)];
|
||||
if let Some(path) = path_with_prepended_entries(&[
|
||||
hermes_home.join("node").join("bin"),
|
||||
venv_bin_dir(install_root),
|
||||
]) {
|
||||
envs.push(("PATH".to_string(), path));
|
||||
}
|
||||
envs
|
||||
}
|
||||
|
||||
fn venv_bin_dir(install_root: &Path) -> PathBuf {
|
||||
if cfg!(target_os = "windows") {
|
||||
install_root.join("venv").join("Scripts")
|
||||
} else {
|
||||
install_root.join("venv").join("bin")
|
||||
}
|
||||
}
|
||||
|
||||
fn path_with_prepended_entries(entries: &[PathBuf]) -> Option<OsString> {
|
||||
let mut parts: Vec<PathBuf> = entries.to_vec();
|
||||
if let Some(existing) = env::var_os("PATH") {
|
||||
parts.extend(env::split_paths(&existing));
|
||||
}
|
||||
env::join_paths(parts).ok()
|
||||
}
|
||||
|
||||
fn update_branch_from_args<I, S>(args: I) -> Option<String>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
arg_value_from_args(args, "--branch")
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn target_app_from_args<I, S>(args: I) -> Option<PathBuf>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
arg_value_from_args(args, "--target-app")
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("app"))
|
||||
}
|
||||
|
||||
fn arg_value_from_args<I, S>(args: I, name: &str) -> Option<String>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let mut iter = args.into_iter().map(|s| s.as_ref().to_string()).peekable();
|
||||
while let Some(arg) = iter.next() {
|
||||
if arg == name {
|
||||
return iter.next();
|
||||
}
|
||||
if let Some(value) = arg.strip_prefix(&format!("{name}=")) {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn install_macos_app_update(
|
||||
app: &AppHandle,
|
||||
install_root: &Path,
|
||||
target_app: &Path,
|
||||
) -> Result<PathBuf> {
|
||||
if target_app.extension().and_then(|e| e.to_str()) != Some("app") {
|
||||
return Err(anyhow!(
|
||||
"refusing to install update into non-app path: {}",
|
||||
target_app.display()
|
||||
));
|
||||
}
|
||||
|
||||
let rebuilt_app = crate::bootstrap::resolve_hermes_desktop_app(install_root).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"desktop rebuild succeeded but no Hermes.app was found under {}",
|
||||
install_root.join("apps").join("desktop").join("release").display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let same = match (rebuilt_app.canonicalize(), target_app.canonicalize()) {
|
||||
(Ok(a), Ok(b)) => a == b,
|
||||
_ => rebuilt_app == target_app,
|
||||
};
|
||||
if same {
|
||||
emit_log(
|
||||
app,
|
||||
Some("install"),
|
||||
LogStream::Stdout,
|
||||
&format!(
|
||||
"[update] rebuilt app is already the launch target: {}",
|
||||
target_app.display()
|
||||
),
|
||||
);
|
||||
return Ok(target_app.to_path_buf());
|
||||
}
|
||||
|
||||
emit_log(
|
||||
app,
|
||||
Some("install"),
|
||||
LogStream::Stdout,
|
||||
&format!(
|
||||
"[update] installing rebuilt app {} -> {}",
|
||||
rebuilt_app.display(),
|
||||
target_app.display()
|
||||
),
|
||||
);
|
||||
|
||||
if let Some(parent) = target_app.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let tmp = PathBuf::from(format!("{}.hermes-update-new", target_app.display()));
|
||||
let old = PathBuf::from(format!("{}.hermes-update-old", target_app.display()));
|
||||
remove_dir_if_exists(&tmp).await;
|
||||
remove_dir_if_exists(&old).await;
|
||||
|
||||
let ditto = Command::new("/usr/bin/ditto")
|
||||
.arg(&rebuilt_app)
|
||||
.arg(&tmp)
|
||||
.current_dir(crate::paths::hermes_home())
|
||||
.status()
|
||||
.await
|
||||
.map_err(|e| anyhow!("running ditto: {e}"))?;
|
||||
if !ditto.success() {
|
||||
return Err(anyhow!(
|
||||
"ditto failed while copying updated app into {}",
|
||||
tmp.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Atomic-as-possible swap with rollback. Extracted so the invariant
|
||||
// (target is never left deleted-with-no-replacement) can be unit-tested
|
||||
// without ditto / a real .app bundle.
|
||||
swap_in_new_bundle(&tmp, target_app, &old).await?;
|
||||
|
||||
let _ = Command::new("/usr/bin/xattr")
|
||||
.arg("-dr")
|
||||
.arg("com.apple.quarantine")
|
||||
.arg(target_app)
|
||||
.current_dir(crate::paths::hermes_home())
|
||||
.status()
|
||||
.await;
|
||||
|
||||
Ok(target_app.to_path_buf())
|
||||
}
|
||||
|
||||
/// Move a freshly-staged bundle (`tmp`) into place at `target`, parking any
|
||||
/// existing bundle at `old` so the move can succeed (macOS `rename` won't
|
||||
/// overwrite a non-empty directory).
|
||||
///
|
||||
/// Invariant: on ANY failure path, `target` is left pointing at a working
|
||||
/// bundle — either the original (rolled back from `old`) or untouched — and we
|
||||
/// never delete the running app with no replacement in place. The staged `tmp`
|
||||
/// copy is cleaned up on failure.
|
||||
async fn swap_in_new_bundle(tmp: &Path, target: &Path, old: &Path) -> Result<()> {
|
||||
let moved_old = if target.exists() {
|
||||
if let Err(err) = tokio::fs::rename(target, old).await {
|
||||
// Could not move the existing app aside. Leave it untouched and
|
||||
// bail — a failed update must not brick the install.
|
||||
remove_dir_if_exists(tmp).await;
|
||||
return Err(anyhow!(
|
||||
"could not move existing app aside at {} (leaving it in place): {err}",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if let Err(err) = tokio::fs::rename(tmp, target).await {
|
||||
// Restore the original app from the backup so the user keeps a working
|
||||
// install, and clean up the staged copy.
|
||||
if moved_old {
|
||||
let _ = tokio::fs::rename(old, target).await;
|
||||
}
|
||||
remove_dir_if_exists(tmp).await;
|
||||
return Err(anyhow!("installing updated app at {}: {err}", target.display()));
|
||||
}
|
||||
remove_dir_if_exists(old).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
async fn install_macos_app_update(
|
||||
_app: &AppHandle,
|
||||
_install_root: &Path,
|
||||
target_app: &Path,
|
||||
) -> Result<PathBuf> {
|
||||
Ok(target_app.to_path_buf())
|
||||
}
|
||||
|
||||
async fn remove_dir_if_exists(path: &Path) {
|
||||
if path.exists() {
|
||||
let _ = tokio::fs::remove_dir_all(path).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn launch_macos_app_and_exit(app: &AppHandle, target_app: &Path) -> Result<()> {
|
||||
crate::bootstrap::open_macos_app_detached(target_app)
|
||||
.map_err(|e| anyhow!("launching {}: {e}", target_app.display()))?;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||
app.exit(0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
async fn launch_macos_app_and_exit(_app: &AppHandle, _target_app: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event helpers — keep emit shape identical to bootstrap.rs so the UI is reused
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -429,7 +761,7 @@ fn emit_stage(
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_log(app: &AppHandle, stage: Option<&str>, line: &str) {
|
||||
fn emit_log(app: &AppHandle, stage: Option<&str>, stream: LogStream, line: &str) {
|
||||
match stage {
|
||||
Some(s) => tracing::info!(target: "bootstrap.log", stage = %s, "{line}"),
|
||||
None => tracing::info!(target: "bootstrap.log", "{line}"),
|
||||
@@ -439,6 +771,7 @@ fn emit_log(app: &AppHandle, stage: Option<&str>, line: &str) {
|
||||
BootstrapEvent::Log {
|
||||
stage: stage.map(|s| s.to_string()),
|
||||
line: line.to_string(),
|
||||
stream,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -459,4 +792,118 @@ mod tests {
|
||||
fn missing_file_is_not_locked() {
|
||||
assert!(!is_locked(Path::new("/nonexistent/does/not/exist/xyz")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_update_branch_from_space_or_equals_args() {
|
||||
assert_eq!(
|
||||
update_branch_from_args(["--update", "--branch", "bb/test"]),
|
||||
Some("bb/test".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
update_branch_from_args(["--update", "--branch=main"]),
|
||||
Some("main".to_string())
|
||||
);
|
||||
assert_eq!(update_branch_from_args(["--update"]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_only_app_targets() {
|
||||
assert_eq!(
|
||||
target_app_from_args(["--update", "--target-app", "/Applications/Hermes.app"]),
|
||||
Some(PathBuf::from("/Applications/Hermes.app"))
|
||||
);
|
||||
assert_eq!(target_app_from_args(["--target-app", "/tmp/not-an-app"]), None);
|
||||
}
|
||||
|
||||
// Helpers for the swap tests: make a throwaway dir tree we can rename.
|
||||
fn unique_tmp_dir(tag: &str) -> PathBuf {
|
||||
let base = std::env::temp_dir().join(format!(
|
||||
"hermes-swap-test-{tag}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
base
|
||||
}
|
||||
|
||||
fn write_marker(dir: &Path, contents: &str) {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
std::fs::write(dir.join("marker.txt"), contents).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn swap_installs_new_bundle_and_cleans_up() {
|
||||
let base = unique_tmp_dir("ok");
|
||||
let target = base.join("Hermes.app");
|
||||
let tmp = base.join("Hermes.app.hermes-update-new");
|
||||
let old = base.join("Hermes.app.hermes-update-old");
|
||||
write_marker(&target, "OLD");
|
||||
write_marker(&tmp, "NEW");
|
||||
|
||||
swap_in_new_bundle(&tmp, &target, &old).await.unwrap();
|
||||
|
||||
// New bundle is now at target; staging + backup dirs are gone.
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(target.join("marker.txt")).unwrap(),
|
||||
"NEW"
|
||||
);
|
||||
assert!(!tmp.exists(), "staged copy should be cleaned up");
|
||||
assert!(!old.exists(), "backup should be cleaned up on success");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn swap_failure_never_leaves_target_missing() {
|
||||
// Regression guard for the catastrophic path: the move-aside of the
|
||||
// existing app fails AND the staged bundle can't be installed. The
|
||||
// buggy version deleted `target` when move-aside failed and then
|
||||
// skipped rollback, bricking the install. The fixed version must leave
|
||||
// the original app intact on disk.
|
||||
//
|
||||
// Trigger both failures deterministically:
|
||||
// - `old` is a NON-EMPTY dir -> rename(target, old) fails
|
||||
// - `tmp` does not exist -> rename(tmp, target) fails
|
||||
let base = unique_tmp_dir("fail");
|
||||
let target = base.join("Hermes.app");
|
||||
let tmp = base.join("Hermes.app.hermes-update-new"); // intentionally absent
|
||||
let old = base.join("Hermes.app.hermes-update-old");
|
||||
write_marker(&target, "OLD");
|
||||
write_marker(&old, "OCCUPIED"); // non-empty => rename(target,old) fails
|
||||
|
||||
let result = swap_in_new_bundle(&tmp, &target, &old).await;
|
||||
|
||||
assert!(result.is_err(), "swap should fail when neither move can complete");
|
||||
assert!(target.exists(), "original app must NOT be deleted on failure");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(target.join("marker.txt")).unwrap(),
|
||||
"OLD",
|
||||
"original app contents must be intact after a failed swap"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn swap_rolls_back_when_install_step_fails() {
|
||||
// Move-aside succeeds but installing the staged bundle fails (tmp
|
||||
// absent). The original must be rolled back from `old` to `target`.
|
||||
let base = unique_tmp_dir("rollback");
|
||||
let target = base.join("Hermes.app");
|
||||
let tmp = base.join("Hermes.app.hermes-update-new"); // absent
|
||||
let old = base.join("Hermes.app.hermes-update-old");
|
||||
write_marker(&target, "OLD");
|
||||
|
||||
let result = swap_in_new_bundle(&tmp, &target, &old).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(target.exists(), "original must be restored after failed install");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(target.join("marker.txt")).unwrap(),
|
||||
"OLD"
|
||||
);
|
||||
assert!(!old.exists(), "backup should be rolled back, not left behind");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ import { useStore } from '@nanostores/react'
|
||||
import { Button } from '../components/button'
|
||||
import {
|
||||
$logPath,
|
||||
$mode,
|
||||
openLogDir,
|
||||
startInstall,
|
||||
startUpdate,
|
||||
type BootstrapStateModel
|
||||
} from '../store'
|
||||
import { RefreshCw, FileText } from 'lucide-react'
|
||||
@@ -22,6 +24,8 @@ interface FailureProps {
|
||||
*/
|
||||
export default function Failure({ bootstrap }: FailureProps) {
|
||||
const logPath = useStore($logPath)
|
||||
const mode = useStore($mode)
|
||||
const isUpdate = mode === 'update'
|
||||
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col items-center justify-center gap-6 px-12 py-10">
|
||||
@@ -37,24 +41,27 @@ export default function Failure({ bootstrap }: FailureProps) {
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<span>Install didn’t finish</span>
|
||||
<span>{isUpdate ? 'Update didn\u2019t finish' : 'Install didn\u2019t finish'}</span>
|
||||
</span>
|
||||
<span aria-hidden="true">Install didn’t finish</span>
|
||||
<span aria-hidden="true">{isUpdate ? 'Update didn\u2019t finish' : 'Install didn\u2019t finish'}</span>
|
||||
</p>
|
||||
|
||||
<p className="m-0 mx-auto max-w-xl text-center text-sm leading-normal tracking-tight text-muted-foreground">
|
||||
{bootstrap.error ?? 'Something went wrong during installation.'}
|
||||
{bootstrap.error ??
|
||||
(isUpdate
|
||||
? 'Something went wrong during the update.'
|
||||
: 'Something went wrong during installation.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={() => void startInstall()}
|
||||
onClick={() => void (isUpdate ? startUpdate() : startInstall())}
|
||||
size="lg"
|
||||
className="inline-flex items-center gap-2 px-6"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
Retry install
|
||||
{isUpdate ? 'Retry update' : 'Retry install'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -115,9 +115,7 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
||||
key={idx}
|
||||
className={clsx(
|
||||
'whitespace-pre-wrap',
|
||||
entry.line.startsWith('stderr:')
|
||||
? 'text-destructive'
|
||||
: 'text-foreground/70'
|
||||
entry.stream === 'stderr' ? 'text-foreground/45' : 'text-foreground/70'
|
||||
)}
|
||||
>
|
||||
{entry.line}
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface BootstrapStateModel {
|
||||
currentStage: string | null
|
||||
installRoot: string | null
|
||||
error: string | null
|
||||
logs: Array<{ stage?: string; line: string }>
|
||||
logs: Array<{ stage?: string; line: string; stream?: 'stdout' | 'stderr' }>
|
||||
}
|
||||
|
||||
const INITIAL: BootstrapStateModel = {
|
||||
@@ -106,6 +106,7 @@ interface BootstrapLogEvent {
|
||||
type: 'log'
|
||||
stage?: string
|
||||
line: string
|
||||
stream?: 'stdout' | 'stderr'
|
||||
}
|
||||
|
||||
interface BootstrapCompleteEvent {
|
||||
@@ -192,7 +193,7 @@ export async function initialize(): Promise<void> {
|
||||
break
|
||||
}
|
||||
case 'log': {
|
||||
const logs = [...cur.logs, { stage: payload.stage, line: payload.line }]
|
||||
const logs = [...cur.logs, { stage: payload.stage, line: payload.line, stream: payload.stream }]
|
||||
// Keep the rolling buffer bounded so the UI doesn't get OOM'd
|
||||
// during a long install (playwright chromium download is ~10k lines).
|
||||
const trimmed = logs.length > 2000 ? logs.slice(-2000) : logs
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* { type: 'manifest', stages: [{name, title, category, needs_user_input}, ...] }
|
||||
* { type: 'stage', name, state: 'running'|'succeeded'|'skipped'|'failed',
|
||||
* json?, durationMs?, error? }
|
||||
* { type: 'log', stage?, line } // raw line from install.ps1
|
||||
* { type: 'log', stage?, line, stream: 'stdout'|'stderr' } // raw line from install.ps1
|
||||
* { type: 'complete', marker: <written marker payload> }
|
||||
* { type: 'failed', stage?, error } // bootstrap aborted
|
||||
*
|
||||
@@ -229,7 +229,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
|
||||
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
|
||||
stdoutBuf = stdoutBuf.slice(nl + 1)
|
||||
if (line) emit && emit({ type: 'log', stage: stageName, line })
|
||||
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -241,7 +241,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
|
||||
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
|
||||
stderrBuf = stderrBuf.slice(nl + 1)
|
||||
if (line) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${line}` })
|
||||
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -253,8 +253,8 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
child.on('close', (code, signal) => {
|
||||
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
|
||||
// Flush any trailing bytes
|
||||
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf })
|
||||
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${stderrBuf}` })
|
||||
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
|
||||
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
|
||||
resolve({ stdout, stderr, code, signal, killed })
|
||||
})
|
||||
})
|
||||
@@ -299,7 +299,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
|
||||
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
|
||||
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
|
||||
stdoutBuf = stdoutBuf.slice(nl + 1)
|
||||
if (line) emit && emit({ type: 'log', stage: stageName, line })
|
||||
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -311,7 +311,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
|
||||
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
|
||||
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
|
||||
stderrBuf = stderrBuf.slice(nl + 1)
|
||||
if (line) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${line}` })
|
||||
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -322,8 +322,8 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
|
||||
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf })
|
||||
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${stderrBuf}` })
|
||||
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
|
||||
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
|
||||
resolve({ stdout, stderr, code, signal, killed })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
nativeImage,
|
||||
nativeTheme,
|
||||
net: electronNet,
|
||||
powerMonitor,
|
||||
protocol,
|
||||
safeStorage,
|
||||
session,
|
||||
@@ -470,12 +471,14 @@ let bootstrapAbortController = null
|
||||
// of re-adopting the install we're repairing. Cleared once a bootstrap runs.
|
||||
let forceBootstrapRepair = false
|
||||
let connectionConfigCache = null
|
||||
let connectionConfigCacheMtime = null
|
||||
const hermesLog = []
|
||||
const previewWatchers = new Map()
|
||||
let previewShortcutActive = false
|
||||
let desktopLogBuffer = ''
|
||||
let desktopLogFlushTimer = null
|
||||
let desktopLogFlushPromise = Promise.resolve()
|
||||
let nativeThemeListenerInstalled = false
|
||||
let bootProgressState = {
|
||||
error: null,
|
||||
fakeMode: BOOT_FAKE_MODE,
|
||||
@@ -722,7 +725,7 @@ function broadcastBootstrapEvent(ev) {
|
||||
error: ev.error ?? null
|
||||
}
|
||||
} else if (ev.type === 'log') {
|
||||
bootstrapState.log.push({ ts: Date.now(), stage: ev.stage || null, line: ev.line })
|
||||
bootstrapState.log.push({ ts: Date.now(), stage: ev.stage || null, line: ev.line, stream: ev.stream || 'stdout' })
|
||||
if (bootstrapState.log.length > BOOTSTRAP_LOG_RING_MAX) {
|
||||
bootstrapState.log.splice(0, bootstrapState.log.length - BOOTSTRAP_LOG_RING_MAX)
|
||||
}
|
||||
@@ -1100,9 +1103,17 @@ function readDesktopUpdateConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic file write: temp + rename (atomic on all platforms). Prevents
|
||||
// partial writes on crash/power loss that corrupt JSON config files.
|
||||
function writeFileAtomic(targetPath, data, encoding) {
|
||||
const tmp = targetPath + '.tmp'
|
||||
fs.writeFileSync(tmp, data, encoding)
|
||||
fs.renameSync(tmp, targetPath)
|
||||
}
|
||||
|
||||
function writeDesktopUpdateConfig(config) {
|
||||
fs.mkdirSync(path.dirname(DESKTOP_UPDATE_CONFIG_PATH), { recursive: true })
|
||||
fs.writeFileSync(DESKTOP_UPDATE_CONFIG_PATH, JSON.stringify(config, null, 2))
|
||||
writeFileAtomic(DESKTOP_UPDATE_CONFIG_PATH, JSON.stringify(config, null, 2))
|
||||
}
|
||||
|
||||
// Match the backend's source resolution but bias toward a real git checkout.
|
||||
@@ -1319,16 +1330,34 @@ async function applyUpdates(opts = {}) {
|
||||
|
||||
emitUpdateProgress({ stage: 'restart', message: 'Handing off to the Hermes updater…', percent: 100 })
|
||||
|
||||
const updateRoot = resolveUpdateRoot()
|
||||
const { branch: configuredBranch } = readDesktopUpdateConfig()
|
||||
const branch = await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH)
|
||||
const updaterArgs = ['--update', '--branch', branch]
|
||||
const targetApp = IS_MAC ? runningAppBundle() : null
|
||||
if (targetApp) {
|
||||
updaterArgs.push('--target-app', targetApp)
|
||||
}
|
||||
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
|
||||
|
||||
// Detached so the updater outlives this process — it needs us GONE before
|
||||
// `hermes update` will run (the venv shim is locked while we live).
|
||||
const child = spawn(updater, ['--update'], {
|
||||
const child = spawn(updater, updaterArgs, {
|
||||
cwd: HERMES_HOME,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME,
|
||||
PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH]
|
||||
.filter(Boolean)
|
||||
.join(path.delimiter)
|
||||
},
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: false
|
||||
})
|
||||
child.unref()
|
||||
|
||||
rememberLog(`[updates] launched updater: ${updater} --update; exiting desktop to release venv shim`)
|
||||
rememberLog(`[updates] launched updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release venv shim`)
|
||||
|
||||
// Give the OS a beat to register the new process, then quit. The updater
|
||||
// rebuilds and relaunches us when it's done.
|
||||
@@ -1412,6 +1441,18 @@ async function applyUpdatesPosixInApp(opts = {}) {
|
||||
PATH: [extraPath, process.env.PATH].filter(Boolean).join(path.delimiter)
|
||||
}
|
||||
|
||||
// `hermes update` reaps stale `hermes dashboard` backends (a code update
|
||||
// leaves the running process serving old Python against the freshly-updated
|
||||
// JS bundle). But OUR backend is one of those processes, and killing it
|
||||
// mid-update produces the boot→kill→crash loop in #37532 — the desktop
|
||||
// already restarts its own backend via the rebuild+relaunch below, so the
|
||||
// reap must spare it. Hand the live backend's PID to the update process;
|
||||
// _kill_stale_dashboard_processes reads HERMES_DESKTOP_CHILD_PID and excludes
|
||||
// it while still reaping any genuinely-orphaned dashboards. (#37532)
|
||||
if (hermesProcess && Number.isInteger(hermesProcess.pid)) {
|
||||
env.HERMES_DESKTOP_CHILD_PID = String(hermesProcess.pid)
|
||||
}
|
||||
|
||||
// Branch-pin so a non-main checkout doesn't get switched to main (and self-heal
|
||||
// to main when the pinned branch no longer exists on origin).
|
||||
let branchArgs = []
|
||||
@@ -1597,7 +1638,7 @@ function writeBootstrapMarker(payload) {
|
||||
completedAt: new Date().toISOString(),
|
||||
desktopVersion: app.getVersion()
|
||||
}
|
||||
fs.writeFileSync(BOOTSTRAP_COMPLETE_MARKER, JSON.stringify(merged, null, 2) + '\n', 'utf8')
|
||||
writeFileAtomic(BOOTSTRAP_COMPLETE_MARKER, JSON.stringify(merged, null, 2) + '\n', 'utf8')
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -2063,6 +2104,7 @@ function fetchJson(url, token, options = {}) {
|
||||
},
|
||||
res => {
|
||||
const chunks = []
|
||||
res.on('error', reject)
|
||||
res.on('data', chunk => chunks.push(chunk))
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8')
|
||||
@@ -2402,6 +2444,7 @@ async function resourceBufferFromUrl(rawUrl) {
|
||||
return
|
||||
}
|
||||
const chunks = []
|
||||
res.on('error', reject)
|
||||
res.on('data', chunk => chunks.push(chunk))
|
||||
res.on('end', () => {
|
||||
resolve({
|
||||
@@ -2663,6 +2706,32 @@ function sendClosePreviewRequested() {
|
||||
webContents.send('hermes:close-preview-requested')
|
||||
}
|
||||
|
||||
// Tell the renderer the machine just woke. Sleep silently drops the
|
||||
// renderer's WebSocket to the local backend; the renderer reconnects on this
|
||||
// signal so the chat composer doesn't stay stuck on "Starting Hermes...".
|
||||
function sendPowerResume() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
const { webContents } = mainWindow
|
||||
if (!webContents || webContents.isDestroyed()) return
|
||||
webContents.send('hermes:power-resume')
|
||||
}
|
||||
|
||||
let powerResumeRegistered = false
|
||||
|
||||
function registerPowerResumeListeners() {
|
||||
if (powerResumeRegistered) return
|
||||
powerResumeRegistered = true
|
||||
try {
|
||||
// 'resume' covers sleep/wake; 'unlock-screen' covers lock/unlock without a
|
||||
// full suspend. Either can drop an idle socket.
|
||||
powerMonitor.on('resume', sendPowerResume)
|
||||
powerMonitor.on('unlock-screen', sendPowerResume)
|
||||
} catch {
|
||||
// powerMonitor is unavailable before app 'ready' on some platforms; the
|
||||
// caller registers after 'ready', so this should not normally throw.
|
||||
}
|
||||
}
|
||||
|
||||
function getAppIconPath() {
|
||||
return APP_ICON_PATHS.find(fileExists)
|
||||
}
|
||||
@@ -3078,7 +3147,17 @@ function decryptDesktopSecret(secret) {
|
||||
}
|
||||
|
||||
function readDesktopConnectionConfig() {
|
||||
if (connectionConfigCache) {
|
||||
// Check if file changed on disk since last read (e.g. modified by another
|
||||
// process or an external tool). Our own writes update the cache inline
|
||||
// via writeDesktopConnectionConfig, but external changes would be missed.
|
||||
let mtime = null
|
||||
try {
|
||||
mtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs
|
||||
} catch {
|
||||
mtime = null
|
||||
}
|
||||
|
||||
if (connectionConfigCache && connectionConfigCacheMtime === mtime) {
|
||||
return connectionConfigCache
|
||||
}
|
||||
|
||||
@@ -3099,14 +3178,16 @@ function readDesktopConnectionConfig() {
|
||||
}
|
||||
|
||||
connectionConfigCache = config
|
||||
connectionConfigCacheMtime = mtime
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
function writeDesktopConnectionConfig(config) {
|
||||
fs.mkdirSync(path.dirname(DESKTOP_CONNECTION_CONFIG_PATH), { recursive: true })
|
||||
fs.writeFileSync(DESKTOP_CONNECTION_CONFIG_PATH, JSON.stringify(config, null, 2))
|
||||
writeFileAtomic(DESKTOP_CONNECTION_CONFIG_PATH, JSON.stringify(config, null, 2))
|
||||
connectionConfigCache = config
|
||||
connectionConfigCacheMtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs
|
||||
}
|
||||
|
||||
function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) {
|
||||
@@ -3434,9 +3515,12 @@ function createWindow() {
|
||||
}
|
||||
|
||||
if (!IS_MAC) {
|
||||
nativeTheme.on('updated', () => {
|
||||
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
|
||||
})
|
||||
if (!nativeThemeListenerInstalled) {
|
||||
nativeThemeListenerInstalled = true
|
||||
nativeTheme.on('updated', () => {
|
||||
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true))
|
||||
@@ -4175,6 +4259,7 @@ app.whenReady().then(() => {
|
||||
registerMediaProtocol()
|
||||
ensureWslWindowsFonts()
|
||||
configureSpellChecker()
|
||||
registerPowerResumeListeners()
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
|
||||
@@ -83,6 +83,11 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
ipcRenderer.on('hermes:backend-exit', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:backend-exit', listener)
|
||||
},
|
||||
onPowerResume: callback => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('hermes:power-resume', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:power-resume', listener)
|
||||
},
|
||||
onBootProgress: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:boot-progress', listener)
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"author": "Nous Research",
|
||||
"type": "module",
|
||||
"main": "electron/main.cjs",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
|
||||
"dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev",
|
||||
@@ -97,6 +100,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/node": "^24.12.2",
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
Pagination,
|
||||
@@ -25,7 +24,9 @@ import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import type { SessionInfo, SessionMessage } from '@/types/hermes'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PAGE_INSET_NEG_X, PAGE_INSET_X } from '../layout-constants'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import { sessionRoute } from '../routes'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
@@ -372,14 +373,11 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
|
||||
const [kindFilter, setKindFilter] = useRouteEnumParam('tab', ARTIFACT_FILTERS, 'all')
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [failedImageIds, setFailedImageIds] = useState<Set<string>>(() => new Set())
|
||||
const [imagePage, setImagePage] = useState(1)
|
||||
const [filePage, setFilePage] = useState(1)
|
||||
|
||||
const refreshArtifacts = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const sessions = (await listSessions(30, 1)).sessions
|
||||
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
|
||||
@@ -398,11 +396,11 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
} catch (err) {
|
||||
notifyError(err, 'Artifacts failed to load')
|
||||
setArtifacts([])
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useRefreshHotkey(refreshArtifacts)
|
||||
|
||||
useEffect(() => {
|
||||
void refreshArtifacts()
|
||||
}, [refreshArtifacts])
|
||||
@@ -502,7 +500,11 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
return (
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
filters={
|
||||
onSearchChange={setQuery}
|
||||
searchHidden={counts.all === 0}
|
||||
searchPlaceholder="Search artifacts..."
|
||||
searchValue={query}
|
||||
tabs={
|
||||
<>
|
||||
<TextTab active={kindFilter === 'all'} onClick={() => setKindFilter('all')}>
|
||||
All <TextTabMeta>({counts.all})</TextTabMeta>
|
||||
@@ -518,23 +520,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
</TextTab>
|
||||
</>
|
||||
}
|
||||
onSearchChange={setQuery}
|
||||
searchPlaceholder="Search artifacts..."
|
||||
searchTrailingAction={
|
||||
<Button
|
||||
aria-label={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'}
|
||||
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refreshArtifacts()}
|
||||
size="icon-xs"
|
||||
title={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
|
||||
</Button>
|
||||
}
|
||||
searchValue={query}
|
||||
>
|
||||
{!artifacts ? (
|
||||
<PageLoader label="Indexing recent session artifacts" />
|
||||
@@ -549,10 +534,16 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="flex flex-col gap-3 px-2 pb-2">
|
||||
<div className={cn('flex flex-col gap-3 pb-2', PAGE_INSET_X)}>
|
||||
{visibleImageArtifacts.length > 0 && (
|
||||
<section className="flex flex-col">
|
||||
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-0 z-10 flex h-7 items-center gap-3 overflow-x-auto bg-background',
|
||||
PAGE_INSET_NEG_X,
|
||||
PAGE_INSET_X
|
||||
)}
|
||||
>
|
||||
<ArtifactsPagination
|
||||
className="ml-auto justify-end px-0"
|
||||
itemLabel="images"
|
||||
@@ -578,7 +569,13 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
|
||||
{visibleFileArtifacts.length > 0 && (
|
||||
<section className="flex flex-col">
|
||||
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-0 z-10 flex h-7 items-center gap-3 overflow-x-auto bg-background',
|
||||
PAGE_INSET_NEG_X,
|
||||
PAGE_INSET_X
|
||||
)}
|
||||
>
|
||||
<ArtifactsPagination
|
||||
className="ml-auto justify-end px-0"
|
||||
itemLabel={itemsLabel(kindFilter)}
|
||||
@@ -588,7 +585,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
total={visibleFileArtifacts.length}
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) shadow-sm">
|
||||
<div className="overflow-x-auto rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)">
|
||||
<ArtifactTable artifacts={pagedFileArtifacts} ctx={cellCtx} filter={kindFilter} />
|
||||
</div>
|
||||
</section>
|
||||
@@ -660,11 +657,7 @@ interface ArtifactImageCardProps {
|
||||
|
||||
function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: ArtifactImageCardProps) {
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) shadow-sm'
|
||||
)}
|
||||
>
|
||||
<article className="group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)">
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-40 w-full items-center justify-center overflow-hidden border-b border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-1.5',
|
||||
@@ -674,7 +667,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
|
||||
{!failedImage && (
|
||||
<ZoomableImage
|
||||
alt={artifact.label}
|
||||
className="max-h-40 max-w-full cursor-zoom-in rounded-md object-contain shadow-sm"
|
||||
className="max-h-40 max-w-full cursor-zoom-in rounded-md object-contain"
|
||||
containerClassName="max-h-full"
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
@@ -702,7 +695,7 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="outline">
|
||||
<Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="textStrong">
|
||||
<FolderOpen className="size-3" />
|
||||
Chat
|
||||
</Button>
|
||||
@@ -741,10 +734,7 @@ function ArtifactCellAction({
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) font-normal text-(--ui-text-secondary) no-underline underline-offset-4 decoration-current/20 transition-colors hover:text-foreground hover:underline',
|
||||
'cursor-pointer'
|
||||
)}
|
||||
className="flex h-full w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) font-normal text-(--ui-text-secondary) no-underline underline-offset-4 decoration-current/20 transition-colors hover:text-foreground hover:underline"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
type="button"
|
||||
@@ -863,7 +853,7 @@ function ArtifactTable({
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-(--ui-stroke-quaternary)">
|
||||
<tbody>
|
||||
{artifacts.map(artifact => (
|
||||
<tr className="group/artifact" key={artifact.id}>
|
||||
{ARTIFACT_COLUMNS.map(col => {
|
||||
|
||||
@@ -137,7 +137,7 @@ function PromptSnippetsDialog({
|
||||
{snippets.map(snippet => (
|
||||
<li key={snippet.label}>
|
||||
<button
|
||||
className="group/snippet flex w-full cursor-pointer items-start gap-2.5 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) focus-visible:border-(--ui-stroke-tertiary) focus-visible:bg-(--ui-control-hover-background) focus-visible:outline-none"
|
||||
className="group/snippet flex w-full items-start gap-2.5 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) focus-visible:border-(--ui-stroke-tertiary) focus-visible:bg-(--ui-control-hover-background) focus-visible:outline-none"
|
||||
onClick={() => {
|
||||
onInsertText(snippet.text)
|
||||
onOpenChange(false)
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
shouldAutoDrainOnSettle,
|
||||
updateQueuedPrompt
|
||||
} from '@/store/composer-queue'
|
||||
import { $messages } from '@/store/session'
|
||||
import { $gatewayState, $messages } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
|
||||
import { extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions'
|
||||
@@ -73,9 +73,39 @@ import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
|
||||
|
||||
const COMPOSER_STACK_BREAKPOINT_PX = 320
|
||||
|
||||
// A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem
|
||||
// vertical padding). Anything taller means the text wrapped to a second line,
|
||||
// which is when the composer should expand to the stacked layout.
|
||||
const COMPOSER_SINGLE_LINE_MAX_PX = 36
|
||||
|
||||
const COMPOSER_FADE_BACKGROUND =
|
||||
'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))'
|
||||
|
||||
// Resting composer placeholders. New sessions get open-ended starters; an
|
||||
// existing chat gets phrasings that read as a continuation of the thread.
|
||||
// One is picked at random per session (stable until the session changes).
|
||||
const NEW_SESSION_PLACEHOLDERS = [
|
||||
'What are we building?',
|
||||
'Give Hermes a task',
|
||||
"What's on your mind?",
|
||||
'Describe what you need',
|
||||
'What should we tackle?',
|
||||
'Ask anything',
|
||||
'Start with a goal'
|
||||
]
|
||||
|
||||
const FOLLOW_UP_PLACEHOLDERS = [
|
||||
'Send a follow-up',
|
||||
'Add more context',
|
||||
'Refine the request',
|
||||
"What's next?",
|
||||
'Keep it going',
|
||||
'Push it further',
|
||||
'Adjust or continue'
|
||||
]
|
||||
|
||||
const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)]
|
||||
|
||||
interface QueueEditState {
|
||||
attachments: ComposerAttachment[]
|
||||
draft: string
|
||||
@@ -142,6 +172,7 @@ export function ChatBar({
|
||||
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
|
||||
const [focusRequestId, setFocusRequestId] = useState(0)
|
||||
const dragDepthRef = useRef(0)
|
||||
const composingRef = useRef(false) // true during IME composition (CJK input)
|
||||
const lastSpokenIdRef = useRef<string | null>(null)
|
||||
|
||||
const narrow = useMediaQuery('(max-width: 30rem)')
|
||||
@@ -156,7 +187,44 @@ export function ChatBar({
|
||||
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
|
||||
const showHelpHint = draft === '?'
|
||||
|
||||
const placeholder = disabled ? 'Starting Hermes...' : 'Send follow-up'
|
||||
const gatewayState = useStore($gatewayState)
|
||||
|
||||
// Resting placeholder: a starter for brand-new sessions, a continuation for
|
||||
// existing ones. Picked once and only re-rolled when we genuinely move to a
|
||||
// *different* conversation. Critically, the first id assignment of a freshly
|
||||
// started session (null → id, on the first send) is treated as the same
|
||||
// conversation so the placeholder doesn't visibly flip mid-stream.
|
||||
const [restingPlaceholder, setRestingPlaceholder] = useState(() =>
|
||||
pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS)
|
||||
)
|
||||
|
||||
const prevSessionIdRef = useRef(sessionId)
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevSessionIdRef.current
|
||||
prevSessionIdRef.current = sessionId
|
||||
|
||||
if (prev === sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
// null → id: the new session we're already in just got persisted. Keep the
|
||||
// starter we showed instead of swapping to a follow-up under the user.
|
||||
if (prev == null && sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
setRestingPlaceholder(pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS))
|
||||
}, [sessionId])
|
||||
|
||||
// When the bar is disabled it's because the gateway isn't open. Distinguish a
|
||||
// cold start ("Starting Hermes...") from a dropped connection we're trying to
|
||||
// restore (e.g. after the Mac slept) so the stuck state reads as recoverable.
|
||||
const placeholder = disabled
|
||||
? gatewayState === 'closed' || gatewayState === 'error'
|
||||
? 'Reconnecting to Hermes…'
|
||||
: 'Starting Hermes...'
|
||||
: restingPlaceholder
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
focusComposerInput(editorRef.current)
|
||||
@@ -247,14 +315,13 @@ export function ChatBar({
|
||||
}
|
||||
}, [urlOpen])
|
||||
|
||||
// Track expansion via cheap heuristics (newline or length threshold) instead
|
||||
// of reading editor.scrollHeight on every keystroke. scrollHeight forces a
|
||||
// synchronous layout flush — measured at 2.27 layouts per character typed
|
||||
// (see scripts/leak-typing.mjs). With ~30 chars before a typical wrap on
|
||||
// composer-default-width, this heuristic flips at roughly the right time
|
||||
// and the user only notices if they type far past the wrap boundary
|
||||
// without a newline; in that case the ResizeObserver below catches it via
|
||||
// a height delta and we still expand.
|
||||
// Expansion (input on its own full-width row, controls below) is driven by
|
||||
// the editor's *actual* rendered height via the ResizeObserver in
|
||||
// syncComposerMetrics — it only fires when the text genuinely wraps to a
|
||||
// second line, so the layout flips exactly at the wrap point rather than at
|
||||
// a guessed character count. We only handle the two cases the observer
|
||||
// can't: an explicit newline (expand before layout settles) and an emptied
|
||||
// draft (collapse back). We never read scrollHeight per keystroke.
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
setExpanded(false)
|
||||
@@ -266,7 +333,7 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
if (draft.includes('\n') || draft.length > 60) {
|
||||
if (draft.includes('\n')) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [draft, expanded])
|
||||
@@ -302,6 +369,18 @@ export function ChatBar({
|
||||
}
|
||||
}
|
||||
|
||||
// Expand once the input has actually wrapped past a single line. The
|
||||
// observer only fires on real size changes, so this reads scrollHeight at
|
||||
// most once per wrap (not per keystroke). One line ≈ 28px (1.625rem
|
||||
// min-height + padding); a second line clears ~36px. We only ever expand
|
||||
// here — collapse is handled by the emptied-draft effect to avoid
|
||||
// oscillating across the wrap boundary as the input switches widths.
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor && editor.scrollHeight > COMPOSER_SINGLE_LINE_MAX_PX) {
|
||||
setExpanded(true)
|
||||
}
|
||||
|
||||
if (height > 0) {
|
||||
const bucket = Math.round(height / 8) * 8
|
||||
|
||||
@@ -321,7 +400,7 @@ export function ChatBar({
|
||||
}
|
||||
}, [])
|
||||
|
||||
useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef)
|
||||
useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef, editorRef)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -399,13 +478,19 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
const pastedText = event.clipboardData.getData('text')
|
||||
// Trim surrounding whitespace so a copy that dragged along leading/trailing
|
||||
// blank lines (common when selecting from terminals, code blocks, web pages)
|
||||
// doesn't dump multiline padding into the composer. Internal newlines are
|
||||
// preserved — only the edges are cleaned up.
|
||||
const pastedText = event.clipboardData.getData('text').trim()
|
||||
|
||||
if (!pastedText) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (DATA_IMAGE_URL_RE.test(pastedText.trim())) {
|
||||
if (DATA_IMAGE_URL_RE.test(pastedText)) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
@@ -468,6 +553,13 @@ export function ChatBar({
|
||||
}, [trigger])
|
||||
|
||||
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
// During IME composition the DOM contains uncommitted preedit text
|
||||
// mixed with real content. Skip state writes — compositionend will
|
||||
// deliver the finalized text via a clean input event.
|
||||
if (composingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const editor = event.currentTarget
|
||||
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
|
||||
@@ -567,7 +659,18 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
const handleEditorKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k') {
|
||||
// IME composition: Enter confirms composed text, not a message submission.
|
||||
// We check both composingRef (set by compositionstart/compositionend, robust
|
||||
// across browsers) and nativeEvent.isComposing (Chromium fallback). Without
|
||||
// this guard, pressing Enter to finalise a Korean/Japanese/Chinese IME
|
||||
// preedit fires submitDraft() and splits the message mid-word.
|
||||
if (composingRef.current || event.nativeEvent.isComposing) {
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Shift+K drains the next queued message. Plain Cmd/Ctrl+K is
|
||||
// reserved for the global command palette.
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
|
||||
if (!busy) {
|
||||
@@ -956,7 +1059,8 @@ export function ChatBar({
|
||||
const submitted = draft
|
||||
triggerHaptic('submit')
|
||||
clearDraft()
|
||||
void onSubmit(submitted)
|
||||
clearComposerAttachments()
|
||||
void onSubmit(submitted, { attachments })
|
||||
}
|
||||
|
||||
focusInput()
|
||||
@@ -1082,10 +1186,10 @@ export function ChatBar({
|
||||
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
|
||||
<div
|
||||
aria-label="Message"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
|
||||
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
|
||||
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
|
||||
'**:data-ref-text:cursor-default',
|
||||
stacked && 'pl-3',
|
||||
@@ -1095,6 +1199,12 @@ export function ChatBar({
|
||||
data-placeholder={placeholder}
|
||||
data-slot={RICH_INPUT_SLOT}
|
||||
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
||||
onCompositionEnd={() => {
|
||||
composingRef.current = false
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
composingRef.current = true
|
||||
}}
|
||||
onDragOver={handleInputDragOver}
|
||||
onDrop={handleInputDrop}
|
||||
onFocus={() => markActiveComposer('main')}
|
||||
@@ -1123,7 +1233,7 @@ export function ChatBar({
|
||||
|
||||
`asChild` swaps TextareaAutosize for a Radix Slot wrapping our
|
||||
plain <textarea>, which carries the binding but skips autosize. */}
|
||||
<ComposerPrimitive.Input asChild tabIndex={-1} unstable_focusOnScrollToBottom={false}>
|
||||
<ComposerPrimitive.Input asChild submitMode="ctrlEnter" tabIndex={-1} unstable_focusOnScrollToBottom={false}>
|
||||
<textarea aria-hidden className="sr-only" tabIndex={-1} />
|
||||
</ComposerPrimitive.Input>
|
||||
</div>
|
||||
@@ -1143,6 +1253,9 @@ export function ChatBar({
|
||||
onDrop={handleDrop}
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
if (composingRef.current) {
|
||||
return
|
||||
}
|
||||
submitDraft()
|
||||
}}
|
||||
ref={composerRef}
|
||||
@@ -1245,7 +1358,7 @@ export function ChatBar({
|
||||
'grid w-full',
|
||||
stacked
|
||||
? 'grid-cols-[auto_1fr] gap-(--composer-row-gap) [grid-template-areas:"input_input"_"menu_controls"]'
|
||||
: 'grid-cols-[auto_1fr_auto] items-end gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
|
||||
: 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center [grid-area:menu]">{contextMenu}</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { detectTrigger } from './text-utils'
|
||||
import { blobDedupeKey, detectTrigger, extractClipboardImageBlobs } from './text-utils'
|
||||
|
||||
describe('detectTrigger', () => {
|
||||
it('detects a bare slash trigger with an empty query', () => {
|
||||
@@ -23,3 +23,55 @@ describe('detectTrigger', () => {
|
||||
expect(detectTrigger('hello there')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractClipboardImageBlobs', () => {
|
||||
it('dedupes the same image exposed on both items and files', () => {
|
||||
const image = new File([new Uint8Array([1, 2, 3])], 'paste.png', {
|
||||
type: 'image/png',
|
||||
lastModified: 1_700_000_000_000
|
||||
})
|
||||
|
||||
const clipboard = {
|
||||
files: {
|
||||
length: 1,
|
||||
item: (index: number) => (index === 0 ? image : null)
|
||||
},
|
||||
getData: () => '',
|
||||
items: [
|
||||
{
|
||||
kind: 'file',
|
||||
type: 'image/png',
|
||||
getAsFile: () => image
|
||||
}
|
||||
]
|
||||
} as unknown as DataTransfer
|
||||
|
||||
expect(extractClipboardImageBlobs(clipboard)).toEqual([image])
|
||||
})
|
||||
|
||||
it('falls back to files when items has no image', () => {
|
||||
const image = new File([new Uint8Array([4, 5])], 'shot.jpg', {
|
||||
type: 'image/jpeg',
|
||||
lastModified: 1_700_000_000_001
|
||||
})
|
||||
|
||||
const clipboard = {
|
||||
files: {
|
||||
length: 1,
|
||||
item: (index: number) => (index === 0 ? image : null)
|
||||
},
|
||||
getData: () => '',
|
||||
items: []
|
||||
} as unknown as DataTransfer
|
||||
|
||||
expect(extractClipboardImageBlobs(clipboard)).toEqual([image])
|
||||
})
|
||||
})
|
||||
|
||||
describe('blobDedupeKey', () => {
|
||||
it('uses file metadata for File blobs', () => {
|
||||
const file = new File([], 'a.png', { type: 'image/png', lastModified: 42 })
|
||||
|
||||
expect(blobDedupeKey(file)).toBe('file:a.png:0:image/png:42')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,16 +8,31 @@ export interface TriggerState {
|
||||
|
||||
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
|
||||
|
||||
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
|
||||
export function blobDedupeKey(blob: Blob): string {
|
||||
if (blob instanceof File) {
|
||||
return `file:${blob.name}:${blob.size}:${blob.type}:${blob.lastModified}`
|
||||
}
|
||||
|
||||
return `blob:${blob.size}:${blob.type}`
|
||||
}
|
||||
|
||||
export function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
|
||||
const blobs: Blob[] = []
|
||||
const seen = new Set<Blob>()
|
||||
const seen = new Set<string>()
|
||||
|
||||
const push = (blob: Blob | null) => {
|
||||
if (!blob || blob.size === 0 || seen.has(blob)) {
|
||||
if (!blob || blob.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
seen.add(blob)
|
||||
const key = blobDedupeKey(blob)
|
||||
|
||||
if (seen.has(key)) {
|
||||
return
|
||||
}
|
||||
|
||||
seen.add(key)
|
||||
blobs.push(blob)
|
||||
}
|
||||
|
||||
@@ -29,7 +44,8 @@ export function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
|
||||
}
|
||||
}
|
||||
|
||||
if (clipboard.files?.length) {
|
||||
// Chromium/Electron expose the same pasted image on both `items` and `files`.
|
||||
if (blobs.length === 0 && clipboard.files?.length) {
|
||||
for (let i = 0; i < clipboard.files.length; i += 1) {
|
||||
const file = clipboard.files.item(i)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useLocation } from 'react-router-dom'
|
||||
import { Thread } from '@/components/assistant-ui/thread'
|
||||
import { Backdrop } from '@/components/Backdrop'
|
||||
import { NotificationStack } from '@/components/notifications'
|
||||
import { PromptOverlays } from '@/components/prompt-overlays'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
|
||||
@@ -36,7 +37,8 @@ import {
|
||||
$introSeed,
|
||||
$messages,
|
||||
$selectedStoredSessionId,
|
||||
$sessions
|
||||
$sessions,
|
||||
sessionPinId
|
||||
} from '@/store/session'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
@@ -96,9 +98,27 @@ function ChatHeader({
|
||||
}: ChatHeaderProps) {
|
||||
const sessions = useStore($sessions)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
|
||||
|
||||
const activeStoredSession =
|
||||
sessions.find(session => session.id === selectedSessionId || session._lineage_root_id === selectedSessionId) || null
|
||||
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
|
||||
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
|
||||
|
||||
// Pins live on the durable lineage-root id, but selectedSessionId is the live
|
||||
// (tip) id — resolve through the loaded row so the menu reflects the pin
|
||||
// state after auto-compression rotates the id.
|
||||
const selectedIsPinned = activeStoredSession
|
||||
? pinnedSessionIds.includes(sessionPinId(activeStoredSession))
|
||||
: selectedSessionId
|
||||
? pinnedSessionIds.includes(selectedSessionId)
|
||||
: false
|
||||
|
||||
// A brand-new session has no session to pin/delete/rename, so the header is
|
||||
// just a dead "New session" label + chevron. Drop it (and its border)
|
||||
// entirely until there's a real session to act on.
|
||||
if (!selectedSessionId && !activeSessionId && !isRoutedSessionView) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||
@@ -113,7 +133,7 @@ function ChatHeader({
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
className="pointer-events-auto h-6 min-w-0 gap-1 rounded-md border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
|
||||
className="pointer-events-auto h-6 min-w-0 gap-1 border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
@@ -306,6 +326,7 @@ export function ChatView({
|
||||
/>
|
||||
|
||||
<NotificationStack />
|
||||
<PromptOverlays />
|
||||
|
||||
<div
|
||||
className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]"
|
||||
|
||||
@@ -82,7 +82,7 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
'mt-0.5 cursor-pointer text-left uppercase opacity-70 transition-colors hover:opacity-100',
|
||||
'mt-0.5 text-left uppercase opacity-70 transition-colors hover:opacity-100',
|
||||
consoleLevelClass[log.level] ?? consoleLevelClass[0]
|
||||
)}
|
||||
onClick={onToggleSelect}
|
||||
|
||||
@@ -11,6 +11,7 @@ import ShikiHighlighter from 'react-shiki'
|
||||
import { Streamdown } from 'streamdown'
|
||||
|
||||
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
|
||||
@@ -481,7 +482,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
|
||||
|
||||
if (state.loading) {
|
||||
return <div className="grid h-full place-items-center text-xs text-muted-foreground">Loading preview…</div>
|
||||
return <PageLoader label="Loading preview" />
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
|
||||
@@ -83,7 +83,7 @@ function PreviewLoadError({
|
||||
body={
|
||||
<>
|
||||
<a
|
||||
className="pointer-events-auto block cursor-pointer font-mono text-muted-foreground/90 underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground"
|
||||
className="pointer-events-auto block font-mono text-muted-foreground/90 underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground"
|
||||
href={error.url}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
@@ -608,7 +608,7 @@ export function PreviewPane({
|
||||
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<a
|
||||
className="pointer-events-auto inline max-w-full cursor-pointer truncate text-left text-xs font-medium text-foreground underline-offset-4 decoration-current/20 transition-colors hover:text-primary hover:underline"
|
||||
className="pointer-events-auto inline max-w-full truncate text-left text-xs font-medium text-foreground underline-offset-4 decoration-current/20 transition-colors hover:text-primary hover:underline"
|
||||
href={currentUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { KbdGroup } from '@/components/ui/kbd'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -36,6 +37,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$panesFlipped,
|
||||
$pinnedSessionIds,
|
||||
$sidebarAgentsGrouped,
|
||||
$sidebarOpen,
|
||||
@@ -143,6 +145,7 @@ function searchResultToSession(result: SessionSearchResult): SessionInfo {
|
||||
cwd: null,
|
||||
ended_at: null,
|
||||
id: result.session_id,
|
||||
_lineage_root_id: result.lineage_root ?? null,
|
||||
input_tokens: 0,
|
||||
is_active: false,
|
||||
last_active: ts,
|
||||
@@ -213,6 +216,7 @@ export function ChatSidebar({
|
||||
onNewSessionInWorkspace
|
||||
}: ChatSidebarProps) {
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const agentsGrouped = useStore($sidebarAgentsGrouped)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const pinsOpen = useStore($sidebarPinsOpen)
|
||||
@@ -226,8 +230,28 @@ export function ChatSidebar({
|
||||
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
|
||||
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
|
||||
const trimmedQuery = searchQuery.trim()
|
||||
|
||||
// Flash the ⌘N hint full-opacity (no transition) for the press, so hitting
|
||||
// the shortcut visibly pings its affordance in the sidebar.
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const onShortcut = () => {
|
||||
setNewSessionKbdFlash(true)
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => setNewSessionKbdFlash(false), 140)
|
||||
}
|
||||
|
||||
window.addEventListener('hermes:new-session-shortcut', onShortcut)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('hermes:new-session-shortcut', onShortcut)
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
|
||||
|
||||
const dndSensors = useSensors(
|
||||
@@ -405,7 +429,8 @@ export function ChatSidebar({
|
||||
return (
|
||||
<Sidebar
|
||||
className={cn(
|
||||
'relative h-full min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground transition-none',
|
||||
'relative h-full min-w-0 overflow-hidden border-t-0 border-b-0 text-foreground transition-none',
|
||||
panesFlipped ? 'border-l border-r-0' : 'border-r border-l-0',
|
||||
sidebarOpen
|
||||
? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
|
||||
: 'pointer-events-none border-transparent bg-transparent opacity-0'
|
||||
@@ -429,7 +454,7 @@ export function ChatSidebar({
|
||||
<SidebarMenuButton
|
||||
aria-disabled={!isInteractive}
|
||||
className={cn(
|
||||
'flex h-7 w-full cursor-pointer justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
|
||||
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
|
||||
active &&
|
||||
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
|
||||
!isInteractive &&
|
||||
@@ -444,7 +469,10 @@ export function ChatSidebar({
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
|
||||
{item.id === 'new-session' && (
|
||||
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={[...NEW_SESSION_KBD]} />
|
||||
<KbdGroup
|
||||
className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
|
||||
keys={[...NEW_SESSION_KBD]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -457,28 +485,13 @@ export function ChatSidebar({
|
||||
</SidebarGroup>
|
||||
|
||||
{sidebarOpen && showSessionSections && (
|
||||
<div className="shrink-0 pb-1 pt-1">
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-transparent bg-transparent px-2 transition-colors focus-within:border-(--ui-stroke-tertiary)">
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="search" size="0.75rem" />
|
||||
<input
|
||||
aria-label="Search sessions"
|
||||
className="h-6 min-w-0 flex-1 bg-transparent text-[0.8125rem] text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
|
||||
onChange={event => setSearchQuery(event.target.value)}
|
||||
placeholder="Search sessions…"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
aria-label="Clear search"
|
||||
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-active-background) hover:text-foreground"
|
||||
onClick={() => setSearchQuery('')}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 px-2 pb-1 pt-1">
|
||||
<SearchField
|
||||
aria-label="Search sessions"
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search sessions…"
|
||||
value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -553,7 +566,7 @@ export function ChatSidebar({
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
|
||||
className={cn(
|
||||
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
@@ -603,7 +616,7 @@ function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSe
|
||||
return (
|
||||
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
|
||||
<button
|
||||
className="group/section-label flex w-fit cursor-pointer items-center gap-1 bg-transparent text-left leading-none"
|
||||
className="group/section-label flex w-fit items-center gap-1 bg-transparent text-left leading-none"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
@@ -644,7 +657,7 @@ function SidebarPinnedEmptyState() {
|
||||
<span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
|
||||
<Codicon name="pin" size="0.75rem" />
|
||||
</span>
|
||||
<span>Shift-click a chat to pin · drag to reorder</span>
|
||||
<span>Shift-click a chat to pin</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -847,7 +860,7 @@ function SidebarWorkspaceGroup({
|
||||
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
|
||||
<div className="group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
|
||||
<button
|
||||
className="flex min-w-0 cursor-pointer items-center gap-1 bg-transparent text-left hover:text-(--ui-text-secondary)"
|
||||
className="flex min-w-0 items-center gap-1 bg-transparent text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => setOpen(value => !value)}
|
||||
title={group.path ?? undefined}
|
||||
type="button"
|
||||
@@ -862,7 +875,7 @@ function SidebarWorkspaceGroup({
|
||||
{onNewSession && (
|
||||
<button
|
||||
aria-label={`New session in ${group.label}`}
|
||||
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
|
||||
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
|
||||
onClick={() => onNewSession(group.path)}
|
||||
title={`New session in ${group.label}`}
|
||||
type="button"
|
||||
@@ -894,7 +907,7 @@ function SidebarWorkspaceGroup({
|
||||
{hiddenCount > 0 && (
|
||||
<button
|
||||
aria-label={`Show ${nextCount} more in ${group.label}`}
|
||||
className="ml-auto grid size-5 cursor-pointer place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
|
||||
title={`Show ${nextCount} more in ${group.label}`}
|
||||
type="button"
|
||||
@@ -948,7 +961,7 @@ function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps)
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex min-h-5 cursor-pointer items-center gap-1 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
|
||||
className="flex min-h-5 items-center gap-1 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
|
||||
disabled={loading}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -6,6 +7,7 @@ import type { SessionInfo } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $attentionSessionIds } from '@/store/session'
|
||||
|
||||
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
|
||||
|
||||
@@ -61,6 +63,10 @@ export function SidebarSessionRow({
|
||||
const title = sessionTitle(session)
|
||||
const age = formatAge(session.last_active || session.started_at)
|
||||
const handleLabel = `Reorder ${title}`
|
||||
// Subscribe per-row (the leaf) instead of drilling a set through the list —
|
||||
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
|
||||
// session is waiting on the user.
|
||||
const needsInput = useStore($attentionSessionIds).includes(session.id)
|
||||
|
||||
return (
|
||||
<SessionContextMenu
|
||||
@@ -84,9 +90,9 @@ export function SidebarSessionRow({
|
||||
style={style}
|
||||
{...rest}
|
||||
>
|
||||
{isWorking && <span aria-hidden="true" className="arc-border" />}
|
||||
{isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />}
|
||||
<button
|
||||
className="z-0 flex min-w-0 cursor-pointer items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left group-hover:pr-12"
|
||||
className="z-0 flex min-w-0 items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left group-hover:pr-12"
|
||||
onClick={event => {
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault()
|
||||
@@ -114,16 +120,25 @@ export function SidebarSessionRow({
|
||||
<span
|
||||
{...dragHandleProps}
|
||||
aria-label={handleLabel}
|
||||
className="relative -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
|
||||
className={cn(
|
||||
// Scope the dot↔grabber swap to a local group so the grabber
|
||||
// only reveals when hovering/focusing the handle itself, not
|
||||
// anywhere on the row.
|
||||
'group/handle relative -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing',
|
||||
// The quest-glow box-shadow extends past the dot; let it bleed
|
||||
// out instead of being clipped by this handle's overflow-hidden.
|
||||
needsInput && 'overflow-visible'
|
||||
)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<SidebarRowDot
|
||||
className="transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
|
||||
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
|
||||
isWorking={isWorking}
|
||||
needsInput={needsInput}
|
||||
/>
|
||||
<Codicon
|
||||
className={cn(
|
||||
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover:opacity-80 group-focus-within:opacity-80 hover:text-(--ui-text-secondary)',
|
||||
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/handle:opacity-80 group-focus-within/handle:opacity-80 hover:text-(--ui-text-secondary)',
|
||||
dragging && 'text-(--ui-text-secondary) opacity-100'
|
||||
)}
|
||||
name="grabber"
|
||||
@@ -131,8 +146,8 @@ export function SidebarSessionRow({
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className="grid w-3.5 shrink-0 place-items-center overflow-hidden">
|
||||
<SidebarRowDot isWorking={isWorking} />
|
||||
<span className={cn('grid w-3.5 shrink-0 place-items-center', needsInput ? 'overflow-visible' : 'overflow-hidden')}>
|
||||
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
|
||||
@@ -155,7 +170,7 @@ export function SidebarSessionRow({
|
||||
>
|
||||
<Button
|
||||
aria-label={`Actions for ${title}`}
|
||||
className="size-5 rounded-md bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
|
||||
className="size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
|
||||
size="icon"
|
||||
title="Session actions"
|
||||
variant="ghost"
|
||||
@@ -169,7 +184,30 @@ export function SidebarSessionRow({
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRowDot({ isWorking, className }: { isWorking: boolean; className?: string }) {
|
||||
function SidebarRowDot({
|
||||
isWorking,
|
||||
needsInput = false,
|
||||
className
|
||||
}: {
|
||||
isWorking: boolean
|
||||
needsInput?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
// "Needs input" wins over "working": a clarify-blocked session is technically
|
||||
// still running, but the actionable state is that it's waiting on the user.
|
||||
// Amber + steady (no ping) reads as "your turn", distinct from the accent
|
||||
// pulse of an active turn.
|
||||
if (needsInput) {
|
||||
return (
|
||||
<span
|
||||
aria-label="Needs your input"
|
||||
className={cn('quest-glow relative size-1.5 rounded-full bg-amber-500', className)}
|
||||
role="status"
|
||||
title="Waiting for your answer"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-label={isWorking ? 'Session running' : undefined}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
460
apps/desktop/src/app/command-palette/index.tsx
Normal file
460
apps/desktop/src/app/command-palette/index.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/command'
|
||||
import { getHermesConfigRecord, listSessions } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import {
|
||||
Activity,
|
||||
Archive,
|
||||
BarChart3,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Cpu,
|
||||
Globe,
|
||||
type IconComponent,
|
||||
Info,
|
||||
KeyRound,
|
||||
MessageCircle,
|
||||
Monitor,
|
||||
Moon,
|
||||
Package,
|
||||
Palette,
|
||||
Plus,
|
||||
Settings,
|
||||
Sun,
|
||||
Users,
|
||||
Wrench
|
||||
} from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
|
||||
import { type ThemeMode, useTheme } from '@/themes/context'
|
||||
|
||||
import {
|
||||
AGENTS_ROUTE,
|
||||
ARTIFACTS_ROUTE,
|
||||
COMMAND_CENTER_ROUTE,
|
||||
CRON_ROUTE,
|
||||
MESSAGING_ROUTE,
|
||||
NEW_CHAT_ROUTE,
|
||||
PROFILES_ROUTE,
|
||||
sessionRoute,
|
||||
SETTINGS_ROUTE,
|
||||
SKILLS_ROUTE
|
||||
} from '../routes'
|
||||
import { FIELD_LABELS, SECTIONS } from '../settings/constants'
|
||||
import { prettyName } from '../settings/helpers'
|
||||
|
||||
interface PaletteItem {
|
||||
active?: boolean
|
||||
icon: IconComponent
|
||||
id: string
|
||||
/** Keep the palette open after running (live-preview pickers like theme/mode). */
|
||||
keepOpen?: boolean
|
||||
keywords?: string[]
|
||||
label: string
|
||||
/** Action to run when selected. Mutually exclusive with `to`. */
|
||||
run?: () => void
|
||||
/** Open a nested palette page (VS Code-style "choose X → options"). */
|
||||
to?: string
|
||||
}
|
||||
|
||||
interface PaletteGroup {
|
||||
heading: string
|
||||
items: PaletteItem[]
|
||||
}
|
||||
|
||||
/** A nested page reachable from a root item via `to`. */
|
||||
interface PalettePage {
|
||||
groups: PaletteGroup[]
|
||||
placeholder: string
|
||||
title: string
|
||||
}
|
||||
|
||||
interface SessionEntry {
|
||||
id: string
|
||||
preview?: string
|
||||
title: string
|
||||
}
|
||||
|
||||
type SessionRow = Awaited<ReturnType<typeof listSessions>>['sessions'][number]
|
||||
|
||||
const toSessionEntry = (session: SessionRow): SessionEntry => ({
|
||||
id: session.id,
|
||||
preview: session.preview ?? undefined,
|
||||
title: sessionTitle(session)
|
||||
})
|
||||
|
||||
const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent; keywords?: string[]; label: string; tab: string }> = [
|
||||
{ icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' },
|
||||
{ icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials'], label: 'API Keys', tab: 'keys' },
|
||||
{ icon: Wrench, keywords: ['servers', 'tools'], label: 'MCP', tab: 'mcp' },
|
||||
{ icon: Archive, keywords: ['history', 'archived'], label: 'Archived Chats', tab: 'sessions' },
|
||||
{ icon: Info, keywords: ['version', 'about'], label: 'About', tab: 'about' }
|
||||
]
|
||||
|
||||
const THEME_MODES: ReadonlyArray<{ icon: IconComponent; label: string; mode: ThemeMode }> = [
|
||||
{ icon: Sun, label: 'Light', mode: 'light' },
|
||||
{ icon: Moon, label: 'Dark', mode: 'dark' },
|
||||
{ icon: Monitor, label: 'System', mode: 'system' }
|
||||
]
|
||||
|
||||
function fieldLabel(key: string): string {
|
||||
return FIELD_LABELS[key] ?? prettyName(key.split('.').pop() ?? key)
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const open = useStore($commandPaletteOpen)
|
||||
const navigate = useNavigate()
|
||||
const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme()
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState<string | null>(null)
|
||||
|
||||
// Server-backed sources for the type-to-search groups, fetched lazily while
|
||||
// the palette is open. react-query handles caching/dedup/staleness.
|
||||
const configQuery = useQuery({ queryKey: ['command-palette', 'config'], queryFn: getHermesConfigRecord, enabled: open })
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ['command-palette', 'sessions'],
|
||||
queryFn: () => listSessions(200, 1, 'exclude'),
|
||||
enabled: open
|
||||
})
|
||||
|
||||
const archivedQuery = useQuery({
|
||||
queryKey: ['command-palette', 'archived'],
|
||||
queryFn: () => listSessions(200, 0, 'only'),
|
||||
enabled: open
|
||||
})
|
||||
|
||||
const mcpServers = useMemo(() => {
|
||||
const raw = configQuery.data?.mcp_servers
|
||||
|
||||
return raw && typeof raw === 'object' && !Array.isArray(raw) ? Object.keys(raw as Record<string, unknown>).sort() : []
|
||||
}, [configQuery.data])
|
||||
|
||||
const sessions = useMemo(() => (sessionsQuery.data?.sessions ?? []).map(toSessionEntry), [sessionsQuery.data])
|
||||
const archivedSessions = useMemo(() => (archivedQuery.data?.sessions ?? []).map(toSessionEntry), [archivedQuery.data])
|
||||
|
||||
// Reset the query/sub-page on close so it reopens clean.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearch('')
|
||||
setPage(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const go = useCallback((path: string) => () => navigate(path), [navigate])
|
||||
|
||||
const baseGroups = useMemo<PaletteGroup[]>(() => {
|
||||
const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}`
|
||||
|
||||
return [
|
||||
{
|
||||
heading: 'Go to',
|
||||
items: [
|
||||
{ icon: Plus, id: 'nav-new', keywords: ['chat', 'create'], label: 'New session', run: go(NEW_CHAT_ROUTE) },
|
||||
{ icon: Settings, id: 'nav-settings', label: 'Settings', run: go(SETTINGS_ROUTE) },
|
||||
{
|
||||
icon: Wrench,
|
||||
id: 'nav-skills',
|
||||
keywords: ['tools', 'toolsets', 'providers'],
|
||||
label: 'Skills & Tools',
|
||||
run: go(SKILLS_ROUTE)
|
||||
},
|
||||
{ icon: MessageCircle, id: 'nav-messaging', label: 'Messaging', run: go(MESSAGING_ROUTE) },
|
||||
{ icon: Package, id: 'nav-artifacts', label: 'Artifacts', run: go(ARTIFACTS_ROUTE) },
|
||||
{ icon: Clock, id: 'nav-cron', keywords: ['schedule', 'jobs'], label: 'Cron', run: go(CRON_ROUTE) },
|
||||
{ icon: Users, id: 'nav-profiles', label: 'Profiles', run: go(PROFILES_ROUTE) },
|
||||
{ icon: Cpu, id: 'nav-agents', label: 'Agents', run: go(AGENTS_ROUTE) }
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Command Center',
|
||||
items: [
|
||||
{
|
||||
icon: Archive,
|
||||
id: 'cc-sessions',
|
||||
keywords: ['command center', 'sessions', 'pin'],
|
||||
label: 'Sessions',
|
||||
run: go(`${COMMAND_CENTER_ROUTE}?section=sessions`)
|
||||
},
|
||||
{
|
||||
icon: Activity,
|
||||
id: 'cc-system',
|
||||
keywords: ['command center', 'system', 'status', 'logs'],
|
||||
label: 'System',
|
||||
run: go(`${COMMAND_CENTER_ROUTE}?section=system`)
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
id: 'cc-usage',
|
||||
keywords: ['command center', 'usage', 'tokens', 'cost'],
|
||||
label: 'Usage',
|
||||
run: go(`${COMMAND_CENTER_ROUTE}?section=usage`)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Settings',
|
||||
items: [
|
||||
...SECTIONS.map(section => ({
|
||||
icon: section.icon,
|
||||
id: `set-config-${section.id}`,
|
||||
keywords: ['settings', section.label],
|
||||
label: section.label,
|
||||
run: go(settingsTab(`config:${section.id}`))
|
||||
})),
|
||||
...NON_CONFIG_SETTINGS.map(entry => ({
|
||||
icon: entry.icon,
|
||||
id: `set-${entry.tab}`,
|
||||
keywords: ['settings', ...(entry.keywords ?? [])],
|
||||
label: entry.label,
|
||||
run: go(settingsTab(entry.tab))
|
||||
}))
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Appearance',
|
||||
items: [
|
||||
{
|
||||
icon: Palette,
|
||||
id: 'appearance-theme',
|
||||
keywords: ['theme', 'appearance', 'color', 'palette', 'skin', 'dark', 'light', 'look'],
|
||||
label: 'Change theme…',
|
||||
to: 'theme'
|
||||
},
|
||||
{
|
||||
icon: Sun,
|
||||
id: 'appearance-mode',
|
||||
keywords: ['appearance', 'color mode', 'brightness', 'dark', 'light', 'system'],
|
||||
label: 'Change color mode…',
|
||||
to: 'color-mode'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}, [go])
|
||||
|
||||
// The long, granular lists (settings fields, API keys, MCP servers, archived
|
||||
// chats) only surface once the user types — otherwise they'd bury the
|
||||
// navigation entries on an empty palette.
|
||||
const searchGroups = useMemo<PaletteGroup[]>(() => {
|
||||
if (!search.trim()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const result: PaletteGroup[] = []
|
||||
|
||||
if (sessions.length > 0) {
|
||||
result.push({
|
||||
heading: 'Sessions',
|
||||
items: sessions.map(session => ({
|
||||
icon: MessageCircle,
|
||||
id: `session-${session.id}`,
|
||||
keywords: ['chat', 'session', ...(session.preview ? [session.preview] : [])],
|
||||
label: session.title,
|
||||
run: go(sessionRoute(session.id))
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
const fieldItems = SECTIONS.flatMap(section =>
|
||||
section.keys.map(key => ({
|
||||
icon: section.icon,
|
||||
id: `field-${key}`,
|
||||
keywords: ['settings', key, section.label],
|
||||
label: `${section.label}: ${fieldLabel(key)}`,
|
||||
run: go(`${SETTINGS_ROUTE}?tab=config:${section.id}&field=${encodeURIComponent(key)}`)
|
||||
}))
|
||||
)
|
||||
|
||||
result.push({ heading: 'Settings fields', items: fieldItems })
|
||||
|
||||
if (mcpServers.length > 0) {
|
||||
result.push({
|
||||
heading: 'MCP servers',
|
||||
items: mcpServers.map(name => ({
|
||||
icon: Wrench,
|
||||
id: `mcp-${name}`,
|
||||
keywords: ['mcp', 'server', 'tool'],
|
||||
label: name,
|
||||
run: go(`${SETTINGS_ROUTE}?tab=mcp&server=${encodeURIComponent(name)}`)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
if (archivedSessions.length > 0) {
|
||||
result.push({
|
||||
heading: 'Archived chats',
|
||||
items: archivedSessions.map(session => ({
|
||||
icon: Archive,
|
||||
id: `archived-${session.id}`,
|
||||
keywords: ['archived', 'chat', 'session', ...(session.preview ? [session.preview] : [])],
|
||||
label: session.title,
|
||||
run: go(`${SETTINGS_ROUTE}?tab=sessions&session=${encodeURIComponent(session.id)}`)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}, [archivedSessions, go, mcpServers, search, sessions])
|
||||
|
||||
const groups = useMemo(() => [...baseGroups, ...searchGroups], [baseGroups, searchGroups])
|
||||
|
||||
// Nested palette pages (VS Code-style submenus). Reusable: add an entry here
|
||||
// and point a root item at it via `to`.
|
||||
const subPages = useMemo<Record<string, PalettePage>>(
|
||||
() => ({
|
||||
theme: {
|
||||
title: 'Theme',
|
||||
placeholder: 'Choose a theme…',
|
||||
// Skins aren't inherently light/dark — the same skin renders in either
|
||||
// mode. Group by appearance so picking an entry sets skin + mode at
|
||||
// once, and keep the palette open so each pick previews live.
|
||||
groups: (['light', 'dark'] as const).map(groupMode => ({
|
||||
heading: groupMode === 'light' ? 'Light' : 'Dark',
|
||||
items: availableThemes.map(theme => ({
|
||||
active: themeName === theme.name && resolvedMode === groupMode,
|
||||
icon: groupMode === 'light' ? Sun : Moon,
|
||||
id: `theme-${theme.name}-${groupMode}`,
|
||||
keepOpen: true,
|
||||
keywords: ['theme', 'appearance', 'palette', groupMode, theme.label, theme.description ?? ''],
|
||||
label: theme.label,
|
||||
run: () => {
|
||||
setTheme(theme.name)
|
||||
setMode(groupMode)
|
||||
}
|
||||
}))
|
||||
}))
|
||||
},
|
||||
'color-mode': {
|
||||
title: 'Color mode',
|
||||
placeholder: 'Choose color mode…',
|
||||
groups: [
|
||||
{
|
||||
heading: 'Color mode',
|
||||
items: THEME_MODES.map(entry => ({
|
||||
active: mode === entry.mode,
|
||||
icon: entry.icon,
|
||||
id: `mode-${entry.mode}`,
|
||||
keepOpen: true,
|
||||
keywords: ['appearance', 'brightness', entry.label],
|
||||
label: entry.label,
|
||||
run: () => setMode(entry.mode)
|
||||
}))
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
[availableThemes, mode, resolvedMode, setMode, setTheme, themeName]
|
||||
)
|
||||
|
||||
const activePage = page ? subPages[page] : null
|
||||
const visibleGroups = activePage ? activePage.groups : groups
|
||||
const placeholder = activePage ? activePage.placeholder : 'Search commands and settings...'
|
||||
|
||||
const handleSelect = (item: PaletteItem) => {
|
||||
if (item.to) {
|
||||
setPage(item.to)
|
||||
setSearch('')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
item.run?.()
|
||||
|
||||
if (!item.keepOpen) {
|
||||
closeCommandPalette()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/15 backdrop-blur-[1px] 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-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg 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]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">Command palette</DialogPrimitive.Title>
|
||||
<Command className="bg-transparent" loop>
|
||||
{activePage && (
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => setPage(null)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
<span>Back</span>
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
<span className="font-medium text-foreground">{activePage.title}</span>
|
||||
</button>
|
||||
)}
|
||||
<CommandInput
|
||||
onKeyDown={event => {
|
||||
if (!activePage) {
|
||||
return
|
||||
}
|
||||
|
||||
// In a submenu: Esc and empty-input Backspace step back out
|
||||
// instead of closing the whole palette.
|
||||
if (event.key === 'Escape' || (event.key === 'Backspace' && search === '')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setPage(null)
|
||||
}
|
||||
}}
|
||||
onValueChange={setSearch}
|
||||
placeholder={placeholder}
|
||||
value={search}
|
||||
/>
|
||||
<CommandList className="max-h-[min(24rem,60vh)]">
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
{visibleGroups.map(group => (
|
||||
<CommandGroup
|
||||
className="**:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-[0.6875rem] **:[[cmdk-group-heading]]:text-muted-foreground/70"
|
||||
heading={group.heading}
|
||||
key={group.heading}
|
||||
>
|
||||
{group.items.map(item => {
|
||||
const Icon = item.icon
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className="gap-2.5"
|
||||
key={item.id}
|
||||
keywords={item.keywords}
|
||||
onSelect={() => handleSelect(item)}
|
||||
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
|
||||
>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.to ? (
|
||||
<ChevronRight className="ml-auto size-4 shrink-0 text-muted-foreground/70" />
|
||||
) : (
|
||||
<Check className={cn('ml-auto size-4 text-foreground', !item.active && 'invisible')} />
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Badge, type BadgeProps } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
@@ -13,6 +13,7 @@ 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 {
|
||||
@@ -25,12 +26,12 @@ import {
|
||||
triggerCronJob,
|
||||
updateCronJob
|
||||
} from '@/hermes'
|
||||
import { AlertTriangle, Clock, Pause, Pencil, Play, Trash2, Zap } from '@/lib/icons'
|
||||
import { AlertTriangle, Clock } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
|
||||
const DEFAULT_DELIVER = 'local'
|
||||
|
||||
@@ -86,23 +87,16 @@ const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
|
||||
}
|
||||
]
|
||||
|
||||
const STATE_TONE: Record<string, 'good' | 'muted' | 'warn' | 'bad'> = {
|
||||
enabled: 'good',
|
||||
scheduled: 'good',
|
||||
running: 'good',
|
||||
const STATE_VARIANT: Record<string, BadgeProps['variant']> = {
|
||||
enabled: 'default',
|
||||
scheduled: 'default',
|
||||
running: 'default',
|
||||
paused: 'warn',
|
||||
disabled: 'muted',
|
||||
error: 'bad',
|
||||
error: 'destructive',
|
||||
completed: 'muted'
|
||||
}
|
||||
|
||||
const PILL_TONE: Record<'good' | 'muted' | 'warn' | 'bad', string> = {
|
||||
good: 'bg-primary/10 text-primary',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
|
||||
bad: 'bg-destructive/10 text-destructive'
|
||||
}
|
||||
|
||||
const asText = (value: unknown): string => (typeof value === 'string' ? value : '')
|
||||
|
||||
const truncate = (value: string, max = 80): string => (value.length > max ? `${value.slice(0, max)}…` : value)
|
||||
@@ -305,14 +299,13 @@ function matchesQuery(job: CronJob, q: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
interface CronViewProps extends React.ComponentProps<'section'> {
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
interface CronViewProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: CronViewProps) {
|
||||
export function CronView({ onClose }: CronViewProps) {
|
||||
const [jobs, setJobs] = useState<CronJob[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [busyJobId, setBusyJobId] = useState<null | string>(null)
|
||||
|
||||
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
|
||||
@@ -320,18 +313,16 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const result = await getCronJobs()
|
||||
setJobs(result)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to load cron jobs')
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useRefreshHotkey(refresh)
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
@@ -426,29 +417,21 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
onSearchChange={setQuery}
|
||||
searchPlaceholder="Search cron jobs..."
|
||||
searchTrailingAction={
|
||||
<Button
|
||||
aria-label={refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs'}
|
||||
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refresh()}
|
||||
size="icon-xs"
|
||||
title={refreshing ? 'Refreshing cron jobs' : 'Refresh cron jobs'}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
|
||||
</Button>
|
||||
}
|
||||
searchValue={query}
|
||||
>
|
||||
{!jobs ? (
|
||||
<PageLoader label="Loading cron jobs..." />
|
||||
) : visibleJobs.length === 0 ? (
|
||||
<OverlayView closeLabel="Close cron" onClose={onClose}>
|
||||
<div className="flex min-h-0 flex-1 flex-col pt-[calc(var(--titlebar-height)+0.5rem)]">
|
||||
{totalCount > 0 && (
|
||||
<div className="mx-auto flex w-full max-w-4xl items-center gap-2 px-4 pb-2">
|
||||
<SearchField
|
||||
containerClassName="max-w-[60vw]"
|
||||
onChange={setQuery}
|
||||
placeholder="Search cron jobs…"
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!jobs ? (
|
||||
<PageLoader label="Loading cron jobs..." />
|
||||
) : 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
|
||||
@@ -463,36 +446,37 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
||||
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
|
||||
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
|
||||
/>
|
||||
) : (
|
||||
<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">
|
||||
{enabledCount}/{totalCount} active
|
||||
</span>
|
||||
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
|
||||
<Codicon name="add" />
|
||||
New cron
|
||||
</Button>
|
||||
) : (
|
||||
<div className="mx-auto w-full max-w-4xl min-h-0 flex-1 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">
|
||||
{enabledCount}/{totalCount} active
|
||||
</span>
|
||||
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
|
||||
<Codicon name="add" />
|
||||
New cron
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobRow
|
||||
busy={busyJobId === job.id}
|
||||
job={job}
|
||||
key={job.id}
|
||||
onDelete={() => setPendingDelete(job)}
|
||||
onEdit={() => setEditor({ mode: 'edit', job })}
|
||||
onPauseResume={() => void handlePauseResume(job)}
|
||||
onTrigger={() => void handleTrigger(job)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</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}
|
||||
job={job}
|
||||
key={job.id}
|
||||
onDelete={() => setPendingDelete(job)}
|
||||
onEdit={() => setEditor({ mode: 'edit', job })}
|
||||
onPauseResume={() => void handlePauseResume(job)}
|
||||
onTrigger={() => void handleTrigger(job)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
@@ -519,7 +503,7 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageSearchShell>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -547,14 +531,20 @@ function CronJobRow({
|
||||
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"
|
||||
className="min-w-0 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'}>{state}</StatePill>
|
||||
{deliver && deliver !== DEFAULT_DELIVER && <StatePill tone="muted">{deliver}</StatePill>}
|
||||
<Badge className="capitalize" variant={STATE_VARIANT[state] ?? 'muted'}>
|
||||
{state}
|
||||
</Badge>
|
||||
{deliver && deliver !== DEFAULT_DELIVER && (
|
||||
<Badge className="capitalize" variant="muted">
|
||||
{deliver}
|
||||
</Badge>
|
||||
)}
|
||||
</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">
|
||||
@@ -580,13 +570,13 @@ function CronJobRow({
|
||||
onClick={onPauseResume}
|
||||
title={isPaused ? 'Resume' : 'Pause'}
|
||||
>
|
||||
{isPaused ? <Play className="size-3.5" /> : <Pause className="size-3.5" />}
|
||||
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
|
||||
</IconAction>
|
||||
<IconAction aria-label="Trigger now" disabled={busy} onClick={onTrigger} title="Trigger now">
|
||||
<Zap className="size-3.5" />
|
||||
<Codicon name="zap" size="0.875rem" />
|
||||
</IconAction>
|
||||
<IconAction aria-label="Edit cron" onClick={onEdit} title="Edit">
|
||||
<Pencil className="size-3.5" />
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
</IconAction>
|
||||
<IconAction
|
||||
aria-label="Delete cron"
|
||||
@@ -594,7 +584,7 @@ function CronJobRow({
|
||||
onClick={onDelete}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
</IconAction>
|
||||
</div>
|
||||
</div>
|
||||
@@ -604,8 +594,8 @@ function CronJobRow({
|
||||
function IconAction({ children, className, ...props }: Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'>) {
|
||||
return (
|
||||
<Button
|
||||
className={cn('size-7 text-muted-foreground hover:text-foreground', className)}
|
||||
size="icon"
|
||||
className={cn('text-muted-foreground hover:text-foreground', className)}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
@@ -614,16 +604,6 @@ function IconAction({ children, className, ...props }: Omit<React.ComponentProps
|
||||
)
|
||||
}
|
||||
|
||||
function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem] capitalize', PILL_TONE[tone])}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
actionLabel,
|
||||
description,
|
||||
@@ -768,7 +748,7 @@ function CronEditorDialog({
|
||||
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||
<Field htmlFor="cron-frequency" label="Frequency">
|
||||
<Select onValueChange={handleSchedulePresetChange} value={schedulePreset}>
|
||||
<SelectTrigger className="h-9 rounded-md" id="cron-frequency">
|
||||
<SelectTrigger id="cron-frequency">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -783,7 +763,7 @@ function CronEditorDialog({
|
||||
|
||||
<Field htmlFor="cron-deliver" label="Deliver to">
|
||||
<Select onValueChange={setDeliver} value={deliver}>
|
||||
<SelectTrigger className="h-9 rounded-md" id="cron-deliver">
|
||||
<SelectTrigger id="cron-deliver">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -13,7 +13,9 @@ import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||
import { getSessionMessages, listSessions } from '../hermes'
|
||||
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
|
||||
import { toggleCommandPalette } from '../store/command-palette'
|
||||
import {
|
||||
$panesFlipped,
|
||||
$pinnedSessionIds,
|
||||
$sessionsLimit,
|
||||
bumpSessionsLimit,
|
||||
@@ -34,7 +36,7 @@ import {
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
$workingSessionIds,
|
||||
mergeWorkingSessions,
|
||||
mergeSessionPage,
|
||||
sessionPinId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
@@ -58,6 +60,7 @@ import {
|
||||
PREVIEW_RAIL_PANE_WIDTH
|
||||
} from './chat/right-rail'
|
||||
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 { ModelPickerOverlay } from './model-picker-overlay'
|
||||
@@ -112,6 +115,7 @@ export function DesktopController() {
|
||||
const previewTarget = useStore($previewTarget)
|
||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
|
||||
const routedSessionId = routeSessionId(location.pathname)
|
||||
const routeToken = `${location.pathname}:${location.search}:${location.hash}`
|
||||
@@ -125,9 +129,11 @@ export function DesktopController() {
|
||||
closeOverlayToPreviousRoute,
|
||||
commandCenterInitialSection,
|
||||
commandCenterOpen,
|
||||
cronOpen,
|
||||
currentView,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
profilesOpen,
|
||||
settingsOpen,
|
||||
toggleCommandCenter
|
||||
} = useOverlayRouting()
|
||||
@@ -195,6 +201,31 @@ export function DesktopController() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Global chrome shortcuts (plain Cmd/Ctrl, no alt/shift): Cmd+K → 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
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
|
||||
if (key === 'k') {
|
||||
event.preventDefault()
|
||||
toggleCommandPalette()
|
||||
} else if (key === '.') {
|
||||
event.preventDefault()
|
||||
toggleCommandCenter()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [toggleCommandCenter])
|
||||
|
||||
const refreshSessions = useCallback(async () => {
|
||||
const requestId = refreshSessionsRequestRef.current + 1
|
||||
refreshSessionsRequestRef.current = requestId
|
||||
@@ -208,12 +239,13 @@ export function DesktopController() {
|
||||
const result = await listSessions(limit, 1)
|
||||
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
// Don't hard-replace: a session whose first turn is still in flight has
|
||||
// message_count 0 in the DB, so min_messages=1 omits it. Since every
|
||||
// message.complete refreshes the list, a plain replace would drop the
|
||||
// other still-running new chats the moment one of them finishes. Keep
|
||||
// any working session the server hasn't surfaced yet.
|
||||
setSessions(prev => mergeWorkingSessions(prev, result.sessions, $workingSessionIds.get()))
|
||||
// Don't hard-replace. Two kinds of rows must survive a refresh the
|
||||
// server didn't return: (1) sessions whose first turn is still in
|
||||
// flight (message_count 0, so min_messages=1 omits them) and (2)
|
||||
// pinned sessions that have aged off the most-recent page — otherwise
|
||||
// the pin "disappears until you refresh". mergeSessionPage keeps both.
|
||||
const keepIds = new Set<string>([...$workingSessionIds.get(), ...$pinnedSessionIds.get()])
|
||||
setSessions(prev => mergeSessionPage(prev, result.sessions, keepIds))
|
||||
setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
|
||||
}
|
||||
} finally {
|
||||
@@ -413,6 +445,8 @@ export function DesktopController() {
|
||||
|
||||
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)
|
||||
@@ -524,7 +558,9 @@ export function DesktopController() {
|
||||
inferenceStatus,
|
||||
modelMenuContent,
|
||||
openAgents,
|
||||
freshDraftReady,
|
||||
openCommandCenterSection,
|
||||
requestGateway,
|
||||
statusSnapshot,
|
||||
toggleCommandCenter
|
||||
})
|
||||
@@ -561,6 +597,7 @@ export function DesktopController() {
|
||||
<UpdatesOverlay />
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
<CommandPalette />
|
||||
|
||||
{settingsOpen && (
|
||||
<Suspense fallback={null}>
|
||||
@@ -589,7 +626,6 @@ export function DesktopController() {
|
||||
initialSection={commandCenterInitialSection}
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onDeleteSession={removeSession}
|
||||
onNavigateRoute={path => navigate(path)}
|
||||
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
</Suspense>
|
||||
@@ -600,6 +636,18 @@ export function DesktopController() {
|
||||
<AgentsView onClose={closeOverlayToPreviousRoute} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{cronOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<CronView onClose={closeOverlayToPreviousRoute} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{profilesOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<ProfilesView onClose={closeOverlayToPreviousRoute} />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -611,7 +659,7 @@ export function DesktopController() {
|
||||
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
|
||||
onAttachDroppedItems={composer.attachDroppedItems}
|
||||
onAttachImageBlob={composer.attachImageBlob}
|
||||
onBranchInNewChat={messageId => void branchInNewChat(messageId)}
|
||||
onBranchInNewChat={branchInNewChat}
|
||||
onCancel={cancelRun}
|
||||
onDeleteSelectedSession={() => {
|
||||
if (selectedStoredSessionId) {
|
||||
@@ -638,12 +686,52 @@ export function DesktopController() {
|
||||
</div>
|
||||
)
|
||||
|
||||
// Flipped layout mirrors the default: sessions sidebar → right, file
|
||||
// browser + preview rail → left. Same panes, swapped sides.
|
||||
const sidebarSide = panesFlipped ? 'right' : 'left'
|
||||
const railSide = panesFlipped ? 'left' : 'right'
|
||||
|
||||
const previewPane = (
|
||||
<Pane
|
||||
disabled={!chatOpen || (!previewTarget && !filePreviewTarget)}
|
||||
id="preview"
|
||||
key="preview"
|
||||
maxWidth={PREVIEW_RAIL_MAX_WIDTH}
|
||||
minWidth={PREVIEW_RAIL_MIN_WIDTH}
|
||||
resizable
|
||||
side={railSide}
|
||||
width={PREVIEW_RAIL_PANE_WIDTH}
|
||||
>
|
||||
{chatOpen ? (
|
||||
<ChatPreviewRail onRestartServer={restartPreviewServer} setTitlebarToolGroup={setTitlebarToolGroup} />
|
||||
) : null}
|
||||
</Pane>
|
||||
)
|
||||
|
||||
const fileBrowserPane = (
|
||||
<Pane
|
||||
defaultOpen={false}
|
||||
disabled={!chatOpen}
|
||||
id="file-browser"
|
||||
key="file-browser"
|
||||
maxWidth={FILE_BROWSER_MAX_WIDTH}
|
||||
minWidth={FILE_BROWSER_MIN_WIDTH}
|
||||
resizable
|
||||
side={railSide}
|
||||
width={FILE_BROWSER_DEFAULT_WIDTH}
|
||||
>
|
||||
<RightSidebarPane
|
||||
onActivateFile={composer.attachContextFilePath}
|
||||
onActivateFolder={composer.attachContextFolderPath}
|
||||
onChangeCwd={changeSessionCwd}
|
||||
/>
|
||||
</Pane>
|
||||
)
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
commandCenterOpen={commandCenterOpen}
|
||||
leftStatusbarItems={leftStatusbarItems}
|
||||
leftTitlebarTools={titlebarToolGroups.flat.left}
|
||||
onOpenSearch={() => openCommandCenterSection('sessions')}
|
||||
onOpenSettings={openSettings}
|
||||
overlays={overlays}
|
||||
statusbarItems={statusbarItems}
|
||||
@@ -655,7 +743,7 @@ export function DesktopController() {
|
||||
maxWidth={SIDEBAR_MAX_WIDTH}
|
||||
minWidth={SIDEBAR_DEFAULT_WIDTH}
|
||||
resizable
|
||||
side="left"
|
||||
side={sidebarSide}
|
||||
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
|
||||
>
|
||||
{sidebar}
|
||||
@@ -688,25 +776,8 @@ export function DesktopController() {
|
||||
}
|
||||
path="artifacts"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<Suspense fallback={null}>
|
||||
<CronView setStatusbarItemGroup={setStatusbarItemGroup} />
|
||||
</Suspense>
|
||||
}
|
||||
path="cron"
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<Suspense fallback={null}>
|
||||
<ProfilesView
|
||||
setStatusbarItemGroup={setStatusbarItemGroup}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
path="profiles"
|
||||
/>
|
||||
<Route element={null} path="cron" />
|
||||
<Route element={null} path="profiles" />
|
||||
<Route element={null} path="settings" />
|
||||
<Route element={null} path="command-center" />
|
||||
<Route element={null} path="agents" />
|
||||
@@ -715,35 +786,13 @@ export function DesktopController() {
|
||||
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="*" />
|
||||
</Routes>
|
||||
</PaneMain>
|
||||
<Pane
|
||||
disabled={!chatOpen || (!previewTarget && !filePreviewTarget)}
|
||||
id="preview"
|
||||
maxWidth={PREVIEW_RAIL_MAX_WIDTH}
|
||||
minWidth={PREVIEW_RAIL_MIN_WIDTH}
|
||||
resizable
|
||||
side="right"
|
||||
width={PREVIEW_RAIL_PANE_WIDTH}
|
||||
>
|
||||
{chatOpen ? (
|
||||
<ChatPreviewRail onRestartServer={restartPreviewServer} setTitlebarToolGroup={setTitlebarToolGroup} />
|
||||
) : null}
|
||||
</Pane>
|
||||
<Pane
|
||||
defaultOpen={false}
|
||||
disabled={!chatOpen}
|
||||
id="file-browser"
|
||||
maxWidth={FILE_BROWSER_MAX_WIDTH}
|
||||
minWidth={FILE_BROWSER_MIN_WIDTH}
|
||||
resizable
|
||||
side="right"
|
||||
width={FILE_BROWSER_DEFAULT_WIDTH}
|
||||
>
|
||||
<RightSidebarPane
|
||||
onActivateFile={composer.attachContextFilePath}
|
||||
onActivateFolder={composer.attachContextFolderPath}
|
||||
onChangeCwd={changeSessionCwd}
|
||||
/>
|
||||
</Pane>
|
||||
{/*
|
||||
Order within a side maps to column order. Default (rail on the right):
|
||||
main | preview | file-browser. Flipped (rail on the left): mirror it to
|
||||
file-browser | preview | main so preview stays adjacent to the chat.
|
||||
*/}
|
||||
{panesFlipped ? fileBrowserPane : previewPane}
|
||||
{panesFlipped ? previewPane : fileBrowserPane}
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -63,6 +63,94 @@ export function useGatewayBoot({
|
||||
return () => void (cancelled = true)
|
||||
}
|
||||
|
||||
// --- Reconnect-after-sleep machinery -------------------------------------
|
||||
// macOS sleep silently drops the renderer's WebSocket. The backend Python
|
||||
// process keeps running, but nothing re-opened the socket on wake, so the
|
||||
// composer stayed disabled forever on "Starting Hermes...". Once the
|
||||
// initial boot succeeds we treat any non-open state as recoverable and
|
||||
// reconnect with backoff, and we nudge a reconnect on the OS/browser
|
||||
// signals that fire around wake (power resume, network online, the window
|
||||
// becoming visible).
|
||||
let bootCompleted = false
|
||||
let reconnecting = false
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let reconnectAttempt = 0
|
||||
|
||||
// Wrap the live getter in a call so TS control-flow analysis doesn't narrow
|
||||
// `connectionState` to a constant across the early-return guards (the state
|
||||
// genuinely changes between reads).
|
||||
const gatewayOpen = () => gateway.connectionState === 'open'
|
||||
|
||||
const clearReconnectTimer = () => {
|
||||
if (reconnectTimer !== null) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const attemptReconnect = async () => {
|
||||
if (cancelled || reconnecting || gatewayOpen()) {
|
||||
return
|
||||
}
|
||||
|
||||
reconnecting = true
|
||||
|
||||
try {
|
||||
const conn = await desktop.getConnection()
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
publish(conn)
|
||||
await gateway.connect(conn.wsUrl)
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
reconnectAttempt = 0
|
||||
// Resync state that may have moved on the backend while we were asleep.
|
||||
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
|
||||
await callbacksRef.current.refreshSessions().catch(() => undefined)
|
||||
} catch {
|
||||
// Fall through to scheduleReconnect's backoff below.
|
||||
} finally {
|
||||
reconnecting = false
|
||||
|
||||
if (!cancelled && !gatewayOpen()) {
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (cancelled || reconnecting || reconnectTimer !== null || gatewayOpen()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 1s, 2s, 4s … capped at 15s.
|
||||
const delay = Math.min(15_000, 1_000 * 2 ** Math.min(reconnectAttempt, 4))
|
||||
reconnectAttempt += 1
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
void attemptReconnect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const reconnectNow = () => {
|
||||
if (cancelled || !bootCompleted) {
|
||||
return
|
||||
}
|
||||
|
||||
clearReconnectTimer()
|
||||
reconnectAttempt = 0
|
||||
|
||||
if (!gatewayOpen()) {
|
||||
void attemptReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
const offBootProgress = desktop.onBootProgress(payload => applyDesktopBootProgress(payload))
|
||||
void desktop
|
||||
.getBootProgress()
|
||||
@@ -79,9 +167,34 @@ export function useGatewayBoot({
|
||||
callbacksRef.current.onGatewayReady(gateway)
|
||||
setGateway(gateway)
|
||||
|
||||
const offState = gateway.onState(st => void setGatewayState(st))
|
||||
const offState = gateway.onState(st => {
|
||||
setGatewayState(st)
|
||||
|
||||
if (st === 'open') {
|
||||
reconnectAttempt = 0
|
||||
clearReconnectTimer()
|
||||
} else if (bootCompleted && (st === 'closed' || st === 'error')) {
|
||||
// The socket dropped after a healthy boot (typically sleep/wake). Try
|
||||
// to bring it back instead of leaving the composer stuck disabled.
|
||||
scheduleReconnect()
|
||||
}
|
||||
})
|
||||
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent(event))
|
||||
|
||||
// Wake signals: power resume (macOS/Windows), network coming back, and the
|
||||
// window regaining focus/visibility. Each nudges an immediate reconnect.
|
||||
const offPowerResume = desktop.onPowerResume?.(() => reconnectNow())
|
||||
|
||||
const onOnline = () => reconnectNow()
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
reconnectNow()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('online', onOnline)
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
|
||||
const offWindowState = desktop.onWindowStateChanged?.(payload => {
|
||||
const current = $connection.get()
|
||||
|
||||
@@ -141,6 +254,7 @@ export function useGatewayBoot({
|
||||
})
|
||||
await callbacksRef.current.refreshSessions()
|
||||
completeDesktopBoot()
|
||||
bootCompleted = true
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
@@ -155,6 +269,10 @@ export function useGatewayBoot({
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearReconnectTimer()
|
||||
window.removeEventListener('online', onOnline)
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
offPowerResume?.()
|
||||
offState()
|
||||
offEvent()
|
||||
offExit()
|
||||
|
||||
45
apps/desktop/src/app/hooks/use-refresh-hotkey.ts
Normal file
45
apps/desktop/src/app/hooks/use-refresh-hotkey.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Binds the bare `r` key to a refresh action while the calling view is mounted.
|
||||
* Ignored when a modifier is held, the event repeats, or focus is in an
|
||||
* editable field (so typing "r" in a search/input never triggers it).
|
||||
*/
|
||||
export function useRefreshHotkey(onRefresh: () => void, enabled = true) {
|
||||
const ref = useRef(onRefresh)
|
||||
ref.current = onRefresh
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'r' && event.key !== 'R') {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.repeat) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = event.target as HTMLElement | null
|
||||
|
||||
if (
|
||||
target?.isContentEditable ||
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
ref.current()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [enabled])
|
||||
}
|
||||
13
apps/desktop/src/app/layout-constants.ts
Normal file
13
apps/desktop/src/app/layout-constants.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Responsive horizontal gutter for primary content bodies (settings right side,
|
||||
// skills, artifacts, command center / sessions). Ratio-based so it scales with
|
||||
// the window, but clamped so it never collapses on narrow widths or runs away
|
||||
// on ultrawide displays. Headers/tabs intentionally keep their own tighter
|
||||
// padding.
|
||||
//
|
||||
// NOTE: these must stay literal strings — Tailwind's scanner only picks up
|
||||
// complete class names, so do not build them via template interpolation.
|
||||
export const PAGE_INSET_X = 'px-[clamp(1.25rem,4vw,4rem)]'
|
||||
|
||||
// Matching negative inline-margin to bleed an element (e.g. a sticky header bar)
|
||||
// out to the gutter edges before re-applying PAGE_INSET_X.
|
||||
export const PAGE_INSET_NEG_X = '-mx-[clamp(1.25rem,4vw,4rem)]'
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { StatusDot, type StatusTone } from '@/components/status-dot'
|
||||
import { Badge, type BadgeProps } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -17,6 +18,7 @@ import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
@@ -41,11 +43,11 @@ const STATE_LABELS: Record<string, string> = {
|
||||
startup_failed: 'Startup failed'
|
||||
}
|
||||
|
||||
const PILL_TONE: Record<StatusTone, string> = {
|
||||
good: 'bg-primary/10 text-primary',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
|
||||
bad: 'bg-destructive/10 text-destructive'
|
||||
const TONE_VARIANT: Record<StatusTone, BadgeProps['variant']> = {
|
||||
good: 'default',
|
||||
muted: 'muted',
|
||||
warn: 'warn',
|
||||
bad: 'destructive'
|
||||
}
|
||||
|
||||
const HINT_BY_STATE: Record<string, string> = {
|
||||
@@ -213,6 +215,8 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
}
|
||||
}, [])
|
||||
|
||||
useRefreshHotkey(() => void refreshPlatforms())
|
||||
|
||||
useEffect(() => {
|
||||
void refreshPlatforms()
|
||||
}, [refreshPlatforms])
|
||||
@@ -343,15 +347,15 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
onSearchChange={setQuery}
|
||||
searchHidden={(platforms?.length ?? 0) === 0}
|
||||
searchPlaceholder="Search messaging..."
|
||||
searchTrailingAction={null}
|
||||
searchValue={query}
|
||||
>
|
||||
{!platforms ? (
|
||||
<PageLoader label="Loading messaging platforms..." />
|
||||
) : (
|
||||
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[14rem_minmax(0,1fr)]">
|
||||
<aside className="min-h-0 overflow-y-auto border-b border-(--ui-stroke-tertiary) p-2 lg:border-b-0 lg:border-r">
|
||||
<aside className="min-h-0 overflow-y-auto p-2">
|
||||
<ul className="space-y-1">
|
||||
{visiblePlatforms.map(platform => (
|
||||
<li key={platform.id}>
|
||||
@@ -406,8 +410,8 @@ function PlatformRow({
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors',
|
||||
active
|
||||
? 'bg-(--ui-bg-tertiary) text-foreground'
|
||||
: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
? 'bg-(--ui-row-active-background) text-foreground'
|
||||
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
@@ -482,7 +486,7 @@ function PlatformDetail({
|
||||
{introCopy(platform)}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Button asChild size="sm" variant="textStrong">
|
||||
<a href={platform.docs_url} rel="noreferrer" target="_blank">
|
||||
Open setup guide
|
||||
<ExternalLink className="size-3.5" />
|
||||
@@ -560,19 +564,15 @@ function PlatformDetail({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-5 py-2.5">
|
||||
<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 ? `Disable ${platform.name}` : `Enable ${platform.name}`}
|
||||
checked={platform.enabled}
|
||||
disabled={saving === `enabled:${platform.id}`}
|
||||
onCheckedChange={onToggle}
|
||||
/>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{platform.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</label>
|
||||
<Switch
|
||||
aria-label={platform.enabled ? `Disable ${platform.name}` : `Enable ${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">Unsaved changes</span>}
|
||||
@@ -651,7 +651,7 @@ function MessagingField({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="h-9 rounded-lg font-mono text-sm"
|
||||
className="font-mono"
|
||||
id={`messaging-field-${field.key}`}
|
||||
onChange={event => onEdit(field.key, event.target.value)}
|
||||
placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder}
|
||||
@@ -698,27 +698,13 @@ function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) {
|
||||
|
||||
function StatePill({ children, tone }: { children: string; tone: StatusTone }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-[0.66rem] font-medium',
|
||||
PILL_TONE[tone]
|
||||
)}
|
||||
>
|
||||
<Badge variant={TONE_VARIANT[tone]}>
|
||||
<StatusDot tone={tone} />
|
||||
{children}
|
||||
</span>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function SetupPill({ active, children }: { active: boolean; children: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-[0.66rem] font-medium',
|
||||
PILL_TONE[active ? 'good' : 'muted']
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
return <Badge variant={active ? 'default' : 'muted'}>{children}</Badge>
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Loader2, Search } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface OverlaySearchInputProps {
|
||||
placeholder: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
containerClassName?: string
|
||||
inputClassName?: string
|
||||
loading?: boolean
|
||||
onClear?: () => void
|
||||
inputRef?: RefObject<HTMLInputElement | null>
|
||||
trailingAction?: ReactNode
|
||||
}
|
||||
|
||||
export function OverlaySearchInput({
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
containerClassName,
|
||||
inputClassName,
|
||||
loading = false,
|
||||
onClear,
|
||||
inputRef,
|
||||
trailingAction
|
||||
}: OverlaySearchInputProps) {
|
||||
const clear = onClear ?? (() => onChange(''))
|
||||
const hasTrailing = Boolean(trailingAction)
|
||||
|
||||
return (
|
||||
<div className={cn('relative', containerClassName)}>
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 z-1 size-3.5 -translate-y-1/2 text-muted-foreground/80" />
|
||||
<Input
|
||||
className={cn(
|
||||
'relative z-0 h-8 rounded-lg py-2 pl-8 text-[length:var(--conversation-text-font-size)]',
|
||||
hasTrailing || loading || value ? 'pr-16' : 'pr-8',
|
||||
inputClassName
|
||||
)}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
/>
|
||||
<div className="absolute right-1.5 top-1/2 z-1 flex -translate-y-1/2 items-center gap-0.5">
|
||||
{trailingAction}
|
||||
{loading ? (
|
||||
<Loader2 className="pointer-events-none size-3.5 animate-spin text-muted-foreground/70" />
|
||||
) : value ? (
|
||||
<Button
|
||||
aria-label="Clear search"
|
||||
className="text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
|
||||
onClick={clear}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="0.875rem" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageSearchInput(props: OverlaySearchInputProps) {
|
||||
return (
|
||||
<OverlaySearchInput
|
||||
{...props}
|
||||
containerClassName={cn('mx-auto w-[min(36rem,calc(100%-2rem))] min-w-0', props.containerClassName)}
|
||||
inputClassName={cn('h-8 rounded-lg py-2 pl-8', props.inputClassName)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import type { ReactNode } from 'react'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { PAGE_INSET_X } from '../layout-constants'
|
||||
|
||||
interface OverlaySplitLayoutProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
@@ -43,7 +45,9 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 py-3',
|
||||
// pt clears the floating titlebar/header; the bg itself fills from the
|
||||
// card's top edge so there's no surface-colored gap above the sidebar.
|
||||
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 pb-3 pt-[calc(var(--titlebar-height)+1rem)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -54,7 +58,15 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
|
||||
|
||||
export function OverlayMain({ children, className }: OverlayMainProps) {
|
||||
return (
|
||||
<main className={cn('flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent p-3', className)}>{children}</main>
|
||||
<main
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent pb-3 pt-[calc(var(--titlebar-height)+1rem)]',
|
||||
PAGE_INSET_X,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -64,23 +64,26 @@ export function OverlayView({
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[calc(var(--titlebar-height)+0.1875rem)] [-webkit-app-region:drag]">
|
||||
{headerContent && (
|
||||
<div className="pointer-events-auto absolute left-1/2 top-[calc(1rem+var(--titlebar-height)/2)] -translate-x-1/2 -translate-y-1/2 [-webkit-app-region:no-drag]">
|
||||
<div className="pointer-events-auto absolute left-1/2 top-[calc(0.5rem+var(--titlebar-height)/2)] -translate-x-1/2 -translate-y-1/2 [-webkit-app-region:no-drag]">
|
||||
{headerContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
aria-label={closeLabel}
|
||||
className="pointer-events-auto absolute right-3 top-[calc(0.1875rem+var(--titlebar-height)/2)] h-7 w-7 -translate-y-1/2 rounded-md text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground [-webkit-app-region:no-drag]"
|
||||
className="pointer-events-auto absolute right-3 top-[calc(0.1875rem+var(--titlebar-height)/2)] -translate-y-1/2 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground [-webkit-app-region:no-drag]"
|
||||
onClick={closeOverlay}
|
||||
size="icon"
|
||||
size="icon-titlebar"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="1rem" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={cn('min-h-0 flex flex-1 flex-col pt-(--titlebar-height)', contentClassName)}>{children}</div>
|
||||
{/* No top padding here: the split-layout columns own their own
|
||||
titlebar clearance so their backgrounds run flush to the card top
|
||||
(otherwise the card surface shows as a gap above the sidebar). */}
|
||||
<div className={cn('min-h-0 flex flex-1 flex-col', contentClassName)}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { PageSearchInput } from './overlays/overlay-search-input'
|
||||
|
||||
interface PageSearchShellProps extends React.ComponentProps<'section'> {
|
||||
children: ReactNode
|
||||
/** Primary tabs shown on the top row, beside the search. */
|
||||
tabs?: ReactNode
|
||||
/** Secondary filters shown full-width on their own row below (expands). */
|
||||
filters?: ReactNode
|
||||
onSearchChange: (value: string) => void
|
||||
searchPlaceholder: string
|
||||
searchTrailingAction?: ReactNode
|
||||
searchValue: string
|
||||
/** Hide the search field when there's nothing to search (empty dataset). */
|
||||
searchHidden?: boolean
|
||||
}
|
||||
|
||||
export function PageSearchShell({
|
||||
children,
|
||||
className,
|
||||
tabs,
|
||||
filters,
|
||||
onSearchChange,
|
||||
searchPlaceholder,
|
||||
searchTrailingAction,
|
||||
searchValue,
|
||||
searchHidden = false,
|
||||
...props
|
||||
}: PageSearchShellProps) {
|
||||
return (
|
||||
@@ -29,29 +33,42 @@ export function PageSearchShell({
|
||||
className={cn('flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)', className)}
|
||||
>
|
||||
{/*
|
||||
This header sits in the titlebar row, so it overlaps the OS window-drag
|
||||
region painted by the shell. Without `-webkit-app-region: no-drag` on
|
||||
the search row, mousedown on the input gets intercepted as a window-
|
||||
drag start and the input never receives focus (visible as "I can't
|
||||
click the search box" on the messaging/cron/etc pages).
|
||||
Header lives in the page body, below the window chrome (the shell floats
|
||||
traffic lights over the top titlebar-height strip, which the `pt` clears
|
||||
and leaves draggable). Top row: primary tabs + search. Second row:
|
||||
secondary filters, full-width so they expand. Interactive bits opt out
|
||||
of the drag region.
|
||||
*/}
|
||||
<div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5 [-webkit-app-region:no-drag]">
|
||||
{/* Reserve the top-right titlebar tools + native window-controls
|
||||
footprint so the full-width search input never slides under them. */}
|
||||
<div
|
||||
style={{
|
||||
paddingRight:
|
||||
'max(0px, calc(var(--titlebar-tools-right, 0px) + var(--titlebar-tools-width, 0px) - 0.75rem))'
|
||||
}}
|
||||
>
|
||||
<PageSearchInput
|
||||
onChange={onSearchChange}
|
||||
placeholder={searchPlaceholder}
|
||||
trailingAction={searchTrailingAction}
|
||||
value={searchValue}
|
||||
/>
|
||||
</div>
|
||||
{filters ? <div className="flex flex-wrap items-center justify-center gap-1.5">{filters}</div> : null}
|
||||
{/*
|
||||
IMPORTANT: do NOT put `-webkit-app-region: drag` on this header. It spans
|
||||
full width over the band where the floating titlebar icon clusters live,
|
||||
and an overlapping OS drag region eats their clicks at the compositor
|
||||
level (pointer-events / no-drag carve-outs across separate stacking
|
||||
contexts don't reliably fix it on macOS). The shell already supplies a
|
||||
draggable titlebar strip that is `calc()`'d around the icon clusters
|
||||
(see app-shell.tsx), so window dragging still works here.
|
||||
*/}
|
||||
<div className="shrink-0">
|
||||
{(tabs || !searchHidden) && (
|
||||
<div className="flex items-center gap-3 px-3 pb-2 pt-[calc(var(--titlebar-height)+0.5rem)]">
|
||||
{tabs ? (
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">{tabs}</div>
|
||||
) : null}
|
||||
{!searchHidden && (
|
||||
<div className={cn('flex shrink-0 items-center', !tabs && 'flex-1')}>
|
||||
<SearchField
|
||||
containerClassName="max-w-[45vw]"
|
||||
onChange={onSearchChange}
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{filters ? (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 pb-2">{filters}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden bg-(--ui-chat-surface-background)">{children}</div>
|
||||
</section>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
@@ -28,9 +28,9 @@ import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icon
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
import { titlebarHeaderBaseClass } from '../shell/titlebar'
|
||||
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { OverlayMain, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
|
||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
||||
|
||||
@@ -40,26 +40,18 @@ function isValidProfileName(name: string): boolean {
|
||||
return PROFILE_NAME_RE.test(name.trim())
|
||||
}
|
||||
|
||||
interface ProfilesViewProps extends React.ComponentProps<'section'> {
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
interface ProfilesViewProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ProfilesView({
|
||||
setStatusbarItemGroup: _setStatusbarItemGroup,
|
||||
setTitlebarToolGroup,
|
||||
...props
|
||||
}: ProfilesViewProps) {
|
||||
export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
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)
|
||||
@@ -72,33 +64,15 @@ export function ProfilesView({
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, 'Failed to load profiles')
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useRefreshHotkey(refresh)
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
useEffect(() => {
|
||||
if (!setTitlebarToolGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
setTitlebarToolGroup('profiles', [
|
||||
{
|
||||
disabled: refreshing,
|
||||
icon: <Codicon name="refresh" spinning={refreshing} />,
|
||||
id: 'refresh-profiles',
|
||||
label: refreshing ? 'Refreshing profiles' : 'Refresh profiles',
|
||||
onSelect: () => void refresh()
|
||||
}
|
||||
])
|
||||
|
||||
return () => setTitlebarToolGroup('profiles', [])
|
||||
}, [refresh, refreshing, setTitlebarToolGroup])
|
||||
|
||||
const selected = useMemo(() => {
|
||||
if (!profiles) {
|
||||
return null
|
||||
@@ -164,62 +138,58 @@ export function ProfilesView({
|
||||
}, [pendingDelete, refresh])
|
||||
|
||||
return (
|
||||
<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">Profiles</h2>
|
||||
<span className="pointer-events-auto text-xs text-muted-foreground">
|
||||
{profiles ? `${profiles.length} ${profiles.length === 1 ? 'profile' : 'profiles'}` : ''}
|
||||
</span>
|
||||
</header>
|
||||
<OverlayView closeLabel="Close profiles" onClose={onClose}>
|
||||
{!profiles ? (
|
||||
<PageLoader label="Loading profiles..." />
|
||||
) : (
|
||||
<OverlaySplitLayout>
|
||||
<OverlaySidebar>
|
||||
<div className="mb-1 flex items-center justify-between gap-2 pl-1.5 pr-0.5">
|
||||
<span className="text-[0.7rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)">
|
||||
Profiles
|
||||
</span>
|
||||
<Button
|
||||
aria-label="New profile"
|
||||
className="text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="0.875rem" />
|
||||
</Button>
|
||||
</div>
|
||||
{profiles.map(profile => (
|
||||
<ProfileRow
|
||||
active={selected?.name === profile.name}
|
||||
key={profile.name}
|
||||
onSelect={() => setSelectedName(profile.name)}
|
||||
profile={profile}
|
||||
/>
|
||||
))}
|
||||
{profiles.length === 0 && (
|
||||
<p className="px-1.5 py-3 text-xs text-muted-foreground">No profiles yet.</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="Loading profiles..." />
|
||||
) : (
|
||||
<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" />
|
||||
New profile
|
||||
</Button>
|
||||
</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">No profiles yet.</li>
|
||||
)}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<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">Select a profile to view its details.</p>
|
||||
</div>
|
||||
<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">Select a profile to view its details.</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
)}
|
||||
|
||||
<CreateProfileDialog
|
||||
onClose={() => setCreateOpen(false)}
|
||||
@@ -250,7 +220,7 @@ export function ProfilesView({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -258,8 +228,10 @@ 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',
|
||||
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
|
||||
'flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors',
|
||||
active
|
||||
? 'bg-(--ui-row-active-background) text-foreground'
|
||||
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
@@ -311,38 +283,30 @@ function ProfileDetail({
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-xl font-semibold tracking-tight">{profile.name}</h3>
|
||||
{profile.is_default && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[0.65rem] font-medium text-primary">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
{profile.has_env && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[0.65rem] font-medium text-muted-foreground">
|
||||
.env
|
||||
</span>
|
||||
)}
|
||||
{profile.is_default && <Badge>Default</Badge>}
|
||||
{profile.has_env && <Badge variant="muted">.env</Badge>}
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-[0.7rem] text-muted-foreground" title={profile.path}>
|
||||
{profile.path}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{!profile.is_default && (
|
||||
<Button onClick={() => setRenameOpen(true)} size="sm" variant="outline">
|
||||
<Button onClick={() => setRenameOpen(true)} size="sm" variant="text">
|
||||
<Pencil />
|
||||
Rename
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={copying} onClick={() => void handleCopySetup()} size="sm" variant="outline">
|
||||
<Button disabled={copying} onClick={() => void handleCopySetup()} size="sm" variant="text">
|
||||
<Terminal />
|
||||
{copying ? 'Copying...' : 'Copy setup'}
|
||||
</Button>
|
||||
{!profile.is_default && (
|
||||
<Button
|
||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
className="hover:text-destructive hover:no-underline"
|
||||
onClick={onDelete}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
variant="text"
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
@@ -351,7 +315,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="Model">
|
||||
{profile.model ? (
|
||||
<>
|
||||
@@ -387,7 +351,7 @@ function DetailRow({ children, label }: { children: React.ReactNode; label: stri
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<dt className="text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">{label}</dt>
|
||||
<dd className="text-sm text-foreground">{children}</dd>
|
||||
<dd className="text-xs text-foreground">{children}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -458,9 +422,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">
|
||||
Loading SOUL.md...
|
||||
</div>
|
||||
<PageLoader className="min-h-44" label="Loading SOUL.md" />
|
||||
) : (
|
||||
<Textarea
|
||||
className="min-h-72 font-mono text-xs leading-5"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { type NodeApi, type NodeRendererProps, Tree, type TreeApi } from 'react-arborist'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -121,11 +122,7 @@ export function ProjectTree({
|
||||
}
|
||||
|
||||
function TreeSizingState() {
|
||||
return (
|
||||
<div className="flex h-full min-h-24 items-center justify-center px-3 text-[0.68rem] text-(--ui-text-tertiary)">
|
||||
Loading files...
|
||||
</div>
|
||||
)
|
||||
return <PageLoader aria-label="Loading files" className="min-h-24 px-3" />
|
||||
}
|
||||
|
||||
function ProjectTreeRow({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $panesFlipped } from '@/store/layout'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { $currentBranch, $currentCwd } from '@/store/session'
|
||||
@@ -31,17 +32,14 @@ interface RightSidebarTab {
|
||||
}
|
||||
|
||||
const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
|
||||
{ id: 'files', label: 'File system', icon: 'files' },
|
||||
{ id: 'files', label: 'File system', icon: 'list-tree' },
|
||||
{ id: 'terminal', label: 'Terminal', icon: 'terminal' }
|
||||
]
|
||||
|
||||
export function RightSidebarPane({
|
||||
onActivateFile,
|
||||
onActivateFolder,
|
||||
onChangeCwd
|
||||
}: RightSidebarPaneProps) {
|
||||
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
|
||||
const activeTab = useStore($rightSidebarTab)
|
||||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const currentBranch = useStore($currentBranch).trim()
|
||||
const currentCwd = useStore($currentCwd).trim()
|
||||
const hasCwd = currentCwd.length > 0
|
||||
@@ -53,8 +51,17 @@ export function RightSidebarPane({
|
||||
.pop() ?? currentCwd)
|
||||
: 'No folder selected'
|
||||
|
||||
const { collapseAll, collapseNonce, data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } =
|
||||
useProjectTree(currentCwd)
|
||||
const {
|
||||
collapseAll,
|
||||
collapseNonce,
|
||||
data,
|
||||
loadChildren,
|
||||
openState,
|
||||
refreshRoot,
|
||||
rootError,
|
||||
rootLoading,
|
||||
setNodeOpen
|
||||
} = useProjectTree(currentCwd)
|
||||
|
||||
const canCollapse = Object.values(openState).some(Boolean)
|
||||
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
|
||||
@@ -86,14 +93,17 @@ export function RightSidebarPane({
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = terminalTakeover
|
||||
? RIGHT_SIDEBAR_TABS.filter(tab => tab.id !== 'terminal')
|
||||
: RIGHT_SIDEBAR_TABS
|
||||
const tabs = terminalTakeover ? RIGHT_SIDEBAR_TABS.filter(tab => tab.id !== 'terminal') : RIGHT_SIDEBAR_TABS
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Right sidebar"
|
||||
className="before:pointer-events-none relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-secondary) bg-(--ui-sidebar-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary) shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)] before:absolute before:inset-x-0 before:top-(--titlebar-height) before:z-1 before:h-px before:bg-(--ui-stroke-tertiary)"
|
||||
className={cn(
|
||||
'before:pointer-events-none relative flex h-full w-full min-w-0 flex-col overflow-hidden border-(--ui-stroke-secondary) bg-(--ui-sidebar-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary)',
|
||||
panesFlipped
|
||||
? 'border-r shadow-[inset_-0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
|
||||
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
|
||||
)}
|
||||
>
|
||||
<RightSidebarChrome activeTab={effectiveTab} branch={currentBranch} tabs={tabs} />
|
||||
|
||||
@@ -135,26 +145,27 @@ function RightSidebarChrome({
|
||||
}) {
|
||||
return (
|
||||
<header className="shrink-0 bg-transparent text-[0.75rem]">
|
||||
<div className="flex items-center gap-2 border-b border-(--ui-stroke-tertiary) px-2.5 py-1">
|
||||
<div className="flex items-center gap-2 px-2.5 py-1">
|
||||
<nav aria-label="Right sidebar panels" className="flex min-w-0 items-center gap-1">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
<Button
|
||||
aria-label={tab.label}
|
||||
aria-pressed={tab.id === activeTab}
|
||||
className={cn(
|
||||
'grid size-6 shrink-0 place-items-center rounded-lg text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring active:bg-(--ui-control-active-background) active:text-foreground',
|
||||
'data-[active=true]:bg-(--ui-control-active-background) data-[active=true]:text-foreground'
|
||||
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
|
||||
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
|
||||
)}
|
||||
data-active={tab.id === activeTab}
|
||||
key={tab.id}
|
||||
onClick={() => setRightSidebarTab(tab.id)}
|
||||
size="icon-xs"
|
||||
title={tab.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={tab.icon} size="0.875rem" />
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{branch && (
|
||||
<span className="ml-auto flex min-w-0 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary)">
|
||||
<Codicon className="shrink-0" name="git-branch" size="0.75rem" />
|
||||
@@ -175,8 +186,11 @@ interface FilesystemTabProps extends FileTreeBodyProps {
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
// Sidebar-specific color/hover treatment only — size, radius, cursor and the
|
||||
// base focus ring come from <Button size="icon-xs">. This constant exists
|
||||
// purely to share the sidebar palette + the hover-reveal behavior below.
|
||||
const HEADER_ACTION_CLASS =
|
||||
'size-6 shrink-0 rounded-md text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-2 focus-visible:ring-sidebar-ring'
|
||||
'text-sidebar-foreground/70 hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-sidebar-ring'
|
||||
|
||||
const HEADER_ACTION_REVEAL_CLASS = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
|
||||
|
||||
@@ -210,11 +224,22 @@ function FilesystemTab({
|
||||
>
|
||||
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
|
||||
</button>
|
||||
<Button
|
||||
aria-label="Refresh tree"
|
||||
className={HEADER_ACTION_CLASS}
|
||||
disabled={!hasCwd || loading}
|
||||
onClick={onRefresh}
|
||||
size="icon-xs"
|
||||
title="Refresh tree"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Open folder"
|
||||
className={HEADER_ACTION_CLASS}
|
||||
onClick={() => void onChangeFolder()}
|
||||
size="icon"
|
||||
size="icon-xs"
|
||||
title={hasCwd ? 'Open a different folder' : 'Open a folder'}
|
||||
variant="ghost"
|
||||
>
|
||||
@@ -225,23 +250,12 @@ function FilesystemTab({
|
||||
className={HEADER_ACTION_REVEAL_CLASS}
|
||||
disabled={!hasCwd || !canCollapse}
|
||||
onClick={onCollapseAll}
|
||||
size="icon"
|
||||
size="icon-xs"
|
||||
title="Collapse all folders"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="collapse-all" size="0.8125rem" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Refresh tree"
|
||||
className={HEADER_ACTION_REVEAL_CLASS}
|
||||
disabled={!hasCwd || loading}
|
||||
onClick={onRefresh}
|
||||
size="icon"
|
||||
title="Refresh tree"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
|
||||
</Button>
|
||||
</RightSidebarSectionHeader>
|
||||
<FileTreeBody
|
||||
collapseNonce={collapseNonce}
|
||||
@@ -261,7 +275,7 @@ function FilesystemTab({
|
||||
}
|
||||
|
||||
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
|
||||
return <div className="flex h-7 shrink-0 items-center px-2">{children}</div>
|
||||
return <div className="flex h-7 shrink-0 items-center px-2.5">{children}</div>
|
||||
}
|
||||
|
||||
interface FileTreeBodyProps {
|
||||
|
||||
@@ -52,6 +52,21 @@ export const APP_ROUTES = [
|
||||
const APP_VIEW_BY_PATH = new Map<string, AppView>(APP_ROUTES.map(route => [route.path, route.view]))
|
||||
const RESERVED_PATHS: ReadonlySet<string> = new Set(APP_ROUTES.map(route => route.path))
|
||||
|
||||
// Views that render as a full-screen modal card (OverlayView) over the shell.
|
||||
// While one is open the app's titlebar control clusters must hide so they don't
|
||||
// bleed over the overlay (they sit at a higher z-index than the overlay card).
|
||||
export const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set([
|
||||
'agents',
|
||||
'command-center',
|
||||
'cron',
|
||||
'profiles',
|
||||
'settings'
|
||||
])
|
||||
|
||||
export function isOverlayView(view: AppView): boolean {
|
||||
return OVERLAY_VIEWS.has(view)
|
||||
}
|
||||
|
||||
export function isNewChatRoute(pathname: string): boolean {
|
||||
return pathname === NEW_CHAT_ROUTE
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { setClarifyRequest } from '@/store/clarify'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
||||
import {
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
@@ -29,7 +30,8 @@ import {
|
||||
setCurrentReasoningEffort,
|
||||
setCurrentServiceTier,
|
||||
setCurrentUsage,
|
||||
setTurnStartedAt
|
||||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
|
||||
import { recordToolDiff } from '@/store/tool-diffs'
|
||||
@@ -311,6 +313,7 @@ export function useMessageStream({
|
||||
// commit and the synthetic harness shows longtask counts drop from ~5/5s
|
||||
// to ~1/5s on big sessions (see scripts/profile-typing-lag.md).
|
||||
const sinceLast = performance.now() - lastFlushAtRef.current
|
||||
|
||||
const runFlush = () => {
|
||||
flushHandleRef.current = null
|
||||
lastFlushAtRef.current = performance.now()
|
||||
@@ -529,7 +532,8 @@ export function useMessageStream({
|
||||
streamId: null,
|
||||
pendingBranchGroup: null,
|
||||
awaitingResponse: false,
|
||||
busy: false
|
||||
busy: false,
|
||||
needsInput: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -586,7 +590,8 @@ export function useMessageStream({
|
||||
pendingBranchGroup: null,
|
||||
sawAssistantPayload: true,
|
||||
awaitingResponse: false,
|
||||
busy: false
|
||||
busy: false,
|
||||
needsInput: false
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -655,6 +660,10 @@ export function useMessageStream({
|
||||
setCurrentFastMode(payload.fast)
|
||||
}
|
||||
|
||||
if (typeof payload?.yolo === 'boolean') {
|
||||
setYoloActive(payload.yolo)
|
||||
}
|
||||
|
||||
if (runningChanged && sessionId) {
|
||||
updateSessionState(sessionId, state => {
|
||||
const busy = Boolean(payload!.running)
|
||||
@@ -746,6 +755,13 @@ export function useMessageStream({
|
||||
return
|
||||
}
|
||||
|
||||
// Turn ended — drop any blocking prompt that's still open (e.g. the
|
||||
// agent was interrupted, or the approval already resolved). Prevents a
|
||||
// stale overlay from outliving the turn that raised it.
|
||||
if (isActiveEvent) {
|
||||
clearAllPrompts()
|
||||
}
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
if (isActiveEvent) {
|
||||
@@ -773,6 +789,11 @@ export function useMessageStream({
|
||||
if (sessionId) {
|
||||
flushQueuedDeltas(sessionId)
|
||||
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type)
|
||||
// A pending clarify blocks the turn, so the first tool.complete after
|
||||
// one is the clarify resolving — drop the "needs input" flag here so
|
||||
// the sidebar indicator clears as soon as it's answered, not only at
|
||||
// message.complete.
|
||||
updateSessionState(sessionId, state => (state.needsInput ? { ...state, needsInput: false } : state))
|
||||
}
|
||||
|
||||
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
|
||||
@@ -793,13 +814,16 @@ export function useMessageStream({
|
||||
)
|
||||
}
|
||||
} else if (event.type === 'clarify.request') {
|
||||
if (!isActiveEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
// Surface the clarify tool's overlay. The Python side is blocked on
|
||||
// `clarify.respond`, so without this handler the agent would hang
|
||||
// forever (see tools/clarify_tool.py + tui_gateway/server.py:_block).
|
||||
//
|
||||
// Store the request for whichever session raised it — even a background
|
||||
// one. clarify.request is a one-shot event; if we dropped it for an
|
||||
// unfocused session, that session would block on `clarify.respond`
|
||||
// indefinitely and re-focusing it could never recover (the event is
|
||||
// gone). Parking it per-session lets the user answer once they switch
|
||||
// over; the inline ClarifyTool reads the active session's entry.
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
const question = typeof payload?.question === 'string' ? payload.question : ''
|
||||
|
||||
@@ -810,11 +834,70 @@ export function useMessageStream({
|
||||
choices: Array.isArray(payload?.choices) ? payload!.choices!.filter(c => typeof c === 'string') : null,
|
||||
sessionId: sessionId ?? null
|
||||
})
|
||||
|
||||
// The transcript only renders the active session, so a background
|
||||
// clarify is otherwise invisible (the row just keeps spinning like
|
||||
// it's working). Flag the session so the sidebar shows a persistent
|
||||
// "needs input" indicator on its row — works for the active session
|
||||
// too, and survives alt-tab / window blur (unlike a toast).
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
}
|
||||
} else if (event.type === 'approval.request') {
|
||||
if (!isActiveEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
// Dangerous-command / execute_code approval. The Python side is
|
||||
// blocked in _await_gateway_decision() until approval.respond lands;
|
||||
// without this the agent stalls until its 5-min timeout and the tool
|
||||
// is BLOCKED. Approval is session-keyed (no request_id) — the overlay
|
||||
// sends back {choice, session_id}.
|
||||
setApprovalRequest({
|
||||
command: typeof payload?.command === 'string' ? payload.command : '',
|
||||
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
|
||||
sessionId: sessionId ?? null
|
||||
})
|
||||
} else if (event.type === 'sudo.request') {
|
||||
if (!isActiveEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
// Sudo password capture (tools/terminal_tool.py). Blocked on
|
||||
// sudo.respond {request_id, password}.
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
|
||||
if (requestId) {
|
||||
setSudoRequest({ requestId })
|
||||
}
|
||||
} else if (event.type === 'secret.request') {
|
||||
if (!isActiveEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skill credential capture (tools/skills_tool.py). Blocked on
|
||||
// secret.respond {request_id, value}.
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
|
||||
if (requestId) {
|
||||
setSecretRequest({
|
||||
requestId,
|
||||
envVar: typeof payload?.env_var === 'string' ? payload.env_var : '',
|
||||
prompt: typeof payload?.prompt === 'string' ? payload.prompt : ''
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
const errorMessage = payload?.message || 'Hermes reported an error'
|
||||
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
|
||||
|
||||
// A turn that errors out has also ended — drop any open blocking
|
||||
// prompt so an approval/sudo/secret overlay can't linger past the
|
||||
// failed turn (same intent as the message.complete clear).
|
||||
if (isActiveEvent) {
|
||||
clearAllPrompts()
|
||||
}
|
||||
|
||||
if (looksLikeProviderSetup) {
|
||||
requestDesktopOnboarding(errorMessage)
|
||||
} else if (isActiveEvent) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import {
|
||||
$composerAttachments,
|
||||
addComposerAttachment,
|
||||
@@ -28,7 +29,15 @@ import {
|
||||
} from '@/store/composer'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { $busy, $messages, setAwaitingResponse, setBusy, setMessages } from '@/store/session'
|
||||
import {
|
||||
$busy,
|
||||
$messages,
|
||||
$yoloActive,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setMessages,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
|
||||
import type { ClientSessionState, ImageAttachResponse, SlashExecResponse } from '../../types'
|
||||
|
||||
@@ -400,6 +409,30 @@ export function usePromptActions({
|
||||
return
|
||||
}
|
||||
|
||||
// /yolo maps to the status-bar YOLO control — a per-session approval
|
||||
// bypass, same scope as the TUI's Shift+Tab. With no session yet we arm
|
||||
// it locally; the session-create path applies it on the first message.
|
||||
if (normalizedName === 'yolo') {
|
||||
const sid = sessionHint || activeSessionIdRef.current
|
||||
const next = !$yoloActive.get()
|
||||
|
||||
if (!sid) {
|
||||
setYoloActive(next)
|
||||
notify({ kind: 'success', message: next ? 'YOLO armed for this chat' : 'YOLO off' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const active = await setSessionYolo(requestGateway, sid, next)
|
||||
appendSessionTextMessage(sid, 'system', `YOLO ${active ? 'on' : 'off'} for this session`)
|
||||
} catch {
|
||||
notify({ kind: 'error', title: 'YOLO', message: 'Could not toggle YOLO' })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizedName === 'skin' && !sessionHint && !activeSessionIdRef.current) {
|
||||
notify({ kind: 'success', message: handleSkinCommand(arg) })
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
|
||||
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
|
||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
|
||||
import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import { clearComposerAttachments, clearComposerDraft } from '@/store/composer'
|
||||
import { clearQueuedPrompts } from '@/store/composer-queue'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
@@ -15,7 +16,9 @@ import {
|
||||
$currentCwd,
|
||||
$messages,
|
||||
$sessions,
|
||||
$yoloActive,
|
||||
getRememberedWorkspaceCwd,
|
||||
sessionPinId,
|
||||
setActiveSessionId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
@@ -35,7 +38,8 @@ import {
|
||||
setSessions,
|
||||
setSessionsTotal,
|
||||
setSessionStartedAt,
|
||||
setTurnStartedAt
|
||||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { reportBackendContract } from '@/store/updates'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'
|
||||
@@ -247,6 +251,10 @@ function applyRuntimeInfo(
|
||||
setCurrentFastMode(info.fast)
|
||||
}
|
||||
|
||||
if (typeof info.yolo === 'boolean') {
|
||||
setYoloActive(info.yolo)
|
||||
}
|
||||
|
||||
if (info.usage) {
|
||||
setCurrentUsage(current => ({ ...current, ...info.usage }))
|
||||
}
|
||||
@@ -342,12 +350,19 @@ export function useSessionActions({
|
||||
setActiveSessionId(created.session_id)
|
||||
setSelectedStoredSessionId(stored)
|
||||
setSessionStartedAt(Date.now())
|
||||
const yoloArmed = $yoloActive.get()
|
||||
const runtimeInfo = applyRuntimeInfo(created.info)
|
||||
|
||||
if (runtimeInfo) {
|
||||
updateSessionState(created.session_id, state => ({ ...state, ...runtimeInfo }), stored)
|
||||
}
|
||||
|
||||
// User may have armed YOLO on the new-chat draft before the runtime
|
||||
// session existed — apply it to the freshly created session.
|
||||
if (yoloArmed) {
|
||||
await setSessionYolo(requestGateway, created.session_id, true).catch(() => undefined)
|
||||
}
|
||||
|
||||
return created.session_id
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
@@ -692,12 +707,15 @@ export function useSessionActions({
|
||||
const closingRuntimeId = wasSelected ? activeSessionId : null
|
||||
const previousMessages = $messages.get()
|
||||
const previousPinned = $pinnedSessionIds.get()
|
||||
// Pins are keyed on the durable lineage-root id; the stored id may be the
|
||||
// live tip after compression. Drop both so the pin can't linger.
|
||||
const removedPinId = removed ? sessionPinId(removed) : storedSessionId
|
||||
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
|
||||
// doesn't keep claiming the removed row is still on the server.
|
||||
setSessionsTotal(prev => Math.max(0, prev - 1))
|
||||
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
|
||||
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId && id !== removedPinId))
|
||||
|
||||
// Tear down before awaiting so the route effect can't resume the
|
||||
// doomed session via the stale /<sid> URL.
|
||||
@@ -769,6 +787,9 @@ export function useSessionActions({
|
||||
const archived = $sessions.get().find(s => s.id === storedSessionId)
|
||||
const wasSelected = selectedStoredSessionId === storedSessionId
|
||||
const previousPinned = $pinnedSessionIds.get()
|
||||
// Pins are keyed on the durable lineage-root id; the stored id may be the
|
||||
// live tip after compression. Drop both so the pin can't linger.
|
||||
const archivedPinId = archived ? sessionPinId(archived) : storedSessionId
|
||||
|
||||
// Soft-hide: drop from the sidebar immediately, keep the data.
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
@@ -776,7 +797,7 @@ export function useSessionActions({
|
||||
// on the next refresh, so they count as "removed" for the load-more
|
||||
// footer math.
|
||||
setSessionsTotal(prev => Math.max(0, prev - 1))
|
||||
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
|
||||
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId && id !== archivedPinId))
|
||||
|
||||
if (wasSelected) {
|
||||
startFreshSessionDraft(true)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $busy, $messages, noteSessionActivity, setSessionWorking } from '@/store/session'
|
||||
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
@@ -152,7 +152,13 @@ export function useSessionStateCache({
|
||||
setSessionWorking(previous.storedSessionId, false)
|
||||
}
|
||||
|
||||
if (previous.storedSessionId !== next.storedSessionId || !next.needsInput) {
|
||||
setSessionAttention(previous.storedSessionId, false)
|
||||
}
|
||||
|
||||
setSessionWorking(next.storedSessionId, next.busy)
|
||||
setSessionAttention(next.storedSessionId, next.needsInput)
|
||||
|
||||
// Every state update is effectively a "still alive" heartbeat for
|
||||
// streaming events. The session-store watchdog uses this to keep the
|
||||
// working flag alive during long-running turns and to clear it once
|
||||
@@ -160,6 +166,7 @@ export function useSessionStateCache({
|
||||
if (next.busy) {
|
||||
noteSessionActivity(next.storedSessionId)
|
||||
}
|
||||
|
||||
syncSessionStateToView(sessionId, next)
|
||||
|
||||
return next
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
|
||||
import { Loader2, RefreshCw, Sparkles } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$desktopVersion,
|
||||
@@ -111,29 +111,22 @@ export function AboutSettings() {
|
||||
statusTone === 'idle' && 'border-border/70 bg-muted/20 text-foreground'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{statusTone === 'available' ? (
|
||||
<Sparkles className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
) : statusTone === 'error' ? null : (
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{statusLine}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Last checked {relativeTime(status?.fetchedAt)}
|
||||
{justChecked && !checking ? ' · just now' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{statusLine}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Last checked {relativeTime(status?.fetchedAt)}
|
||||
{justChecked && !checking ? ' · just now' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4">
|
||||
<Button
|
||||
disabled={checking || applying || !supported}
|
||||
onClick={() => void handleCheck()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="textStrong"
|
||||
>
|
||||
{checking ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||
{checking && <Loader2 className="size-3 animate-spin" />}
|
||||
{checking ? 'Checking…' : 'Check now'}
|
||||
</Button>
|
||||
|
||||
@@ -143,12 +136,7 @@ export function AboutSettings() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className="ml-auto text-xs text-muted-foreground hover:text-foreground"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Button asChild className="ml-auto" size="sm" variant="text">
|
||||
<a
|
||||
href={RELEASE_NOTES_URL}
|
||||
onClick={event => {
|
||||
@@ -158,7 +146,6 @@ export function AboutSettings() {
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
Release notes
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Palette } from '@/lib/icons'
|
||||
import { Check } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
||||
import { useTheme } from '@/themes/context'
|
||||
import { BUILTIN_THEMES } from '@/themes/presets'
|
||||
|
||||
import { MODE_OPTIONS } from './constants'
|
||||
import { prettyName } from './helpers'
|
||||
import { Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import { SettingsContent } from './primitives'
|
||||
|
||||
function ThemePreview({ name }: { name: string }) {
|
||||
const t = BUILTIN_THEMES[name]
|
||||
@@ -51,146 +52,80 @@ function ThemePreview({ name }: { name: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHead({ title, description, control }: { title: string; description: string; control?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{title}</div>
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
{control && <div className="shrink-0">{control}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
|
||||
const toolViewMode = useStore($toolViewMode)
|
||||
const activeTheme = availableThemes.find(t => t.name === themeName)
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<SectionHeading icon={Palette} title="Appearance" />
|
||||
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and
|
||||
chat surface styling.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-8">
|
||||
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and
|
||||
chat surface styling.
|
||||
</p>
|
||||
|
||||
<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">Color Mode</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Pick a fixed mode or let Hermes follow your system setting.
|
||||
</div>
|
||||
</div>
|
||||
<Pill>{prettyName(mode)}</Pill>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{MODE_OPTIONS.map(({ id, label, description, icon: Icon }) => {
|
||||
const active = mode === 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">{label}</div>
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<section>
|
||||
<SectionHead
|
||||
control={
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('crisp')
|
||||
setMode(id)
|
||||
}}
|
||||
options={MODE_OPTIONS}
|
||||
value={mode}
|
||||
/>
|
||||
}
|
||||
description="Pick a fixed mode or let Hermes follow your system setting."
|
||||
title="Color Mode"
|
||||
/>
|
||||
</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">Tool Call Display</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Product hides raw tool payloads; Technical shows full input/output.
|
||||
</div>
|
||||
</div>
|
||||
<Pill>{toolViewMode === 'technical' ? 'Technical' : 'Product'}</Pill>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{(
|
||||
[
|
||||
{
|
||||
id: 'product',
|
||||
label: 'Product',
|
||||
description: 'Human-friendly tool activity with concise summaries.'
|
||||
},
|
||||
{
|
||||
id: 'technical',
|
||||
label: 'Technical',
|
||||
description: 'Include raw tool args/results and low-level details.'
|
||||
<section>
|
||||
<SectionHead
|
||||
control={
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('selection')
|
||||
setToolViewMode(id)
|
||||
}}
|
||||
options={
|
||||
[
|
||||
{ id: 'product', label: 'Product' },
|
||||
{ id: 'technical', label: 'Technical' }
|
||||
] as const
|
||||
}
|
||||
] 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>
|
||||
value={toolViewMode}
|
||||
/>
|
||||
}
|
||||
description="Product hides raw tool payloads; Technical shows full input/output."
|
||||
title="Tool Call Display"
|
||||
/>
|
||||
</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">Theme</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Desktop palettes only. The selected mode is applied on top.
|
||||
</div>
|
||||
</div>
|
||||
{activeTheme && <Pill>{activeTheme.label}</Pill>}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<section className="grid gap-3">
|
||||
<SectionHead description="Desktop palettes only. The selected mode is applied on top." title="Theme" />
|
||||
<div className="grid gap-x-4 gap-y-5 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)'
|
||||
)}
|
||||
className="group text-left"
|
||||
key={theme.name}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
@@ -198,8 +133,17 @@ export function AppearanceSettings() {
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ThemePreview name={theme.name} />
|
||||
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl transition',
|
||||
active
|
||||
? 'ring-2 ring-primary ring-offset-2 ring-offset-background'
|
||||
: 'opacity-90 group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<ThemePreview name={theme.name} />
|
||||
</div>
|
||||
<div className="mt-2.5 flex items-start justify-between gap-2 px-0.5">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
@@ -208,11 +152,7 @@ export function AppearanceSettings() {
|
||||
{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>
|
||||
)}
|
||||
{active && <Check className="mt-0.5 size-4 shrink-0 text-primary" />}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ChangeEvent, ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
@@ -17,10 +18,9 @@ import { notify, notifyError } from '@/store/notifications'
|
||||
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
|
||||
import { enumOptionsFor, getNested, includesQuery, prettyName, setNested } from './helpers'
|
||||
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
|
||||
import { ModelSettings } from './model-settings'
|
||||
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
|
||||
import type { SearchProps } from './types'
|
||||
|
||||
function ConfigField({
|
||||
schemaKey,
|
||||
@@ -53,8 +53,7 @@ function ConfigField({
|
||||
|
||||
if (schema.type === 'boolean') {
|
||||
return row(
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<span className="text-xs text-muted-foreground">{value ? 'On' : 'Off'}</span>
|
||||
<div className="flex items-center justify-end">
|
||||
<Switch checked={Boolean(value)} onCheckedChange={onChange} />
|
||||
</div>
|
||||
)
|
||||
@@ -89,7 +88,7 @@ function ConfigField({
|
||||
if (schema.type === 'number') {
|
||||
return row(
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
className={CONTROL_TEXT}
|
||||
onChange={e => {
|
||||
const raw = e.target.value
|
||||
const n = raw === '' ? 0 : Number(raw)
|
||||
@@ -108,7 +107,7 @@ function ConfigField({
|
||||
if (schema.type === 'list') {
|
||||
return row(
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
className={CONTROL_TEXT}
|
||||
onChange={e =>
|
||||
onChange(
|
||||
e.target.value
|
||||
@@ -154,7 +153,7 @@ function ConfigField({
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
className={CONTROL_TEXT}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder="Not set"
|
||||
value={String(value ?? '')}
|
||||
@@ -165,12 +164,11 @@ function ConfigField({
|
||||
}
|
||||
|
||||
export function ConfigSettings({
|
||||
query,
|
||||
activeSectionId,
|
||||
onConfigSaved,
|
||||
onMainModelChanged,
|
||||
importInputRef
|
||||
}: SearchProps & {
|
||||
}: {
|
||||
activeSectionId: string
|
||||
onConfigSaved?: () => void
|
||||
onMainModelChanged?: (provider: string, model: string) => void
|
||||
@@ -265,37 +263,41 @@ export function ConfigSettings({
|
||||
)
|
||||
}, [schema])
|
||||
|
||||
const matched = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const fields = sectionFields.get(activeSectionId) ?? []
|
||||
|
||||
if (!schema || !q) {
|
||||
return []
|
||||
// Deep-link target from the command palette (?field=<key>): scroll the row
|
||||
// into view and flash it, then drop the param so it doesn't re-fire.
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const targetField = searchParams.get('field')
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetField || !config || !schema) {
|
||||
return
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const element = document.getElementById(`setting-field-${targetField}`)
|
||||
|
||||
return SECTIONS.flatMap(s =>
|
||||
s.keys.flatMap(k => {
|
||||
if (seen.has(k) || !schema[k]) {
|
||||
return []
|
||||
}
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
|
||||
seen.add(k)
|
||||
const label = prettyName(k.split('.').pop() ?? k)
|
||||
const item = schema[k]
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
element.classList.add('setting-field-highlight')
|
||||
|
||||
const hit =
|
||||
k.toLowerCase().includes(q) ||
|
||||
label.toLowerCase().includes(q) ||
|
||||
includesQuery(item.category, q) ||
|
||||
includesQuery(item.description, q)
|
||||
const timeout = window.setTimeout(() => element.classList.remove('setting-field-highlight'), 1600)
|
||||
|
||||
return hit ? [[k, item] as [string, ConfigFieldSchema]] : []
|
||||
})
|
||||
setSearchParams(
|
||||
previous => {
|
||||
const next = new URLSearchParams(previous)
|
||||
next.delete('field')
|
||||
|
||||
return next
|
||||
},
|
||||
{ replace: true }
|
||||
)
|
||||
}, [schema, query])
|
||||
|
||||
const fields = query.trim() ? matched : (sectionFields.get(activeSectionId) ?? [])
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [config, schema, setSearchParams, targetField])
|
||||
|
||||
function handleImport(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
@@ -325,34 +327,30 @@ export function ConfigSettings({
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
{activeSectionId === 'model' && !query.trim() && (
|
||||
{activeSectionId === 'model' && (
|
||||
<div className="mb-6">
|
||||
<ModelSettings onMainModelChanged={onMainModelChanged} />
|
||||
</div>
|
||||
)}
|
||||
{query.trim() && (
|
||||
<div className="mb-4 text-xs text-muted-foreground">
|
||||
{fields.length} result{fields.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
)}
|
||||
{fields.length === 0 ? (
|
||||
<EmptyState description="Try a different search term or choose another section." title="No matching settings" />
|
||||
<EmptyState description="This section has no adjustable settings." title="Nothing to configure" />
|
||||
) : (
|
||||
<div className="divide-y divide-border/40">
|
||||
<div className="grid gap-1">
|
||||
{fields.map(([key, field]) => (
|
||||
<ConfigField
|
||||
enumOptions={
|
||||
key === 'tts.elevenlabs.voice_id'
|
||||
? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined)
|
||||
: enumOptionsFor(key, getNested(config, key), config)
|
||||
}
|
||||
key={key}
|
||||
onChange={value => updateConfig(setNested(config, key, value))}
|
||||
optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined}
|
||||
schema={field}
|
||||
schemaKey={key}
|
||||
value={getNested(config, key)}
|
||||
/>
|
||||
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
|
||||
<ConfigField
|
||||
enumOptions={
|
||||
key === 'tts.elevenlabs.voice_id'
|
||||
? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined)
|
||||
: enumOptionsFor(key, getNested(config, key), config)
|
||||
}
|
||||
onChange={value => updateConfig(setNested(config, key, value))}
|
||||
optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined}
|
||||
schema={field}
|
||||
schemaKey={key}
|
||||
value={getNested(config, key)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -22,7 +22,7 @@ interface ProviderPrefix {
|
||||
}
|
||||
|
||||
export const EMPTY_SELECT_VALUE = '__hermes_empty__'
|
||||
export const CONTROL_TEXT = 'text-[0.8125rem]'
|
||||
export const CONTROL_TEXT = 'text-xs'
|
||||
|
||||
export const PROVIDER_GROUPS: ProviderPrefix[] = [
|
||||
{ prefix: 'NOUS_', name: 'Nous Portal', priority: 0 },
|
||||
@@ -289,21 +289,11 @@ export const SECTIONS: DesktopConfigSection[] = [
|
||||
export interface ModeOption {
|
||||
id: ThemeMode
|
||||
label: string
|
||||
description: string
|
||||
icon: IconComponent
|
||||
}
|
||||
|
||||
export const MODE_OPTIONS: ModeOption[] = [
|
||||
{ id: 'light', label: 'Light', description: 'Bright desktop surfaces', icon: Sun },
|
||||
{ id: 'dark', label: 'Dark', description: 'Low-glare workspace', icon: Moon },
|
||||
{ id: 'system', label: 'System', description: 'Follow OS appearance', icon: Monitor }
|
||||
{ id: 'light', label: 'Light', icon: Sun },
|
||||
{ id: 'dark', label: 'Dark', icon: Moon },
|
||||
{ id: 'system', label: 'System', icon: Monitor }
|
||||
]
|
||||
|
||||
export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string> = {
|
||||
about: 'About Hermes Desktop',
|
||||
config: 'Search settings...',
|
||||
gateway: 'Gateway connection...',
|
||||
keys: 'Search API keys...',
|
||||
mcp: 'Search MCP servers...',
|
||||
sessions: 'Search archived sessions...'
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ export function GatewaySettings() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 divide-y divide-border/40">
|
||||
<div className="mt-5 grid gap-1">
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
@@ -272,28 +272,30 @@ export function GatewaySettings() {
|
||||
|
||||
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
|
||||
|
||||
<div className="mt-6 flex flex-wrap justify-end gap-3">
|
||||
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
|
||||
<Button
|
||||
className="mr-auto"
|
||||
disabled={state.envOverride || testing || !canUseRemote}
|
||||
onClick={() => void testRemote()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant="text"
|
||||
>
|
||||
{testing ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Test remote
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} variant="outline">
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
|
||||
Save for next restart
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(true)}>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
|
||||
{saving ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Save and reconnect
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 divide-y divide-border/40">
|
||||
<div className="mt-6 grid gap-1">
|
||||
<ListRow
|
||||
action={
|
||||
<Button onClick={() => void window.hermesDesktop?.revealLogs()} variant="outline">
|
||||
<Button onClick={() => void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong">
|
||||
<FileText className="size-4" />
|
||||
Open logs
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IconDownload, IconRefresh, IconUpload } from '@tabler/icons-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
@@ -8,19 +8,18 @@ import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { OverlayIconButton } from '../overlays/overlay-chrome'
|
||||
import { OverlaySearchInput } from '../overlays/overlay-search-input'
|
||||
import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
|
||||
import { AboutSettings } from './about-settings'
|
||||
import { AppearanceSettings } from './appearance-settings'
|
||||
import { ConfigSettings } from './config-settings'
|
||||
import { SEARCH_PLACEHOLDER, SECTIONS } from './constants'
|
||||
import { SECTIONS } from './constants'
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KeysSettings } from './keys-settings'
|
||||
import { McpSettings } from './mcp-settings'
|
||||
import { SessionsSettings } from './sessions-settings'
|
||||
import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
|
||||
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
|
||||
|
||||
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
|
||||
@@ -34,22 +33,8 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
|
||||
|
||||
const [queries, setQueries] = useState<Record<SettingsQueryKey, string>>({
|
||||
about: '',
|
||||
config: '',
|
||||
gateway: '',
|
||||
keys: '',
|
||||
mcp: '',
|
||||
sessions: ''
|
||||
})
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const queryKey: SettingsQueryKey = activeView.startsWith('config:') ? 'config' : (activeView as SettingsQueryKey)
|
||||
const query = queries[queryKey]
|
||||
const setQuery = (next: string) => setQueries(c => ({ ...c, [queryKey]: next }))
|
||||
|
||||
const exportConfig = async () => {
|
||||
try {
|
||||
const cfg = await getHermesConfigRecord()
|
||||
@@ -80,35 +65,8 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
}
|
||||
}
|
||||
|
||||
// OverlayView handles Esc; this just adds Cmd/Ctrl+P → focus search.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'p') {
|
||||
e.preventDefault()
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<OverlayView
|
||||
closeLabel="Close settings"
|
||||
headerContent={
|
||||
<OverlaySearchInput
|
||||
containerClassName="w-[min(36rem,calc(100vw-32rem))] min-w-80"
|
||||
inputRef={searchInputRef}
|
||||
onChange={setQuery}
|
||||
placeholder={SEARCH_PLACEHOLDER[queryKey]}
|
||||
value={query}
|
||||
/>
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<OverlayView closeLabel="Close settings" onClose={onClose}>
|
||||
<OverlaySplitLayout>
|
||||
<OverlaySidebar>
|
||||
{SECTIONS.map(s => {
|
||||
@@ -116,7 +74,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
|
||||
return (
|
||||
<OverlayNavItem
|
||||
active={activeView === view && !queries.config.trim()}
|
||||
active={activeView === view}
|
||||
icon={s.icon}
|
||||
key={s.id}
|
||||
label={s.label}
|
||||
@@ -182,7 +140,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
</div>
|
||||
</OverlaySidebar>
|
||||
|
||||
<OverlayMain className="p-0">
|
||||
<OverlayMain className="px-0 pb-0 pt-[calc(var(--titlebar-height)+1rem)]">
|
||||
{activeView === 'config:appearance' ? (
|
||||
<AppearanceSettings />
|
||||
) : activeView === 'about' ? (
|
||||
@@ -195,14 +153,13 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
importInputRef={importInputRef}
|
||||
onConfigSaved={onConfigSaved}
|
||||
onMainModelChanged={onMainModelChanged}
|
||||
query={queries.config}
|
||||
/>
|
||||
) : activeView === 'keys' ? (
|
||||
<KeysSettings query={queries.keys} />
|
||||
<KeysSettings />
|
||||
) : activeView === 'mcp' ? (
|
||||
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} query={queries.mcp} />
|
||||
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
|
||||
) : (
|
||||
<SessionsSettings query={queries.sessions} />
|
||||
<SessionsSettings />
|
||||
)}
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
|
||||
import { Check, Eye, EyeOff, Save, Settings2, Trash2, Zap } from '@/lib/icons'
|
||||
@@ -10,17 +9,10 @@ import { notify, notifyError } from '@/store/notifications'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import {
|
||||
asText,
|
||||
includesQuery,
|
||||
prettyName,
|
||||
providerGroup,
|
||||
providerPriority,
|
||||
redactedValue,
|
||||
withoutKey
|
||||
} from './helpers'
|
||||
import { asText, prettyName, providerGroup, providerPriority, redactedValue, withoutKey } from './helpers'
|
||||
import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import type { EnvPatch, EnvRowProps, ProviderGroup, SearchProps } from './types'
|
||||
import type { EnvPatch, EnvRowProps, ProviderGroup } from './types'
|
||||
import { useDeepLinkHighlight } from './use-deep-link-highlight'
|
||||
|
||||
interface EnvActionsProps {
|
||||
varKey: string
|
||||
@@ -62,7 +54,7 @@ function EnvActions({
|
||||
{isRevealed ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onEdit} size="xs" variant="outline">
|
||||
<Button onClick={onEdit} size="xs" variant="textStrong">
|
||||
{info.is_set ? 'Replace' : 'Set'}
|
||||
</Button>
|
||||
{info.is_set && (
|
||||
@@ -167,8 +159,7 @@ function EnvVarRow({
|
||||
<Save />
|
||||
{saving === varKey ? 'Saving' : 'Save'}
|
||||
</Button>
|
||||
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="outline">
|
||||
<Codicon name="close" />
|
||||
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="text">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
@@ -179,16 +170,24 @@ function EnvVarRow({
|
||||
|
||||
function EnvProviderGroup({
|
||||
group,
|
||||
rowProps
|
||||
rowProps,
|
||||
forceExpand = false
|
||||
}: {
|
||||
group: ProviderGroup
|
||||
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
|
||||
forceExpand?: boolean
|
||||
}) {
|
||||
const setCount = group.entries.filter(([, info]) => info.is_set).length
|
||||
// Default-expand providers that already have at least one key set; the
|
||||
// user is much more likely to be coming back to edit those than to start
|
||||
// configuring a fresh provider from scratch.
|
||||
const [expanded, setExpanded] = useState(setCount > 0)
|
||||
const [expanded, setExpanded] = useState(setCount > 0 || forceExpand)
|
||||
|
||||
useEffect(() => {
|
||||
if (forceExpand) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [forceExpand])
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl bg-background/60">
|
||||
@@ -209,7 +208,9 @@ function EnvProviderGroup({
|
||||
{expanded && (
|
||||
<div className="grid gap-2 bg-muted/20 p-3">
|
||||
{group.entries.map(([key, info]) => (
|
||||
<EnvVarRow compact={!info.is_set} info={info} key={key} varKey={key} {...rowProps} />
|
||||
<div className="scroll-mt-6 rounded-md" id={`env-var-${key}`} key={key}>
|
||||
<EnvVarRow compact={!info.is_set} info={info} varKey={key} {...rowProps} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -217,12 +218,20 @@ function EnvProviderGroup({
|
||||
)
|
||||
}
|
||||
|
||||
export function KeysSettings({ query }: SearchProps) {
|
||||
export function KeysSettings() {
|
||||
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null)
|
||||
const [edits, setEdits] = useState<Record<string, string>>({})
|
||||
const [revealed, setRevealed] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
|
||||
// Deep-link from the command palette (?key=<ENV_VAR>): force-expand the
|
||||
// matching provider group, scroll the row in, and flash it.
|
||||
const highlightKey = useDeepLinkHighlight({
|
||||
elementId: key => `env-var-${key}`,
|
||||
param: 'key',
|
||||
ready: key => Boolean(vars?.[key])
|
||||
})
|
||||
|
||||
// We used to hide ~80% of rows behind a global "Show advanced" toggle, but
|
||||
// everything in this view is configuration-level — "advanced" was a poor
|
||||
// distinction. The full list is rendered now and provider groups
|
||||
@@ -253,32 +262,12 @@ export function KeysSettings({ query }: SearchProps) {
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
const filterEnv = useCallback((info: EnvVarInfo, key: string, q: string, cat: string, extra?: string) => {
|
||||
if (asText(info.category) !== cat) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!q) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
key.toLowerCase().includes(q) ||
|
||||
includesQuery(info.description, q) ||
|
||||
Boolean(extra && extra.toLowerCase().includes(q))
|
||||
)
|
||||
}, [])
|
||||
|
||||
const providerGroups = useMemo<ProviderGroup[]>(() => {
|
||||
if (!vars) {
|
||||
return []
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
const entries = Object.entries(vars).filter(([key, info]) =>
|
||||
filterEnv(info, key, q, 'provider', providerGroup(key))
|
||||
)
|
||||
const entries = Object.entries(vars).filter(([, info]) => asText(info.category) === 'provider')
|
||||
|
||||
const groups = new Map<string, [string, EnvVarInfo][]>()
|
||||
|
||||
@@ -293,15 +282,13 @@ export function KeysSettings({ query }: SearchProps) {
|
||||
entries: entries.sort(([a], [b]) => a.localeCompare(b)),
|
||||
hasAnySet: entries.some(([, info]) => info.is_set)
|
||||
})).sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
|
||||
}, [filterEnv, query, vars])
|
||||
}, [vars])
|
||||
|
||||
const otherGroups = useMemo(() => {
|
||||
if (!vars) {
|
||||
return []
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
tool: 'Tools',
|
||||
messaging: 'Messaging',
|
||||
@@ -310,12 +297,12 @@ export function KeysSettings({ query }: SearchProps) {
|
||||
|
||||
return ['tool', 'messaging', 'setting'].flatMap(cat => {
|
||||
const entries = Object.entries(vars)
|
||||
.filter(([key, info]) => filterEnv(info, key, q, cat))
|
||||
.filter(([, info]) => asText(info.category) === cat)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
return entries.length === 0 ? [] : [{ category: cat, label: labels[cat] ?? prettyName(cat), entries }]
|
||||
})
|
||||
}, [filterEnv, query, vars])
|
||||
}, [vars])
|
||||
|
||||
function patchVar(key: string, patch: EnvPatch) {
|
||||
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
|
||||
@@ -407,7 +394,12 @@ export function KeysSettings({ query }: SearchProps) {
|
||||
/>
|
||||
<div className="grid gap-2">
|
||||
{providerGroups.map(group => (
|
||||
<EnvProviderGroup group={group} key={group.name} rowProps={rowProps} />
|
||||
<EnvProviderGroup
|
||||
forceExpand={Boolean(highlightKey) && group.entries.some(([key]) => key === highlightKey)}
|
||||
group={group}
|
||||
key={group.name}
|
||||
rowProps={rowProps}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -421,7 +413,9 @@ export function KeysSettings({ query }: SearchProps) {
|
||||
/>
|
||||
<div className="grid gap-2">
|
||||
{group.entries.map(([key, info]) => (
|
||||
<EnvVarRow info={info} key={key} varKey={key} {...rowProps} />
|
||||
<div className="scroll-mt-6 rounded-md" id={`env-var-${key}`} key={key}>
|
||||
<EnvVarRow info={info} varKey={key} {...rowProps} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { OverlayActionButton, OverlayCard } from '@/app/overlays/overlay-chrome'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { getHermesConfigRecord, type HermesGateway, saveHermesConfig } from '@/hermes'
|
||||
import { Package, Wrench } from '@/lib/icons'
|
||||
import { Wrench } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
import type { HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { includesQuery } from './helpers'
|
||||
import { EmptyState, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import type { SearchProps } from './types'
|
||||
import { EmptyState, LoadingState, Pill, SettingsContent } from './primitives'
|
||||
import { useDeepLinkHighlight } from './use-deep-link-highlight'
|
||||
|
||||
interface McpSettingsProps extends SearchProps {
|
||||
interface McpSettingsProps {
|
||||
gateway?: HermesGateway | null
|
||||
onConfigSaved?: () => void
|
||||
}
|
||||
@@ -42,15 +42,7 @@ const transportLabel = (server: Record<string, unknown>) =>
|
||||
? 'stdio'
|
||||
: 'custom'
|
||||
|
||||
function serverMatches(name: string, server: Record<string, unknown>, query: string) {
|
||||
if (!query) {
|
||||
return true
|
||||
}
|
||||
|
||||
return includesQuery(name, query) || includesQuery(JSON.stringify(server), query)
|
||||
}
|
||||
|
||||
export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps) {
|
||||
export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
@@ -80,10 +72,13 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
|
||||
const servers = useMemo(() => getServers(config), [config])
|
||||
const names = useMemo(() => Object.keys(servers).sort(), [servers])
|
||||
|
||||
const filtered = useMemo(
|
||||
() => names.filter(serverName => serverMatches(serverName, servers[serverName], query.trim().toLowerCase())),
|
||||
[names, query, servers]
|
||||
)
|
||||
useDeepLinkHighlight({
|
||||
block: 'nearest',
|
||||
elementId: serverName => `mcp-server-${serverName}`,
|
||||
onResolve: setSelected,
|
||||
param: 'server',
|
||||
ready: serverName => Boolean(config) && serverName in servers
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const server = selected ? servers[selected] : null
|
||||
@@ -188,31 +183,32 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<SectionHeading icon={Package} meta={`${names.length} configured`} title="MCP servers" />
|
||||
<div className="flex items-center gap-2">
|
||||
<OverlayActionButton onClick={() => setSelected(null)}>New server</OverlayActionButton>
|
||||
<OverlayActionButton disabled={reloading} onClick={() => void reloadMcp()}>
|
||||
{reloading ? 'Reloading...' : 'Reload MCP'}
|
||||
</OverlayActionButton>
|
||||
</div>
|
||||
<div className="mb-4 flex items-center justify-end gap-4">
|
||||
<Button onClick={() => setSelected(null)} size="xs" variant="text">
|
||||
New server
|
||||
</Button>
|
||||
<Button disabled={reloading} onClick={() => void reloadMcp()} size="xs" variant="text">
|
||||
{reloading ? 'Reloading...' : 'Reload MCP'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 gap-4 lg:grid-cols-[17rem_minmax(0,1fr)]">
|
||||
<OverlayCard className="min-h-64 overflow-hidden p-2">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="grid min-h-0 gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]">
|
||||
<div className="min-h-64">
|
||||
{names.length === 0 ? (
|
||||
<EmptyState description="Add a stdio or HTTP server to expose MCP tools." title="No MCP servers" />
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
{filtered.map(serverName => {
|
||||
<div className="grid gap-0.5">
|
||||
{names.map(serverName => {
|
||||
const server = servers[serverName]
|
||||
const active = selected === serverName
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`rounded-md px-2 py-2 text-left transition-colors hover:bg-(--chrome-action-hover) ${
|
||||
active ? 'bg-accent/45 text-foreground' : 'text-muted-foreground'
|
||||
}`}
|
||||
className={cn(
|
||||
'scroll-mt-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-(--chrome-action-hover)',
|
||||
active ? 'bg-(--ui-bg-tertiary) text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
id={`mcp-server-${serverName}`}
|
||||
key={serverName}
|
||||
onClick={() => setSelected(serverName)}
|
||||
type="button"
|
||||
@@ -227,9 +223,9 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</OverlayCard>
|
||||
</div>
|
||||
|
||||
<OverlayCard className="grid gap-3 p-4">
|
||||
<div className="grid content-start gap-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Wrench className="size-4 text-muted-foreground" />
|
||||
{selected ? 'Edit server' : 'New server'}
|
||||
@@ -249,17 +245,23 @@ export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps)
|
||||
</label>
|
||||
<div className="flex items-center justify-between">
|
||||
{selected ? (
|
||||
<OverlayActionButton disabled={saving} onClick={() => void removeServer(selected)} tone="danger">
|
||||
<Button
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={saving}
|
||||
onClick={() => void removeServer(selected)}
|
||||
size="xs"
|
||||
variant="text"
|
||||
>
|
||||
Remove
|
||||
</OverlayActionButton>
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<OverlayActionButton disabled={saving} onClick={() => void saveServer()}>
|
||||
<Button disabled={saving} onClick={() => void saveServer()} size="sm">
|
||||
{saving ? 'Saving...' : 'Save server'}
|
||||
</OverlayActionButton>
|
||||
</Button>
|
||||
</div>
|
||||
</OverlayCard>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes'
|
||||
import type { AuxiliaryModelsResponse, ModelOptionProvider } from '@/hermes'
|
||||
import { Cpu, Loader2, Sparkles } from '@/lib/icons'
|
||||
import { Cpu, Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
@@ -204,11 +204,6 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<section>
|
||||
<SectionHeading
|
||||
icon={Sparkles}
|
||||
meta={mainModel ? `${mainModel.provider} / ${mainModel.model}` : undefined}
|
||||
title="Main model"
|
||||
/>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Applies to new sessions. Use the model picker in the composer to hot-swap the active chat.
|
||||
</p>
|
||||
@@ -238,7 +233,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button disabled={!selectedProvider || !selectedModel || applying} onClick={() => void applyMainModel()} size="sm">
|
||||
{applying ? <Loader2 className="size-3.5 animate-spin" /> : <Sparkles className="size-3.5" />}
|
||||
{applying && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{applying ? 'Applying...' : 'Apply'}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -252,7 +247,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
disabled={!mainModel || applying}
|
||||
onClick={() => void resetAuxiliaryModels()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="textStrong"
|
||||
>
|
||||
Reset all to main
|
||||
</Button>
|
||||
@@ -260,7 +255,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Helper tasks run on the main model by default. Assign a dedicated model to any task to override.
|
||||
</p>
|
||||
<div className="divide-y divide-border/40">
|
||||
<div className="grid gap-1">
|
||||
{AUX_TASKS.map(meta => {
|
||||
const current = auxiliary?.tasks.find(entry => entry.task === meta.key)
|
||||
const isAuto = !current || !current.provider || current.provider === 'auto'
|
||||
@@ -275,7 +270,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
disabled={!mainModel || applying}
|
||||
onClick={() => void setAuxiliaryToMain(meta.key)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
variant="text"
|
||||
>
|
||||
Set to main
|
||||
</Button>
|
||||
@@ -283,7 +278,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
disabled={!providers.length || applying}
|
||||
onClick={() => beginAuxiliaryEdit(meta.key)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant="textStrong"
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
@@ -292,7 +287,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
}
|
||||
below={
|
||||
isEditing && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 border-t border-border/40 pt-2">
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 pt-1">
|
||||
<Select
|
||||
onValueChange={value => setAuxDraft(prev => ({ ...prev, provider: value, model: '' }))}
|
||||
value={auxDraft.provider}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { PAGE_INSET_X } from '../layout-constants'
|
||||
|
||||
export function SettingsContent({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<section className="min-h-0 overflow-hidden">
|
||||
<div className="h-full min-h-0 overflow-y-auto px-5 py-4 pb-20">
|
||||
<div className={cn('h-full min-h-0 overflow-y-auto pb-20', PAGE_INSET_X)}>
|
||||
<div className="mx-auto w-full max-w-4xl">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -16,16 +19,7 @@ export function SettingsContent({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
export function Pill({ tone = 'muted', children }: { tone?: 'muted' | 'primary'; children: ReactNode }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.6875rem]',
|
||||
tone === 'primary' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
return <Badge variant={tone === 'primary' ? 'default' : 'muted'}>{children}</Badge>
|
||||
}
|
||||
|
||||
export function SectionHeading({ icon: Icon, title, meta }: { icon: IconComponent; title: string; meta?: string }) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
|
||||
@@ -10,7 +10,7 @@ import { setSessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
|
||||
import type { SearchProps } from './types'
|
||||
import { useDeepLinkHighlight } from './use-deep-link-highlight'
|
||||
|
||||
const ARCHIVED_FETCH_LIMIT = 200
|
||||
|
||||
@@ -30,7 +30,7 @@ function workspaceLabel(cwd: null | string | undefined): string {
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionsSettings({ query }: SearchProps) {
|
||||
export function SessionsSettings() {
|
||||
const [sessions, setLocalSessions] = useState<SessionInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
@@ -87,17 +87,11 @@ export function SessionsSettings({ query }: SearchProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase()
|
||||
|
||||
if (!needle) {
|
||||
return sessions
|
||||
}
|
||||
|
||||
return sessions.filter(session =>
|
||||
[sessionTitle(session), session.preview ?? '', session.cwd ?? ''].join(' ').toLowerCase().includes(needle)
|
||||
)
|
||||
}, [query, sessions])
|
||||
useDeepLinkHighlight({
|
||||
elementId: id => `archived-session-${id}`,
|
||||
param: 'session',
|
||||
ready: id => !loading && sessions.some(session => session.id === id)
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState label="Loading archived sessions…" />
|
||||
@@ -117,50 +111,48 @@ export function SessionsSettings({ query }: SearchProps) {
|
||||
archive it.
|
||||
</p>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
description={query.trim() ? 'No archived chats match your search.' : 'Archive a chat to hide it here.'}
|
||||
title="Nothing archived"
|
||||
/>
|
||||
{sessions.length === 0 ? (
|
||||
<EmptyState description="Archive a chat to hide it here." title="Nothing archived" />
|
||||
) : (
|
||||
<div className="divide-y divide-border/30">
|
||||
{filtered.map(session => {
|
||||
<div className="grid gap-1">
|
||||
{sessions.map(session => {
|
||||
const label = workspaceLabel(session.cwd)
|
||||
const busy = busyId === session.id
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => void unarchive(session)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />}
|
||||
<span>Unarchive</span>
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Delete permanently"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(session)}
|
||||
size="icon"
|
||||
title="Delete permanently"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
description={session.preview || undefined}
|
||||
hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`}
|
||||
key={session.id}
|
||||
title={sessionTitle(session)}
|
||||
/>
|
||||
<div className="scroll-mt-6 rounded-lg" id={`archived-session-${session.id}`} key={session.id}>
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => void unarchive(session)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="textStrong"
|
||||
>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />}
|
||||
<span>Unarchive</span>
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Delete permanently"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(session)}
|
||||
size="icon"
|
||||
title="Delete permanently"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
description={session.preview || undefined}
|
||||
hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`}
|
||||
title={sessionTitle(session)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -192,7 +184,10 @@ function DefaultProjectDirSetting() {
|
||||
let alive = true
|
||||
|
||||
void settings.getDefaultProjectDir().then(result => {
|
||||
if (!alive) return
|
||||
if (!alive) {
|
||||
return
|
||||
}
|
||||
|
||||
setDir(result.dir)
|
||||
setFallback(result.defaultLabel)
|
||||
})
|
||||
@@ -205,7 +200,9 @@ function DefaultProjectDirSetting() {
|
||||
const choose = useCallback(async () => {
|
||||
const settings = window.hermesDesktop?.settings
|
||||
|
||||
if (!settings) return
|
||||
if (!settings) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
|
||||
@@ -229,7 +226,9 @@ function DefaultProjectDirSetting() {
|
||||
const clear = useCallback(async () => {
|
||||
const settings = window.hermesDesktop?.settings
|
||||
|
||||
if (!settings) return
|
||||
if (!settings) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
|
||||
@@ -251,13 +250,19 @@ function DefaultProjectDirSetting() {
|
||||
</p>
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button disabled={busy} onClick={() => void choose()} size="sm" type="button" variant="outline">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => void choose()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="textStrong"
|
||||
>
|
||||
<FolderOpen className="size-3.5" />
|
||||
<span>{dir ? 'Change' : 'Choose'}</span>
|
||||
</Button>
|
||||
{dir && (
|
||||
<Button disabled={busy} onClick={() => void clear()} size="sm" type="button" variant="ghost">
|
||||
<Button disabled={busy} onClick={() => void clear()} size="sm" type="button" variant="text">
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, 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'
|
||||
@@ -121,7 +122,7 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
||||
{revealed !== null ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => setEditing(e => !e)} size="xs" variant="outline">
|
||||
<Button onClick={() => setEditing(e => !e)} size="xs" variant="textStrong">
|
||||
{isSet ? 'Replace' : 'Set'}
|
||||
</Button>
|
||||
{isSet && (
|
||||
@@ -150,7 +151,7 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={() => setEditing(false)} size="sm" variant="outline">
|
||||
<Button onClick={() => setEditing(false)} size="sm" variant="text">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
@@ -210,6 +211,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
|
||||
(cfg?.active_provider ? providers.find(p => p.name === cfg.active_provider) : undefined) ??
|
||||
providers.find(p => providerConfigured(p, envState)) ??
|
||||
providers[0]
|
||||
|
||||
setActiveProvider(selected.name)
|
||||
}, [activeProvider, providers, envState, cfg])
|
||||
|
||||
@@ -250,12 +252,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
|
||||
}, [cfg, loading, providers.length])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 py-3 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Loading configuration...
|
||||
</div>
|
||||
)
|
||||
return <PageLoader className="min-h-32" label="Loading configuration" />
|
||||
}
|
||||
|
||||
if (emptyMessage) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { IconComponent } from '@/lib/icons'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | `config:${string}`
|
||||
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions'
|
||||
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
|
||||
|
||||
export interface SettingsPageProps {
|
||||
@@ -15,10 +14,6 @@ export interface SettingsPageProps {
|
||||
onMainModelChanged?: (provider: string, model: string) => void
|
||||
}
|
||||
|
||||
export interface SearchProps {
|
||||
query: string
|
||||
}
|
||||
|
||||
export interface ProviderGroup {
|
||||
name: string
|
||||
priority: number
|
||||
|
||||
60
apps/desktop/src/app/settings/use-deep-link-highlight.ts
Normal file
60
apps/desktop/src/app/settings/use-deep-link-highlight.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
interface DeepLinkHighlightOptions {
|
||||
param: string
|
||||
ready: (target: string) => boolean
|
||||
elementId: (target: string) => string
|
||||
onResolve?: (target: string) => void
|
||||
block?: ScrollLogicalPosition
|
||||
}
|
||||
|
||||
// Deep-link from the command palette (?<param>=<id>): once the target row is
|
||||
// renderable, scroll it into view and flash it, then drop the param so it
|
||||
// doesn't re-fire. Returns the pending target (null once consumed) so callers
|
||||
// can force the row open before it mounts.
|
||||
export function useDeepLinkHighlight({
|
||||
param,
|
||||
ready,
|
||||
elementId,
|
||||
onResolve,
|
||||
block = 'center'
|
||||
}: DeepLinkHighlightOptions): null | string {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const target = searchParams.get(param)
|
||||
|
||||
useEffect(() => {
|
||||
if (!target || !ready(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
onResolve?.(target)
|
||||
|
||||
// Defer a frame so async state (expansion, selection) mounts the row first.
|
||||
const scrollTimeout = window.setTimeout(() => {
|
||||
const element = document.getElementById(elementId(target))
|
||||
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
|
||||
element.scrollIntoView({ behavior: 'smooth', block })
|
||||
element.classList.add('setting-field-highlight')
|
||||
window.setTimeout(() => element.classList.remove('setting-field-highlight'), 1600)
|
||||
}, 80)
|
||||
|
||||
setSearchParams(
|
||||
previous => {
|
||||
const next = new URLSearchParams(previous)
|
||||
next.delete(param)
|
||||
|
||||
return next
|
||||
},
|
||||
{ replace: true }
|
||||
)
|
||||
|
||||
return () => window.clearTimeout(scrollTimeout)
|
||||
}, [block, elementId, onResolve, param, ready, setSearchParams, target])
|
||||
|
||||
return target
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { PaneShell } from '@/components/pane-shell'
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import {
|
||||
$fileBrowserOpen,
|
||||
$panesFlipped,
|
||||
$sidebarOpen,
|
||||
FILE_BROWSER_DEFAULT_WIDTH,
|
||||
FILE_BROWSER_PANE_ID,
|
||||
@@ -20,11 +21,9 @@ import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode
|
||||
commandCenterOpen?: boolean
|
||||
leftStatusbarItems?: readonly StatusbarItem[]
|
||||
leftTitlebarTools?: readonly TitlebarTool[]
|
||||
onOpenSettings: () => void
|
||||
onOpenSearch: () => void
|
||||
overlays?: ReactNode
|
||||
statusbarItems?: readonly StatusbarItem[]
|
||||
titlebarTools?: readonly TitlebarTool[]
|
||||
@@ -47,17 +46,16 @@ const viewportIsFullscreen = () =>
|
||||
|
||||
export function AppShell({
|
||||
children,
|
||||
commandCenterOpen = false,
|
||||
leftStatusbarItems,
|
||||
leftTitlebarTools,
|
||||
onOpenSettings,
|
||||
onOpenSearch,
|
||||
overlays,
|
||||
statusbarItems,
|
||||
titlebarTools
|
||||
}: AppShellProps) {
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const fileBrowserOpen = useStore($fileBrowserOpen)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID))
|
||||
const connection = useStore($connection)
|
||||
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
|
||||
@@ -69,7 +67,12 @@ export function AppShell({
|
||||
const nativeOverlayWidth = connection?.nativeOverlayWidth ?? 0
|
||||
const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem'
|
||||
|
||||
const titlebarContentInset = sidebarOpen
|
||||
// The inset clears the top-left titlebar buttons when nothing covers the
|
||||
// window's left edge. Default layout: the sessions sidebar sits there.
|
||||
// Flipped layout: the file browser does instead.
|
||||
const leftEdgePaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen
|
||||
|
||||
const titlebarContentInset = leftEdgePaneOpen
|
||||
? 0
|
||||
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
|
||||
|
||||
@@ -130,13 +133,7 @@ export function AppShell({
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<TitlebarControls
|
||||
commandCenterOpen={commandCenterOpen}
|
||||
leftTools={leftTitlebarTools}
|
||||
onOpenSearch={onOpenSearch}
|
||||
onOpenSettings={onOpenSettings}
|
||||
tools={titlebarTools}
|
||||
/>
|
||||
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
|
||||
|
||||
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden transition-none">
|
||||
<PaneShell className="min-h-0 flex-1">
|
||||
|
||||
@@ -78,7 +78,7 @@ export function GatewayMenuPanel({
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
aria-label="Open system panel"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenSystem}
|
||||
size="icon-sm"
|
||||
title="Open system panel"
|
||||
|
||||
@@ -2,10 +2,9 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { type CommandCenterSection } from '@/app/command-center'
|
||||
import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, NEW_CHAT_ROUTE } from '@/app/routes'
|
||||
import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, isOverlayView, NEW_CHAT_ROUTE } from '@/app/routes'
|
||||
|
||||
const SECTIONS = ['sessions', 'system', 'usage'] as const
|
||||
const OVERLAY_VIEWS = new Set(['settings', 'command-center', 'agents'])
|
||||
|
||||
export function useOverlayRouting() {
|
||||
const location = useLocation()
|
||||
@@ -15,8 +14,10 @@ export function useOverlayRouting() {
|
||||
const settingsOpen = currentView === 'settings'
|
||||
const commandCenterOpen = currentView === 'command-center'
|
||||
const agentsOpen = currentView === 'agents'
|
||||
const cronOpen = currentView === 'cron'
|
||||
const profilesOpen = currentView === 'profiles'
|
||||
const chatOpen = currentView === 'chat'
|
||||
const overlayOpen = OVERLAY_VIEWS.has(currentView)
|
||||
const overlayOpen = isOverlayView(currentView)
|
||||
|
||||
// Overlay routes (settings/command-center/agents) stash the underlying path
|
||||
// so closing them returns there instead of bouncing to /.
|
||||
@@ -59,9 +60,11 @@ export function useOverlayRouting() {
|
||||
closeOverlayToPreviousRoute,
|
||||
commandCenterInitialSection,
|
||||
commandCenterOpen,
|
||||
cronOpen,
|
||||
currentView,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
profilesOpen,
|
||||
settingsOpen,
|
||||
toggleCommandCenter
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
import type { CommandCenterSection } from '@/app/command-center'
|
||||
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
|
||||
import { Activity, AlertCircle, ChevronDown, Clock, Command, Hash, Loader2, Sparkles } from '@/lib/icons'
|
||||
import { Activity, AlertCircle, ChevronDown, Clock, Command, Hash, Loader2, Sparkles, Zap, ZapFilled } from '@/lib/icons'
|
||||
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
||||
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import { $desktopActionTasks } from '@/store/activity'
|
||||
import { $previewServerRestartStatus } from '@/store/preview'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$busy,
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
@@ -21,7 +23,9 @@ import {
|
||||
$sessionStartedAt,
|
||||
$turnStartedAt,
|
||||
$workingSessionIds,
|
||||
setModelPickerOpen
|
||||
$yoloActive,
|
||||
setModelPickerOpen,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
|
||||
import { $desktopVersion, $updateApply, $updateStatus, setUpdateOverlayOpen } from '@/store/updates'
|
||||
@@ -41,6 +45,8 @@ interface StatusbarItemsOptions {
|
||||
modelMenuContent?: ReactNode
|
||||
openAgents: () => void
|
||||
openCommandCenterSection: (section: CommandCenterSection) => void
|
||||
freshDraftReady: boolean
|
||||
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
statusSnapshot: StatusResponse | null
|
||||
toggleCommandCenter: () => void
|
||||
}
|
||||
@@ -56,9 +62,13 @@ export function useStatusbarItems({
|
||||
modelMenuContent,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
freshDraftReady,
|
||||
requestGateway,
|
||||
statusSnapshot,
|
||||
toggleCommandCenter
|
||||
}: StatusbarItemsOptions) {
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const yoloActive = useStore($yoloActive)
|
||||
const busy = useStore($busy)
|
||||
const currentFastMode = useStore($currentFastMode)
|
||||
const currentModel = useStore($currentModel)
|
||||
@@ -78,6 +88,28 @@ export function useStatusbarItems({
|
||||
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
|
||||
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
|
||||
|
||||
// Per-session approval bypass (same scope as the TUI's Shift+Tab). On a
|
||||
// new-chat draft (no runtime session yet) we arm locally; the session-create
|
||||
// path applies it once the backend session exists.
|
||||
const toggleYolo = useCallback(async () => {
|
||||
const next = !$yoloActive.get()
|
||||
const sid = $activeSessionId.get()
|
||||
|
||||
setYoloActive(next)
|
||||
|
||||
if (!sid) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await setSessionYolo(requestGateway, sid, next)
|
||||
} catch {
|
||||
setYoloActive(!next)
|
||||
}
|
||||
}, [requestGateway])
|
||||
|
||||
const showYoloToggle = gatewayState === 'open' && (!!activeSessionId || freshDraftReady)
|
||||
|
||||
const gatewayMenuContent = useMemo(
|
||||
() => (
|
||||
<GatewayMenuPanel
|
||||
@@ -276,6 +308,17 @@ export function useStatusbarItems({
|
||||
title: 'Runtime session elapsed',
|
||||
variant: 'text'
|
||||
},
|
||||
{
|
||||
className: cn('px-1', yoloActive && 'bg-(--chrome-action-hover)'),
|
||||
hidden: !showYoloToggle,
|
||||
icon: yoloActive ? <ZapFilled className="size-3.5 shrink-0" /> : <Zap className="size-3.5 shrink-0 opacity-70" />,
|
||||
id: 'yolo',
|
||||
onSelect: () => void toggleYolo(),
|
||||
title: yoloActive
|
||||
? 'YOLO on — auto-approving dangerous commands. Click to turn off.'
|
||||
: 'YOLO off — click to auto-approve dangerous commands.',
|
||||
variant: 'action'
|
||||
},
|
||||
{
|
||||
id: 'model-summary',
|
||||
label: (
|
||||
@@ -294,16 +337,12 @@ export function useStatusbarItems({
|
||||
menuAlign: 'end' as const,
|
||||
menuClassName: 'w-64',
|
||||
menuContent: modelMenuContent,
|
||||
title: currentProvider
|
||||
? `Model · ${currentProvider}: ${currentModel || 'none'}`
|
||||
: 'Switch model',
|
||||
title: currentProvider ? `Model · ${currentProvider}: ${currentModel || 'none'}` : 'Switch model',
|
||||
variant: 'menu' as const
|
||||
}
|
||||
: {
|
||||
onSelect: () => setModelPickerOpen(true),
|
||||
title: currentProvider
|
||||
? `${currentProvider} · ${currentModel || 'no model'}`
|
||||
: 'Open model picker',
|
||||
title: currentProvider ? `${currentProvider} · ${currentModel || 'no model'}` : 'Open model picker',
|
||||
variant: 'action' as const
|
||||
})
|
||||
},
|
||||
@@ -319,8 +358,11 @@ export function useStatusbarItems({
|
||||
currentReasoningEffort,
|
||||
modelMenuContent,
|
||||
sessionStartedAt,
|
||||
showYoloToggle,
|
||||
toggleYolo,
|
||||
turnStartedAt,
|
||||
versionItem
|
||||
versionItem,
|
||||
yoloActive
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
DropdownMenuSubContent
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import {
|
||||
$activeSessionId,
|
||||
@@ -184,24 +183,25 @@ export function ModelEditSubmenu({
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Options</DropdownMenuLabel>
|
||||
{reasoning ? (
|
||||
<DropdownMenuItem
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer')}
|
||||
className={dropdownMenuRow}
|
||||
onSelect={event => event.preventDefault()}
|
||||
>
|
||||
Thinking
|
||||
<Switch
|
||||
checked={thinkingOn}
|
||||
className="ml-auto cursor-pointer"
|
||||
className="ml-auto"
|
||||
onCheckedChange={checked => void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)}
|
||||
size="xs"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{hasFast ? (
|
||||
<DropdownMenuItem
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer')}
|
||||
className={dropdownMenuRow}
|
||||
onSelect={event => event.preventDefault()}
|
||||
>
|
||||
Fast
|
||||
<Switch checked={fastOn} className="ml-auto cursor-pointer" onCheckedChange={toggleFast} />
|
||||
<Switch checked={fastOn} className="ml-auto" onCheckedChange={toggleFast} size="xs" />
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{reasoning ? (
|
||||
@@ -214,7 +214,7 @@ export function ModelEditSubmenu({
|
||||
>
|
||||
{EFFORT_OPTIONS.map(option => (
|
||||
<DropdownMenuRadioItem
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer')}
|
||||
className={dropdownMenuRow}
|
||||
key={option.value}
|
||||
onSelect={event => event.preventDefault()}
|
||||
value={option.value}
|
||||
|
||||
@@ -178,7 +178,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
return (
|
||||
<DropdownMenuSub key={`${group.provider.slug}:${family.id}`}>
|
||||
<DropdownMenuSubTrigger
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer')}
|
||||
className={dropdownMenuRow}
|
||||
hideChevron
|
||||
onClick={activate}
|
||||
onKeyDown={event => {
|
||||
@@ -212,7 +212,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
|
||||
<DropdownMenuItem
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer text-(--ui-text-tertiary)')}
|
||||
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
|
||||
onSelect={() => setModelVisibilityOpen(true)}
|
||||
>
|
||||
Edit Models…
|
||||
|
||||
@@ -4,6 +4,11 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Shared chrome styling for interactive statusbar items (button / link / menu
|
||||
// trigger). The 'text' variant intentionally omits hover/transition/disabled.
|
||||
const STATUSBAR_ACTION_CLASS =
|
||||
'inline-flex h-full items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45'
|
||||
|
||||
export interface StatusbarMenuItem {
|
||||
id: string
|
||||
icon?: ReactNode
|
||||
@@ -93,10 +98,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
|
||||
item.className
|
||||
)}
|
||||
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
|
||||
disabled={item.disabled}
|
||||
title={title}
|
||||
type="button"
|
||||
@@ -167,10 +169,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
if (item.href || item.variant === 'link') {
|
||||
return (
|
||||
<a
|
||||
className={cn(
|
||||
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
|
||||
item.className
|
||||
)}
|
||||
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
|
||||
href={item.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
@@ -183,10 +182,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45',
|
||||
item.className
|
||||
)}
|
||||
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
if (item.to) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -12,12 +13,18 @@ import {
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Volume2, VolumeX } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
|
||||
import { $fileBrowserOpen, $sidebarOpen, toggleFileBrowserOpen, toggleSidebarOpen } from '@/store/layout'
|
||||
import {
|
||||
$fileBrowserOpen,
|
||||
$panesFlipped,
|
||||
$sidebarOpen,
|
||||
toggleFileBrowserOpen,
|
||||
togglePanesFlipped,
|
||||
toggleSidebarOpen
|
||||
} from '@/store/layout'
|
||||
|
||||
import { PROFILES_ROUTE } from '../routes'
|
||||
import { appViewForPath, isOverlayView, PROFILES_ROUTE } from '../routes'
|
||||
|
||||
import { titlebarButtonClass } from './titlebar'
|
||||
|
||||
@@ -41,22 +48,16 @@ export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[],
|
||||
interface TitlebarControlsProps extends ComponentProps<'div'> {
|
||||
leftTools?: readonly TitlebarTool[]
|
||||
tools?: readonly TitlebarTool[]
|
||||
commandCenterOpen?: boolean
|
||||
onOpenSettings: () => void
|
||||
onOpenSearch: () => void
|
||||
}
|
||||
|
||||
export function TitlebarControls({
|
||||
leftTools = [],
|
||||
tools = [],
|
||||
commandCenterOpen = false,
|
||||
onOpenSettings,
|
||||
onOpenSearch
|
||||
}: TitlebarControlsProps) {
|
||||
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const hapticsMuted = useStore($hapticsMuted)
|
||||
const fileBrowserOpen = useStore($fileBrowserOpen)
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
|
||||
const toggleHaptics = () => {
|
||||
if (!hapticsMuted) {
|
||||
@@ -70,38 +71,45 @@ export function TitlebarControls({
|
||||
}
|
||||
}
|
||||
|
||||
// Each titlebar button controls the pane physically on its side, so a flip
|
||||
// swaps which pane each one toggles. Default: sessions left, file browser
|
||||
// right. Flipped: file browser left, sessions right. Sidebar toggles never
|
||||
// carry an active highlight — they're plain show/hide affordances.
|
||||
const fileBrowserEdge = { open: fileBrowserOpen, toggle: toggleFileBrowserOpen }
|
||||
const sessionsEdge = { open: sidebarOpen, toggle: toggleSidebarOpen }
|
||||
const leftEdge = panesFlipped ? fileBrowserEdge : sessionsEdge
|
||||
const rightEdge = panesFlipped ? sessionsEdge : fileBrowserEdge
|
||||
|
||||
const leftToolbarTools: TitlebarTool[] = [
|
||||
{
|
||||
icon: <Codicon name="layout-sidebar-left" />,
|
||||
id: 'sidebar',
|
||||
label: sidebarOpen ? 'Hide sidebar' : 'Show sidebar',
|
||||
label: `${leftEdge.open ? 'Hide' : 'Show'} left sidebar`,
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
toggleSidebarOpen()
|
||||
leftEdge.toggle()
|
||||
}
|
||||
},
|
||||
{
|
||||
active: commandCenterOpen,
|
||||
icon: <Codicon name="search" />,
|
||||
id: 'search',
|
||||
label: 'Search',
|
||||
icon: <Codicon name="arrow-swap" />,
|
||||
id: 'flip-panes',
|
||||
label: 'Swap sidebar sides',
|
||||
onSelect: () => {
|
||||
triggerHaptic('open')
|
||||
onOpenSearch()
|
||||
triggerHaptic('tap')
|
||||
togglePanesFlipped()
|
||||
},
|
||||
title: 'Search sessions, views, and actions'
|
||||
title: 'Swap the sessions and file browser sides'
|
||||
},
|
||||
...leftTools
|
||||
]
|
||||
|
||||
const rightSidebarTool: TitlebarTool = {
|
||||
active: fileBrowserOpen,
|
||||
icon: <Codicon name="layout-sidebar-right" />,
|
||||
id: 'right-sidebar',
|
||||
label: fileBrowserOpen ? 'Hide right sidebar' : 'Show right sidebar',
|
||||
label: `${rightEdge.open ? 'Hide' : 'Show'} right sidebar`,
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
toggleFileBrowserOpen()
|
||||
rightEdge.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +117,7 @@ export function TitlebarControls({
|
||||
const systemTools: TitlebarTool[] = [
|
||||
{
|
||||
active: hapticsMuted,
|
||||
icon: hapticsMuted ? <VolumeX /> : <Volume2 />,
|
||||
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
|
||||
id: 'haptics',
|
||||
label: hapticsMuted ? 'Unmute haptics' : 'Mute haptics',
|
||||
onSelect: toggleHaptics
|
||||
@@ -125,6 +133,14 @@ export function TitlebarControls({
|
||||
}
|
||||
]
|
||||
|
||||
// While a full-screen overlay (settings, command center, …) is open it should
|
||||
// visually own the window. These control clusters are `fixed` at a higher
|
||||
// z-index than the overlay card, so they'd otherwise bleed over it — hide them
|
||||
// and let the overlay's own chrome (close button, drag region) take over.
|
||||
if (isOverlayView(appViewForPath(location.pathname))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const visibleSystemTools = systemTools.filter(tool => !tool.hidden)
|
||||
const settingsTool = visibleSystemTools.find(tool => tool.id === 'settings')
|
||||
const visibleSystemToolsBeforeSettings = visibleSystemTools.filter(tool => tool.id !== 'settings')
|
||||
@@ -181,15 +197,20 @@ function ProfilesMenuButton({ navigate }: { navigate: ReturnType<typeof useNavig
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
<Button
|
||||
aria-label="Profiles"
|
||||
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent select-none [&_svg]:size-4')}
|
||||
className={cn(titlebarButtonClass, 'bg-transparent select-none')}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
size="icon-titlebar"
|
||||
title="Profiles"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="account" />
|
||||
</button>
|
||||
{/* Optical bump: the `account` glyph has more internal padding than
|
||||
`search`/`settings-gear`, so at the shared 0.875rem it reads small.
|
||||
Nudge just this glyph to visually match its neighbours. */}
|
||||
<Codicon name="account" size="1rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64" sideOffset={8}>
|
||||
<DropdownMenuLabel>
|
||||
@@ -214,31 +235,30 @@ function ProfilesMenuButton({ navigate }: { navigate: ReturnType<typeof useNavig
|
||||
}
|
||||
|
||||
function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof useNavigate>; tool: TitlebarTool }) {
|
||||
const className = cn(
|
||||
titlebarButtonClass,
|
||||
'grid place-items-center bg-transparent select-none [&_svg]:size-4',
|
||||
tool.active && 'bg-(--ui-control-active-background)! text-foreground!',
|
||||
tool.className
|
||||
)
|
||||
// Titlebar actions never show an active background — state reads from the
|
||||
// icon itself (e.g. the mute/unmute glyph). aria-pressed still carries it
|
||||
// for a11y.
|
||||
const className = cn(titlebarButtonClass, 'bg-transparent select-none', tool.className)
|
||||
|
||||
if (tool.href) {
|
||||
return (
|
||||
<a
|
||||
aria-label={tool.label}
|
||||
className={className}
|
||||
href={tool.href}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={tool.title ?? tool.label}
|
||||
>
|
||||
{tool.icon}
|
||||
</a>
|
||||
<Button asChild className={className} size="icon-titlebar" variant="ghost">
|
||||
<a
|
||||
aria-label={tool.label}
|
||||
href={tool.href}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={tool.title ?? tool.label}
|
||||
>
|
||||
{tool.icon}
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
aria-label={tool.label}
|
||||
aria-pressed={tool.active ?? undefined}
|
||||
className={className}
|
||||
@@ -251,10 +271,12 @@ function TitlebarToolButton({ navigate, tool }: { navigate: ReturnType<typeof us
|
||||
tool.onSelect?.()
|
||||
}}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
size="icon-titlebar"
|
||||
title={tool.title ?? tool.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{tool.icon}
|
||||
</button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ export const TITLEBAR_FALLBACK_WINDOW_BUTTON_X = 24
|
||||
// (traffic lights are hidden). Matches the right-cluster's 0.75rem padding.
|
||||
export const TITLEBAR_EDGE_INSET = 14
|
||||
|
||||
export const titlebarButtonClass =
|
||||
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] cursor-pointer rounded-md text-muted-foreground/85 transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground'
|
||||
// Titlebar palette only. All sizing/radius/cursor/centering come from the
|
||||
// shared <Button size="icon-titlebar"> (used polymorphically via asChild) —
|
||||
// Button is the single source of button styling.
|
||||
export const titlebarButtonClass = 'text-muted-foreground/85 hover:bg-(--ui-control-hover-background) hover:text-foreground'
|
||||
|
||||
export const titlebarHeaderBaseClass =
|
||||
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]'
|
||||
|
||||
@@ -2,8 +2,7 @@ import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
|
||||
import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes'
|
||||
@@ -11,7 +10,9 @@ import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
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 } from '../settings/helpers'
|
||||
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
|
||||
@@ -72,25 +73,22 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
|
||||
const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null)
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [savingSkill, setSavingSkill] = useState<string | null>(null)
|
||||
const [savingToolset, setSavingToolset] = useState<string | null>(null)
|
||||
const [expandedToolset, setExpandedToolset] = useState<string | null>(null)
|
||||
|
||||
const refreshCapabilities = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const [nextSkills, nextToolsets] = await Promise.all([getSkills(), getToolsets()])
|
||||
setSkills(nextSkills)
|
||||
setToolsets(nextToolsets)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Skills failed to load')
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useRefreshHotkey(refreshCapabilities)
|
||||
|
||||
const refreshToolsets = useCallback(() => {
|
||||
getToolsets()
|
||||
.then(setToolsets)
|
||||
@@ -181,65 +179,54 @@ 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')}>
|
||||
Skills
|
||||
mode === 'skills' && categories.length > 0 ? (
|
||||
<>
|
||||
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
|
||||
All <TextTabMeta>{totalSkills}</TextTabMeta>
|
||||
</TextTab>
|
||||
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
|
||||
Toolsets
|
||||
</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)}>
|
||||
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}
|
||||
searchPlaceholder={mode === 'skills' ? 'Search skills...' : 'Search toolsets...'}
|
||||
searchTrailingAction={
|
||||
<Button
|
||||
aria-label={refreshing ? 'Refreshing skills' : 'Refresh skills'}
|
||||
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refreshCapabilities()}
|
||||
size="icon-xs"
|
||||
title={refreshing ? 'Refreshing skills' : 'Refresh skills'}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
|
||||
</Button>
|
||||
}
|
||||
searchValue={query}
|
||||
tabs={
|
||||
<>
|
||||
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
|
||||
Skills
|
||||
</TextTab>
|
||||
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
|
||||
Toolsets
|
||||
</TextTab>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{!skills || !toolsets ? (
|
||||
<PageLoader label="Loading capabilities..." />
|
||||
) : 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="Try a broader search or different category." title="No skills found" />
|
||||
) : (
|
||||
<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"
|
||||
@@ -265,7 +252,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="Try a broader search query." title="No toolsets found" />
|
||||
) : (
|
||||
@@ -273,7 +260,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{enabledToolsets}/{toolsets.length} toolsets enabled
|
||||
</div>
|
||||
<div className="divide-y divide-(--ui-stroke-quaternary)">
|
||||
<div>
|
||||
{visibleToolsets.map(toolset => {
|
||||
const tools = toolNames(toolset)
|
||||
const label = asText(toolset.label || toolset.name)
|
||||
@@ -287,7 +274,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
aria-label={`Configure ${label}`}
|
||||
className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
onClick={() => setExpandedToolset(current => (current === toolset.name ? null : toolset.name))}
|
||||
type="button"
|
||||
>
|
||||
@@ -333,14 +320,11 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
|
||||
function StatusPill({ active, children }: { active: boolean; children: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem]',
|
||||
active ? 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)' : 'bg-(--ui-bg-quinary) text-(--ui-text-tertiary)'
|
||||
)}
|
||||
<Badge
|
||||
className={active ? 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)' : 'bg-(--ui-bg-quinary) text-(--ui-text-tertiary)'}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -73,4 +73,7 @@ export interface ClientSessionState {
|
||||
sawAssistantPayload: boolean
|
||||
pendingBranchGroup: string | null
|
||||
interrupted: boolean
|
||||
/** 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
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
||||
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 type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global'
|
||||
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
|
||||
import { AlertCircle, Check, CheckCircle2, Copy, Loader2, Sparkles, Terminal } from '@/lib/icons'
|
||||
@@ -146,11 +147,6 @@ function IdleView({
|
||||
if (!status.supported) {
|
||||
return (
|
||||
<CenteredStatus
|
||||
action={
|
||||
<Button onClick={onLater} size="sm" variant="outline">
|
||||
Close
|
||||
</Button>
|
||||
}
|
||||
body={status.message ?? 'This version of Hermes can’t update itself from inside the app.'}
|
||||
icon={<AlertCircle className="size-6 text-muted-foreground" />}
|
||||
title="Update not available"
|
||||
@@ -176,11 +172,6 @@ function IdleView({
|
||||
if (behind === 0) {
|
||||
return (
|
||||
<CenteredStatus
|
||||
action={
|
||||
<Button onClick={onLater} size="sm" variant="outline">
|
||||
Close
|
||||
</Button>
|
||||
}
|
||||
body="You’re running the latest version."
|
||||
icon={<CheckCircle2 className="size-7 text-emerald-600 dark:text-emerald-400" />}
|
||||
title="You’re all set"
|
||||
@@ -208,11 +199,13 @@ function IdleView({
|
||||
<div className="grid gap-3 rounded-xl border border-border/70 bg-muted/20 px-4 py-3">
|
||||
{groups.map(group => (
|
||||
<div key={group.id}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{group.label}</p>
|
||||
<ul className="mt-1.5 grid gap-1.5 text-sm text-foreground">
|
||||
<p className="text-[0.625rem] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{group.label}
|
||||
</p>
|
||||
<ul className="mt-1.5 grid gap-1.5 text-xs text-foreground">
|
||||
{group.items.map(item => (
|
||||
<li className="flex items-start gap-2" key={item}>
|
||||
<span aria-hidden className="mt-2 inline-block size-1.5 shrink-0 rounded-full bg-primary" />
|
||||
<span aria-hidden className="mt-1.5 inline-block size-1 shrink-0 rounded-full bg-primary" />
|
||||
<span className="leading-snug">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -222,7 +215,7 @@ function IdleView({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Button className="h-10 text-sm font-semibold" onClick={onInstall} size="default">
|
||||
<Button className="font-semibold" onClick={onInstall} size="lg">
|
||||
Update now
|
||||
</Button>
|
||||
<button
|
||||
@@ -267,9 +260,9 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="group flex w-full items-center justify-between gap-3 rounded-xl border border-border/70 bg-muted/30 px-4 py-3 text-left transition-colors hover:border-border hover:bg-muted/50"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
>
|
||||
<code className="select-all font-mono text-sm text-foreground">
|
||||
<span className="text-muted-foreground">$ </span>
|
||||
@@ -294,7 +287,7 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
|
||||
Hermes will pick up the new version next time you launch it.
|
||||
</p>
|
||||
|
||||
<Button className="h-10 text-sm font-semibold" onClick={onDone} variant="outline">
|
||||
<Button className="font-semibold" onClick={onDone} size="lg" variant="outline">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
@@ -339,31 +332,22 @@ function ApplyingView({ apply }: { apply: UpdateApplyState }) {
|
||||
|
||||
function ErrorView({ message, onDismiss, onRetry }: { message: string; onDismiss: () => void; onRetry: () => 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-destructive/10 text-destructive">
|
||||
<AlertCircle className="size-7" />
|
||||
</span>
|
||||
|
||||
<DialogTitle className="text-center text-xl">Update didn’t finish</DialogTitle>
|
||||
<DialogDescription className="text-center text-sm">
|
||||
<ErrorState
|
||||
className="px-6 pb-6 pt-7 pr-8"
|
||||
description={
|
||||
<DialogDescription className="max-w-prose text-center text-sm leading-5 text-muted-foreground">
|
||||
{message || 'No worries — nothing was lost. You can try again now.'}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Button className="h-10 text-sm font-semibold" onClick={onRetry}>
|
||||
Try again
|
||||
</Button>
|
||||
<button
|
||||
className="text-center text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
title={<DialogTitle className="text-center text-xl font-semibold tracking-tight">Update didn’t finish</DialogTitle>}
|
||||
>
|
||||
<Button className="font-semibold" onClick={onRetry} size="lg">
|
||||
Try again
|
||||
</Button>
|
||||
<Button onClick={onDismiss} variant="text">
|
||||
Not now
|
||||
</Button>
|
||||
</ErrorState>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { HelpCircle, Loader2, PencilLine } from '@/lib/icons'
|
||||
import { Check, HelpCircle, Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
@@ -33,6 +33,23 @@ function readClarifyArgs(args: unknown): ClarifyArgs {
|
||||
}
|
||||
}
|
||||
|
||||
// Choice and "Other" rows share a layout; only color/hover differs.
|
||||
const OPTION_ROW_CLASS = 'flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors'
|
||||
|
||||
function RadioDot({ selected }: { selected: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'grid size-3.5 shrink-0 place-items-center rounded-full border transition-colors',
|
||||
selected ? 'border-primary' : 'border-muted-foreground/40'
|
||||
)}
|
||||
>
|
||||
{selected && <span className="size-1.5 rounded-full bg-primary" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const ClarifyTool = (props: ToolCallMessagePartProps) => {
|
||||
const isPending = props.result === undefined
|
||||
|
||||
@@ -74,6 +91,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
const [typing, setTyping] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [selectedChoice, setSelectedChoice] = useState<string | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
// Race: tool.start fires a tick before clarify.request, so request_id
|
||||
@@ -103,7 +121,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
answer
|
||||
})
|
||||
triggerHaptic('submit')
|
||||
clearClarifyRequest(matchingRequest.requestId)
|
||||
clearClarifyRequest(matchingRequest.requestId, matchingRequest.sessionId)
|
||||
// The matching tool.complete will land shortly after, swapping this
|
||||
// panel for the ToolFallback view above.
|
||||
} catch (error) {
|
||||
@@ -140,72 +158,49 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
[draft, respond]
|
||||
)
|
||||
|
||||
const handleChoiceKey = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (typing || submitting) {
|
||||
return
|
||||
}
|
||||
|
||||
const numeric = Number.parseInt(event.key, 10)
|
||||
|
||||
if (Number.isFinite(numeric) && numeric >= 1 && numeric <= choices.length) {
|
||||
event.preventDefault()
|
||||
void respond(choices[numeric - 1]!)
|
||||
}
|
||||
},
|
||||
[choices, respond, submitting, typing]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mb-3 mt-2 grid gap-3 rounded-xl border border-border/70 bg-card/40 px-4 py-3.5 text-sm',
|
||||
'shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]'
|
||||
)}
|
||||
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)]"
|
||||
data-slot="clarify-inline"
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span aria-hidden className="arc-border" />
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-0.5 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="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>
|
||||
<div className="grid flex-1 gap-0.5">
|
||||
<span className="text-[0.6875rem] font-medium uppercase tracking-wide text-muted-foreground/85">
|
||||
Hermes is asking
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap leading-snug text-foreground">
|
||||
{question || <em className="text-muted-foreground/70">Loading question…</em>}
|
||||
</span>
|
||||
</div>
|
||||
<span className="flex-1 whitespace-pre-wrap font-medium leading-snug text-foreground">
|
||||
{question || <em className="font-normal text-muted-foreground/70">Loading question…</em>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!typing && hasChoices && (
|
||||
<div className="grid gap-1.5" onKeyDown={handleChoiceKey} role="group">
|
||||
<div className="grid gap-0.5" role="group">
|
||||
{choices.map((choice, index) => (
|
||||
<button
|
||||
className={cn(
|
||||
'group/choice flex w-full items-center gap-3 rounded-lg border border-border/70 bg-background/60 px-3 py-2 text-left text-sm text-foreground/95',
|
||||
'transition-colors hover:border-border hover:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-55'
|
||||
OPTION_ROW_CLASS,
|
||||
'text-foreground/95 hover:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-55',
|
||||
selectedChoice === choice && 'bg-accent/60'
|
||||
)}
|
||||
data-choice
|
||||
disabled={!ready || submitting}
|
||||
key={`${index}-${choice}`}
|
||||
onClick={() => void respond(choice)}
|
||||
onClick={() => {
|
||||
setSelectedChoice(choice)
|
||||
void respond(choice)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-[0.6875rem] font-mono tabular-nums text-muted-foreground group-hover/choice:bg-background">
|
||||
{index + 1}
|
||||
</span>
|
||||
<RadioDot selected={selectedChoice === choice} />
|
||||
<span className="flex-1 wrap-anywhere">{choice}</span>
|
||||
{selectedChoice === choice && <Check aria-hidden className="size-4 shrink-0 text-primary" />}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg border border-dashed border-border/60 bg-transparent px-3 py-2 text-left text-sm text-muted-foreground',
|
||||
'transition-colors hover:border-border hover:bg-accent/40 hover:text-foreground'
|
||||
)}
|
||||
className={cn(OPTION_ROW_CLASS, 'text-muted-foreground hover:bg-accent/40 hover:text-foreground')}
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setTyping(true)
|
||||
@@ -213,12 +208,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground"
|
||||
>
|
||||
<PencilLine className="size-3" />
|
||||
</span>
|
||||
<RadioDot selected={false} />
|
||||
<span className="flex-1">Other (type your answer)</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -227,7 +217,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
{(typing || !hasChoices) && (
|
||||
<form className="grid gap-2" onSubmit={handleSubmitFreeform}>
|
||||
<Textarea
|
||||
className="min-h-20 resize-y rounded-lg border-border/70 bg-background/60 text-sm"
|
||||
className="min-h-20 resize-y rounded-lg border-transparent bg-accent/40 text-sm focus-visible:bg-background/60"
|
||||
disabled={submitting}
|
||||
onChange={event => setDraft(event.target.value)}
|
||||
onKeyDown={handleTextareaKey}
|
||||
@@ -270,10 +260,9 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
)}
|
||||
|
||||
{!typing && hasChoices && (
|
||||
<div className="flex items-center justify-between text-[0.6875rem] text-muted-foreground/85">
|
||||
<span>1–{choices.length} to pick</span>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
className="bg-transparent text-muted-foreground/85 underline-offset-4 decoration-current/20 hover:text-foreground hover:underline disabled:opacity-50"
|
||||
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"
|
||||
disabled={!ready || submitting}
|
||||
onClick={() => void respond('')}
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { TextMessagePartProvider, useAuiState, useMessagePartText } from '@assistant-ui/react'
|
||||
import { TextMessagePartProvider, useMessagePartText } from '@assistant-ui/react'
|
||||
import {
|
||||
type StreamdownTextComponents,
|
||||
StreamdownTextPrimitive,
|
||||
@@ -259,6 +259,11 @@ function DeferStreamingText({ children }: { children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
interface MarkdownTextSurfaceProps {
|
||||
containerClassName?: string
|
||||
containerProps?: ComponentProps<'div'>
|
||||
}
|
||||
|
||||
// Headings shrink to chat scale rather than the prose default (h1≈xl). Kept
|
||||
// table-driven so adding/tweaking levels is one row.
|
||||
const HEADING_SIZES: Record<'h1' | 'h2' | 'h3' | 'h4', string> = {
|
||||
@@ -268,15 +273,24 @@ const HEADING_SIZES: Record<'h1' | 'h2' | 'h3' | 'h4', string> = {
|
||||
h4: 'text-[0.8125rem]'
|
||||
}
|
||||
|
||||
const MarkdownTextImpl = () => {
|
||||
const isStreaming = useAuiState(s => s.message.status?.type === 'running')
|
||||
const MARKDOWN_CONTAINER_CLASS_NAME = cn(
|
||||
'aui-md prose w-full max-w-none overflow-hidden text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground',
|
||||
'prose-p:leading-(--dt-line-height) prose-li:leading-(--dt-line-height)',
|
||||
'prose-headings:text-foreground prose-strong:text-foreground',
|
||||
'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'
|
||||
)
|
||||
|
||||
// Stable per-state plugin object. The previous inline `{ math: mathPlugin,
|
||||
// ...(isStreaming ? {} : { code }) }` created a new object identity on every
|
||||
// render, which churns Streamdown's outer memo + propagates new prop
|
||||
// identities into every Block. The plugin set really only varies on
|
||||
// `isStreaming`, so memoize on that.
|
||||
const plugins = useMemo(() => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }), [isStreaming])
|
||||
function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) {
|
||||
const { status } = useMessagePartText()
|
||||
const isStreaming = status.type === 'running'
|
||||
|
||||
// Keep code parsing enabled while streaming so incomplete fenced blocks still
|
||||
// render as code cards. The expensive Shiki pass is deferred by
|
||||
// `SyntaxHighlighter` below when `isStreaming` is true.
|
||||
const plugins = useMemo(() => ({ math: mathPlugin, code }), [])
|
||||
|
||||
const components = useMemo(
|
||||
() =>
|
||||
@@ -347,33 +361,47 @@ const MarkdownTextImpl = () => {
|
||||
[isStreaming]
|
||||
)
|
||||
|
||||
return (
|
||||
<StreamdownTextPrimitive
|
||||
components={components}
|
||||
containerClassName={cn(MARKDOWN_CONTAINER_CLASS_NAME, containerClassName)}
|
||||
containerProps={containerProps}
|
||||
lineNumbers={false}
|
||||
mode="streaming"
|
||||
// Always auto-close incomplete fences — even during streaming.
|
||||
// Without this, an unclosed ```python ... ``` whose body contains
|
||||
// `$` (very common: shell snippets, JS template strings, dollar
|
||||
// amounts) leaks those dollars out to the math parser and they
|
||||
// get rendered as broken inline math until the closing fence
|
||||
// arrives. Shiki is independently deferred via `defer={isStreaming}`
|
||||
// on the SyntaxHighlighter component, so we don't pay code-block
|
||||
// tokenization on every token even with this set.
|
||||
parseIncompleteMarkdown
|
||||
plugins={plugins}
|
||||
preprocess={preprocessMarkdown}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface MarkdownTextContentProps extends MarkdownTextSurfaceProps {
|
||||
isRunning: boolean
|
||||
text: string
|
||||
}
|
||||
|
||||
export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: MarkdownTextContentProps) {
|
||||
return (
|
||||
<TextMessagePartProvider isRunning={isRunning} text={text}>
|
||||
<DeferStreamingText>
|
||||
<MarkdownTextSurface {...surfaceProps} />
|
||||
</DeferStreamingText>
|
||||
</TextMessagePartProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const MarkdownTextImpl = () => {
|
||||
return (
|
||||
<DeferStreamingText>
|
||||
<StreamdownTextPrimitive
|
||||
components={components}
|
||||
containerClassName={cn(
|
||||
'aui-md prose w-full max-w-none overflow-hidden text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground',
|
||||
'prose-p:leading-(--dt-line-height) prose-li:leading-(--dt-line-height)',
|
||||
'prose-headings:text-foreground prose-strong:text-foreground',
|
||||
'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'
|
||||
)}
|
||||
lineNumbers={false}
|
||||
mode="streaming"
|
||||
// Always auto-close incomplete fences — even during streaming.
|
||||
// Without this, an unclosed ```python ... ``` whose body contains
|
||||
// `$` (very common: shell snippets, JS template strings, dollar
|
||||
// amounts) leaks those dollars out to the math parser and they
|
||||
// get rendered as broken inline math until the closing fence
|
||||
// arrives. Shiki is independently deferred via `defer={isStreaming}`
|
||||
// on the SyntaxHighlighter component, so we don't pay code-block
|
||||
// tokenization on every token even with this set.
|
||||
parseIncompleteMarkdown
|
||||
plugins={plugins}
|
||||
preprocess={preprocessMarkdown}
|
||||
/>
|
||||
<MarkdownTextSurface />
|
||||
</DeferStreamingText>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -130,12 +130,12 @@ function assistantErrorMessage(error: string): ThreadMessage {
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantReasoningMessage(text: string): ThreadMessage {
|
||||
function assistantReasoningMessage(text: string, running = false): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-reasoning-1',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'reasoning', text }],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
@@ -263,6 +263,20 @@ function StreamingHarness() {
|
||||
)
|
||||
}
|
||||
|
||||
function StaticThreadHarness() {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [userMessage(), assistantMessage('complete response', false)],
|
||||
isRunning: false,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TodoHarness({ message }: { message: ThreadMessage }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [message],
|
||||
@@ -291,6 +305,20 @@ function MessageHarness({ message }: { message: ThreadMessage }) {
|
||||
)
|
||||
}
|
||||
|
||||
function RunningMessageHarness({ message }: { message: ThreadMessage }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [message],
|
||||
isRunning: true,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ReasoningHarness() {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [assistantReasoningMessage(' The user is asking what this file is.')],
|
||||
@@ -305,6 +333,20 @@ function ReasoningHarness() {
|
||||
)
|
||||
}
|
||||
|
||||
function RunningReasoningHarness() {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [assistantReasoningMessage('```ts\nconst answer = 42\n', true)],
|
||||
isRunning: true,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupedReasoningHarness() {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [assistantMultiReasoningMessage([' First thought.', ' Second thought.'])],
|
||||
@@ -415,10 +457,207 @@ describe('assistant-ui streaming renderer', () => {
|
||||
expect(viewport.scrollTop).toBe(420)
|
||||
})
|
||||
|
||||
it('does not auto-follow idle layout shifts', async () => {
|
||||
const { container } = render(<StaticThreadHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 420
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
scrollHeight = 1_200
|
||||
|
||||
await act(async () => {
|
||||
for (const observer of resizeObservers) {
|
||||
observer.trigger(1_200)
|
||||
}
|
||||
})
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(420)
|
||||
})
|
||||
|
||||
it('keeps sticky-bottom armed through viewport height changes during streaming', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let clientHeight = 200
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => clientHeight
|
||||
})
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 800
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
clientHeight = 240
|
||||
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 760
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
scrollHeight = 1_200
|
||||
|
||||
await act(async () => {
|
||||
for (const observer of resizeObservers) {
|
||||
observer.trigger(1_200)
|
||||
}
|
||||
})
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(1_200)
|
||||
})
|
||||
|
||||
it('honors the first upward wheel scroll even when a programmatic bottom-pin scroll event is still pending', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
await wait(0)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.wheel(viewport, { deltaY: -120 })
|
||||
viewport.scrollTop = 420
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
scrollHeight = 1_200
|
||||
|
||||
await act(async () => {
|
||||
for (const observer of resizeObservers) {
|
||||
observer.trigger(1_200)
|
||||
}
|
||||
})
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(420)
|
||||
})
|
||||
|
||||
it('keeps following final code-highlight growth when a run completes at bottom', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 800
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
await wait(650)
|
||||
|
||||
scrollHeight = 1_700
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(1_700)
|
||||
})
|
||||
|
||||
it('does not restart bottom-follow after completion when the user scrolled up', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 800
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.wheel(viewport, { deltaY: -120 })
|
||||
viewport.scrollTop = 420
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
await wait(650)
|
||||
|
||||
scrollHeight = 1_700
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(420)
|
||||
})
|
||||
|
||||
it('renders an incomplete streaming fenced code block as a code card', async () => {
|
||||
const { container } = render(<RunningMessageHarness message={assistantMessage('```ts\nconst answer = 42\n')} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-slot="code-card"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain('const answer = 42')
|
||||
expect(container.textContent).not.toContain('```ts')
|
||||
})
|
||||
|
||||
it('renders an incomplete streaming reasoning fenced code block as a code card', async () => {
|
||||
const { container } = render(<RunningReasoningHarness />)
|
||||
const ui = within(container)
|
||||
|
||||
fireEvent.click(ui.getByRole('button', { name: /thinking/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-slot="code-card"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toContain('const answer = 42')
|
||||
expect(container.textContent).not.toContain('```ts')
|
||||
})
|
||||
|
||||
it('renders reasoning text without a leading token space', () => {
|
||||
const { container } = render(<ReasoningHarness />)
|
||||
const ui = within(container)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /thinking/i }))
|
||||
fireEvent.click(ui.getByRole('button', { name: /thinking/i }))
|
||||
|
||||
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toBe(
|
||||
'The user is asking what this file is.'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
|
||||
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual'
|
||||
import { type ComponentProps, type FC, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { type ComponentProps, type FC, memo, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { setThreadScrolledUp } from '@/store/thread-scroll'
|
||||
@@ -8,6 +8,7 @@ import { setThreadScrolledUp } from '@/store/thread-scroll'
|
||||
const ESTIMATED_ITEM_HEIGHT = 220
|
||||
const OVERSCAN = 4
|
||||
const AT_BOTTOM_THRESHOLD = 4
|
||||
const POST_RUN_BOTTOM_LOCK_MS = 1_200
|
||||
|
||||
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
|
||||
|
||||
@@ -55,7 +56,7 @@ function buildGroups(signature: string): MessageGroup[] {
|
||||
return groups
|
||||
}
|
||||
|
||||
export const VirtualizedThread: FC<VirtualizedThreadProps> = ({
|
||||
const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
|
||||
clampToComposer,
|
||||
components,
|
||||
emptyPlaceholder,
|
||||
@@ -65,11 +66,16 @@ export const VirtualizedThread: FC<VirtualizedThreadProps> = ({
|
||||
const messageSignature = useAuiState(s =>
|
||||
s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n')
|
||||
)
|
||||
const isRunning = useAuiState(s => s.thread.isRunning)
|
||||
|
||||
const groups = useMemo(() => buildGroups(messageSignature), [messageSignature])
|
||||
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// Shared ref so scrollToFn can check whether the user is parked at the
|
||||
// bottom without needing a ref from inside useThreadScrollAnchor.
|
||||
const stickyBottomRef = useRef(true)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: groups.length,
|
||||
estimateSize: () => ESTIMATED_ITEM_HEIGHT,
|
||||
@@ -78,14 +84,39 @@ export const VirtualizedThread: FC<VirtualizedThreadProps> = ({
|
||||
// Seed the rect so the initial range mounts something before
|
||||
// `observeElementRect` reports the real layout (it overrides this).
|
||||
initialRect: { height: 600, width: 800 },
|
||||
overscan: OVERSCAN
|
||||
overscan: OVERSCAN,
|
||||
// When the virtualizer adjusts scroll due to item measurement changes,
|
||||
// skip the adjustment if the user is at the bottom. Our ResizeObserver +
|
||||
// pinToBottom loop handles scroll anchoring; letting the virtualizer also
|
||||
// adjust creates a feedback loop where the two fight each other,
|
||||
// producing visible rubber-banding (the view snaps to the composer
|
||||
// then jumps back up).
|
||||
scrollToFn: (offset, _options, instance) => {
|
||||
const el = instance.scrollElement
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
if (stickyBottomRef.current) {
|
||||
const maxScroll = el.scrollHeight - el.clientHeight
|
||||
const distFromBottom = maxScroll - el.scrollTop
|
||||
|
||||
if (distFromBottom <= AT_BOTTOM_THRESHOLD && offset < maxScroll) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
;(el as HTMLElement).scrollTo(0, offset)
|
||||
}
|
||||
})
|
||||
|
||||
useThreadScrollAnchor({
|
||||
enabled: !renderEmpty,
|
||||
groupCount: groups.length,
|
||||
isRunning,
|
||||
scrollerRef,
|
||||
sessionKey: sessionKey ?? null,
|
||||
stickyBottomRef,
|
||||
virtualizer
|
||||
})
|
||||
|
||||
@@ -169,19 +200,34 @@ export const VirtualizedThread: FC<VirtualizedThreadProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export const VirtualizedThread = memo(VirtualizedThreadInner)
|
||||
|
||||
interface ScrollAnchorOptions {
|
||||
enabled: boolean
|
||||
groupCount: number
|
||||
isRunning: boolean
|
||||
scrollerRef: React.RefObject<HTMLDivElement | null>
|
||||
sessionKey: string | null
|
||||
stickyBottomRef: React.MutableRefObject<boolean>
|
||||
virtualizer: Virtualizer<HTMLDivElement, Element>
|
||||
}
|
||||
|
||||
function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, virtualizer }: ScrollAnchorOptions) {
|
||||
// `armed` = parked at bottom, content growth should follow. Cleared on
|
||||
function useThreadScrollAnchor({
|
||||
enabled,
|
||||
groupCount,
|
||||
isRunning,
|
||||
scrollerRef,
|
||||
sessionKey,
|
||||
stickyBottomRef,
|
||||
virtualizer
|
||||
}: ScrollAnchorOptions) {
|
||||
// `stickyBottomRef` = parked at bottom, content growth should follow. Cleared on
|
||||
// user-driven upward scroll; re-armed when they reach bottom again.
|
||||
const armedRef = useRef(true)
|
||||
// This is a shared ref — scrollToFn reads it to prevent the virtualizer's
|
||||
// measurement adjustments from fighting our pinToBottom.
|
||||
const lastTopRef = useRef(0)
|
||||
const lastHeightRef = useRef(0)
|
||||
const lastClientHeightRef = useRef(0)
|
||||
// Counter that tracks how many scroll events we expect to be ours rather
|
||||
// than the user's. `pinToBottom` writes `el.scrollTop`, which fires an
|
||||
// async `scroll` event; without this guard the on-scroll handler can race
|
||||
@@ -206,21 +252,23 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
|
||||
programmaticScrollPendingRef.current += 1
|
||||
el.scrollTop = el.scrollHeight
|
||||
lastTopRef.current = el.scrollTop
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
lastClientHeightRef.current = el.clientHeight
|
||||
}, [scrollerRef])
|
||||
|
||||
const jumpToBottom = useCallback(() => {
|
||||
armedRef.current = true
|
||||
stickyBottomRef.current = true
|
||||
|
||||
if (groupCount > 0) {
|
||||
virtualizer.scrollToIndex(groupCount - 1, { align: 'end', behavior: 'auto' })
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (armedRef.current) {
|
||||
if (stickyBottomRef.current) {
|
||||
pinToBottom()
|
||||
}
|
||||
})
|
||||
}, [groupCount, pinToBottom, virtualizer])
|
||||
}, [groupCount, pinToBottom, stickyBottomRef, virtualizer])
|
||||
|
||||
useEffect(() => () => setThreadScrolledUp(false), [])
|
||||
|
||||
@@ -234,7 +282,8 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
|
||||
}
|
||||
|
||||
const disarm = () => {
|
||||
armedRef.current = false
|
||||
stickyBottomRef.current = false
|
||||
programmaticScrollPendingRef.current = 0
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
@@ -250,24 +299,36 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
|
||||
if (programmaticScrollPendingRef.current > 0) {
|
||||
programmaticScrollPendingRef.current -= 1
|
||||
lastTopRef.current = top
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
lastClientHeightRef.current = el.clientHeight
|
||||
// Always re-arm — sticky-bottom should hold through clamp races.
|
||||
armedRef.current = true
|
||||
stickyBottomRef.current = true
|
||||
const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
|
||||
setThreadScrolledUp(!atBottom)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (top + 1 < lastTopRef.current) {
|
||||
armedRef.current = false
|
||||
// Disarm only when `scrollTop` decreases while both content height and
|
||||
// viewport height are stable. A bare `top < lastTopRef.current` check is
|
||||
// unsafe: virtualizer measurement, streaming markdown, composer resizing,
|
||||
// window resizing, and toolbar/status updates can all move scrollTop as a
|
||||
// layout side effect. Wheel-up and touchmove still disarm immediately via
|
||||
// their own listeners below, so real user intent remains covered.
|
||||
const heightGrew = el.scrollHeight > lastHeightRef.current
|
||||
const clientHeightChanged = Math.abs(el.clientHeight - lastClientHeightRef.current) > 1
|
||||
if (!heightGrew && !clientHeightChanged && top + 1 < lastTopRef.current) {
|
||||
stickyBottomRef.current = false
|
||||
}
|
||||
|
||||
lastTopRef.current = top
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
lastClientHeightRef.current = el.clientHeight
|
||||
|
||||
const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
|
||||
|
||||
if (atBottom) {
|
||||
armedRef.current = true
|
||||
stickyBottomRef.current = true
|
||||
}
|
||||
|
||||
setThreadScrolledUp(!atBottom)
|
||||
@@ -288,7 +349,7 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
el.removeEventListener('touchmove', disarm)
|
||||
}
|
||||
}, [scrollerRef])
|
||||
}, [scrollerRef, stickyBottomRef])
|
||||
|
||||
// Follow content growth (streaming, item measurements, loading indicator)
|
||||
// while armed. During fast streaming the ResizeObserver can fire many
|
||||
@@ -297,7 +358,7 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
|
||||
// (~20+ ms self in `Virtualizer.getMaxScrollOffset`) several times per
|
||||
// token.
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
if (!enabled || !isRunning) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -309,13 +370,13 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
|
||||
|
||||
let pinRafScheduled = false
|
||||
const schedulePin = () => {
|
||||
if (pinRafScheduled || !armedRef.current) {
|
||||
if (pinRafScheduled || !stickyBottomRef.current) {
|
||||
return
|
||||
}
|
||||
pinRafScheduled = true
|
||||
requestAnimationFrame(() => {
|
||||
pinRafScheduled = false
|
||||
if (armedRef.current) {
|
||||
if (stickyBottomRef.current) {
|
||||
pinToBottom()
|
||||
}
|
||||
})
|
||||
@@ -323,14 +384,15 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
|
||||
|
||||
const observer = new ResizeObserver(schedulePin)
|
||||
|
||||
observer.observe(el)
|
||||
|
||||
// Observe ONLY the content (firstElementChild), not the scroller `el`
|
||||
// itself. Resizes of the viewport/scroller (window resize, devtools
|
||||
// panel toggle) shouldn't trigger a pin — only content growth should.
|
||||
if (el.firstElementChild) {
|
||||
observer.observe(el.firstElementChild)
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [enabled, pinToBottom, scrollerRef])
|
||||
}, [enabled, isRunning, pinToBottom, scrollerRef, stickyBottomRef])
|
||||
|
||||
// Jump to bottom on session change OR when an empty thread first gets
|
||||
// content. Both share the same intent and the same effect.
|
||||
@@ -367,16 +429,61 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
if (groupCount > prevGroupCountForLayoutRef.current && armedRef.current) {
|
||||
pinToBottom()
|
||||
if (groupCount > prevGroupCountForLayoutRef.current && stickyBottomRef.current) {
|
||||
// Defer to rAF so that browser scroll/wheel events from the current
|
||||
// frame are processed first. Without this deferral, a trackpad
|
||||
// scroll-up during streaming can race with this effect: the wheel
|
||||
// event hasn't fired yet so stickyBottomRef is still true, and the
|
||||
// immediate pinToBottom() would snap the viewport back to bottom
|
||||
// against the user's intent.
|
||||
requestAnimationFrame(() => {
|
||||
if (armedRef.current) {
|
||||
if (stickyBottomRef.current) {
|
||||
pinToBottom()
|
||||
}
|
||||
})
|
||||
}
|
||||
prevGroupCountForLayoutRef.current = groupCount
|
||||
}, [enabled, groupCount, pinToBottom])
|
||||
}, [enabled, groupCount, pinToBottom, stickyBottomRef])
|
||||
|
||||
// Completion swaps streaming placeholders/plain code for final rendered DOM
|
||||
// (notably Shiki-highlighted code). Keep following the bottom briefly after
|
||||
// `isRunning` flips false so that final measurement pass cannot strand the
|
||||
// viewport near the top of a large code block.
|
||||
const prevIsRunningForLayoutRef = useRef(isRunning)
|
||||
useLayoutEffect(() => {
|
||||
const finishedRun = prevIsRunningForLayoutRef.current && !isRunning
|
||||
prevIsRunningForLayoutRef.current = isRunning
|
||||
|
||||
if (!enabled || !finishedRun || !stickyBottomRef.current) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const lockUntil = performance.now() + POST_RUN_BOTTOM_LOCK_MS
|
||||
let lockRaf: number | null = null
|
||||
|
||||
const lockFrame = () => {
|
||||
lockRaf = null
|
||||
|
||||
if (!stickyBottomRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
pinToBottom()
|
||||
|
||||
if (performance.now() < lockUntil) {
|
||||
lockRaf = requestAnimationFrame(lockFrame)
|
||||
}
|
||||
}
|
||||
|
||||
pinToBottom()
|
||||
lockRaf = requestAnimationFrame(lockFrame)
|
||||
|
||||
return () => {
|
||||
if (lockRaf !== null) {
|
||||
cancelAnimationFrame(lockRaf)
|
||||
}
|
||||
}
|
||||
}, [enabled, isRunning, pinToBottom, stickyBottomRef])
|
||||
|
||||
useAuiEvent('thread.runStart', jumpToBottom)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useStore } from '@nanostores/react'
|
||||
import { IconPlayerStopFilled } from '@tabler/icons-react'
|
||||
import {
|
||||
type ClipboardEvent,
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
type FocusEvent,
|
||||
type FormEvent,
|
||||
@@ -48,14 +49,13 @@ import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/co
|
||||
import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
|
||||
import { extractDroppedFiles, HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
|
||||
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
|
||||
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
|
||||
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
|
||||
import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer'
|
||||
import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool'
|
||||
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
|
||||
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
@@ -221,6 +221,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
const messageStatus = useAuiState(s => s.message.status?.type)
|
||||
const isPlaceholder = messageStatus === 'running' && content.length === 0
|
||||
const interruptedOnly = useMemo(() => isInterruptedOnlyMessage(messageText), [messageText])
|
||||
const enterRef = useEnterAnimation(messageStatus === 'running', `assistant-message:${messageId}`)
|
||||
|
||||
if (isPlaceholder) {
|
||||
return null
|
||||
@@ -231,6 +232,8 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
className="group flex w-full min-w-0 max-w-full flex-col gap-0 self-start overflow-hidden"
|
||||
data-role="assistant"
|
||||
data-slot="aui_assistant-message-root"
|
||||
data-streaming={messageStatus === 'running' ? 'true' : undefined}
|
||||
ref={enterRef}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -372,7 +375,9 @@ const ThinkingDisclosure: FC<{
|
||||
observer.observe(content)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [isPreview])
|
||||
// Re-run when the disclosure toggles so the observer attaches to the new
|
||||
// DOM after expand/collapse (refs are conditionally rendered on `open`).
|
||||
}, [isPreview, open])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -446,17 +451,19 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
|
||||
|
||||
const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => {
|
||||
const displayText = text.trimStart()
|
||||
const messageRunning = useAuiState(s => s.message.status?.type === 'running')
|
||||
const isRunning = status?.type === 'running' || messageRunning
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground/85',
|
||||
status?.type === 'running' && 'shimmer text-muted-foreground/55'
|
||||
<MarkdownTextContent
|
||||
containerClassName={cn(
|
||||
'text-xs leading-relaxed text-muted-foreground/85',
|
||||
isRunning && 'shimmer text-muted-foreground/55'
|
||||
)}
|
||||
data-slot="aui_reasoning-text"
|
||||
>
|
||||
{displayText}
|
||||
</div>
|
||||
containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>}
|
||||
isRunning={isRunning}
|
||||
text={displayText}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -611,13 +618,13 @@ const AssistantFooter: FC<MessageActionProps> = props => (
|
||||
className="inline-flex h-6 items-center gap-1 text-xs text-muted-foreground"
|
||||
hideWhenSingleBranch
|
||||
>
|
||||
<BranchPickerPrimitive.Previous className="grid size-6 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
|
||||
<BranchPickerPrimitive.Previous className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
|
||||
<Codicon name="chevron-left" size="0.875rem" />
|
||||
</BranchPickerPrimitive.Previous>
|
||||
<span className="tabular-nums">
|
||||
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next className="grid size-6 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
|
||||
<BranchPickerPrimitive.Next className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
|
||||
<Codicon name="chevron-right" size="0.875rem" />
|
||||
</BranchPickerPrimitive.Next>
|
||||
</BranchPickerPrimitive.Root>
|
||||
@@ -655,7 +662,7 @@ 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'
|
||||
|
||||
const USER_ACTION_ICON_BUTTON_CLASS =
|
||||
'grid cursor-pointer 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'
|
||||
'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'
|
||||
|
||||
const USER_ACTION_ICON_SIZE = '0.6875rem'
|
||||
const StopGlyph = <IconPlayerStopFilled aria-hidden className="size-3.5 -translate-y-px" />
|
||||
@@ -798,7 +805,7 @@ const UserMessage: FC<{
|
||||
>
|
||||
<span aria-hidden className="checkpoint-icon size-1.5 rounded-full border border-current" />
|
||||
<BranchPickerPrimitive.Previous
|
||||
className="checkpoint-restore-text cursor-pointer rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
|
||||
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
|
||||
title="Restore previous checkpoint"
|
||||
>
|
||||
Restore checkpoint
|
||||
@@ -807,7 +814,7 @@ const UserMessage: FC<{
|
||||
<BranchPickerPrimitive.Number />/<BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next
|
||||
className="checkpoint-restore-text cursor-pointer rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
|
||||
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
|
||||
title="Restore next checkpoint"
|
||||
>
|
||||
Go forward
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { $approvalRequest } from '@/store/prompts'
|
||||
|
||||
import { PendingToolApproval } from './tool-approval'
|
||||
import type { ToolPart } from './tool-fallback-model'
|
||||
|
||||
function part(toolName: string): ToolPart {
|
||||
return { toolName, type: `tool-${toolName}` } as unknown as ToolPart
|
||||
}
|
||||
|
||||
function setRequest(command = 'rm -rf /tmp/x') {
|
||||
$approvalRequest.set({ command, description: 'dangerous command', sessionId: 'sess-1' })
|
||||
}
|
||||
|
||||
function mockGateway() {
|
||||
const request = vi.fn().mockResolvedValue({ resolved: true })
|
||||
$gateway.set({ request } as unknown as HermesGateway)
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$approvalRequest.set(null)
|
||||
$gateway.set(null)
|
||||
})
|
||||
|
||||
describe('PendingToolApproval', () => {
|
||||
it('renders nothing when there is no pending approval', () => {
|
||||
const { container } = render(<PendingToolApproval part={part('terminal')} />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders nothing for tools that never raise approval', () => {
|
||||
setRequest()
|
||||
const { container } = render(<PendingToolApproval part={part('read_file')} />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders the inline run/reject controls on the pending terminal row', () => {
|
||||
setRequest('chmod -R 777 /tmp/x')
|
||||
render(<PendingToolApproval part={part('terminal')} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /Run/ })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: /Reject/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('sends approval.respond {choice: "once"} and clears the request on Run', async () => {
|
||||
const request = mockGateway()
|
||||
setRequest()
|
||||
render(<PendingToolApproval part={part('terminal')} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Run/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'once', session_id: 'sess-1' })
|
||||
})
|
||||
expect($approvalRequest.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('sends choice "deny" on Reject', async () => {
|
||||
const request = mockGateway()
|
||||
setRequest()
|
||||
render(<PendingToolApproval part={part('terminal')} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Reject/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'sess-1' })
|
||||
})
|
||||
})
|
||||
})
|
||||
213
apps/desktop/src/components/assistant-ui/tool-approval.tsx
Normal file
213
apps/desktop/src/components/assistant-ui/tool-approval.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client'
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type FC, useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { ChevronDown, Loader2 } from '@/lib/icons'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
|
||||
|
||||
import type { ToolPart } from './tool-fallback-model'
|
||||
|
||||
// Inline approval control. Rendered as a compact button strip
|
||||
// under the pending tool row that raised the approval (the row already shows
|
||||
// the command, so the strip deliberately doesn't repeat it) instead of as a
|
||||
// modal overlay.
|
||||
//
|
||||
// Binding is POSITIONAL, not command-matched: the desktop `tool.start` payload
|
||||
// carries no structured args (only tool_id/name/context — see
|
||||
// tui_gateway/server.py::_on_tool_start), so we cannot join the approval to the
|
||||
// row by command string. But `approval.request` only ever fires from the
|
||||
// `terminal` / `execute_code` guards and the agent thread blocks on exactly one
|
||||
// approval at a time, so the single pending row of those tools IS the row that
|
||||
// raised it. The command/description text comes from `$approvalRequest` (the
|
||||
// event payload), which is the only place that data reliably exists.
|
||||
const APPROVAL_TOOLS = new Set(['terminal', 'execute_code'])
|
||||
|
||||
// Canonical gateway choices (ui-tui/src/components/prompts.tsx).
|
||||
type ApprovalChoice = 'once' | 'session' | 'always' | 'deny'
|
||||
|
||||
export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
|
||||
const request = useStore($approvalRequest)
|
||||
|
||||
if (!request || !APPROVAL_TOOLS.has(part.toolName)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <ApprovalBar request={request} />
|
||||
}
|
||||
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
|
||||
|
||||
const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
const gateway = useStore($gateway)
|
||||
const [submitting, setSubmitting] = useState<ApprovalChoice | null>(null)
|
||||
// "Always allow" persists the pattern to ~/.hermes/config.yaml permanently, so
|
||||
// it goes through a confirm step rather than firing straight from the menu.
|
||||
const [confirmAlways, setConfirmAlways] = useState(false)
|
||||
const busy = submitting !== null
|
||||
|
||||
const respond = useCallback(
|
||||
async (choice: ApprovalChoice) => {
|
||||
// Another bar (or the keyboard path) may have already resolved this
|
||||
// approval; the atom is the single source of truth, so bail if it's gone.
|
||||
if (busy || !$approvalRequest.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!gateway) {
|
||||
notifyError(new Error('Hermes gateway is not connected'), 'Could not send approval response')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(choice)
|
||||
|
||||
try {
|
||||
await gateway.request<{ resolved?: boolean }>('approval.respond', {
|
||||
choice,
|
||||
session_id: request.sessionId ?? undefined
|
||||
})
|
||||
triggerHaptic(choice === 'deny' ? 'cancel' : 'submit')
|
||||
clearApprovalRequest()
|
||||
} catch (error) {
|
||||
notifyError(error, 'Could not send approval response')
|
||||
setSubmitting(null)
|
||||
}
|
||||
},
|
||||
[busy, gateway, request.sessionId]
|
||||
)
|
||||
|
||||
// ⌘/Ctrl+Enter → Run, Esc → Reject.
|
||||
// While the confirm dialog is open it owns the keyboard (Esc closes it), so
|
||||
// the strip-level shortcuts stand down to avoid denying the whole approval.
|
||||
useEffect(() => {
|
||||
if (confirmAlways) {
|
||||
return
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
void respond('once')
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
void respond('deny')
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [confirmAlways, respond])
|
||||
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-2.5 ps-5" data-slot="tool-approval-inline">
|
||||
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
|
||||
<Button
|
||||
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
|
||||
disabled={busy}
|
||||
onClick={() => void respond('once')}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : 'Run'}
|
||||
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
|
||||
</Button>
|
||||
<span aria-hidden className="w-px self-stretch bg-primary/20" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="More approval options"
|
||||
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
|
||||
disabled={busy}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-44">
|
||||
<DropdownMenuItem onSelect={() => void respond('session')}>Allow this session</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
// Defer one tick so the menu fully unmounts before the dialog
|
||||
// mounts — otherwise Radix's focus-return races the dialog and
|
||||
// dismisses it via onInteractOutside.
|
||||
setTimeout(() => setConfirmAlways(true), 0)
|
||||
}}
|
||||
>
|
||||
Always allow…
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
|
||||
Reject
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
|
||||
disabled={busy}
|
||||
onClick={() => void respond('deny')}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : 'Reject'}
|
||||
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
|
||||
</Button>
|
||||
|
||||
<Dialog onOpenChange={setConfirmAlways} open={confirmAlways}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Always allow this command?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This adds the “{request.description}” pattern to your permanent allowlist (
|
||||
<code className="font-mono text-xs">~/.hermes/config.yaml</code>). Hermes won’t ask again for commands
|
||||
like this — in this session or any future one.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{request.command.trim() && (
|
||||
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-words rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-2.5 py-1.5 font-mono text-xs leading-snug text-foreground">
|
||||
{request.command.trim()}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setConfirmAlways(false)} size="sm" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setConfirmAlways(false)
|
||||
void respond('always')
|
||||
}}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
Always allow
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { cn } from '@/lib/utils'
|
||||
import { $toolInlineDiffs } from '@/store/tool-diffs'
|
||||
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
|
||||
|
||||
import { PendingToolApproval } from './tool-approval'
|
||||
import {
|
||||
groupCopyText as buildGroupCopyText,
|
||||
buildToolView,
|
||||
@@ -309,6 +310,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
</span>
|
||||
</DisclosureRow>
|
||||
</div>
|
||||
{isPending && <PendingToolApproval part={part} />}
|
||||
{open && (
|
||||
<div className="grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
|
||||
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
|
||||
@@ -387,7 +389,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
))}
|
||||
{showRawSearchDrilldown && (
|
||||
<details className="max-w-full">
|
||||
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'cursor-pointer mb-0')}>Raw response</summary>
|
||||
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0')}>Raw response</summary>
|
||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
|
||||
{view.rawResult}
|
||||
</pre>
|
||||
|
||||
@@ -14,11 +14,11 @@ export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButton
|
||||
({ children, tooltip, side: _side = 'bottom', className, ...rest }, ref) => {
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
{...rest}
|
||||
aria-label={tooltip}
|
||||
className={cn('aui-button-icon size-6 p-1', className)}
|
||||
className={cn('aui-button-icon', className)}
|
||||
ref={ref}
|
||||
title={tooltip}
|
||||
>
|
||||
|
||||
@@ -34,7 +34,7 @@ export function DisclosureRow({
|
||||
// background fill, just the cursor + the affordance caret.
|
||||
'flex min-w-0 max-w-fit items-start gap-1.5 text-left transition-colors',
|
||||
onToggle
|
||||
? 'cursor-pointer hover:text-foreground focus-visible:text-foreground focus-visible:outline-none'
|
||||
? 'hover:text-foreground focus-visible:text-foreground focus-visible:outline-none'
|
||||
: 'cursor-default'
|
||||
)}
|
||||
disabled={!onToggle}
|
||||
|
||||
@@ -142,6 +142,8 @@ function pickCopy(copies: IntroCopy[], seed = 0): IntroCopy {
|
||||
return copies[Math.abs(seed) % copies.length] || FALLBACK_COPY[0]
|
||||
}
|
||||
|
||||
const WORDMARK = 'HERMES AGENT'
|
||||
|
||||
function resolveCopy(personality?: string, seed?: number): IntroCopy {
|
||||
const personalityKey = normalizeKey(personality)
|
||||
|
||||
@@ -163,15 +165,14 @@ export function Intro({ personality, seed }: IntroProps) {
|
||||
>
|
||||
<div className="w-full min-w-0">
|
||||
<p
|
||||
className="fit-text mx-auto mb-3 w-4/5 font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
|
||||
style={
|
||||
{ '--fit-text-line-height': '0.9', '--fit-text-max': '8rem', '--fit-text-min': '2.75rem' } as CSSProperties
|
||||
}
|
||||
aria-label={WORDMARK}
|
||||
className="fit-text mx-auto mb-3 w-[88%] font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
|
||||
style={{ '--fit-text-line-height': '0.9', '--fit-text-min': '2.75rem' } as CSSProperties}
|
||||
>
|
||||
<span>
|
||||
<span>HERMES AGENT</span>
|
||||
<span>{WORDMARK}</span>
|
||||
</span>
|
||||
<span aria-hidden="true">HERMES AGENT</span>
|
||||
<span aria-hidden="true">{WORDMARK}</span>
|
||||
</p>
|
||||
|
||||
<p className="m-0 text-center leading-normal tracking-tight">{copy.body}</p>
|
||||
|
||||
@@ -64,7 +64,7 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
||||
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
|
||||
|
||||
return (
|
||||
<CodeCard>
|
||||
<CodeCard data-streaming={defer ? 'true' : undefined}>
|
||||
<CodeCardHeader>
|
||||
<CodeCardTitle>
|
||||
<CodeCardIcon name={codiconForLanguage(label)} />
|
||||
|
||||
@@ -177,7 +177,7 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
|
||||
}
|
||||
}
|
||||
if (ev.type === 'log') {
|
||||
const next = state.log.concat({ ts: Date.now(), stage: ev.stage ?? null, line: ev.line })
|
||||
const next = state.log.concat({ ts: Date.now(), stage: ev.stage ?? null, line: ev.line, stream: ev.stream })
|
||||
while (next.length > 500) next.shift()
|
||||
return { ...state, log: next }
|
||||
}
|
||||
@@ -431,7 +431,10 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
) : (
|
||||
<>
|
||||
{state.log.map((entry, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap break-words">
|
||||
<div
|
||||
key={i}
|
||||
className={cn('whitespace-pre-wrap break-words', entry.stream === 'stderr' && 'text-muted-foreground')}
|
||||
>
|
||||
{entry.stage ? <span className="text-muted-foreground/70">[{entry.stage}] </span> : null}
|
||||
<span>{entry.line}</span>
|
||||
</div>
|
||||
|
||||
@@ -52,11 +52,11 @@ describe('onboarding Picker', () => {
|
||||
|
||||
expect(screen.getByText('Nous Portal')).toBeTruthy()
|
||||
expect(screen.getByText('Recommended')).toBeTruthy()
|
||||
expect(screen.queryByText('Anthropic Claude')).toBeNull()
|
||||
expect(screen.queryByText('Anthropic API Key')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Other providers' }))
|
||||
|
||||
expect(screen.getByText('Anthropic Claude')).toBeTruthy()
|
||||
expect(screen.getByText('Anthropic API Key')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Collapse' })).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -64,8 +64,8 @@ describe('onboarding Picker', () => {
|
||||
setProviders([provider('anthropic', 'Anthropic Claude'), provider('openai-codex', 'OpenAI Codex / ChatGPT')])
|
||||
render(<Picker ctx={ctx} />)
|
||||
|
||||
expect(screen.getByText('Anthropic Claude')).toBeTruthy()
|
||||
expect(screen.getByText('OpenAI Codex / ChatGPT')).toBeTruthy()
|
||||
expect(screen.getByText('Anthropic API Key')).toBeTruthy()
|
||||
expect(screen.getByText('OpenAI OAuth (ChatGPT)')).toBeTruthy()
|
||||
expect(screen.queryByText('Other sign-in options')).toBeNull()
|
||||
expect(screen.queryByText('Recommended')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { Input } from '@/components/ui/input'
|
||||
import { getGlobalModelOptions } from '@/hermes'
|
||||
import {
|
||||
@@ -56,8 +57,6 @@ interface ApiKeyOption {
|
||||
short?: string
|
||||
}
|
||||
|
||||
const MIN_KEY_LENGTH = 8
|
||||
|
||||
const API_KEY_OPTIONS: ApiKeyOption[] = [
|
||||
{
|
||||
id: 'openrouter',
|
||||
@@ -104,12 +103,14 @@ const API_KEY_OPTIONS: ApiKeyOption[] = [
|
||||
|
||||
const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
|
||||
nous: { order: 0, title: 'Nous Portal' },
|
||||
anthropic: { order: 1, title: 'Anthropic Claude' },
|
||||
'openai-codex': { order: 2, title: 'OpenAI Codex / ChatGPT' },
|
||||
'minimax-oauth': { order: 3, title: 'MiniMax' },
|
||||
'openai-codex': { order: 1, title: 'OpenAI OAuth (ChatGPT)' },
|
||||
'minimax-oauth': { order: 2, title: 'MiniMax' },
|
||||
'qwen-oauth': { order: 3, title: 'Qwen Code' },
|
||||
'xai-oauth': { order: 4, title: 'xAI Grok' },
|
||||
'claude-code': { order: 5, title: 'Claude Code' },
|
||||
'qwen-oauth': { order: 6, title: 'Qwen Code' }
|
||||
// Both Anthropic entries sit at the bottom: the API-key path first, then
|
||||
// the subscription OAuth path (only works with extra usage credits).
|
||||
anthropic: { order: 5, title: 'Anthropic API Key' },
|
||||
'claude-code': { order: 6, title: 'Anthropic OAuth: Required Extra Usage Credits to Use Subscription' }
|
||||
}
|
||||
|
||||
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
|
||||
@@ -167,20 +168,20 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
|
||||
<div className="w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
|
||||
<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 />
|
||||
{onboarding.manual ? (
|
||||
<Button
|
||||
aria-label="Close"
|
||||
className="absolute right-3 top-3 z-10 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
onClick={() => closeManualOnboarding()}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="1rem" />
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="grid gap-3 p-5">
|
||||
{onboarding.manual ? (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
className="text-xs font-medium text-muted-foreground transition hover:text-foreground"
|
||||
onClick={() => closeManualOnboarding()}
|
||||
type="button"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{reason ? <ReasonNotice reason={reason} /> : null}
|
||||
{ready ? showPicker ? <Picker ctx={ctx} /> : <FlowPanel ctx={ctx} flow={flow} /> : <Preparing boot={boot} />}
|
||||
</div>
|
||||
@@ -418,7 +419,9 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
const isLocal = option.envKey === 'OPENAI_BASE_URL'
|
||||
const canSave = value.trim().length >= (isLocal ? 1 : MIN_KEY_LENGTH)
|
||||
// Only require a non-empty value — no length/format validation, so a short
|
||||
// or unusual key can't block the user from continuing.
|
||||
const canSave = value.trim().length >= 1
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSave || saving) {
|
||||
@@ -692,9 +695,11 @@ function ConfirmingModelPanel({
|
||||
queryKey: ['onboarding-model-options', flow.providerSlug],
|
||||
queryFn: () => getGlobalModelOptions()
|
||||
})
|
||||
|
||||
const providerRow = options.data?.providers?.find(
|
||||
p => String(p.slug).toLowerCase() === flow.providerSlug.toLowerCase()
|
||||
)
|
||||
|
||||
const price = providerRow?.pricing?.[flow.currentModel]
|
||||
const freeTier = providerRow?.free_tier
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle, RefreshCw } from '@/lib/icons'
|
||||
import { ErrorState } from '@/components/ui/error-state'
|
||||
|
||||
export interface ErrorBoundaryFallbackProps {
|
||||
error: Error
|
||||
@@ -53,39 +53,25 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
|
||||
function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1500] 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>
|
||||
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Something broke in the interface</h2>
|
||||
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
|
||||
The view hit an unexpected error. Your chats and settings are safe - try again, or reload the window.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 p-5">
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 font-mono text-[0.7rem] leading-4 text-destructive">
|
||||
{error.message || String(error)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={reset}>
|
||||
<RefreshCw className="size-4" />
|
||||
Try again
|
||||
</Button>
|
||||
<Button onClick={() => window.location.reload()} variant="outline">
|
||||
Reload window
|
||||
</Button>
|
||||
<Button onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)} variant="ghost">
|
||||
Open logs
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fixed inset-0 z-[1500] grid place-items-center bg-(--ui-chat-surface-background) p-6">
|
||||
<ErrorState
|
||||
className="w-full max-w-[28rem]"
|
||||
description={error.message || 'The view hit an unexpected error. Your chats and settings are safe.'}
|
||||
title="Something broke in the interface"
|
||||
>
|
||||
<Button className="font-semibold" onClick={reset} size="lg">
|
||||
Try again
|
||||
</Button>
|
||||
<Button onClick={() => window.location.reload()} variant="text">
|
||||
Reload window
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)}
|
||||
variant="text"
|
||||
>
|
||||
Open logs
|
||||
</Button>
|
||||
</ErrorState>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
@@ -86,7 +87,11 @@ export function ModelVisibilityDialog({ gw, onOpenChange, onOpenProviders, open,
|
||||
<div className="max-h-[55vh] overflow-y-auto pb-1">
|
||||
{providers.length === 0 ? (
|
||||
<div className="px-3 py-5 text-center text-xs text-muted-foreground">
|
||||
{modelOptions.isPending ? 'Loading…' : 'No authenticated providers.'}
|
||||
{modelOptions.isPending ? (
|
||||
<BrailleSpinner className="mx-auto text-sm" />
|
||||
) : (
|
||||
'No authenticated providers.'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
providers.map(provider => {
|
||||
@@ -118,7 +123,6 @@ export function ModelVisibilityDialog({ gw, onOpenChange, onOpenProviders, open,
|
||||
</span>
|
||||
<Switch
|
||||
checked={visible.has(key)}
|
||||
className="cursor-pointer"
|
||||
onCheckedChange={() => toggle(provider, family.id)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -132,7 +132,7 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
|
||||
function NotificationDetail({ detail }: { detail: string }) {
|
||||
return (
|
||||
<details className="mt-2 text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer select-none font-medium text-muted-foreground hover:text-foreground">
|
||||
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">
|
||||
Details
|
||||
</summary>
|
||||
<div className="mt-1 rounded-md border border-border/70 bg-background/65 p-2">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user