Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
b7511191b5 chore(actions)(deps): bump docker/setup-buildx-action
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.12.0 to 4.1.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](8d2750c68a...d7f5e7f509)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 10:03:49 +00:00
675 changed files with 15070 additions and 41103 deletions

View File

@@ -3,21 +3,6 @@
.gitignore
.gitmodules
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
# Virtual environments
venv/
env/
ENV/
# Dependencies
node_modules
**/node_modules
@@ -39,20 +24,7 @@ ui-tui/packages/hermes-ink/dist/
# Environment files
.env
.env.*
# IDE
.vscode/
.idea/
*.swp
*.swo
# Testing
.pytest_cache/
.coverage
htmlcov/
# Documentation
*.md
# Runtime data (bind-mounted at /opt/data; must not leak into build context)

8
.gitattributes vendored
View File

@@ -1,10 +1,2 @@
# Auto-generated files — collapse diffs and exclude from language stats
web/package-lock.json linguist-generated=true
# Enforce LF for scripts that run inside Linux containers.
# Without this, Windows checkout converts to CRLF and breaks `exec` in the
# container entrypoint with "no such file or directory".
*.sh text eol=lf
Dockerfile text eol=lf
*.dockerfile text eol=lf
docker/entrypoint.sh text eol=lf

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -60,7 +60,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
# Build once, load into the local daemon for smoke testing. Cached
# to gha with a per-arch scope; the push step below reuses every
@@ -194,7 +194,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
# Log in to ghcr.io so the registry-backed build cache below can be
# read (cache-from) on every event and written (cache-to) on
@@ -319,7 +319,7 @@ jobs:
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0

View File

@@ -171,11 +171,6 @@ 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

1
.gitignore vendored
View File

@@ -1,6 +1,5 @@
.DS_Store
/venv/
/venv.old/
/_pycache/
*.pyc*
__pycache__/

View File

@@ -283,21 +283,6 @@ 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

View File

@@ -73,7 +73,7 @@ This isn't a quality bar — it's a coupling-and-maintenance decision. Memory pr
| Requirement | Notes |
|-------------|-------|
| **Git** | With the `git-lfs` extension installed |
| **Git** | With `--recurse-submodules` support, and 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) |

View File

@@ -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 libolm-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 procps git openssh-client docker-cli xz-utils && \
rm -rf /var/lib/apt/lists/*
# ---------- s6-overlay install ----------
@@ -157,17 +157,10 @@ 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 --extra hindsight
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity
# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
@@ -185,16 +178,13 @@ 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/gateway /opt/hermes/node_modules
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /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

View File

@@ -1,6 +1,5 @@
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

View File

@@ -33,7 +33,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
### Linux, macOS, WSL2, Termux
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
```
### Windows (native, PowerShell)
@@ -43,7 +43,7 @@ curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
Run this in PowerShell:
```powershell
iex (irm https://hermes-agent.nousresearch.com/install.ps1)
iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1)
```
The installer handles everything: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **and a portable Git Bash** (MinGit, unpacked to `%LOCALAPPDATA%\hermes\git` — no admin required, completely isolated from any system Git install). Hermes uses this bundled Git Bash to run shell commands.
@@ -52,7 +52,7 @@ If you already have Git installed, the installer detects it and uses that instea
> **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies.
>
> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively).
> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively).
After installation:
@@ -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 info`. 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 status`. 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.

View File

@@ -31,7 +31,7 @@
## 快速安装
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
```
支持 Linux、macOS、WSL2 和 Android (Termux)。安装程序会自动处理平台特定的配置。
@@ -80,7 +80,7 @@ Hermes 始终允许你使用任意服务商,这点不会改变。但如果你
hermes setup --portal
```
它会通过 OAuth 登录、把 Nous 设为推理服务商,并启用 Tool Gateway。随时用 `hermes portal info` 查看路由状态。完整说明见 [Tool Gateway 文档](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway)。
它会通过 OAuth 登录、把 Nous 设为推理服务商,并启用 Tool Gateway。随时用 `hermes portal status` 查看路由状态。完整说明见 [Tool Gateway 文档](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway)。
你随时可以按工具单独切回自己的 API Key — Gateway 是按工具粒度生效的,不是一刀切。

View File

@@ -457,7 +457,12 @@ class SessionManager:
else:
# Update model_config (contains cwd) if changed.
try:
db.update_session_meta(state.session_id, cwd_json, model_str)
with db._lock:
db._conn.execute(
"UPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?",
(cwd_json, model_str, state.session_id),
)
db._conn.commit()
except Exception:
logger.debug("Failed to update ACP session metadata", exc_info=True)

View File

@@ -47,20 +47,6 @@ 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]]:
"""
@@ -1632,84 +1618,36 @@ 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 "",
session_id=getattr(agent, "session_id", "") or "",
tool_call_id=tool_call_id or "",
turn_id=getattr(agent, "_current_turn_id", "") or "",
api_request_id=getattr(agent, "_current_api_request_id", "") or "",
function_name, function_args, task_id=effective_task_id or "",
)
except Exception:
pass
if block_message is not None:
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
return json.dumps({"error": block_message}, ensure_ascii=False)
if function_name == "todo":
from tools.todo_tool import todo_tool as _todo_tool
return _finish_agent_tool(
_todo_tool(
todos=function_args.get("todos"),
merge=function_args.get("merge", False),
store=agent._todo_store,
)
return _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 _finish_agent_tool(json.dumps({"success": False, "error": format_session_db_unavailable()}))
return json.dumps({"success": False, "error": format_session_db_unavailable()})
from tools.session_search_tool import session_search as _session_search
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,
)
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,
)
elif function_name == "memory":
target = function_args.get("target", "memory")
@@ -1735,27 +1673,23 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
)
except Exception:
pass
return _finish_agent_tool(result)
return result
elif agent._memory_manager and agent._memory_manager.has_tool(function_name):
return _finish_agent_tool(agent._memory_manager.handle_tool_call(function_name, function_args))
return agent._memory_manager.handle_tool_call(function_name, function_args)
elif function_name == "clarify":
from tools.clarify_tool import clarify_tool as _clarify_tool
return _finish_agent_tool(
_clarify_tool(
question=function_args.get("question", ""),
choices=function_args.get("choices"),
callback=agent.clarify_callback,
)
return _clarify_tool(
question=function_args.get("question", ""),
choices=function_args.get("choices"),
callback=agent.clarify_callback,
)
elif function_name == "delegate_task":
return _finish_agent_tool(agent._dispatch_delegate_task(function_args))
return 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),

View File

@@ -265,6 +265,9 @@ _API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = {
"stepfun": "step-3.5-flash",
"kimi-coding-cn": "kimi-k2-turbo-preview",
"gmi": "google/gemini-3.1-flash-lite-preview",
"minimax": "MiniMax-M2.7",
"minimax-oauth": "MiniMax-M2.7-highspeed",
"minimax-cn": "MiniMax-M2.7",
"anthropic": "claude-haiku-4-5-20251001",
"opencode-zen": "gemini-3-flash",
"opencode-go": "glm-5",
@@ -4753,14 +4756,10 @@ def _is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool:
def _convert_openai_images_to_anthropic(messages: list) -> list:
"""Convert OpenAI ``image_url``/``video_url`` blocks to Anthropic format.
"""Convert OpenAI ``image_url`` content blocks to Anthropic ``image`` blocks.
Converts:
- ``image_url`` blocks to Anthropic ``image`` blocks
- ``video_url`` blocks to Anthropic ``video`` blocks (MiniMax M3 compat)
Only touches messages that have list-type content with ``image_url`` or
``video_url`` blocks; plain text messages pass through unchanged.
Only touches messages that have list-type content with ``image_url`` blocks;
plain text messages pass through unchanged.
"""
converted = []
for msg in messages:
@@ -4797,39 +4796,6 @@ def _convert_openai_images_to_anthropic(messages: list) -> list:
},
})
changed = True
elif block.get("type") == "video_url":
# MiniMax's Anthropic-compatible endpoint expects a "video"
# block (not OpenAI's "video_url", and not "input_video").
# See https://platform.minimax.io/docs/api-reference/text-anthropic-api
# — the Messages-field table lists type="video" (M3 only,
# URL/base64/mm_file://). The source shape mirrors the "image"
# block: base64 → {type:"base64", media_type, data}, URL →
# {type:"url", url}.
video_url_val = (block.get("video_url") or {}).get("url", "")
if video_url_val.startswith("data:"):
# Parse data URI: data:<media_type>;base64,<data>
header, _, b64data = video_url_val.partition(",")
media_type = "video/mp4"
if ":" in header and ";" in header:
media_type = header.split(":", 1)[1].split(";", 1)[0]
new_content.append({
"type": "video",
"source": {
"type": "base64",
"media_type": media_type,
"data": b64data,
},
})
else:
# URL-based video
new_content.append({
"type": "video",
"source": {
"type": "url",
"url": video_url_val,
},
})
changed = True
else:
new_content.append(block)
converted.append({**msg, "content": new_content} if changed else msg)

View File

@@ -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, model=agent.model)
agent._sanitize_tool_calls_for_strict_api(api_msg)
api_messages.append(api_msg)
effective_system = agent._cached_system_prompt or ""

View File

@@ -646,11 +646,6 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
# much larger; shrinking to 4 MB here loses quality but only fires
# after a confirmed provider rejection, so the alternative is failure.
target_bytes = 4 * 1024 * 1024
# Anthropic enforces an 8000px per-side dimension cap independently of
# the 5 MB byte cap. A tall screenshot can be well under 5 MB yet far
# over 8000px (e.g. 1200×12000 at 0.06 MB). We check pixel dimensions
# even when the byte budget is fine.
max_dimension = 8000
changed_count = 0
# Track parts that are over the target but could NOT be shrunk under it.
# If any survive, retrying is pointless — the same oversized payload will
@@ -663,30 +658,9 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
"""Return a smaller data URL, or None if shrink can't help."""
if not isinstance(url, str) or not url.startswith("data:"):
return None
# Check both byte size AND pixel dimensions.
needs_shrink = len(url) > target_bytes # over byte budget
if not needs_shrink:
# Even if bytes are fine, check pixel dimensions against
# Anthropic's 8000px cap. A tall image can be tiny in bytes
# yet huge in pixels.
try:
import base64 as _b64_dim
header_d, _, data_d = url.partition(",")
if not data_d:
return None
raw_d = _b64_dim.b64decode(data_d)
from PIL import Image as _PILImage
import io as _io_dim
with _PILImage.open(_io_dim.BytesIO(raw_d)) as _img:
if max(_img.size) <= max_dimension:
return None # both bytes and pixels are fine
needs_shrink = True # pixels exceed limit, force shrink
except Exception:
# If we can't check dimensions (Pillow unavailable, corrupt
# image, etc.), fall back to byte-only check.
return None
if len(url) <= target_bytes:
# This specific image wasn't the oversized one.
return None
try:
header, _, data = url.partition(",")
mime = "image/jpeg"
@@ -710,7 +684,6 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
Path(tmp.name),
mime_type=mime,
max_base64_bytes=target_bytes,
max_dimension=max_dimension,
)
finally:
try:

View File

@@ -435,9 +435,6 @@ 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.
@@ -705,8 +702,6 @@ 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)),
@@ -982,7 +977,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, model=agent.model)
agent._sanitize_tool_calls_for_strict_api(api_msg)
# Keep 'reasoning_details' - OpenRouter uses this for multi-turn reasoning context
# The signature field helps maintain reasoning continuity
api_messages.append(api_msg)
@@ -1158,8 +1153,6 @@ 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 ──────────────────────
@@ -1227,58 +1220,37 @@ def run_conversation(
api_kwargs = agent._get_transport().preflight_kwargs(api_kwargs, allow_stream=False)
try:
from hermes_cli.plugins import (
has_hook,
invoke_hook as _invoke_hook,
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,
)
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
@@ -1328,14 +1300,12 @@ def run_conversation(
if isinstance(getattr(agent, "client", None), Mock):
_use_streaming = False
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)
if _use_streaming:
response = agent._interruptible_streaming_api_call(
api_kwargs, on_first_delta=_stop_spinner
)
else:
response = agent._interruptible_api_call(api_kwargs)
api_duration = time.time() - api_start_time
@@ -1436,21 +1406,6 @@ 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:
@@ -2323,21 +2278,6 @@ 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
@@ -3255,7 +3195,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 portal", 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} 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.
@@ -3438,12 +3378,6 @@ 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
@@ -3567,44 +3501,29 @@ def run_conversation(
assistant_message.content = str(raw)
try:
from hermes_cli.plugins import (
has_hook,
invoke_hook as _invoke_hook,
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),
)
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
@@ -4698,8 +4617,6 @@ 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),
@@ -4819,8 +4736,6 @@ 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,

View File

@@ -171,9 +171,6 @@ _IMAGE_TOO_LARGE_PATTERNS = [
"image too large", # generic
"image_too_large", # error_code variant
"image size exceeds", # variant
"image dimensions exceed", # Anthropic: "image dimensions exceed max allowed size: 8000 pixels"
"dimensions exceed max allowed size", # Anthropic dimension-cap (wording variant)
"max allowed size: 8000", # Anthropic dimension-cap (explicit pixel ceiling)
# "request_too_large" on a request known to contain an image → image is
# the likely culprit; we still try the shrink path before giving up.
]

View File

@@ -32,7 +32,6 @@ from __future__ import annotations
import logging
import os
import sysconfig
import threading
from functools import lru_cache
from pathlib import Path
@@ -88,54 +87,11 @@ _catalog_lock = threading.Lock()
def _locales_dir() -> Path:
"""Return the directory containing locale YAML files.
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.
Lives next to the repo root so both the bundled install and editable
checkouts find it without PYTHONPATH gymnastics.
"""
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
# agent/i18n.py -> agent/ -> repo root
return Path(__file__).resolve().parent.parent / "locales"
def _normalize_lang(value: Any) -> str:

View File

@@ -441,10 +441,6 @@ def is_local_endpoint(base_url: str) -> bool:
# Docker / Podman / Lima internal DNS names (e.g. host.docker.internal)
if any(host.endswith(suffix) for suffix in _CONTAINER_LOCAL_SUFFIXES):
return True
# Unqualified hostnames (no dots) are local by definition — Docker
# Compose service names, /etc/hosts entries, or mDNS names.
if host and "." not in host:
return True
# RFC-1918 private ranges, link-local, and Tailscale CGNAT
try:
addr = ipaddress.ip_address(host)
@@ -1144,18 +1140,6 @@ def _model_name_suggests_minimax_m3(model: str) -> bool:
return "minimax-m3" in model.lower()
def _model_name_suggests_grok_4_3(model: str) -> bool:
"""Return True if the model name looks like a Grok 4.3 variant.
Catches ``grok-4.3``, ``grok-4.3-latest``, and similar slugs.
Used as a guard against stale cache entries seeded by pre-catalog builds
that resolved grok-4.3 via the generic ``grok-4`` catch-all (256,000)
before the ``grok-4.3`` (1M) entry was added to DEFAULT_CONTEXT_LENGTHS
on 2026-05-15.
"""
return "grok-4.3" in model.lower()
def _query_local_context_length(model: str, base_url: str, api_key: str = "") -> Optional[int]:
"""Query a local server for the model's context length."""
import httpx
@@ -1580,19 +1564,6 @@ def get_model_context_length(
model, base_url, f"{cached:,}",
)
_invalidate_cached_context_length(model, base_url)
# Invalidate stale ≤256,000 cache entries for Grok-4.3. The
# ``grok-4.3`` (1M) entry was added to DEFAULT_CONTEXT_LENGTHS on
# 2026-05-15; prior to that, grok-4.3 slugs resolved via the
# ``grok-4`` catch-all (256,000) and that value was persisted.
# grok-4.3 is 1M, so any sub-262K cached value is a pre-catalog
# leftover — drop it and fall through to the hardcoded default.
elif cached <= 256_000 and _model_name_suggests_grok_4_3(model):
logger.info(
"Dropping stale Grok-4.3 cache entry %s@%s -> %s (pre-catalog value); "
"re-resolving via hardcoded defaults",
model, base_url, f"{cached:,}",
)
_invalidate_cached_context_length(model, base_url)
# Nous Portal: the portal /v1/models endpoint is authoritative.
# Bypass the persistent cache so step 5b can always reconcile
# against it — this corrects pre-fix entries seeded from the

View File

@@ -22,7 +22,6 @@ from agent.skill_utils import (
get_disabled_skill_names,
iter_skill_index_files,
parse_frontmatter,
skill_matches_environment,
skill_matches_platform,
)
from utils import atomic_json_write
@@ -130,14 +129,9 @@ DEFAULT_AGENT_IDENTITY = (
)
HERMES_AGENT_HELP_GUIDANCE = (
"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."
"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"
)
MEMORY_GUIDANCE = (
@@ -1006,13 +1000,6 @@ def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]:
if not skill_matches_platform(frontmatter):
return False, frontmatter, ""
# Environment relevance gate (offer-time only): hide skills tagged for
# a runtime environment that isn't active (e.g. kanban-only skills for
# non-kanban users, s6-only skills outside the container). Explicit
# loads (skill_view / --skills) bypass this — see skill_matches_environment.
if not skill_matches_environment(frontmatter):
return False, frontmatter, ""
return True, frontmatter, extract_skill_description(frontmatter)
except Exception as e:
logger.warning("Failed to parse skill file %s: %s", skill_file, e)

View File

@@ -270,7 +270,7 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
_skill_commands_platform = _resolve_skill_commands_platform()
_skill_commands = {}
try:
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, skill_matches_environment, _get_disabled_skill_names
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, _get_disabled_skill_names
from agent.skill_utils import get_external_skills_dirs, iter_skill_index_files
disabled = _get_disabled_skill_names()
seen_names: set = set()
@@ -291,10 +291,6 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
# Skip skills incompatible with the current OS platform
if not skill_matches_platform(frontmatter):
continue
# Skip skills not relevant to the current runtime env
# (kanban/docker/s6). Offer-time only; explicit load bypasses.
if not skill_matches_environment(frontmatter):
continue
name = frontmatter.get('name', skill_md.parent.name)
if name in seen_names:
continue

View File

@@ -169,106 +169,6 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
return False
# ── Environment matching ──────────────────────────────────────────────────
# Recognized environment tags and how each is detected. An environment tag is
# a *relevance* gate, not a hard-compatibility gate (that is what ``platforms:``
# is for). A skill tagged for an environment it isn't relevant to is hidden from
# the skills index / offer surfaces so it does not add noise for users who will
# never need it — but it can ALWAYS still be loaded explicitly (``skill_view``,
# ``--skills``), because an explicit request is explicit consent.
#
# Detection is cached for the process lifetime via ``_ENV_DETECT_CACHE``.
_KNOWN_ENVIRONMENTS = frozenset({"kanban", "docker", "s6"})
_ENV_DETECT_CACHE: Dict[str, bool] = {}
def _detect_environment(env: str) -> bool:
"""Return True when the named runtime environment is currently active.
Cached per process. Unknown env names return True (fail-open: never hide a
skill because of a tag we don't understand).
"""
if env in _ENV_DETECT_CACHE:
return _ENV_DETECT_CACHE[env]
result = True
if env == "kanban":
# Kanban is "active" either as a dispatcher-spawned worker (the
# dispatcher sets ``HERMES_KANBAN_TASK`` / ``HERMES_KANBAN_BOARD`` in the
# worker env) or as an orchestrator profile that has opted into the
# kanban toolset. Mirror the same signals the kanban tools themselves
# gate on (``tools/kanban_tools.py``) so the offer filter agrees with
# tool availability.
if os.getenv("HERMES_KANBAN_TASK") or os.getenv("HERMES_KANBAN_BOARD"):
result = True
else:
try:
from tools.kanban_tools import _profile_has_kanban_toolset
result = bool(_profile_has_kanban_toolset())
except Exception:
result = False
elif env == "docker":
try:
from hermes_constants import is_container
result = is_container()
except Exception:
result = False
elif env == "s6":
# The Hermes Docker image runs s6-overlay as PID 1 (/init). s6 plants
# its runtime scaffolding under /run/s6 and ships its admin tree under
# /package/admin/s6-overlay. Either marker means we're inside an
# s6-supervised container.
result = os.path.isdir("/run/s6") or os.path.isdir(
"/package/admin/s6-overlay"
)
_ENV_DETECT_CACHE[env] = result
return result
def skill_matches_environment(frontmatter: Dict[str, Any]) -> bool:
"""Return True when the skill is relevant to the current runtime environment.
Skills may declare an ``environments`` list in their YAML frontmatter::
environments: [kanban] # only relevant when kanban is active
environments: [s6] # only relevant inside the s6 Docker image
environments: [docker] # only relevant inside any container
If the field is absent or empty the skill is relevant in **all**
environments (backward-compatible default).
This is an OFFER-time filter: it controls whether a skill shows up in the
skills index / autocomplete / slash-command list. It is intentionally NOT
enforced by ``skill_view`` or ``--skills`` preloading — an explicit load is
explicit consent, and load-bearing force-loads (e.g. the kanban dispatcher
injecting ``--skills kanban-worker``) must always succeed regardless of how
the offer surfaces filter the skill.
A skill matches when ANY of its declared environments is currently active
(OR semantics, mirroring ``platforms``). Unknown env tags fail open.
"""
environments = frontmatter.get("environments")
if not environments:
return True
if not isinstance(environments, list):
environments = [environments]
for env in environments:
normalized = str(env).lower().strip()
if not normalized:
continue
if normalized not in _KNOWN_ENVIRONMENTS:
# Tag we don't understand — don't hide the skill over it.
return True
if _detect_environment(normalized):
return True
return False
# ── Disabled skills ───────────────────────────────────────────────────────

View File

@@ -19,7 +19,7 @@ import os
import random
import threading
import time
from typing import Any, Optional
from typing import Optional
from agent.display import (
KawaiiSpinner,
@@ -58,76 +58,6 @@ 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.
@@ -258,61 +188,22 @@ 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 "",
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 "",
function_name, function_args, task_id=effective_task_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:
@@ -424,23 +315,6 @@ 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)
@@ -552,30 +426,8 @@ 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
@@ -740,21 +592,13 @@ 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 "",
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 "",
function_name, function_args, task_id=effective_task_id or "",
)
except Exception:
pass
@@ -843,33 +687,11 @@ 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(
@@ -1028,29 +850,12 @@ 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)
@@ -1067,27 +872,11 @@ 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)
@@ -1106,27 +895,6 @@ 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,

View File

@@ -99,22 +99,6 @@ 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'.
@@ -135,14 +119,6 @@ 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,
@@ -161,9 +137,6 @@ 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):
@@ -182,9 +155,7 @@ 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
or (strip_extra_content and "extra_content" in tc)
"call_id" in tc or "response_item_id" in tc
):
needs_sanitize = True
break
@@ -212,8 +183,6 @@ 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]]:
@@ -271,10 +240,8 @@ class ChatCompletionsTransport(ProviderTransport):
anthropic_max_output: int | None
extra_body_additions: dict | None
"""
# 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)
# Codex sanitization: drop reasoning_items / call_id / response_item_id
sanitized = self.convert_messages(messages)
# ── Provider profile: single-path when present ──────────────────
_profile = params.get("provider_profile")

View File

@@ -21,7 +21,7 @@ use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use tokio::sync::{mpsc, Mutex};
use crate::events::{BootstrapEvent, LogStream, Manifest, StageState};
use crate::events::{BootstrapEvent, Manifest, StageState};
use crate::install_script::{self, Pin, ScriptKind, ScriptSource};
use crate::powershell::{self, StreamSink};
use crate::AppState;
@@ -179,11 +179,9 @@ pub async fn launch_hermes_desktop(
tracing::info!(?exe_path, "launching Hermes desktop");
// 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);
// 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));
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
@@ -234,24 +232,6 @@ 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.
@@ -267,7 +247,8 @@ 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 = desktop_launch_command_std(&exe, install_root);
let mut cmd = std::process::Command::new(&exe);
cmd.current_dir(exe.parent().unwrap_or(install_root));
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
@@ -280,62 +261,6 @@ 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
// ---------------------------------------------------------------------------
@@ -366,7 +291,6 @@ 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
@@ -701,7 +625,6 @@ 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
@@ -720,8 +643,7 @@ async fn run_install_script(
&app_for_stderr,
BootstrapEvent::Log {
stage: stage_for_stderr.clone(),
line: line.to_string(),
stream: LogStream::Stderr,
line: format!("stderr: {line}"),
},
);
// stderr-level lines get warn! so they're visually distinct
@@ -817,90 +739,3 @@ 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);
}
}

View File

@@ -51,16 +51,6 @@ 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")]
@@ -82,14 +72,11 @@ pub enum BootstrapEvent {
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
/// 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.
/// Raw stdout/stderr line from install.ps1 (or our wrapper).
Log {
#[serde(skip_serializing_if = "Option::is_none")]
stage: Option<String>,
line: String,
stream: LogStream,
},
/// Sent once when all stages complete successfully.
Complete {

View File

@@ -45,14 +45,6 @@ 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);
}
@@ -154,16 +146,6 @@ 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) => {
@@ -282,11 +264,4 @@ 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("/")));
}
}

View File

@@ -19,11 +19,8 @@
//! 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};
@@ -31,7 +28,7 @@ use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};
use crate::events::{BootstrapEvent, StageInfo, StageState};
/// `hermes update` exit code meaning "another hermes process is holding the
/// venv shim open / dirty precondition" — see _cmd_update_impl in
@@ -43,48 +40,10 @@ 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;
@@ -97,7 +56,6 @@ pub async fn start_update(app: AppHandle) -> Result<(), String> {
},
);
}
UPDATE_RUNNING.store(false, Ordering::SeqCst);
});
Ok(())
}
@@ -105,14 +63,6 @@ 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!(
@@ -131,18 +81,13 @@ 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,
stages: vec![
stage_info("update", "Updating Hermes"),
stage_info("rebuild", "Rebuilding the desktop app"),
],
protocol_version: None,
},
);
@@ -162,17 +107,12 @@ 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.
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);
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_stage(&app, "update", StageState::Running, None, None);
let started = Instant::now();
@@ -181,7 +121,6 @@ async fn run_update(app: AppHandle) -> Result<()> {
&hermes,
&update_args,
&install_root,
&child_env,
Some("update"),
)
.await?;
@@ -243,13 +182,11 @@ 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,
&rebuild_args,
&["desktop", "--build-only"],
&install_root,
&child_env,
Some("rebuild"),
)
.await?;
@@ -280,43 +217,6 @@ 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,
@@ -326,17 +226,10 @@ async fn run_update(app: AppHandle) -> Result<()> {
},
);
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
// 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
{
// 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
@@ -344,7 +237,6 @@ async fn run_update(app: AppHandle) -> Result<()> {
emit_log(
&app,
None,
LogStream::Stdout,
&format!("[update] could not auto-launch desktop: {err}. Launch Hermes manually."),
);
}
@@ -359,7 +251,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"), LogStream::Stdout, "[update] waiting for Hermes to exit…");
emit_log(app, Some("update"), "[update] waiting for Hermes to exit…");
loop {
if !is_locked(&shim) {
@@ -369,7 +261,6 @@ 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;
@@ -398,9 +289,8 @@ fn is_locked(path: &Path) -> bool {
async fn run_streamed(
app: &AppHandle,
program: &Path,
args: &[String],
args: &[&str],
cwd: &Path,
envs: &[(String, OsString)],
stage: Option<&str>,
) -> Result<CmdResult> {
let mut cmd = Command::new(program);
@@ -409,9 +299,6 @@ 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")]
{
@@ -433,22 +320,22 @@ async fn run_streamed(
loop {
tokio::select! {
line = out.next_line() => match line {
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), LogStream::Stdout, &l),
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), &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(), LogStream::Stderr, &l),
Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), &format!("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(), LogStream::Stdout, &l);
emit_log(app, stage_owned.as_deref(), &l);
}
while let Ok(Some(l)) = err.next_line().await {
emit_log(app, stage_owned.as_deref(), LogStream::Stderr, &l);
emit_log(app, stage_owned.as_deref(), &format!("stderr: {l}"));
}
let status = child.wait().await.map_err(|e| anyhow!("waiting for child: {e}"))?;
@@ -491,225 +378,6 @@ 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
// ---------------------------------------------------------------------------
@@ -761,7 +429,7 @@ fn emit_stage(
);
}
fn emit_log(app: &AppHandle, stage: Option<&str>, stream: LogStream, line: &str) {
fn emit_log(app: &AppHandle, stage: Option<&str>, line: &str) {
match stage {
Some(s) => tracing::info!(target: "bootstrap.log", stage = %s, "{line}"),
None => tracing::info!(target: "bootstrap.log", "{line}"),
@@ -771,7 +439,6 @@ fn emit_log(app: &AppHandle, stage: Option<&str>, stream: LogStream, line: &str)
BootstrapEvent::Log {
stage: stage.map(|s| s.to_string()),
line: line.to_string(),
stream,
},
);
}
@@ -792,118 +459,4 @@ 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);
}
}

View File

@@ -3,10 +3,8 @@ 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'
@@ -24,8 +22,6 @@ 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">
@@ -41,27 +37,24 @@ export default function Failure({ bootstrap }: FailureProps) {
}
>
<span>
<span>{isUpdate ? 'Update didn\u2019t finish' : 'Install didn\u2019t finish'}</span>
<span>Install didn&rsquo;t finish</span>
</span>
<span aria-hidden="true">{isUpdate ? 'Update didn\u2019t finish' : 'Install didn\u2019t finish'}</span>
<span aria-hidden="true">Install didn&rsquo;t 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 ??
(isUpdate
? 'Something went wrong during the update.'
: 'Something went wrong during installation.')}
{bootstrap.error ?? 'Something went wrong during installation.'}
</p>
</div>
<div className="flex items-center gap-3">
<Button
onClick={() => void (isUpdate ? startUpdate() : startInstall())}
onClick={() => void startInstall()}
size="lg"
className="inline-flex items-center gap-2 px-6"
>
<RefreshCw size={16} />
{isUpdate ? 'Retry update' : 'Retry install'}
Retry install
</Button>
<Button
variant="outline"

View File

@@ -115,7 +115,9 @@ export default function ProgressScreen({ bootstrap }: ProgressProps) {
key={idx}
className={clsx(
'whitespace-pre-wrap',
entry.stream === 'stderr' ? 'text-foreground/45' : 'text-foreground/70'
entry.line.startsWith('stderr:')
? 'text-destructive'
: 'text-foreground/70'
)}
>
{entry.line}

View File

@@ -42,7 +42,7 @@ export interface BootstrapStateModel {
currentStage: string | null
installRoot: string | null
error: string | null
logs: Array<{ stage?: string; line: string; stream?: 'stdout' | 'stderr' }>
logs: Array<{ stage?: string; line: string }>
}
const INITIAL: BootstrapStateModel = {
@@ -106,7 +106,6 @@ interface BootstrapLogEvent {
type: 'log'
stage?: string
line: string
stream?: 'stdout' | 'stderr'
}
interface BootstrapCompleteEvent {
@@ -193,7 +192,7 @@ export async function initialize(): Promise<void> {
break
}
case 'log': {
const logs = [...cur.logs, { stage: payload.stage, line: payload.line, stream: payload.stream }]
const logs = [...cur.logs, { stage: payload.stage, line: payload.line }]
// 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

View File

@@ -27,7 +27,7 @@
Add `--include-desktop` to the [one-line installer](../../README.md#quick-install) and it sets up the agent and builds the desktop app in one go:
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --include-desktop
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --include-desktop
```
Already have the Hermes CLI? Just run:
@@ -40,7 +40,7 @@ It builds and launches the GUI against your existing install — same config, ke
### Prebuilt installers
Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/desktop).
When a release ships desktop installers they're attached to its [releases page](https://github.com/NousResearch/hermes-agent/releases) — `.dmg` (macOS), `.exe` / `.msi` (Windows), `.AppImage` / `.deb` / `.rpm` (Linux). These are published manually, so the install-with-Hermes path above is the most reliable way to get the latest.
---
@@ -56,7 +56,10 @@ hermes update
## Requirements
The installer handles everything for you (Python 3.11+, a portable Git, ripgrep).
The installer handles everything for you (Python 3.11+, a portable Git, ripgrep). The only thing worth knowing:
- **Windows** — the installer bundles its own Git and Python; no admin rights or system changes required.
- **macOS / Linux** — uses your system Python 3.11+ (installed automatically if missing).
---
@@ -91,7 +94,7 @@ Installers are built and uploaded to GitHub Releases manually. macOS/Windows sig
### How it works
The packaged app ships only the Electron shell. On first launch it installs the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. The renderer (React, in `src/`) talks to a `hermes dashboard` backend over the standard gateway APIs and reuses the embedded TUI rather than reimplementing chat. The install, backend-resolution, and self-update logic all live in `electron/main.cjs`.
The packaged app ships only the Electron shell. On first launch it installs the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. The renderer (React, in `src/`) talks to a `hermes dashboard --tui` backend over the standard gateway APIs and reuses the embedded TUI rather than reimplementing chat. The install, backend-resolution, and self-update logic all live in `electron/main.cjs`.
### Verification

View File

@@ -67,9 +67,7 @@ test('verifyHermesCli returns true when --version exits 0', () => {
} finally {
try {
fs.unlinkSync(scriptPath)
} catch {
void 0
}
} catch {}
}
})

View File

@@ -52,9 +52,7 @@ function detectRemoteDisplay(options = {}) {
const env = options.env ?? process.env
const platform = options.platform ?? process.platform
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '')
.trim()
.toLowerCase()
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '').trim().toLowerCase()
if (GPU_OVERRIDE_ON.has(override)) return 'override (HERMES_DESKTOP_DISABLE_GPU)'
if (GPU_OVERRIDE_OFF.has(override)) return null

View File

@@ -45,17 +45,11 @@ test('detectRemoteDisplay does not treat WSLg as remote', () => {
// WSLg renders locally via vGPU and doesn't show the flicker, so a WSL
// session with a local DISPLAY keeps hardware acceleration on.
assert.equal(detectRemoteDisplay({ env: { WSL_DISTRO_NAME: 'Ubuntu', DISPLAY: ':0' }, platform: 'linux' }), null)
assert.equal(
detectRemoteDisplay({ env: { WSL_INTEROP: '/run/WSL/1_interop', DISPLAY: ':0' }, platform: 'linux' }),
null
)
assert.equal(detectRemoteDisplay({ env: { WSL_INTEROP: '/run/WSL/1_interop', DISPLAY: ':0' }, platform: 'linux' }), null)
})
test('detectRemoteDisplay flags SSH sessions on any platform', () => {
assert.equal(
detectRemoteDisplay({ env: { SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' }, platform: 'linux' }),
'ssh-session'
)
assert.equal(detectRemoteDisplay({ env: { SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' }, platform: 'linux' }), 'ssh-session')
assert.equal(detectRemoteDisplay({ env: { SSH_CLIENT: '1.2.3.4 5 22' }, platform: 'darwin' }), 'ssh-session')
assert.equal(detectRemoteDisplay({ env: { SSH_TTY: '/dev/pts/0' }, platform: 'win32' }), 'ssh-session')
})

View File

@@ -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, stream: 'stdout'|'stderr' } // raw line from install.ps1
* { type: 'log', stage?, line } // raw line from install.ps1
* { type: 'complete', marker: <written marker payload> }
* { type: 'failed', stage?, error } // bootstrap aborted
*
@@ -101,9 +101,7 @@ function downloadInstallScript(commit, destPath) {
.get(res.headers.location, res2 => {
if (res2.statusCode !== 200) {
reject(
new Error(
`Failed to download ${scriptName}: HTTP ${res2.statusCode} from redirect ${res.headers.location}`
)
new Error(`Failed to download ${scriptName}: HTTP ${res2.statusCode} from redirect ${res.headers.location}`)
)
return
}
@@ -123,9 +121,7 @@ function downloadInstallScript(commit, destPath) {
out.close()
try {
fs.unlinkSync(tmpPath)
} catch {
void 0
}
} catch {}
reject(new Error(`Failed to download ${scriptName}: HTTP ${res.statusCode} from ${url}`))
return
}
@@ -138,18 +134,14 @@ function downloadInstallScript(commit, destPath) {
out.on('error', err => {
try {
fs.unlinkSync(tmpPath)
} catch {
void 0
}
} catch {}
reject(err)
})
})
.on('error', err => {
try {
fs.unlinkSync(tmpPath)
} catch {
void 0
}
} catch {}
reject(err)
})
})
@@ -176,19 +168,13 @@ async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome,
const cached = cachedScriptPath(hermesHome, installStamp.commit)
try {
await fsp.access(cached, fs.constants.R_OK)
emit({
type: 'log',
line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}`
})
emit({ type: 'log', line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}` })
return { path: cached, source: 'cache', commit: installStamp.commit, kind: installScriptKind() }
} catch {
// not cached; download
}
emit({
type: 'log',
line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub`
})
emit({ type: 'log', line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub` })
await downloadInstallScript(installStamp.commit, cached)
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
@@ -221,9 +207,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
killed = true
try {
child.kill('SIGTERM')
} catch {
void 0
}
} catch {}
}
if (abortSignal) {
if (abortSignal.aborted) {
@@ -245,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, stream: 'stdout' })
if (line) emit && emit({ type: 'log', stage: stageName, line })
}
})
@@ -257,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, stream: 'stderr' })
if (line) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${line}` })
}
})
@@ -269,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, stream: 'stdout' })
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf })
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${stderrBuf}` })
resolve({ stdout, stderr, code, signal, killed })
})
})
@@ -294,9 +278,7 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
killed = true
try {
child.kill('SIGTERM')
} catch {
void 0
}
} catch {}
}
if (abortSignal) {
if (abortSignal.aborted) {
@@ -317,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, stream: 'stdout' })
if (line) emit && emit({ type: 'log', stage: stageName, line })
}
})
@@ -329,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, stream: 'stderr' })
if (line) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${line}` })
}
})
@@ -340,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, stream: 'stdout' })
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf })
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${stderrBuf}` })
resolve({ stdout, stderr, code, signal, killed })
})
})
@@ -387,9 +369,7 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
hermesHome
})
if (result.code !== 0) {
throw new Error(
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${result.stderr || result.stdout}`
)
throw new Error(`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${result.stderr || result.stdout}`)
}
// The manifest is the LAST JSON line on stdout (install.ps1 may print
// banner / info lines first depending on Console.OutputEncoding effects).
@@ -401,13 +381,9 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
if (parsed && Array.isArray(parsed.stages)) {
return parsed
}
} catch {
void 0
}
} catch {}
}
throw new Error(
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} produced no parseable JSON payload\n${result.stdout}`
)
throw new Error(`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} produced no parseable JSON payload\n${result.stdout}`)
}
// Parse the JSON result frame from a stage run. The protocol guarantees
@@ -421,9 +397,7 @@ function parseStageResult(stdout) {
if (parsed && typeof parsed.ok === 'boolean' && typeof parsed.stage === 'string') {
return parsed
}
} catch {
void 0
}
} catch {}
}
return null
}
@@ -434,20 +408,13 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
const isPosix = installerKind === 'posix'
const args = isPosix
? [
'--stage',
stage.name,
'--non-interactive',
'--json',
...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })
]
? ['--stage', stage.name, '--non-interactive', '--json', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })]
: ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp)]
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
emit,
stageName: stage.name,
abortSignal,
hermesHome
})
const result = await (isPosix ? spawnBash : spawnPowerShell)(
scriptPath,
args,
{ emit, stageName: stage.name, abortSignal, hermesHome }
)
const durationMs = Date.now() - startedAt
@@ -482,14 +449,7 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
emit(ev)
return ev
}
const ev = {
type: 'stage',
name: stage.name,
state: 'failed',
durationMs,
json,
error: json.reason || `exit code ${result.code}`
}
const ev = { type: 'stage', name: stage.name, state: 'failed', durationMs, json, error: json.reason || `exit code ${result.code}` }
emit(ev)
return ev
}
@@ -529,9 +489,7 @@ async function runBootstrap(opts) {
if (typeof onEvent === 'function') {
try {
onEvent({ type: 'failed', error: 'bootstrap cancelled by user' })
} catch {
void 0
}
} catch {}
}
return { ok: false, cancelled: true }
}
@@ -543,9 +501,7 @@ async function runBootstrap(opts) {
const emit = ev => {
try {
runLog.stream.write(JSON.stringify(ev) + '\n')
} catch {
void 0
}
} catch {}
try {
if (typeof onEvent === 'function') onEvent(ev)
} catch (err) {
@@ -622,9 +578,7 @@ async function runBootstrap(opts) {
} finally {
try {
runLog.stream.end()
} catch {
void 0
}
} catch {}
}
}

View File

@@ -1,172 +0,0 @@
/**
* connection-config.cjs
*
* Pure, electron-free helpers for the desktop's remote-gateway connection
* config: URL normalization, WS-URL construction (token vs OAuth ticket),
* auth-mode classification, and the auth-mode coercion rules.
*
* Kept standalone (no `require('electron')`) so it can be unit-tested with
* `node --test` — same pattern as backend-probes.cjs / bootstrap-platform.cjs.
* main.cjs requires these and wires them into the electron-coupled IPC layer.
*
* Background on the two auth models a remote gateway can use:
* - 'token': legacy static dashboard session token. REST uses an
* `X-Hermes-Session-Token` header; WS uses `?token=`.
* - 'oauth': hosted gateways gate behind an OAuth provider. REST is authed
* by an HttpOnly session cookie; WS upgrades require a single-use
* `?ticket=` minted at POST /api/auth/ws-ticket. The gateway advertises
* this via the public `/api/status` field `auth_required: true`.
*/
// Bare + prefixed variants of the access-token cookie the gateway may set,
// depending on its deploy shape (HTTPS direct → __Host-, behind a path prefix
// → __Secure-, loopback HTTP → bare). Mirrors
// hermes_cli/dashboard_auth/cookies.py.
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
function normalizeRemoteBaseUrl(rawUrl) {
const value = String(rawUrl || '').trim()
if (!value) {
throw new Error('Remote gateway URL is required.')
}
let parsed
try {
parsed = new URL(value)
} catch (error) {
throw new Error(`Remote gateway URL is not valid: ${error.message}`)
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`Remote gateway URL must be http:// or https://, got ${parsed.protocol}`)
}
parsed.hash = ''
parsed.search = ''
parsed.pathname = parsed.pathname.replace(/\/+$/, '')
return parsed.toString().replace(/\/+$/, '')
}
function buildGatewayWsUrl(baseUrl, token) {
const parsed = new URL(baseUrl)
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
const prefix = parsed.pathname.replace(/\/+$/, '')
return `${wsScheme}://${parsed.host}${prefix}/api/ws?token=${encodeURIComponent(token)}`
}
function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
const parsed = new URL(baseUrl)
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
const prefix = parsed.pathname.replace(/\/+$/, '')
return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}`
}
/**
* Build the WS URL the renderer would connect with, so the connection test can
* exercise the same transport the app actually uses.
*
* The OAuth ticket-minter is injected (`mintTicket(baseUrl) -> Promise<ticket>`)
* so this stays electron-free and unit-testable; main.cjs passes the real
* `mintGatewayWsTicket`.
*
* Return semantics:
* - token mode + token → ws(s)://…/api/ws?token=…
* - token mode, no token → null (genuine skip; nothing to authenticate with)
* - oauth, mint ok → ws(s)://…/api/ws?ticket=…
* - oauth, mint fails → THROWS (NOT a skip)
*
* The oauth-mint-failure throw is the important case: the real boot path
* (resolveRemoteBackend in main.cjs) treats a mint failure as a hard
* "session expired" auth error and refuses to connect. Swallowing it here
* would re-introduce the exact false-positive this test exists to catch —
* HTTP /api/status passes, the test reports "reachable", then the renderer
* can't authenticate /api/ws and boot dies with "Could not connect".
*
* @param {string} baseUrl
* @param {'token'|'oauth'} authMode
* @param {string|null} token
* @param {{ mintTicket: (baseUrl: string) => Promise<string> }} deps
* @returns {Promise<string|null>}
*/
async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
if (authMode === 'oauth') {
const mintTicket = deps.mintTicket
if (typeof mintTicket !== 'function') {
throw new Error('resolveTestWsUrl: a mintTicket function is required in OAuth mode.')
}
let ticket
try {
ticket = await mintTicket(baseUrl)
} catch (error) {
const err = new Error(
'Reached the gateway over HTTP, but could not mint a WebSocket ticket for the OAuth session ' +
'(it may have expired). Open Settings → Gateway and sign in again.'
)
err.needsOauthLogin = true
err.cause = error
throw err
}
return buildGatewayWsUrlWithTicket(baseUrl, ticket)
}
if (!token) {
return null
}
return buildGatewayWsUrl(baseUrl, token)
}
function tokenPreview(value) {
const raw = String(value || '')
if (!raw) {
return null
}
return raw.length <= 8 ? 'set' : `...${raw.slice(-6)}`
}
/**
* Classify a gateway's auth mode from its public /api/status body.
* `auth_required: true` → OAuth gate engaged; otherwise legacy token auth.
* Returns 'oauth' | 'token'.
*/
function authModeFromStatus(statusBody) {
return statusBody && statusBody.auth_required ? 'oauth' : 'token'
}
/**
* Resolve the effective auth mode for a coerce/save operation.
* Explicit input wins; otherwise inherit the saved value; default 'token'.
* Returns 'oauth' | 'token'.
*/
function resolveAuthMode(inputAuthMode, existingAuthMode) {
if (inputAuthMode === 'oauth') return 'oauth'
if (inputAuthMode === 'token') return 'token'
if (existingAuthMode === 'oauth') return 'oauth'
return 'token'
}
/**
* True if any cookie in `cookies` is a hermes session access-token cookie
* with a non-empty value. `cookies` is an array of {name, value} (the shape
* Electron's session.cookies.get returns).
*/
function cookiesHaveSession(cookies) {
if (!Array.isArray(cookies)) return false
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
}
module.exports = {
AT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
cookiesHaveSession,
normalizeRemoteBaseUrl,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
}

View File

@@ -1,211 +0,0 @@
/**
* Tests for electron/connection-config.cjs.
*
* Run with: node --test electron/connection-config.test.cjs
* (Wire into npm test:desktop:platforms in package.json.)
*
* These are the pure helpers behind the remote-gateway connection settings:
* URL normalization, WS-URL construction (token vs OAuth ticket), auth-mode
* classification from /api/status, the coerce-time auth-mode resolution rules,
* and the OAuth session-cookie detector.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
AT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
cookiesHaveSession,
normalizeRemoteBaseUrl,
resolveAuthMode,
resolveTestWsUrl,
tokenPreview
} = require('./connection-config.cjs')
// --- normalizeRemoteBaseUrl ---
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/'), 'https://gw.example.com')
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/hermes/'), 'https://gw.example.com/hermes')
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/hermes?x=1#frag'), 'https://gw.example.com/hermes')
})
test('normalizeRemoteBaseUrl preserves a path prefix', () => {
assert.equal(normalizeRemoteBaseUrl('https://host/hermes'), 'https://host/hermes')
})
test('normalizeRemoteBaseUrl rejects empty input', () => {
assert.throws(() => normalizeRemoteBaseUrl(''), /required/)
assert.throws(() => normalizeRemoteBaseUrl(' '), /required/)
})
test('normalizeRemoteBaseUrl rejects non-http(s) protocols', () => {
assert.throws(() => normalizeRemoteBaseUrl('ftp://host'), /http:\/\/ or https:\/\//)
assert.throws(() => normalizeRemoteBaseUrl('file:///etc/passwd'), /http:\/\/ or https:\/\//)
})
test('normalizeRemoteBaseUrl rejects garbage', () => {
assert.throws(() => normalizeRemoteBaseUrl('not a url'), /not valid/)
})
// --- buildGatewayWsUrl (token) ---
test('buildGatewayWsUrl uses wss for https and bakes the token', () => {
assert.equal(buildGatewayWsUrl('https://gw.example.com', 'tok123'), 'wss://gw.example.com/api/ws?token=tok123')
})
test('buildGatewayWsUrl uses ws for http', () => {
assert.equal(buildGatewayWsUrl('http://127.0.0.1:9119', 'abc'), 'ws://127.0.0.1:9119/api/ws?token=abc')
})
test('buildGatewayWsUrl honors a path prefix', () => {
assert.equal(buildGatewayWsUrl('https://host/hermes', 't'), 'wss://host/hermes/api/ws?token=t')
})
test('buildGatewayWsUrl url-encodes the token', () => {
assert.equal(buildGatewayWsUrl('https://host', 'a/b c+d'), 'wss://host/api/ws?token=a%2Fb%20c%2Bd')
})
// --- buildGatewayWsUrlWithTicket (oauth) ---
test('buildGatewayWsUrlWithTicket uses ?ticket= not ?token=', () => {
const url = buildGatewayWsUrlWithTicket('https://gw.example.com/hermes', 'tkt-9')
assert.equal(url, 'wss://gw.example.com/hermes/api/ws?ticket=tkt-9')
assert.ok(!url.includes('token='))
})
test('buildGatewayWsUrlWithTicket url-encodes the ticket', () => {
assert.equal(buildGatewayWsUrlWithTicket('https://host', 'a+b/c'), 'wss://host/api/ws?ticket=a%2Bb%2Fc')
})
// --- authModeFromStatus ---
test('authModeFromStatus returns oauth when auth_required is true', () => {
assert.equal(authModeFromStatus({ auth_required: true, auth_providers: ['nous'] }), 'oauth')
})
test('authModeFromStatus returns token when auth_required is false/missing', () => {
assert.equal(authModeFromStatus({ auth_required: false }), 'token')
assert.equal(authModeFromStatus({}), 'token')
assert.equal(authModeFromStatus(null), 'token')
assert.equal(authModeFromStatus(undefined), 'token')
})
// --- resolveAuthMode ---
test('resolveAuthMode: explicit input wins over existing', () => {
assert.equal(resolveAuthMode('oauth', 'token'), 'oauth')
assert.equal(resolveAuthMode('token', 'oauth'), 'token')
})
test('resolveAuthMode: falls back to existing when input absent', () => {
assert.equal(resolveAuthMode(undefined, 'oauth'), 'oauth')
assert.equal(resolveAuthMode(undefined, 'token'), 'token')
assert.equal(resolveAuthMode('', 'oauth'), 'oauth')
})
test('resolveAuthMode: defaults to token when nothing is set', () => {
assert.equal(resolveAuthMode(undefined, undefined), 'token')
assert.equal(resolveAuthMode(null, null), 'token')
})
test('resolveAuthMode: ignores unknown values, defaults to token', () => {
assert.equal(resolveAuthMode('bogus', 'also-bogus'), 'token')
})
// --- cookiesHaveSession ---
test('cookiesHaveSession detects the bare access-token cookie', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: 'x' }]), true)
})
test('cookiesHaveSession detects the __Host- and __Secure- prefixed variants', () => {
assert.equal(cookiesHaveSession([{ name: '__Host-hermes_session_at', value: 'x' }]), true)
assert.equal(cookiesHaveSession([{ name: '__Secure-hermes_session_at', value: 'x' }]), true)
})
test('cookiesHaveSession is false for an empty value', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: '' }]), false)
})
test('cookiesHaveSession ignores unrelated cookies', () => {
assert.equal(cookiesHaveSession([{ name: 'hermes_session_rt', value: 'x' }]), false)
assert.equal(cookiesHaveSession([{ name: 'other', value: 'x' }]), false)
})
test('cookiesHaveSession handles non-arrays', () => {
assert.equal(cookiesHaveSession(null), false)
assert.equal(cookiesHaveSession(undefined), false)
assert.equal(cookiesHaveSession([]), false)
})
test('AT_COOKIE_VARIANTS covers all three deploy shapes', () => {
assert.deepEqual(AT_COOKIE_VARIANTS, ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at'])
})
// --- tokenPreview ---
test('tokenPreview returns null for empty', () => {
assert.equal(tokenPreview(''), null)
assert.equal(tokenPreview(null), null)
})
test('tokenPreview returns set for short tokens', () => {
assert.equal(tokenPreview('12345678'), 'set')
})
test('tokenPreview returns a masked suffix for long tokens', () => {
assert.equal(tokenPreview('abcdefghijklmnop'), '...klmnop')
})
// --- resolveTestWsUrl ---
//
// The "Test remote" button must exercise the same WS transport the app uses,
// and must FAIL (not skip) when an OAuth session can't mint a ws-ticket — that
// is the exact false-positive PR #39098 set out to eliminate.
test('resolveTestWsUrl (token mode) builds a ?token= URL the WS probe can use', async () => {
const url = await resolveTestWsUrl('https://gw.example.com', 'token', 'tok123')
assert.equal(url, 'wss://gw.example.com/api/ws?token=tok123')
})
test('resolveTestWsUrl (token mode, no token) returns null — genuine skip', async () => {
assert.equal(await resolveTestWsUrl('https://gw.example.com', 'token', null), null)
})
test('resolveTestWsUrl (oauth, mint ok) builds a ?ticket= URL', async () => {
const url = await resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
mintTicket: async () => 'tkt-9'
})
assert.equal(url, 'wss://gw.example.com/api/ws?ticket=tkt-9')
})
test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validation', async () => {
await assert.rejects(
() =>
resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
mintTicket: async () => {
throw new Error('401 ticket mint failed')
}
}),
err => {
// Actionable, points the user at re-auth, and preserves the cause + flag
// the boot overlay uses to offer a sign-in prompt.
assert.match(err.message, /WebSocket ticket/i)
assert.match(err.message, /sign in again/i)
assert.equal(err.needsOauthLogin, true)
assert.ok(err.cause instanceof Error)
return true
}
)
})
test('resolveTestWsUrl (oauth) requires a mintTicket function', async () => {
await assert.rejects(
() => resolveTestWsUrl('https://gw.example.com', 'oauth', null),
/mintTicket function is required/
)
})

View File

@@ -1,188 +0,0 @@
/**
* Live WebSocket validation for the remote-gateway "Test remote" button.
*
* Background: the desktop boot does two independent things to a remote gateway:
*
* 1. The MAIN process hits ``GET /api/status`` over HTTP (token in a header)
* to confirm the backend is up. This is what "Test remote" historically
* checked, and what the boot logs print as "Remote Hermes backend is
* ready".
* 2. The RENDERER then opens a live WebSocket to ``/api/ws`` (credential in a
* query param) via ``gateway.connect()``. The chat surface only works once
* THIS succeeds.
*
* Those two paths use different processes, transports, and credentials, and the
* server applies extra guards to the WS upgrade that the HTTP status route never
* sees (Host/Origin checks, ws-ticket/token auth, peer-IP checks). So a gateway
* can pass the HTTP status check yet reject the WebSocket — which surfaces to
* the user as a green "Test remote" followed by an opaque "Could not connect to
* Hermes gateway" on the boot overlay.
*
* This module performs the second half of the check: it actually opens the WS
* URL and confirms the upgrade is accepted (and isn't immediately torn down by
* a post-upgrade auth rejection). The ``WebSocketImpl`` is injectable so the
* unit tests can drive the handshake without a real socket; in production the
* caller passes the Node/Electron global ``WebSocket``.
*/
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
// After the upgrade is accepted, a gateway that rejects the credential
// post-handshake closes the socket almost immediately. Wait a short grace
// window: a frame (gateway.ready) or a still-open socket means success; an
// early close means the upgrade was accepted but the session was refused.
const DEFAULT_READY_GRACE_MS = 750
/**
* Attempt a live WebSocket connection and classify the outcome.
*
* @param {string} wsUrl - Fully-formed ws(s):// URL including the credential.
* @param {object} [options]
* @param {new (url: string) => any} [options.WebSocketImpl] - WebSocket ctor.
* @param {number} [options.connectTimeoutMs]
* @param {number} [options.readyGraceMs]
* @returns {Promise<{ ok: boolean, reason?: string }>}
*/
function probeGatewayWebSocket(wsUrl, options = {}) {
const WebSocketImpl = options.WebSocketImpl
const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
const readyGraceMs = options.readyGraceMs ?? DEFAULT_READY_GRACE_MS
if (typeof WebSocketImpl !== 'function') {
return Promise.resolve({
ok: false,
reason: 'WebSocket is not available in this runtime.'
})
}
return new Promise(resolve => {
let settled = false
let opened = false
let connectTimer = null
let graceTimer = null
let socket
const clearTimers = () => {
if (connectTimer !== null) {
clearTimeout(connectTimer)
connectTimer = null
}
if (graceTimer !== null) {
clearTimeout(graceTimer)
graceTimer = null
}
}
const finish = result => {
if (settled) return
settled = true
clearTimers()
try {
socket?.close?.()
} catch {
// ignore — best effort teardown
}
resolve(result)
}
try {
socket = new WebSocketImpl(wsUrl)
} catch (error) {
finish({
ok: false,
reason: error instanceof Error ? error.message : String(error)
})
return
}
const onOpen = () => {
if (settled) return
opened = true
// Upgrade accepted. Give the server a brief window to reject the
// credential post-handshake (early close) before declaring success.
graceTimer = setTimeout(() => {
finish({ ok: true })
}, readyGraceMs)
}
const onMessage = () => {
// Any frame means the gateway accepted us and is talking — unambiguous
// success, no need to wait out the grace window.
finish({ ok: true })
}
const onError = event => {
finish({
ok: false,
reason: extractErrorReason(event) || 'WebSocket connection failed.'
})
}
const onClose = event => {
if (settled) return
if (opened) {
// Opened, then closed inside the grace window: the upgrade was accepted
// but the session was refused (e.g. ws-ticket/token rejected, or a
// server-side Host/Origin guard tripped after accept).
finish({
ok: false,
reason: closeReason(event, 'The gateway accepted the connection then closed it (credential rejected?).')
})
return
}
finish({
ok: false,
reason: closeReason(event, 'The gateway closed the WebSocket before it opened.')
})
}
addListener(socket, 'open', onOpen)
addListener(socket, 'message', onMessage)
addListener(socket, 'error', onError)
addListener(socket, 'close', onClose)
if (connectTimeoutMs > 0) {
connectTimer = setTimeout(() => {
finish({
ok: false,
reason: `Timed out after ${connectTimeoutMs}ms waiting for the WebSocket to open.`
})
}, connectTimeoutMs)
}
})
}
function addListener(socket, type, handler) {
if (typeof socket.addEventListener === 'function') {
socket.addEventListener(type, handler)
return
}
// Node's global WebSocket implements addEventListener; this fallback keeps the
// helper usable with the `ws` package's EventEmitter shape too.
if (typeof socket.on === 'function') {
socket.on(type, handler)
}
}
function extractErrorReason(event) {
if (!event) return ''
if (event instanceof Error) return event.message
const err = event.error || event.message
if (err instanceof Error) return err.message
if (typeof err === 'string') return err
return ''
}
function closeReason(event, fallback) {
const code = event && typeof event.code === 'number' ? event.code : null
const reason = event && typeof event.reason === 'string' ? event.reason.trim() : ''
if (code && reason) return `${fallback} (code ${code}: ${reason})`
if (code) return `${fallback} (code ${code})`
if (reason) return `${fallback} (${reason})`
return fallback
}
module.exports = {
DEFAULT_CONNECT_TIMEOUT_MS,
DEFAULT_READY_GRACE_MS,
probeGatewayWebSocket
}

View File

@@ -1,122 +0,0 @@
/**
* Tests for electron/gateway-ws-probe.cjs.
*
* Run with: node --test electron/gateway-ws-probe.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*
* The probe drives a real WebSocket handshake for the "Test remote" button.
* Here we inject a fake socket so we can deterministically replay each handshake
* outcome (open, frame, error, early close, never-opens) without a network.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
// Minimal WebSocket double: records listeners synchronously (the probe attaches
// them in its executor) and exposes emit() so the test can replay events.
function makeFakeWs() {
const instances = []
class FakeWs {
constructor(url) {
this.url = url
this.listeners = {}
this.closed = false
instances.push(this)
}
addEventListener(type, fn) {
;(this.listeners[type] ||= []).push(fn)
}
close() {
this.closed = true
}
emit(type, event) {
for (const fn of this.listeners[type] || []) fn(event)
}
}
return { FakeWs, instances }
}
const FAST = { connectTimeoutMs: 1_000, readyGraceMs: 10 }
test('probe resolves ok when the socket opens and stays open', async () => {
const { FakeWs, instances } = makeFakeWs()
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST })
instances[0].emit('open')
const result = await promise
assert.deepEqual(result, { ok: true })
assert.equal(instances[0].closed, true)
})
test('probe resolves ok immediately when a frame arrives', async () => {
const { FakeWs, instances } = makeFakeWs()
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', {
WebSocketImpl: FakeWs,
connectTimeoutMs: 1_000,
readyGraceMs: 10_000 // long grace: success must come from the frame, not the timer
})
instances[0].emit('open')
instances[0].emit('message', { data: '{"jsonrpc":"2.0"}' })
const result = await promise
assert.deepEqual(result, { ok: true })
})
test('probe fails when the socket errors before opening', async () => {
const { FakeWs, instances } = makeFakeWs()
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST })
instances[0].emit('error', { message: 'ECONNREFUSED' })
const result = await promise
assert.equal(result.ok, false)
assert.match(result.reason, /ECONNREFUSED/)
})
test('probe fails when the gateway closes before opening', async () => {
const { FakeWs, instances } = makeFakeWs()
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST })
instances[0].emit('close', { code: 1006 })
const result = await promise
assert.equal(result.ok, false)
assert.match(result.reason, /before it opened/)
assert.match(result.reason, /1006/)
})
test('probe fails when the gateway accepts then immediately closes (auth rejected)', async () => {
const { FakeWs, instances } = makeFakeWs()
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST })
instances[0].emit('open')
instances[0].emit('close', { code: 4403, reason: 'forbidden' })
const result = await promise
assert.equal(result.ok, false)
assert.match(result.reason, /credential rejected/)
assert.match(result.reason, /4403/)
assert.match(result.reason, /forbidden/)
})
test('probe times out when the socket never opens', async () => {
const { FakeWs } = makeFakeWs()
const result = await probeGatewayWebSocket('ws://host/api/ws?token=t', {
WebSocketImpl: FakeWs,
connectTimeoutMs: 20,
readyGraceMs: 10
})
assert.equal(result.ok, false)
assert.match(result.reason, /Timed out/)
})
test('probe fails gracefully when the constructor throws', async () => {
class ThrowingWs {
constructor() {
throw new Error('bad url')
}
}
const result = await probeGatewayWebSocket('ws://host/api/ws', { WebSocketImpl: ThrowingWs, ...FAST })
assert.equal(result.ok, false)
assert.match(result.reason, /bad url/)
})
test('probe reports unavailable when no WebSocket implementation is provided', async () => {
const result = await probeGatewayWebSocket('ws://host/api/ws', { WebSocketImpl: undefined })
assert.equal(result.ok, false)
assert.match(result.reason, /not available/)
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,12 @@
const { contextBridge, ipcRenderer, webUtils } = require('electron')
contextBridge.exposeInMainWorld('hermesDesktop', {
getConnection: profile => ipcRenderer.invoke('hermes:connection', profile),
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
getConnection: () => ipcRenderer.invoke('hermes:connection'),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),
profile: {
get: () => ipcRenderer.invoke('hermes:profile:get'),
set: name => ipcRenderer.invoke('hermes:profile:set', name)
},
api: request => ipcRenderer.invoke('hermes:api', request),
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),
@@ -92,11 +83,6 @@ 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)

View File

@@ -7,9 +7,6 @@
"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",
@@ -35,7 +32,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -100,7 +97,6 @@
},
"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",
@@ -146,7 +142,6 @@
"package.json"
],
"beforeBuild": "scripts/before-build.cjs",
"beforePack": "scripts/before-pack.cjs",
"afterPack": "scripts/after-pack.cjs",
"extraResources": [
{

View File

@@ -1,78 +0,0 @@
'use strict'
/**
* before-pack.cjs — electron-builder beforePack hook.
*
* Removes any stale unpacked app directory (`appOutDir`) before
* electron-builder stages the Electron binaries into it.
*
* WHY THIS EXISTS
* ---------------
* electron-builder's final packaging step copies the stock `electron`
* binary into `release/<platform>-unpacked/` and then renames it to the
* product name (`Hermes`). If a PREVIOUS `npm run pack` was interrupted
* (Ctrl-C, OOM kill, crash, full disk) the unpacked directory is left in a
* corrupted partial state: it keeps the already-renamed `LICENSE.electron.txt`
* and the Chromium payload (.pak/.so/icudtl.dat/chrome-sandbox) but is MISSING
* the `electron` binary itself.
*
* On the next run, electron-builder sees the destination directory already
* populated, skips re-copying the binary it thinks is present, then tries to
* rename a `electron` file that no longer exists. The build dies with:
*
* ENOENT: no such file or directory, rename
* '.../release/linux-unpacked/electron' -> '.../release/linux-unpacked/Hermes'
*
* This is a hard failure with no obvious cause for the user — `hermes desktop`
* just prints "Desktop GUI build failed" and the only fix is to manually
* `rm -rf` the release directory, which a normal user has no way to know.
*
* The packaging step is not idempotent across an interrupted run, so we make
* it idempotent ourselves: wipe the target unpacked directory up front so
* electron-builder always stages into a clean tree. This is safe — the
* directory is a pure build artifact that electron-builder fully recreates
* on every pack; nothing else depends on its prior contents.
*
* Cross-platform: the same partial-state trap exists on macOS
* (the mac-unpacked Hermes.app bundle) and Windows (win-unpacked), so we
* clean whatever `appOutDir` electron-builder hands us regardless of platform.
*
* Best-effort: a cleanup failure must never mask the real build. We log and
* resolve rather than throw — worst case electron-builder hits the original
* ENOENT, which is no worse than not having this hook at all.
*
* electron-builder passes a context with:
* - appOutDir: the unpacked app directory about to be staged
* - electronPlatformName: 'win32' | 'darwin' | 'linux'
*/
const fs = require('node:fs')
function cleanStaleAppOutDir(appOutDir) {
if (!appOutDir || typeof appOutDir !== 'string') {
return false
}
if (!fs.existsSync(appOutDir)) {
return false
}
// Recursive + force so a half-written tree (read-only bits, partial files)
// can't block the wipe. retry/maxRetries rides out transient EBUSY on
// Windows where an AV/indexer may briefly hold a handle.
fs.rmSync(appOutDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
return true
}
exports.cleanStaleAppOutDir = cleanStaleAppOutDir
exports.default = async function beforePack(context) {
const appOutDir = context && context.appOutDir
try {
if (cleanStaleAppOutDir(appOutDir)) {
console.log(`[before-pack] removed stale unpacked dir before staging: ${appOutDir}`)
}
} catch (err) {
// Never fail the build over cleanup; surface why so a genuinely stuck
// directory (permissions, mount) is still diagnosable.
console.warn(`[before-pack] could not clean ${appOutDir} (${err.message}); continuing`)
}
}

View File

@@ -1,53 +0,0 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { cleanStaleAppOutDir } = require('../scripts/before-pack.cjs')
test('cleanStaleAppOutDir removes a populated unpacked directory', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
try {
const appOutDir = path.join(tempRoot, 'linux-unpacked')
fs.mkdirSync(appOutDir, { recursive: true })
// Reproduce the corrupted partial state: license + payload present,
// electron binary missing — exactly what trips the ENOENT rename.
fs.writeFileSync(path.join(appOutDir, 'LICENSE.electron.txt'), 'x', 'utf8')
fs.writeFileSync(path.join(appOutDir, 'resources.pak'), 'x', 'utf8')
fs.mkdirSync(path.join(appOutDir, 'resources'), { recursive: true })
fs.writeFileSync(path.join(appOutDir, 'resources', 'app.asar'), 'x', 'utf8')
const removed = cleanStaleAppOutDir(appOutDir)
assert.equal(removed, true)
assert.equal(fs.existsSync(appOutDir), false)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('cleanStaleAppOutDir is a no-op when the directory is absent', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
try {
const missing = path.join(tempRoot, 'does-not-exist')
assert.equal(cleanStaleAppOutDir(missing), false)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('cleanStaleAppOutDir ignores empty or invalid input', () => {
assert.equal(cleanStaleAppOutDir(''), false)
assert.equal(cleanStaleAppOutDir(undefined), false)
assert.equal(cleanStaleAppOutDir(null), false)
assert.equal(cleanStaleAppOutDir(42), false)
})
test('beforePack default export resolves even when cleanup throws', async () => {
const { default: beforePack } = require('../scripts/before-pack.cjs')
// A directory path that rmSync can't remove is simulated by passing a
// context whose appOutDir is a file the hook will try (and be allowed) to
// remove; the contract under test is that the hook never rejects.
await assert.doesNotReject(beforePack({ appOutDir: '', electronPlatformName: 'linux' }))
})

View File

@@ -5,6 +5,7 @@ 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,
@@ -16,7 +17,6 @@ import {
PaginationPrevious
} from '@/components/ui/pagination'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { Tip } from '@/components/ui/tooltip'
import { getSessionMessages, listSessions } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
@@ -25,9 +25,7 @@ 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'
@@ -374,11 +372,14 @@ 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)))
@@ -397,11 +398,11 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
} catch (err) {
notifyError(err, 'Artifacts failed to load')
setArtifacts([])
} finally {
setRefreshing(false)
}
}, [])
useRefreshHotkey(refreshArtifacts)
useEffect(() => {
void refreshArtifacts()
}, [refreshArtifacts])
@@ -501,11 +502,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
return (
<PageSearchShell
{...props}
onSearchChange={setQuery}
searchHidden={counts.all === 0}
searchPlaceholder="Search artifacts..."
searchValue={query}
tabs={
filters={
<>
<TextTab active={kindFilter === 'all'} onClick={() => setKindFilter('all')}>
All <TextTabMeta>({counts.all})</TextTabMeta>
@@ -521,6 +518,23 @@ 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" />
@@ -535,16 +549,10 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
</div>
) : (
<div className="h-full overflow-y-auto">
<div className={cn('flex flex-col gap-3 pb-2', PAGE_INSET_X)}>
<div className="flex flex-col gap-3 px-2 pb-2">
{visibleImageArtifacts.length > 0 && (
<section className="flex flex-col">
<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
)}
>
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel="images"
@@ -570,13 +578,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
{visibleFileArtifacts.length > 0 && (
<section className="flex flex-col">
<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
)}
>
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
<ArtifactsPagination
className="ml-auto justify-end px-0"
itemLabel={itemsLabel(kindFilter)}
@@ -586,7 +588,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)">
<div className="overflow-x-auto rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) shadow-sm">
<ArtifactTable artifacts={pagedFileArtifacts} ctx={cellCtx} filter={kindFilter} />
</div>
</section>
@@ -658,7 +660,11 @@ interface ArtifactImageCardProps {
function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: ArtifactImageCardProps) {
return (
<article className="group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)">
<article
className={cn(
'group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) shadow-sm'
)}
>
<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',
@@ -668,7 +674,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"
className="max-h-40 max-w-full cursor-zoom-in rounded-md object-contain shadow-sm"
containerClassName="max-h-full"
decoding="async"
loading="lazy"
@@ -696,7 +702,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="textStrong">
<Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="outline">
<FolderOpen className="size-3" />
Chat
</Button>
@@ -735,8 +741,12 @@ function ArtifactCellAction({
return (
<button
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"
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'
)}
onClick={onClick}
title={title}
type="button"
>
{children}
@@ -774,16 +784,15 @@ function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx })
return (
<div className="group/location flex min-w-0 items-center gap-1.5">
<Tip label={artifact.value}>
<div
className={cn(
'min-w-0 flex-1 truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)',
isLink ? 'font-normal' : 'font-mono'
)}
>
{value}
</div>
</Tip>
<div
className={cn(
'min-w-0 flex-1 truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)',
isLink ? 'font-normal' : 'font-mono'
)}
title={artifact.value}
>
{value}
</div>
<CopyButton
appearance="icon"
buttonSize="icon-xs"
@@ -854,7 +863,7 @@ function ArtifactTable({
))}
</tr>
</thead>
<tbody>
<tbody className="divide-y divide-(--ui-stroke-quaternary)">
{artifacts.map(artifact => (
<tr className="group/artifact" key={artifact.id}>
{ARTIFACT_COLUMNS.map(col => {

View File

@@ -1,43 +1,26 @@
import { useRef } from 'react'
import type { DragKind } from '@/app/chat/hooks/use-file-drop-zone'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
const COPY: Record<'files' | 'session', { icon: string; label: string }> = {
files: { icon: 'cloud-upload', label: 'Drop files to attach' },
session: { icon: 'comment-discussion', label: 'Drop to link this chat' }
}
/**
* Full-bleed affordance shown while files or a session are dragged over the chat
* area. Always `pointer-events-none` so the drop lands on the real element
* underneath and the drop-zone handler claims it — the overlay is purely visual.
* Copy adapts to whatever is being dragged; the last kind is held through the
* fade-out so the label doesn't blank.
* Full-bleed affordance shown while files are dragged over the chat area. Always
* `pointer-events-none` so the drop lands on the real element underneath and the
* drop-zone handler claims it — the overlay is purely visual. Mirrors the
* composer surface so the two read as one family.
*/
export function ChatDropOverlay({ kind }: { kind: DragKind }) {
const lastKind = useRef<'files' | 'session'>('files')
if (kind) {
lastKind.current = kind
}
const { icon, label } = COPY[kind ?? lastKind.current]
export function ChatDropOverlay({ active }: { active: boolean }) {
return (
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center p-4 transition-opacity duration-150 ease-out',
kind ? 'opacity-100' : 'opacity-0'
active ? 'opacity-100' : 'opacity-0'
)}
data-slot="chat-drop-overlay"
>
<div className="absolute inset-2 rounded-2xl border-2 border-dashed border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)] backdrop-blur-[2px] [-webkit-backdrop-filter:blur(2px)]" />
<div className="relative flex items-center gap-2 rounded-full border border-[color-mix(in_srgb,var(--dt-composer-ring)_45%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 text-[0.8125rem] font-medium text-foreground shadow-composer">
<Codicon className="text-(--ui-accent)" name={icon} size="1rem" />
{label}
<Codicon className="text-(--ui-accent)" name="cloud-upload" size="1rem" />
Drop files to attach
</div>
</div>
)

View File

@@ -1,45 +0,0 @@
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
// Braille spinner frames — reads as a tiny ASCII loader in monospace.
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
// Shown over the conversation while the live gateway swaps to another profile's
// backend (lazily spawned). Keeps the last profile name through the fade-out so
// the label doesn't blank. Purely visual — pointer-events-none.
export function ChatSwapOverlay({ profile }: { profile: string | null }) {
const [frame, setFrame] = useState(0)
const [label, setLabel] = useState<null | string>(profile)
useEffect(() => {
if (profile) {
setLabel(profile)
}
}, [profile])
useEffect(() => {
if (!profile) {
return
}
const id = window.setInterval(() => setFrame(value => (value + 1) % FRAMES.length), 80)
return () => window.clearInterval(id)
}, [profile])
return (
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 z-50 flex items-center justify-center transition-opacity duration-150 ease-out',
profile ? 'opacity-100' : 'opacity-0'
)}
>
<div className="flex items-center gap-2 bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 font-mono text-[0.8125rem] text-foreground shadow-composer">
<span className="w-3 text-(--ui-accent)">{FRAMES[frame]}</span>
Waking up {label}
</div>
</div>
)
}

View File

@@ -1,7 +1,6 @@
import { useStore } from '@nanostores/react'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import type { ComposerAttachment } from '@/store/composer'
@@ -63,49 +62,49 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
}
return (
<Tip label={attachment.path || attachment.detail || attachment.label}>
<div className="group/attachment relative min-w-0 shrink-0">
<div
className="group/attachment relative min-w-0 shrink-0"
title={attachment.path || attachment.detail || attachment.label}
>
<button
aria-label={canPreview ? `Preview ${attachment.label}` : attachment.label}
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
disabled={!canPreview}
onClick={() => void openPreview()}
title={canPreview ? `Preview ${attachment.label}` : attachment.label}
type="button"
>
{attachment.previewUrl && attachment.kind === 'image' ? (
<img
alt={attachment.label}
className="size-8 shrink-0 border border-border/70 object-cover"
draggable={false}
src={attachment.previewUrl}
/>
) : (
<span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground">
<Icon className="size-3.5" />
</span>
)}
<span className="min-w-0">
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
{attachment.label}
</span>
{detail && (
<span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">{detail}</span>
)}
</span>
</button>
{onRemove && (
<button
aria-label={canPreview ? `Preview ${attachment.label}` : attachment.label}
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
disabled={!canPreview}
onClick={() => void openPreview()}
aria-label={`Remove ${attachment.label}`}
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
onClick={() => onRemove(attachment.id)}
type="button"
>
{attachment.previewUrl && attachment.kind === 'image' ? (
<img
alt={attachment.label}
className="size-8 shrink-0 border border-border/70 object-cover"
draggable={false}
src={attachment.previewUrl}
/>
) : (
<span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground">
<Icon className="size-3.5" />
</span>
)}
<span className="min-w-0">
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
{attachment.label}
</span>
{detail && (
<span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">
{detail}
</span>
)}
</span>
<Codicon name="close" size="0.625rem" />
</button>
{onRemove && (
<button
aria-label={`Remove ${attachment.label}`}
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
onClick={() => onRemove(attachment.id)}
type="button"
>
<Codicon name="close" size="0.625rem" />
</button>
)}
</div>
</Tip>
)}
</div>
)
}

View File

@@ -2,7 +2,13 @@ import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
@@ -98,8 +104,8 @@ export function ContextMenu({
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference
files inline.
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference files
inline.
</div>
</DropdownMenuContent>
</DropdownMenu>
@@ -114,7 +120,12 @@ export function ContextMenu({
)
}
function PromptSnippetsDialog({ onInsertText, onOpenChange, open, snippets }: PromptSnippetsDialogProps) {
function PromptSnippetsDialog({
onInsertText,
onOpenChange,
open,
snippets
}: PromptSnippetsDialogProps) {
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md gap-3">
@@ -126,7 +137,7 @@ function PromptSnippetsDialog({ onInsertText, onOpenChange, open, snippets }: Pr
{snippets.map(snippet => (
<li key={snippet.label}>
<button
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"
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"
onClick={() => {
onInsertText(snippet.text)
onOpenChange(false)
@@ -149,7 +160,12 @@ function PromptSnippetsDialog({ onInsertText, onOpenChange, open, snippets }: Pr
)
}
export function ContextMenuItem({ children, disabled, icon: Icon, onSelect }: ContextMenuItemProps) {
export function ContextMenuItem({
children,
disabled,
icon: Icon,
onSelect
}: ContextMenuItemProps) {
return (
<DropdownMenuItem disabled={disabled} onSelect={onSelect}>
<Icon />

View File

@@ -1,6 +1,5 @@
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, Layers3, Loader2, Square } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -65,40 +64,38 @@ export function ComposerControls({
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
{showVoicePrimary ? (
<Tip label="Start voice conversation">
<Button
aria-label="Start voice conversation"
className={PRIMARY_ICON_BTN}
disabled={disabled}
onClick={() => {
triggerHaptic('open')
conversation.onStart()
}}
size="icon"
type="button"
>
<AudioLines size={17} />
</Button>
</Tip>
<Button
aria-label="Start voice conversation"
className={PRIMARY_ICON_BTN}
disabled={disabled}
onClick={() => {
triggerHaptic('open')
conversation.onStart()
}}
size="icon"
title="Start voice conversation"
type="button"
>
<AudioLines size={17} />
</Button>
) : (
<Tip label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}>
<Button
aria-label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
className={PRIMARY_ICON_BTN}
disabled={disabled || !canSubmit}
type="submit"
>
{busy ? (
busyAction === 'queue' ? (
<Layers3 size={16} />
) : (
<span className="block size-3 rounded-[0.1875rem] bg-current" />
)
<Button
aria-label={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
className={PRIMARY_ICON_BTN}
disabled={disabled || !canSubmit}
title={busy ? (busyAction === 'queue' ? 'Queue message' : 'Stop') : 'Send'}
type="submit"
>
{busy ? (
busyAction === 'queue' ? (
<Layers3 size={16} />
) : (
<Codicon name="arrow-up" size="1rem" />
)}
</Button>
</Tip>
<span className="block size-3 rounded-[0.1875rem] bg-current" />
)
) : (
<Codicon name="arrow-up" size="1rem" />
)}
</Button>
)}
</div>
)
@@ -129,23 +126,22 @@ function ConversationPill({
return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<Tip label={muted ? 'Unmute microphone' : 'Mute microphone'}>
<Button
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'}
aria-pressed={muted}
className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')}
disabled={disabled}
onClick={() => {
triggerHaptic('selection')
onToggleMute()
}}
size="icon"
type="button"
variant="ghost"
>
<Codicon name={muted ? 'mic-off' : 'mic'} size="1rem" />
</Button>
</Tip>
<Button
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'}
aria-pressed={muted}
className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')}
disabled={disabled}
onClick={() => {
triggerHaptic('selection')
onToggleMute()
}}
size="icon"
title={muted ? 'Unmute microphone' : 'Mute microphone'}
type="button"
variant="ghost"
>
<Codicon name={muted ? 'mic-off' : 'mic'} size="1rem" />
</Button>
{listening && (
<Button
aria-label="Stop listening and send"
@@ -155,6 +151,7 @@ function ConversationPill({
triggerHaptic('submit')
onStopTurn()
}}
title="Stop listening and send"
type="button"
variant="ghost"
>
@@ -170,6 +167,7 @@ function ConversationPill({
triggerHaptic('close')
onEnd()
}}
title="End voice conversation"
type="button"
>
<ConversationIndicator level={level} listening={listening} speaking={speaking} />
@@ -226,35 +224,34 @@ function DictationButton({
status === 'recording' ? 'Stop dictation' : status === 'transcribing' ? 'Transcribing dictation' : 'Voice dictation'
return (
<Tip label={aria}>
<Button
aria-label={aria}
aria-pressed={active}
className={cn(
GHOST_ICON_BTN,
'p-0',
'data-[active=true]:bg-accent data-[active=true]:text-foreground',
status === 'recording' && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary',
status === 'transcribing' && 'bg-primary/10 text-primary'
)}
data-active={active}
disabled={disabled || !state.enabled || status === 'transcribing'}
onClick={() => {
triggerHaptic(active ? 'close' : 'open')
onToggle()
}}
size="icon"
type="button"
variant="ghost"
>
{status === 'recording' ? (
<Square className="fill-current" size={12} />
) : status === 'transcribing' ? (
<Loader2 className="animate-spin" size={16} />
) : (
<Codicon name="mic" size="1rem" />
)}
</Button>
</Tip>
<Button
aria-label={aria}
aria-pressed={active}
className={cn(
GHOST_ICON_BTN,
'p-0',
'data-[active=true]:bg-accent data-[active=true]:text-foreground',
status === 'recording' && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary',
status === 'transcribing' && 'bg-primary/10 text-primary'
)}
data-active={active}
disabled={disabled || !state.enabled || status === 'transcribing'}
onClick={() => {
triggerHaptic(active ? 'close' : 'open')
onToggle()
}}
size="icon"
title={aria}
type="button"
variant="ghost"
>
{status === 'recording' ? (
<Square className="fill-current" size={12} />
) : status === 'transcribing' ? (
<Loader2 className="animate-spin" size={16} />
) : (
<Codicon name="mic" size="1rem" />
)}
</Button>
)
}

View File

@@ -10,8 +10,6 @@
* steal focus from the composer effect.
*/
import type { InlineRefInput } from './inline-refs'
export type ComposerTarget = 'edit' | 'main'
export type ComposerInsertMode = 'block' | 'inline'
@@ -25,14 +23,8 @@ interface InsertDetail {
text: string
}
interface InsertRefsDetail {
refs: InlineRefInput[]
target: ComposerTarget
}
const FOCUS_EVENT = 'hermes:composer-focus'
const INSERT_EVENT = 'hermes:composer-insert'
const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
let activeTarget: ComposerTarget = 'main'
@@ -90,20 +82,6 @@ export const onComposerFocusRequest = (handler: (target: ComposerTarget) => void
export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) =>
subscribe<InsertDetail>(INSERT_EVENT, handler)
/** Insert typed ref chips (carrying a display label) into a composer — the
* structured cousin of {@link requestComposerInsert}, used for session links. */
export const requestComposerInsertRefs = (
refs: InlineRefInput[],
{ target = 'active' }: { target?: ComposerTarget | 'active' } = {}
) => {
if (refs.length) {
dispatch<InsertRefsDetail>(INSERT_REFS_EVENT, { refs, target: resolve(target) })
}
}
export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) =>
subscribe<InsertRefsDetail>(INSERT_REFS_EVENT, handler)
/**
* Focus a composer input across React commit + browser focus restore.
*

View File

@@ -16,7 +16,6 @@ interface SlashItemMetadata extends Record<string, string> {
command: string
display: string
meta: string
rawText: string
}
function textValue(value: unknown, fallback = ''): string {
@@ -92,13 +91,7 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
const metadata: SlashItemMetadata = {
command,
display,
meta,
// Provide rawText so hermesDirectiveFormatter.serialize uses the
// direct-insertion path instead of the legacy @type:id fallback.
// Without this, the item.id (which includes a "|index" suffix for
// trigger-adapter uniqueness) leaks into the serialized chip text
// and the submitted command.
rawText: command
meta
}
return {

View File

@@ -18,11 +18,14 @@ import { Button } from '@/components/ui/button'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { chatMessageText } from '@/lib/chat-messages'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
import {
$composerAttachments,
clearComposerAttachments,
type ComposerAttachment
} from '@/store/composer'
import {
$queuedPromptsBySession,
enqueueQueuedPrompt,
@@ -31,7 +34,7 @@ import {
shouldAutoDrainOnSettle,
updateQueuedPrompt
} from '@/store/composer-queue'
import { $gatewayState, $messages } from '@/store/session'
import { $messages } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions'
@@ -45,7 +48,6 @@ import {
focusComposerInput,
markActiveComposer,
onComposerFocusRequest,
onComposerInsertRefsRequest,
onComposerInsertRequest
} from './focus'
import { HelpHint } from './help-hint'
@@ -53,12 +55,7 @@ import { useAtCompletions } from './hooks/use-at-completions'
import { useSlashCompletions } from './hooks/use-slash-completions'
import { useVoiceConversation } from './hooks/use-voice-conversation'
import { useVoiceRecorder } from './hooks/use-voice-recorder'
import {
dragHasAttachments,
droppedFileInlineRef,
type InlineRefInput,
insertInlineRefsIntoEditor
} from './inline-refs'
import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from './inline-refs'
import { QueuePanel } from './queue-panel'
import {
composerPlainText,
@@ -76,39 +73,9 @@ 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
@@ -175,7 +142,6 @@ 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)')
@@ -190,44 +156,7 @@ export function ChatBar({
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
const showHelpHint = draft === '?'
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 placeholder = disabled ? 'Starting Hermes...' : 'Send follow-up'
const focusInput = useCallback(() => {
focusComposerInput(editorRef.current)
@@ -318,13 +247,14 @@ export function ChatBar({
}
}, [urlOpen])
// 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.
// 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.
useEffect(() => {
if (!draft) {
setExpanded(false)
@@ -336,7 +266,7 @@ export function ChatBar({
return
}
if (draft.includes('\n')) {
if (draft.includes('\n') || draft.length > 60) {
setExpanded(true)
}
}, [draft, expanded])
@@ -372,18 +302,6 @@ 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
@@ -403,7 +321,7 @@ export function ChatBar({
}
}, [])
useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef, editorRef)
useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef)
useEffect(() => {
return () => {
@@ -438,7 +356,7 @@ export function ChatBar({
requestMainFocus()
}
const insertInlineRefs = (refs: InlineRefInput[]) => {
const insertInlineRefs = (refs: string[]) => {
const editor = editorRef.current
if (!editor) {
@@ -458,19 +376,6 @@ export function ChatBar({
return true
}
// Latest-closure ref so the (once-only) subscription always calls the current
// insertInlineRefs without re-subscribing every render.
const insertInlineRefsRef = useRef(insertInlineRefs)
insertInlineRefsRef.current = insertInlineRefs
useEffect(() => {
return onComposerInsertRefsRequest(({ refs, target }) => {
if (target === 'main') {
insertInlineRefsRef.current(refs)
}
})
}, [])
const selectSkinSlashCommand = (command: string) => {
draftRef.current = command
aui.composer().setText(command)
@@ -494,19 +399,13 @@ export function ChatBar({
return
}
// 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()
const pastedText = event.clipboardData.getData('text')
if (!pastedText) {
event.preventDefault()
return
}
if (DATA_IMAGE_URL_RE.test(pastedText)) {
if (DATA_IMAGE_URL_RE.test(pastedText.trim())) {
event.preventDefault()
return
@@ -569,13 +468,6 @@ 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') {
@@ -675,18 +567,7 @@ export function ChatBar({
}
const handleEditorKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
// 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') {
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k') {
event.preventDefault()
if (!busy) {
@@ -1057,19 +938,7 @@ export function ChatBar({
if (queueEdit) {
exitQueuedEdit('save')
} else if (busy) {
// Slash commands should execute immediately even while the agent is
// busy — they're client-side operations (/yolo, /skin, /new, /help,
// etc.) or self-contained gateway RPCs (/status, /compress). onSubmit
// routes them to executeSlashCommand, which has its own per-command
// busy guard for commands that genuinely need an idle session (skill
// /send directives). Queuing them would make every slash command wait
// for the current turn to finish, which is how the TUI never behaves.
if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) {
const submitted = draft
triggerHaptic('submit')
clearDraft()
void onSubmit(submitted)
} else if (hasComposerPayload) {
if (hasComposerPayload) {
queueCurrentDraft()
} else {
// Stop button: an explicit interrupt must actually halt the running
@@ -1087,8 +956,7 @@ export function ChatBar({
const submitted = draft
triggerHaptic('submit')
clearDraft()
clearComposerAttachments()
void onSubmit(submitted, { attachments })
void onSubmit(submitted)
}
focusInput()
@@ -1214,10 +1082,10 @@ export function ChatBar({
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
<div
aria-label="Message"
autoCapitalize="off"
autoCorrect="off"
autoCapitalize="off"
className={cn(
'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',
'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',
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
'**:data-ref-text:cursor-default',
stacked && 'pl-3',
@@ -1227,12 +1095,6 @@ 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')}
@@ -1261,7 +1123,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 submitMode="ctrlEnter" tabIndex={-1} unstable_focusOnScrollToBottom={false}>
<ComposerPrimitive.Input asChild tabIndex={-1} unstable_focusOnScrollToBottom={false}>
<textarea aria-hidden className="sr-only" tabIndex={-1} />
</ComposerPrimitive.Input>
</div>
@@ -1281,11 +1143,6 @@ export function ChatBar({
onDrop={handleDrop}
onSubmit={e => {
e.preventDefault()
if (composingRef.current) {
return
}
submitDraft()
}}
ref={composerRef}
@@ -1388,7 +1245,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-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
: 'grid-cols-[auto_1fr_auto] items-end gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
)}
>
<div className="flex items-center [grid-area:menu]">{contextMenu}</div>

View File

@@ -5,49 +5,6 @@ import type { DroppedFile } from '../hooks/use-composer-actions'
import { composerPlainText, escapeHtml, placeCaretEnd, refChipHtml } from './rich-editor'
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
export type InlineRefInput = string | { kind: string; label?: string; value: string }
/** MIME for an in-app session drag (sidebar row → composer). */
export const HERMES_SESSION_MIME = 'application/x-hermes-session'
export interface SessionDragPayload {
id: string
profile: string
title: string
}
export function writeSessionDrag(transfer: DataTransfer, payload: SessionDragPayload) {
transfer.setData(HERMES_SESSION_MIME, JSON.stringify(payload))
transfer.effectAllowed = 'copy'
}
export function dragHasSession(transfer: DataTransfer | null) {
return Boolean(transfer) && Array.from(transfer!.types || []).includes(HERMES_SESSION_MIME)
}
export function readSessionDrag(transfer: DataTransfer | null): null | SessionDragPayload {
const raw = transfer?.getData(HERMES_SESSION_MIME)
if (!raw) {
return null
}
try {
const parsed = JSON.parse(raw) as Partial<SessionDragPayload>
return parsed.id ? { id: parsed.id, profile: parsed.profile || 'default', title: parsed.title || '' } : null
} catch {
return null
}
}
/** Build a `@session:<profile>/<id>` chip. Value carries the metadata the agent
* needs to resolve the link (session_search); label shows the friendly title. */
export function sessionInlineRef({ id, profile, title }: SessionDragPayload): InlineRefInput {
return { kind: 'session', label: title || `chat ${id.slice(0, 8)}`, value: `${profile || 'default'}/${id}` }
}
export function dragHasAttachments(transfer: DataTransfer | null, pathsMime: string) {
if (!transfer) {
return false
@@ -83,17 +40,13 @@ export function droppedFileInlineRef(candidate: DroppedFile, cwd: string | null
return `@${kind}:${formatRefValue(rel)}`
}
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly string[]) {
if (!refs.length) {
return null
}
const refsHtml = refs
.map(ref => {
if (typeof ref !== 'string') {
return refChipHtml(ref.kind, ref.value, ref.label)
}
const match = ref.match(/^@([^:]+):(.+)$/)
return match ? refChipHtml(match[1], match[2]) : escapeHtml(ref)

View File

@@ -2,7 +2,6 @@ import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Tip } from '@/components/ui/tooltip'
import { ArrowUp, Pencil, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { QueuedPromptEntry } from '@/store/composer-queue'
@@ -81,44 +80,41 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN
: 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100'
)}
>
<Tip label="Edit queued turn">
<Button
aria-label="Edit queued turn"
className="h-5 w-5 rounded-md"
disabled={Boolean(editingId) && !isEditing}
onClick={() => onEdit(entry)}
size="icon-xs"
type="button"
variant="ghost"
>
<Pencil size={11} />
</Button>
</Tip>
<Tip label="Send queued turn now">
<Button
aria-label="Send queued turn now"
className="h-5 w-5 rounded-md"
disabled={busy || isEditing}
onClick={() => onSendNow(entry.id)}
size="icon-xs"
type="button"
variant="ghost"
>
<ArrowUp size={11} />
</Button>
</Tip>
<Tip label="Delete queued turn">
<Button
aria-label="Delete queued turn"
className="h-5 w-5 rounded-md"
onClick={() => onDelete(entry.id)}
size="icon-xs"
type="button"
variant="ghost"
>
<Trash2 size={11} />
</Button>
</Tip>
<Button
aria-label="Edit queued turn"
className="h-5 w-5 rounded-md"
disabled={Boolean(editingId) && !isEditing}
onClick={() => onEdit(entry)}
size="icon-xs"
title="Edit queued turn"
type="button"
variant="ghost"
>
<Pencil size={11} />
</Button>
<Button
aria-label="Send queued turn now"
className="h-5 w-5 rounded-md"
disabled={busy || isEditing}
onClick={() => onSendNow(entry.id)}
size="icon-xs"
title="Send queued turn now"
type="button"
variant="ghost"
>
<ArrowUp size={11} />
</Button>
<Button
aria-label="Delete queued turn"
className="h-5 w-5 rounded-md"
onClick={() => onDelete(entry.id)}
size="icon-xs"
title="Delete queued turn"
type="button"
variant="ghost"
>
<Trash2 size={11} />
</Button>
</div>
</div>
)

View File

@@ -15,7 +15,7 @@ import {
export const RICH_INPUT_SLOT = 'composer-rich-input'
export const REF_RE = /@(file|folder|url|image|tool|line|terminal|session):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
export const REF_RE = /@(file|folder|url|image|tool|line|terminal):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }
@@ -52,14 +52,14 @@ export function quoteRefValue(value: string) {
return formatRefValue(value)
}
export function refChipHtml(kind: string, rawValue: string, displayLabel?: string) {
export function refChipHtml(kind: string, rawValue: string) {
const id = unquoteRef(rawValue)
const text = `@${kind}:${quoteRefValue(id)}`
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(displayLabel || refLabel(id))}</span></span>`
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(refLabel(id))}</span></span>`
}
export function refChipElement(kind: string, rawValue: string, displayLabel?: string) {
export function refChipElement(kind: string, rawValue: string) {
const id = unquoteRef(rawValue)
const text = `@${kind}:${quoteRefValue(id)}`
const chip = document.createElement('span')
@@ -71,7 +71,7 @@ export function refChipElement(kind: string, rawValue: string, displayLabel?: st
chip.dataset.refKind = kind
chip.className = DIRECTIVE_CHIP_CLASS
label.className = 'truncate'
label.textContent = displayLabel || refLabel(id)
label.textContent = refLabel(id)
chip.append(directiveIconElement(kind), label)
return chip

View File

@@ -37,10 +37,7 @@ function Harness({
const refreshTrigger = useCallback(() => {
const editor = editorRef.current
if (!editor) {
return
}
if (!editor) {return}
const raw = editor.textContent ?? ''
if (!raw.includes('@') && !raw.includes('/')) {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { blobDedupeKey, detectTrigger, extractClipboardImageBlobs } from './text-utils'
import { detectTrigger } from './text-utils'
describe('detectTrigger', () => {
it('detects a bare slash trigger with an empty query', () => {
@@ -23,55 +23,3 @@ 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')
})
})

View File

@@ -8,31 +8,16 @@ 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<string>()
const seen = new Set<Blob>()
const push = (blob: Blob | null) => {
if (!blob || blob.size === 0) {
if (!blob || blob.size === 0 || seen.has(blob)) {
return
}
const key = blobDedupeKey(blob)
if (seen.has(key)) {
return
}
seen.add(key)
seen.add(blob)
blobs.push(blob)
}
@@ -44,8 +29,7 @@ export function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
}
}
// Chromium/Electron expose the same pasted image on both `items` and `files`.
if (blobs.length === 0 && clipboard.files?.length) {
if (clipboard.files?.length) {
for (let i = 0; i < clipboard.files.length; i += 1) {
const file = clipboard.files.item(i)

View File

@@ -1,71 +1,50 @@
import { type DragEvent as ReactDragEvent, useCallback, useRef, useState } from 'react'
import {
dragHasAttachments,
dragHasSession,
readSessionDrag,
type SessionDragPayload
} from '@/app/chat/composer/inline-refs'
import { dragHasAttachments } from '@/app/chat/composer/inline-refs'
import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from './use-composer-actions'
export type DragKind = 'files' | 'session' | null
const dragKindOf = (event: ReactDragEvent): DragKind => {
if (dragHasSession(event.dataTransfer)) {
return 'session'
}
if (dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) {
return 'files'
}
return null
}
const hasFiles = (event: ReactDragEvent) => dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)
interface FileDropZoneOptions {
/** When false the zone ignores drags entirely. */
enabled?: boolean
onDropFiles: (files: DroppedFile[]) => void
onDropSession?: (session: SessionDragPayload) => void
}
/**
* "Drop anywhere in this region" affordance for files *and* in-app session
* links. An enter/leave depth counter keeps nested children from flickering the
* active state; `onDropCapture` clears it even when a nested target (the
* composer) handles the drop and stops propagation before our bubble-phase
* `onDrop` would fire.
* "Drop files anywhere in this region" affordance. An enter/leave depth counter
* keeps nested children from flickering the active state; `onDropCapture` clears
* it even when a nested target (the composer) handles the drop and stops
* propagation before our bubble-phase `onDrop` would fire.
*
* Spread `dropHandlers` onto the container; render an overlay off `dragKind`.
* Spread `dropHandlers` onto the container; render an overlay off `dragActive`.
*/
export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }: FileDropZoneOptions) {
const [dragKind, setDragKind] = useState<DragKind>(null)
export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOptions) {
const [dragActive, setDragActive] = useState(false)
const depth = useRef(0)
const reset = useCallback(() => {
depth.current = 0
setDragKind(null)
setDragActive(false)
}, [])
const onDragEnter = useCallback(
(event: ReactDragEvent) => {
const kind = enabled ? dragKindOf(event) : null
if (!kind) {
if (!enabled || !hasFiles(event)) {
return
}
event.preventDefault()
depth.current += 1
setDragKind(kind)
setDragActive(true)
},
[enabled]
)
const onDragOver = useCallback(
(event: ReactDragEvent) => {
if (!enabled || !dragKindOf(event)) {
if (!enabled || !hasFiles(event)) {
return
}
@@ -83,36 +62,21 @@ export function useFileDropZone({ enabled = true, onDropFiles, onDropSession }:
const onDrop = useCallback(
(event: ReactDragEvent) => {
const kind = enabled ? dragKindOf(event) : null
if (!kind) {
if (!enabled || !hasFiles(event)) {
return
}
event.preventDefault()
reset()
if (kind === 'session') {
const session = readSessionDrag(event.dataTransfer)
if (session) {
onDropSession?.(session)
}
return
}
const files = extractDroppedFiles(event.dataTransfer)
if (files.length) {
onDropFiles(files)
}
},
[enabled, onDropFiles, onDropSession, reset]
[enabled, onDropFiles, reset]
)
return {
dragKind,
dropHandlers: { onDragEnter, onDragLeave, onDragOver, onDrop, onDropCapture: reset }
}
return { dragActive, dropHandlers: { onDragEnter, onDragLeave, onDragOver, onDrop, onDropCapture: reset } }
}

View File

@@ -12,7 +12,7 @@ import { useLocation } from 'react-router-dom'
import { Thread } from '@/components/assistant-ui/thread'
import { Backdrop } from '@/components/Backdrop'
import { PromptOverlays } from '@/components/prompt-overlays'
import { NotificationStack } from '@/components/notifications'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
@@ -22,7 +22,6 @@ import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-s
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
import { $pinnedSessionIds } from '@/store/layout'
import { $gatewaySwapTarget } from '@/store/profile'
import {
$activeSessionId,
$awaitingResponse,
@@ -37,8 +36,7 @@ import {
$introSeed,
$messages,
$selectedStoredSessionId,
$sessions,
sessionPinId
$sessions
} from '@/store/session'
import type { ModelOptionsResponse } from '@/types/hermes'
@@ -46,10 +44,9 @@ import { routeSessionId } from '../routes'
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
import { ChatDropOverlay } from './chat-drop-overlay'
import { ChatSwapOverlay } from './chat-swap-overlay'
import { ChatBar, ChatBarFallback } from './composer'
import { requestComposerInsert, requestComposerInsertRefs } from './composer/focus'
import { droppedFileInlineRef, type SessionDragPayload, sessionInlineRef } from './composer/inline-refs'
import { requestComposerInsert } from './composer/focus'
import { droppedFileInlineRef } from './composer/inline-refs'
import type { ChatBarState } from './composer/types'
import type { DroppedFile } from './hooks/use-composer-actions'
import { useFileDropZone } from './hooks/use-file-drop-zone'
@@ -99,27 +96,9 @@ function ChatHeader({
}: ChatHeaderProps) {
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)
const activeStoredSession =
sessions.find(session => session.id === selectedSessionId || session._lineage_root_id === selectedSessionId) || null
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
// 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
}
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
return (
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
@@ -134,7 +113,7 @@ function ChatHeader({
title={title}
>
<Button
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]"
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]"
type="button"
variant="ghost"
>
@@ -180,7 +159,6 @@ export function ChatView({
const currentProvider = useStore($currentProvider)
const freshDraftReady = useStore($freshDraftReady)
const gatewayState = useStore($gatewayState)
const gatewaySwapTarget = useStore($gatewaySwapTarget)
const gatewayOpen = gatewayState === 'open'
const introPersonality = useStore($introPersonality)
const introSeed = useStore($introSeed)
@@ -309,13 +287,7 @@ export function ChatView({
[currentCwd]
)
// Dropping a sidebar session inserts an @session link the agent can resolve
// via session_search (carries the source profile, so cross-profile works).
const onDropSession = useCallback((session: SessionDragPayload) => {
requestComposerInsertRefs([sessionInlineRef(session)], { target: 'main' })
}, [])
const { dragKind, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles, onDropSession })
const { dragActive, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles })
return (
<div
@@ -333,7 +305,7 @@ export function ChatView({
selectedSessionId={selectedSessionId}
/>
<PromptOverlays />
<NotificationStack />
<div
className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]"
@@ -379,8 +351,7 @@ export function ChatView({
</Suspense>
)}
</AssistantRuntimeProvider>
<ChatDropOverlay kind={dragKind} />
<ChatSwapOverlay profile={gatewaySwapTarget} />
<ChatDropOverlay active={dragActive} />
</div>
</div>
)

View File

@@ -1,6 +1,6 @@
import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react'
import { $messages, setBusy, setMessages } from '@/store/session'
import { $messages, setMessages, setBusy } from '@/store/session'
type Sample = {
id: string
@@ -40,16 +40,13 @@ if (typeof window !== 'undefined' && !window.__PERF_PROBE__) {
},
summary: () => {
const byId = new Map<string, number[]>()
for (const s of samples) {
const k = `${s.id}:${s.phase}`
const arr = byId.get(k) ?? []
arr.push(s.actualDuration)
byId.set(k, arr)
}
const out: Record<string, { count: number; total: number; max: number; p50: number; p95: number }> = {}
for (const [k, arr] of byId) {
arr.sort((a, b) => a - b)
const total = arr.reduce((a, b) => a + b, 0)
@@ -58,27 +55,19 @@ if (typeof window !== 'undefined' && !window.__PERF_PROBE__) {
total: Math.round(total * 100) / 100,
max: Math.round(arr[arr.length - 1] * 100) / 100,
p50: Math.round(arr[Math.floor(arr.length * 0.5)] * 100) / 100,
p95: Math.round(arr[Math.floor(arr.length * 0.95)] * 100) / 100
p95: Math.round(arr[Math.floor(arr.length * 0.95)] * 100) / 100,
}
}
return out
}
},
}
}
const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration, baseDuration, startTime, commitTime) => {
const probe = typeof window !== 'undefined' ? window.__PERF_PROBE__ : undefined
if (!probe || !probe.enabled) {
return
}
if (!probe || !probe.enabled) return
probe.samples.push({ id, phase, actualDuration, baseDuration, startTime, commitTime })
if (probe.samples.length > 5000) {
probe.samples.splice(0, probe.samples.length - 5000)
}
if (probe.samples.length > 5000) probe.samples.splice(0, probe.samples.length - 5000)
}
if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
@@ -97,11 +86,7 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
snapshotMsgs: () => $messages.get().length,
reset: () => {
activeHandle?.stop()
if (baseline) {
setMessages(baseline)
}
if (baseline) setMessages(baseline)
baseline = null
setBusy(false)
},
@@ -119,11 +104,7 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
}: { chunk?: string; intervalMs?: number; totalTokens?: number; flushMinMs?: number } = {}) => {
activeHandle?.stop()
const current = $messages.get()
if (!baseline) {
baseline = current
}
if (!baseline) baseline = current
const msgId = `synthetic-${Date.now()}`
// Seed an empty assistant message — assistant-ui will see it grow.
setMessages([
@@ -145,20 +126,13 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
let flushHandle: number | null = null
const applyDelta = (delta: string) => {
if (!delta) {
return
}
if (!delta) return
setMessages(prev =>
prev.map(m => {
if (m.id !== msgId) {
return m
}
if (m.id !== msgId) return m
const head = m.parts.slice(0, -1)
const last = m.parts.at(-1)
const lastText = last && last.type === 'text' ? last.text : ''
return {
...m,
parts: [...head, { type: 'text', text: lastText + delta }]
@@ -176,16 +150,8 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
}
const scheduleFlush = () => {
if (flushHandle !== null) {
return
}
if (flushMinMs <= 0) {
flushNow()
return
}
if (flushHandle !== null) return
if (flushMinMs <= 0) { flushNow(); return }
const since = performance.now() - lastFlushAt
const wait = Math.max(0, flushMinMs - since)
flushHandle =
@@ -196,62 +162,48 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
const handle: SyntheticDriverHandle = {
stop: () => {
if (timer) {
clearTimeout(timer)
}
if (timer) clearTimeout(timer)
timer = null
if (flushHandle !== null) {
clearTimeout(flushHandle)
cancelAnimationFrame?.(flushHandle)
}
flushHandle = null
if (pendingDelta) {
applyDelta(pendingDelta)
pendingDelta = ''
}
activeHandle = null
// Mark message finalized.
setMessages(prev => prev.map(m => (m.id === msgId ? { ...m, pending: false } : m)))
setMessages(prev =>
prev.map(m =>
m.id === msgId
? { ...m, pending: false }
: m
)
)
setBusy(false)
}
}
activeHandle = handle
const tick = () => {
if (activeHandle !== handle) {
return
}
if (activeHandle !== handle) return
if (pushed >= totalTokens) {
if (pendingDelta) {
flushNow()
}
if (pendingDelta) flushNow()
handle.stop()
return
}
pushed += 1
if (flushMinMs > 0) {
pendingDelta += chunk
scheduleFlush()
} else {
applyDelta(chunk)
}
timer = setTimeout(tick, intervalMs)
}
timer = setTimeout(tick, intervalMs)
return handle
}
}

View File

@@ -4,7 +4,6 @@ import { useEffect, useMemo, useRef } from 'react'
import { requestComposerInsert } from '@/app/chat/composer/focus'
import { CopyButton } from '@/components/ui/copy-button'
import { Tip } from '@/components/ui/tooltip'
import { PanelBottom, Send, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify } from '@/store/notifications'
@@ -81,18 +80,17 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console
selected && 'border-border/60 bg-accent/40'
)}
>
<Tip label={selected ? 'Deselect entry' : 'Select entry'}>
<button
className={cn(
'mt-0.5 text-left uppercase opacity-70 transition-colors hover:opacity-100',
consoleLevelClass[log.level] ?? consoleLevelClass[0]
)}
onClick={onToggleSelect}
type="button"
>
{consoleLevelLabel[log.level] || 'log'}
</button>
</Tip>
<button
className={cn(
'mt-0.5 cursor-pointer text-left uppercase opacity-70 transition-colors hover:opacity-100',
consoleLevelClass[log.level] ?? consoleLevelClass[0]
)}
onClick={onToggleSelect}
title={selected ? 'Deselect entry' : 'Select entry'}
type="button"
>
{consoleLevelLabel[log.level] || 'log'}
</button>
<div className="min-w-0" data-selectable-text="true">
<span className={cn('block wrap-break-word', consoleLevelClass[log.level] ?? consoleLevelClass[0])}>
{log.message}
@@ -114,15 +112,14 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console
showLabel={false}
text={copyText}
/>
<Tip label="Send this entry to chat">
<button
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onSend}
type="button"
>
<Send className="size-3" />
</button>
</Tip>
<button
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onSend}
title="Send this entry to chat"
type="button"
>
<Send className="size-3" />
</button>
</span>
</div>
)
@@ -228,6 +225,11 @@ export function PreviewConsolePanel({
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={sendableLogs.length === 0}
onClick={() => sendLogsToComposer(sendableLogs)}
title={
visibleSelection.length > 0
? `Send ${visibleSelection.length} selected to chat`
: 'Send all log entries to chat'
}
type="button"
>
<Send className="size-3" />
@@ -248,6 +250,7 @@ export function PreviewConsolePanel({
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={logs.length === 0}
onClick={consoleState.clear}
title="Clear console"
type="button"
>
<Trash2 className="size-3" />

View File

@@ -11,7 +11,6 @@ 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'
@@ -482,7 +481,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
if (state.loading) {
return <PageLoader label="Loading preview" />
return <div className="grid h-full place-items-center text-xs text-muted-foreground">Loading preview</div>
}
if (state.error) {

View File

@@ -3,7 +3,6 @@ import type { PointerEvent as ReactPointerEvent } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
import { Tip } from '@/components/ui/tooltip'
import { Bug } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -84,7 +83,7 @@ function PreviewLoadError({
body={
<>
<a
className="pointer-events-auto block font-mono text-muted-foreground/90 underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground"
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"
href={error.url}
onClick={event => {
event.preventDefault()
@@ -608,16 +607,15 @@ export function PreviewPane({
{!embedded && (
<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">
<Tip label={`Open ${currentUrl}`}>
<a
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"
>
{previewLabel || 'Preview'}
</a>
</Tip>
<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"
href={currentUrl}
rel="noreferrer"
target="_blank"
title={`Open ${currentUrl}`}
>
{previewLabel || 'Preview'}
</a>
</div>
</div>
)}

View File

@@ -3,7 +3,6 @@ import { useEffect, useMemo } from 'react'
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import {
$rightRailActiveTabId,
@@ -102,33 +101,27 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
// memory. `onMouseDown` swallows the middle-button press so
// Chromium doesn't switch into autoscroll mode.
onAuxClick={event => {
if (event.button !== 1) {
return
}
if (event.button !== 1) return
event.preventDefault()
closeRightRailTab(tab.id)
}}
onMouseDown={event => {
if (event.button === 1) {
event.preventDefault()
}
if (event.button === 1) event.preventDefault()
}}
>
{active && (
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
)}
<Tip label={tab.label}>
<button
aria-selected={active}
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
type="button"
>
<span className="block min-w-0 truncate">{tab.label}</span>
</button>
</Tip>
<button
aria-selected={active}
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
title={tab.label}
type="button"
>
<span className="block min-w-0 truncate">{tab.label}</span>
</button>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
@@ -137,6 +130,7 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
aria-label={`Close ${tab.label}`}
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
onClick={() => closeRightRailTab(tab.id)}
title={`Close ${tab.label}`}
type="button"
>
<Codicon name="close" size="0.75rem" />
@@ -149,6 +143,7 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
aria-label="Close preview pane"
className="mr-1.5 grid size-6 shrink-0 self-center place-items-center rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring group-hover/rail-tabs:opacity-100 [-webkit-app-region:no-drag]"
onClick={closeRightRail}
title="Close preview pane"
type="button"
>
<Codicon name="close" size="0.75rem" />

View File

@@ -17,13 +17,12 @@ import {
import { CSS } from '@dnd-kit/utilities'
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
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,
@@ -34,13 +33,9 @@ import {
SidebarMenuItem
} from '@/components/ui/sidebar'
import { Skeleton } from '@/components/ui/skeleton'
import { Tip } from '@/components/ui/tooltip'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { profileColor } from '@/lib/profile-color'
import { sessionMatchesSearch } from '@/lib/session-search'
import { cn } from '@/lib/utils'
import {
$panesFlipped,
$pinnedSessionIds,
$sidebarAgentsGrouped,
$sidebarOpen,
@@ -54,17 +49,8 @@ import {
SIDEBAR_SESSIONS_PAGE_SIZE,
unpinSession
} from '@/store/layout'
import {
$newChatProfile,
$profiles,
$profileScope,
ALL_PROFILES,
newSessionInProfile,
normalizeProfileKey
} from '@/store/profile'
import {
$selectedStoredSessionId,
$sessionProfileTotals,
$sessions,
$sessionsLoading,
$sessionsTotal,
@@ -76,7 +62,6 @@ import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '..
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import type { SidebarNavItem } from '../../types'
import { ProfileRail } from './profile-switcher'
import { SidebarSessionRow } from './session-row'
import { VirtualSessionList } from './virtual-session-list'
@@ -106,9 +91,6 @@ const SIDEBAR_NAV: SidebarNavItem[] = [
]
const WORKSPACE_PAGE = 5
// ALL-profiles view: show only the latest N per profile up front to keep the
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
const PROFILE_INITIAL_PAGE = 5
const WS_ID_PREFIX = 'workspace:'
const wsId = (id: string) => `${WS_ID_PREFIX}${id}`
@@ -161,7 +143,6 @@ 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,
@@ -216,7 +197,6 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
currentView: AppView
onNavigate: (item: SidebarNavItem) => void
onLoadMoreSessions: () => void
onLoadMoreProfileSessions?: (profile: string) => Promise<void> | void
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onArchiveSession: (sessionId: string) => void
@@ -227,14 +207,12 @@ export function ChatSidebar({
currentView,
onNavigate,
onLoadMoreSessions,
onLoadMoreProfileSessions,
onResumeSession,
onDeleteSession,
onArchiveSession,
onNewSessionInWorkspace
}: ChatSidebarProps) {
const sidebarOpen = useStore($sidebarOpen)
const panesFlipped = useStore($panesFlipped)
const agentsGrouped = useStore($sidebarAgentsGrouped)
const pinnedSessionIds = useStore($pinnedSessionIds)
const pinsOpen = useStore($sidebarPinsOpen)
@@ -243,44 +221,13 @@ export function ChatSidebar({
const sessions = useStore($sessions)
const sessionsLoading = useStore($sessionsLoading)
const sessionsTotal = useStore($sessionsTotal)
const sessionProfileTotals = useStore($sessionProfileTotals)
const workingSessionIds = useStore($workingSessionIds)
const profiles = useStore($profiles)
const profileScope = useStore($profileScope)
// Only surface the profile switcher when more than one profile exists, so
// single-profile users see the unchanged sidebar.
const multiProfile = profiles.length > 1
// Gate ALL-profiles grouping on multiProfile too: if a user drops back to one
// profile while scope is still ALL (persisted), the rail is hidden and they'd
// otherwise be stuck in the grouped view with no way out.
const showAllProfiles = multiProfile && profileScope === ALL_PROFILES
const [agentOrderIds, setAgentOrderIds] = useState<string[]>([])
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})
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(
@@ -288,19 +235,7 @@ export function ChatSidebar({
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
// Profile scope = the "workspace switcher" context. Concrete scope shows only
// that profile's sessions (clean rows, no per-row tags); ALL fans every
// profile in, grouped by profile below. Single-profile users land here with
// scope === their only profile, so nothing is filtered out.
const visibleSessions = useMemo(
() => (showAllProfiles ? sessions : sessions.filter(s => normalizeProfileKey(s.profile) === profileScope)),
[sessions, showAllProfiles, profileScope]
)
const sortedSessions = useMemo(
() => [...visibleSessions].sort((a, b) => sessionTime(b) - sessionTime(a)),
[visibleSessions]
)
const sortedSessions = useMemo(() => [...sessions].sort((a, b) => sessionTime(b) - sessionTime(a)), [sessions])
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])
@@ -309,7 +244,7 @@ export function ChatSidebar({
const sessionByAnyId = useMemo(() => {
const map = new Map<string, SessionInfo>()
for (const s of visibleSessions) {
for (const s of sessions) {
map.set(s.id, s)
if (s._lineage_root_id && !map.has(s._lineage_root_id)) {
@@ -318,7 +253,7 @@ export function ChatSidebar({
}
return map
}, [visibleSessions])
}, [sessions])
const pinnedSessions = useMemo(() => {
const seen = new Set<string>()
@@ -371,10 +306,11 @@ export function ChatSidebar({
return []
}
const needle = trimmedQuery.toLowerCase()
const out = new Map<string, SessionInfo>()
for (const s of sortedSessions) {
if (sessionMatchesSearch(s, trimmedQuery)) {
if (`${s.title ?? ''} ${s.preview ?? ''} ${s.cwd ?? ''}`.toLowerCase().includes(needle)) {
out.set(s.id, s)
}
}
@@ -406,87 +342,11 @@ export function ChatSidebar({
[agentSessions, workspaceOrderIds]
)
const loadMoreForProfileGroup = useCallback(
(profile: string) => {
if (!onLoadMoreProfileSessions) {
return
}
setProfileLoadMorePending(prev => ({ ...prev, [profile]: true }))
void Promise.resolve(onLoadMoreProfileSessions(profile))
.catch(() => undefined)
.finally(() =>
setProfileLoadMorePending(({ [profile]: _done, ...rest }) => rest)
)
},
[onLoadMoreProfileSessions]
)
// ALL-profiles view: one collapsible group per profile, color on the header
// (not on every row). Default profile floats to the top, the rest alpha.
const profileGroups = useMemo<SidebarSessionGroup[] | undefined>(() => {
if (!showAllProfiles) {
return undefined
}
const groups = new Map<string, SidebarSessionGroup>()
for (const session of agentSessions) {
const key = normalizeProfileKey(session.profile)
const group = groups.get(key) ?? {
color: profileColor(key),
id: key,
label: key,
mode: 'profile',
path: null,
sessions: []
}
group.sessions.push(session)
groups.set(key, group)
}
return [...groups.values()]
.map(group => ({
...group,
loadingMore: Boolean(profileLoadMorePending[group.id]),
onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined,
totalCount: Math.max(group.sessions.length, sessionProfileTotals[group.id] ?? 0)
}))
// default (root) first, then the rest alphabetically.
.sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label)))
}, [
showAllProfiles,
agentSessions,
loadMoreForProfileGroup,
onLoadMoreProfileSessions,
profileLoadMorePending,
sessionProfileTotals
])
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
// Pagination is scope-aware. In "All profiles" mode it tracks the global
// unified set. When scoped to one profile it must compare that profile's own
// loaded rows against that profile's total — otherwise a huge default profile
// keeps "Load more" stuck on while you browse a small one (the aggregator's
// total sums every profile). Per-profile totals come from the aggregator
// (children excluded); fall back to the global total / loaded count.
const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length
const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]
const knownSessionTotal = Math.max(
showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
loadedSessionCount
)
const hasMoreSessions = knownSessionTotal > loadedSessionCount
const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
const knownSessionTotal = Math.max(sessionsTotal, sortedSessions.length)
const hasMoreSessions = knownSessionTotal > sortedSessions.length
const remainingSessionCount = Math.max(0, knownSessionTotal - sortedSessions.length)
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) {
@@ -545,8 +405,7 @@ export function ChatSidebar({
return (
<Sidebar
className={cn(
'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',
'relative h-full min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground transition-none',
sidebarOpen
? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
: 'pointer-events-none border-transparent bg-transparent opacity-0'
@@ -565,30 +424,18 @@ export function ChatSidebar({
(item.id === 'messaging' && currentView === 'messaging') ||
(item.id === 'artifacts' && currentView === 'artifacts')
const isNewSession = item.id === 'new-session'
return (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
aria-disabled={!isInteractive}
className={cn(
'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',
'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',
active &&
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
!isInteractive &&
'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
)}
onClick={() => {
// A plain new session lands in whatever profile the live
// gateway is on (= the active switcher context). null →
// no swap. The switcher header is the single place to
// change which profile that is.
if (isNewSession) {
$newChatProfile.set(null)
}
onNavigate(item)
}}
onClick={() => onNavigate(item)}
tooltip={item.label}
type="button"
>
@@ -596,11 +443,8 @@ export function ChatSidebar({
{sidebarOpen && (
<>
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
{isNewSession && (
<KbdGroup
className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
keys={[...NEW_SESSION_KBD]}
/>
{item.id === 'new-session' && (
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={[...NEW_SESSION_KBD]} />
)}
</>
)}
@@ -613,13 +457,28 @@ export function ChatSidebar({
</SidebarGroup>
{sidebarOpen && showSessionSections && (
<div className="shrink-0 px-2 pb-1 pt-1">
<SearchField
aria-label="Search sessions"
onChange={setSearchQuery}
placeholder="Search sessions"
value={searchQuery}
/>
<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>
)}
@@ -672,19 +531,11 @@ export function ChatSidebar({
{sidebarOpen && showSessionSections && !trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName={cn(
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75',
// Separate profile sections clearly in the ALL view; rows inside
// each group keep their own tight gap-px rhythm.
showAllProfiles ? 'gap-3' : 'gap-px'
)}
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
dndSensors={dndSensors}
emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />}
footer={
// Hide "load more" only when workspace-grouped (those groups page
// themselves). ALL-profiles now pages per-profile from each profile
// header; the global footer only applies to non-ALL views.
!showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
!agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
<SidebarLoadMoreRow
loading={sessionsLoading}
onClick={onLoadMoreSessions}
@@ -693,43 +544,37 @@ export function ChatSidebar({
) : null
}
forceEmptyState={showSessionSkeletons}
groups={showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined}
groups={agentsGrouped ? agentGroups : undefined}
headerAction={
// Always reserve the icon-xs (size-6) slot so the header keeps the
// same height whether or not the toggle renders — otherwise the
// "Sessions" label jumps when switching to the ALL-profiles view.
// Grouping operates on unpinned recents; if everything is pinned
// the toggle does nothing, and it's irrelevant in the ALL-profiles
// view (always grouped by profile), so hide the button (not the slot).
<div className="grid size-6 shrink-0 place-items-center">
{!showAllProfiles && agentSessions.length > 0 ? (
<Tip label={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}>
<Button
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
className={cn(
'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 => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
</Tip>
) : null}
</div>
// Grouping operates on unpinned recents; if everything is
// pinned the toggle does nothing visible, so hide it to avoid
// a phantom click target.
agentSessions.length > 0 ? (
<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',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
) : null
}
label="Sessions"
labelMeta={recentsMeta}
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
onReorder={showAllProfiles ? undefined : handleAgentDragEnd}
onNewSessionInWorkspace={onNewSessionInWorkspace}
onReorder={handleAgentDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
onTogglePin={pinSession}
@@ -737,18 +582,10 @@ export function ChatSidebar({
pinned={false}
rootClassName="min-h-0 flex-1 p-0"
sessions={agentSessions}
sortable={!showAllProfiles && agentSessions.length > 1}
sortable={agentSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{sidebarOpen && !showSessionSections && <div className="min-h-0 flex-1" />}
{sidebarOpen && (
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
<ProfileRail />
</div>
)}
</SidebarContent>
</Sidebar>
)
@@ -766,7 +603,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 items-center gap-1 bg-transparent text-left leading-none"
className="group/section-label flex w-fit cursor-pointer items-center gap-1 bg-transparent text-left leading-none"
onClick={onToggle}
type="button"
>
@@ -807,7 +644,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</span>
<span>Shift-click a chat to pin · drag to reorder</span>
</div>
)
}
@@ -817,12 +654,6 @@ interface SidebarSessionGroup {
label: string
path: null | string
sessions: SessionInfo[]
// Profile color for the ALL-profiles view; absent for workspace groups.
color?: null | string
loadingMore?: boolean
mode?: 'profile' | 'workspace'
onLoadMore?: () => void
totalCount?: number
}
interface SidebarSessionsSectionProps {
@@ -1006,65 +837,38 @@ function SidebarWorkspaceGroup({
ref,
...rest
}: SidebarWorkspaceGroupProps) {
const isProfileGroup = group.mode === 'profile'
const pageStep = isProfileGroup ? PROFILE_INITIAL_PAGE : WORKSPACE_PAGE
const [open, setOpen] = useState(true)
const [visibleCount, setVisibleCount] = useState(pageStep)
const loadedCount = group.sessions.length
// Profile groups know their on-disk total (children excluded); workspace
// groups only ever page within what's already loaded.
const totalCount = isProfileGroup ? Math.max(group.totalCount ?? loadedCount, loadedCount) : loadedCount
const [visibleCount, setVisibleCount] = useState(WORKSPACE_PAGE)
const visibleSessions = group.sessions.slice(0, visibleCount)
const hiddenCount = Math.max(0, totalCount - visibleSessions.length)
const nextCount = Math.min(pageStep, hiddenCount)
// Reveal already-loaded rows first; only hit the backend when the next page
// crosses what's been fetched for this profile.
const handleProfileLoadMore = () => {
const target = visibleCount + pageStep
setVisibleCount(target)
if (target > loadedCount && loadedCount < totalCount) {
group.onLoadMore?.()
}
}
const hiddenCount = Math.max(0, group.sessions.length - visibleSessions.length)
const nextCount = Math.min(WORKSPACE_PAGE, hiddenCount)
return (
<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 items-center gap-1.5 bg-transparent text-left hover:text-(--ui-text-secondary)"
className="flex min-w-0 cursor-pointer items-center gap-1 bg-transparent text-left hover:text-(--ui-text-secondary)"
onClick={() => setOpen(value => !value)}
title={group.path ?? undefined}
type="button"
>
{group.color ? (
<span aria-hidden="true" className="size-2 shrink-0 rounded-full" style={{ backgroundColor: group.color }} />
) : null}
<span className="truncate">{group.label}</span>
<SidebarCount>
{isProfileGroup ? countLabel(visibleSessions.length, totalCount) : group.sessions.length}
</SidebarCount>
<SidebarCount>{group.sessions.length}</SidebarCount>
<DisclosureCaret
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
</button>
{(onNewSession || isProfileGroup) && (
<Tip label={`New session in ${group.label}`}>
<button
aria-label={`New session in ${group.label}`}
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"
// Profile groups start a fresh session in that profile but keep the
// all-profiles browse view (newSessionInProfile leaves the scope
// alone); workspace groups seed the new session's cwd from the path.
onClick={() => (isProfileGroup ? newSessionInProfile(group.id) : onNewSession?.(group.path))}
type="button"
>
<Codicon name="add" size="0.75rem" />
</button>
</Tip>
{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"
onClick={() => onNewSession(group.path)}
title={`New session in ${group.label}`}
type="button"
>
<Codicon name="add" size="0.75rem" />
</button>
)}
{reorderable && (
<span
@@ -1087,21 +891,17 @@ function SidebarWorkspaceGroup({
{open && (
<>
{renderRows(visibleSessions)}
{hiddenCount > 0 &&
(isProfileGroup ? (
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
) : (
<Tip label={`Show ${nextCount} more in ${group.label}`}>
<button
aria-label={`Show ${nextCount} more in ${group.label}`}
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
type="button"
>
<Codicon name="ellipsis" size="0.75rem" />
</button>
</Tip>
))}
{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"
onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
title={`Show ${nextCount} more in ${group.label}`}
type="button"
>
<Codicon name="ellipsis" size="0.75rem" />
</button>
)}
</>
)}
</div>
@@ -1148,16 +948,12 @@ function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps)
return (
<button
className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
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)"
disabled={loading}
onClick={onClick}
type="button"
>
{/* Seat the icon in the same w-3.5 column session rows use for their dot
so the chevron + label line up with the rows above. */}
<span className="grid w-3.5 shrink-0 place-items-center">
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
</span>
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
<span>{label}</span>
</button>
)

View File

@@ -1,491 +0,0 @@
import {
closestCenter,
DndContext,
type DragEndEvent,
type DragOverEvent,
type DragStartEvent,
KeyboardSensor,
type Modifier,
PointerSensor,
useSensor,
useSensors
} from '@dnd-kit/core'
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
useSortable
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { triggerHaptic } from '@/lib/haptics'
import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
import {
$activeGatewayProfile,
$profileColors,
$profileOrder,
$profiles,
$profileScope,
ALL_PROFILES,
normalizeProfileKey,
refreshActiveProfile,
selectProfile,
setProfileColor,
setProfileOrder,
setShowAllProfiles,
sortByProfileOrder
} from '@/store/profile'
import type { ProfileInfo } from '@/types/hermes'
import { CreateProfileDialog } from '../../profiles/create-profile-dialog'
import { DeleteProfileDialog } from '../../profiles/delete-profile-dialog'
import { RenameProfileDialog } from '../../profiles/rename-profile-dialog'
import { PROFILES_ROUTE } from '../../routes'
const RAIL_GAP = 4 // px — matches gap-1 between squares.
// easeOutBack — a little overshoot so squares spring into their new slot rather
// than sliding in flat. Neighbors reflow on RAIL_TRANSITION; the dragged square
// glides between snapped cells on the snappier DRAG_TRANSITION.
const SPRING = 'cubic-bezier(0.34, 1.56, 0.64, 1)'
const RAIL_TRANSITION = { duration: 300, easing: SPRING }
const DRAG_TRANSITION = `transform 200ms ${SPRING}`
// The rail is a single horizontal strip of fixed cells. Pin drags to the x-axis
// (no cross-axis scrollbar), snap to whole cells so a square steps slot-to-slot
// instead of gliding, and clamp to the occupied strip so it can't float past the
// last profile onto the "+".
const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, transform }) => {
if (!draggingNodeRect || !containerNodeRect) {
return { ...transform, y: 0 }
}
const pitch = draggingNodeRect.width + RAIL_GAP
const minX = containerNodeRect.left - draggingNodeRect.left
const maxX = containerNodeRect.right - draggingNodeRect.right
const snapped = Math.round(transform.x / pitch) * pitch
return { ...transform, x: Math.min(maxX, Math.max(minX, snapped)), y: 0 }
}
// Arc-Spaces-style profile rail at the sidebar foot: a default↔all toggle pinned
// left, the colored named profiles scrolling between, and Manage pinned right.
// The active profile pops in its own color — the "where am I" cue. Single-
// profile users see only the "+" (create their first profile); everything else
// appears once a second profile exists.
export function ProfileRail() {
const profiles = useStore($profiles)
const scope = useStore($profileScope)
const gatewayProfile = useStore($activeGatewayProfile)
const order = useStore($profileOrder)
const colors = useStore($profileColors)
const navigate = useNavigate()
const [createOpen, setCreateOpen] = useState(false)
const [pendingRename, setPendingRename] = useState<null | ProfileInfo>(null)
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
const scrollRef = useRef<HTMLDivElement>(null)
// A plain mouse wheel only emits deltaY; map it to horizontal scroll so the
// rail is navigable without a trackpad. Trackpad x-scroll (deltaX) passes
// through. Native + non-passive so we can preventDefault and not bleed the
// gesture into the sessions list above.
useEffect(() => {
const el = scrollRef.current
if (!el) {
return
}
const onWheel = (event: WheelEvent) => {
if (el.scrollWidth <= el.clientWidth || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) {
return
}
el.scrollLeft += event.deltaY
event.preventDefault()
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => el.removeEventListener('wheel', onWheel)
}, [])
const isAll = scope === ALL_PROFILES
const activeKey = normalizeProfileKey(gatewayProfile)
const defaultProfile = profiles.find(profile => profile.is_default)
const onDefault = !isAll && activeKey === 'default'
const named = sortByProfileOrder(profiles.filter(profile => !profile.is_default), order)
const multiProfile = profiles.length > 1
// distance constraint: a small drag reorders, a tap still selects the profile.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
// Tick a haptic each time the drag crosses into a new cell, and a satisfying
// confirm on a committed reorder.
const lastOverRef = useRef<string | null>(null)
const handleDragStart = ({ active }: DragStartEvent) => {
lastOverRef.current = String(active.id)
}
const handleDragOver = ({ over }: DragOverEvent) => {
const id = over ? String(over.id) : null
if (id && id !== lastOverRef.current) {
lastOverRef.current = id
triggerHaptic('selection')
}
}
const handleDragEnd = ({ active, over }: DragEndEvent) => {
lastOverRef.current = null
if (!over || active.id === over.id) {
return
}
const ids = named.map(profile => profile.name)
const from = ids.indexOf(String(active.id))
const to = ids.indexOf(String(over.id))
if (from >= 0 && to >= 0) {
setProfileOrder(arrayMove(ids, from, to))
triggerHaptic('success')
}
}
// Re-pull the running profile + list on mount so a profile created elsewhere
// shows up; cheap and best-effort.
useEffect(() => {
void refreshActiveProfile()
}, [])
return (
<div aria-label="Profiles" className="flex items-center gap-0.5" role="tablist">
{/* One button toggles default ↔ all: home face when scoped to a profile,
layers face when showing everything. Pinned left like Manage is right.
Hidden until a second profile exists. */}
{multiProfile &&
(defaultProfile ? (
// On default → toggle to all. Anywhere else (all view or a named
// profile) → return to default. So leaving a profile never lands on all.
<ProfilePill
active={isAll || onDefault}
glyph={isAll ? 'layers' : 'home'}
label={onDefault ? 'Show all profiles' : `Switch to ${defaultProfile.name}`}
onSelect={() => (onDefault ? setShowAllProfiles(true) : selectProfile(defaultProfile.name))}
/>
) : (
<ProfilePill active={isAll} glyph="layers" label="All profiles" onSelect={() => setShowAllProfiles(true)} />
))}
{/* Single-profile: the active default's home icon next to the create +. */}
{!multiProfile && defaultProfile && (
<ProfilePill active glyph="home" label={defaultProfile.name} onSelect={() => selectProfile(defaultProfile.name)} />
)}
<div
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
ref={scrollRef}
>
{multiProfile && (
<DndContext
collisionDetection={closestCenter}
modifiers={[stepThroughCells]}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragStart={handleDragStart}
sensors={sensors}
>
<SortableContext items={named.map(profile => profile.name)} strategy={horizontalListSortingStrategy}>
{/* relative → the strip is the dragged square's offsetParent, so the
clamp modifier bounds drags to the occupied cells (not the +). */}
<div className="relative flex items-center gap-1">
{named.map(profile => (
<ProfileSquare
active={!isAll && normalizeProfileKey(profile.name) === activeKey}
color={resolveProfileColor(profile.name, colors)}
key={profile.name}
label={profile.name}
onDelete={() => setPendingDelete(profile)}
onRecolor={color => setProfileColor(profile.name, color)}
onRename={() => setPendingRename(profile)}
onSelect={() => selectProfile(profile.name)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
<Tip label="New profile">
<button
aria-label="New profile"
className="grid size-5 shrink-0 place-items-center rounded-[3px] text-(--ui-text-tertiary) opacity-55 transition hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100"
onClick={() => setCreateOpen(true)}
type="button"
>
<Codicon name="add" size="0.75rem" />
</button>
</Tip>
</div>
{multiProfile && (
<ProfilePill active={false} glyph="ellipsis" label="Manage profiles…" onSelect={() => navigate(PROFILES_ROUTE)} />
)}
{/* Land in the new profile on a fresh chat (selectProfile triggers the
new-session reset), not stuck on the session you were just in. */}
<CreateProfileDialog
onClose={() => setCreateOpen(false)}
onCreated={async name => {
await refreshActiveProfile()
selectProfile(name)
}}
open={createOpen}
/>
<RenameProfileDialog
currentName={pendingRename?.name ?? ''}
onClose={() => setPendingRename(null)}
onRenamed={refreshActiveProfile}
open={pendingRename !== null}
/>
<DeleteProfileDialog
onClose={() => setPendingDelete(null)}
onDeleted={refreshActiveProfile}
open={pendingDelete !== null}
profile={pendingDelete}
/>
</div>
)
}
interface ProfilePillProps {
active: boolean
// home / All / Manage are glyph action buttons (navigation, not identity).
glyph: string
label: string
onSelect: () => void
}
function ProfilePill({ active, glyph, label, onSelect }: ProfilePillProps) {
return (
<Tip label={label}>
<Button
aria-label={label}
aria-pressed={active}
className={cn(
'bg-transparent text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
active && 'bg-(--ui-control-active-background) text-foreground'
)}
onClick={onSelect}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name={glyph} size="0.875rem" />
</Button>
</Tip>
)
}
interface ProfileSquareProps {
active: boolean
color: null | string
label: string
onSelect: () => void
onRecolor: (color: null | string) => void
onRename: () => void
onDelete: () => void
}
// Hold this long without moving (a drag would have started first) to open the
// color picker — the "hard press" gesture, distinct from tap-to-select.
const LONG_PRESS_MS = 450
// A profile *is* its colored square — no icon-button chrome. Soft profile-tint
// fill + the initial in the full color; the active one pops to full opacity with
// a color ring. These pack tightly so the rail reads as a strip of profiles,
// drag-sort to reorder (a tap below the drag threshold still selects), and
// right-click to rename/delete. The button carries both the tooltip and
// context-menu triggers via nested asChild Slots, so a single element keeps the
// dnd listeners, hover tip, and right-click menu.
function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, onSelect }: ProfileSquareProps) {
const hue = color ?? 'var(--ui-text-quaternary)'
const [pickerOpen, setPickerOpen] = useState(false)
const pressTimer = useRef<null | number>(null)
const suppressClick = useRef(false)
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({
id: label,
transition: RAIL_TRANSITION
})
const clearPress = () => {
if (pressTimer.current != null) {
clearTimeout(pressTimer.current)
pressTimer.current = null
}
}
// A real drag (movement past the dnd threshold) cancels the pending hold, so a
// reorder never doubles as a color pick. Also tidy up on unmount.
useEffect(() => {
if (isDragging) {
clearPress()
}
}, [isDragging])
useEffect(() => clearPress, [])
const base = CSS.Transform.toString(transform)
const ring = active ? `inset 0 0 0 1.5px ${hue}` : ''
const lift = isDragging ? '0 6px 16px -4px rgb(0 0 0 / 0.4)' : ''
const pickColor = (next: null | string) => {
onRecolor(next)
setPickerOpen(false)
triggerHaptic('selection')
}
return (
<Popover onOpenChange={setPickerOpen} open={pickerOpen}>
<ContextMenu>
<TooltipProvider delayDuration={0}>
<Tooltip>
<PopoverAnchor asChild>
<ContextMenuTrigger asChild>
<TooltipTrigger asChild>
<button
className={cn(
'grid size-5 shrink-0 cursor-grab touch-none select-none place-items-center rounded-[3px] text-[0.5625rem] font-semibold uppercase leading-none transition-opacity hover:opacity-100',
active ? 'opacity-100' : 'opacity-55',
isDragging && 'z-10 cursor-grabbing opacity-100'
)}
ref={setNodeRef}
style={{
backgroundColor: profileColorSoft(hue, active ? 30 : 22),
boxShadow: [ring, lift].filter(Boolean).join(', ') || undefined,
color: color ?? undefined,
// Glide the dragged square between snapped cells with a little
// overshoot (no scale — the overflow-x strip would clip it).
transform: base,
transition: isDragging ? DRAG_TRANSITION : transition
}}
type="button"
{...attributes}
{...listeners}
aria-label={label}
aria-pressed={active}
// Hold-to-recolor rides alongside the dnd pointer listener (call
// it first so drag tracking still arms), then a timer opens the
// picker and flags the trailing click so it doesn't also select.
onClick={() => {
if (suppressClick.current) {
suppressClick.current = false
return
}
onSelect()
}}
onPointerCancel={clearPress}
onPointerDown={event => {
listeners?.onPointerDown?.(event)
if (event.button !== 0) {
return
}
suppressClick.current = false
clearPress()
pressTimer.current = window.setTimeout(() => {
suppressClick.current = true
triggerHaptic('success')
setPickerOpen(true)
}, LONG_PRESS_MS)
}}
onPointerLeave={clearPress}
onPointerUp={clearPress}
>
{label.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
</button>
</TooltipTrigger>
</ContextMenuTrigger>
</PopoverAnchor>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* The rail sits at the very bottom, so pad off the chrome (esp. the
statusbar) — Radix then flips the menu up instead of squishing it. */}
<ContextMenuContent
aria-label={`Actions for ${label}`}
className="w-40"
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
>
<ContextMenuItem onSelect={() => setPickerOpen(true)}>
<Codicon name="symbol-color" size="0.875rem" />
<span>Color</span>
</ContextMenuItem>
<ContextMenuItem onSelect={onRename}>
<Codicon name="edit" size="0.875rem" />
<span>Rename</span>
</ContextMenuItem>
<ContextMenuItem className="text-destructive focus:text-destructive" onSelect={onDelete} variant="destructive">
<Codicon name="trash" size="0.875rem" />
<span>Delete</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<PopoverContent
aria-label={`Color for ${label}`}
className="w-auto p-2"
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
side="top"
>
<div className="grid grid-cols-6 gap-1.5">
{PROFILE_SWATCHES.map(swatch => (
<button
aria-label={`Set color ${swatch}`}
className="size-5 rounded-full transition-transform hover:scale-110"
key={swatch}
onClick={() => pickColor(swatch)}
style={{
backgroundColor: swatch,
boxShadow: swatch === color ? '0 0 0 2px var(--ui-bg-elevated), 0 0 0 3.5px currentColor' : undefined,
color: swatch
}}
type="button"
/>
))}
</div>
<button
className="mt-2 flex w-full items-center justify-center gap-1.5 rounded-md py-1 text-xs text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={() => pickColor(null)}
type="button"
>
<Codicon name="sync" size="0.75rem" />
Auto
</button>
</PopoverContent>
</Popover>
)
}

View File

@@ -25,7 +25,6 @@ interface SessionActions {
sessionId: string
title: string
pinned?: boolean
profile?: string
onPin?: () => void
onArchive?: () => void
onDelete?: () => void
@@ -42,7 +41,7 @@ interface ItemSpec {
variant?: 'destructive'
}
function useSessionActions({ sessionId, title, pinned = false, profile, onPin, onArchive, onDelete }: SessionActions) {
function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive, onDelete }: SessionActions) {
const [renameOpen, setRenameOpen] = useState(false)
const items: ItemSpec[] = [
@@ -114,13 +113,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
))
const renameDialog = (
<RenameSessionDialog
currentTitle={title}
onOpenChange={setRenameOpen}
open={renameOpen}
profile={profile}
sessionId={sessionId}
/>
<RenameSessionDialog currentTitle={title} onOpenChange={setRenameOpen} open={renameOpen} sessionId={sessionId} />
)
return { renameDialog, renderItems }
@@ -177,10 +170,9 @@ interface RenameSessionDialogProps {
onOpenChange: (open: boolean) => void
sessionId: string
currentTitle: string
profile?: string
}
function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, profile }: RenameSessionDialogProps) {
function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle }: RenameSessionDialogProps) {
const [value, setValue] = useState(currentTitle)
const [submitting, setSubmitting] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
@@ -208,7 +200,7 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
setSubmitting(true)
try {
const result = await renameSession(sessionId, next, profile)
const result = await renameSession(sessionId, next)
const finalTitle = result.title || next || ''
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
notify({ durationMs: 2_000, kind: 'success', message: 'Renamed' })

View File

@@ -1,14 +1,11 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
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'
@@ -64,10 +61,6 @@ 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
@@ -75,7 +68,6 @@ export function SidebarSessionRow({
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
profile={session.profile}
sessionId={session.id}
title={title}
>
@@ -88,29 +80,13 @@ export function SidebarSessionRow({
className
)}
data-working={isWorking ? 'true' : undefined}
draggable
onDragStart={event => {
// Reorder drags belong to dnd-kit (the grab handle) — cancel the
// native drag so the two DnD systems don't fight.
if ((event.target as HTMLElement).closest('[data-reorder-handle]')) {
event.preventDefault()
return
}
writeSessionDrag(event.dataTransfer, {
id: session.id,
profile: session.profile || 'default',
title
})
}}
ref={ref}
style={style}
{...rest}
>
{isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />}
{isWorking && <span aria-hidden="true" className="arc-border" />}
<button
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"
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"
onClick={event => {
if (event.shiftKey) {
event.preventDefault()
@@ -138,28 +114,16 @@ export function SidebarSessionRow({
<span
{...dragHandleProps}
aria-label={handleLabel}
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. Width MUST match the non-reorderable dot
// column (w-3.5) so rows don't shift horizontally when reorder is
// toggled (e.g. scoped → ALL-profiles view).
'group/handle relative -my-0.5 grid w-3.5 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'
)}
data-reorder-handle
className="relative -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
onClick={event => event.stopPropagation()}
>
<SidebarRowDot
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
className="transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
isWorking={isWorking}
needsInput={needsInput}
/>
<Codicon
className={cn(
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/handle:opacity-80 group-focus-within/handle:opacity-80 hover:text-(--ui-text-secondary)',
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover:opacity-80 group-focus-within:opacity-80 hover:text-(--ui-text-secondary)',
dragging && 'text-(--ui-text-secondary) opacity-100'
)}
name="grabber"
@@ -167,16 +131,11 @@ export function SidebarSessionRow({
/>
</span>
) : (
<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="grid w-3.5 shrink-0 place-items-center overflow-hidden">
<SidebarRowDot isWorking={isWorking} />
</span>
)}
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
<span className="truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</span>
</button>
@@ -191,13 +150,12 @@ export function SidebarSessionRow({
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
profile={session.profile}
sessionId={session.id}
title={title}
>
<Button
aria-label={`Actions for ${title}`}
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!"
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!"
size="icon"
title="Session actions"
variant="ghost"
@@ -211,30 +169,7 @@ export function SidebarSessionRow({
)
}
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"
/>
)
}
function SidebarRowDot({ isWorking, className }: { isWorking: boolean; className?: string }) {
return (
<span
aria-label={isWorking ? 'Session running' : undefined}

File diff suppressed because it is too large Load Diff

View File

@@ -1,487 +0,0 @@
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,
Settings2,
Sun,
Users,
Wrench,
Zap
} 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: Zap,
keywords: ['accounts', 'sign in', 'oauth', 'login', 'subscription', 'models', 'anthropic', 'openai'],
label: 'Providers',
tab: 'providers&pview=accounts'
},
{
icon: KeyRound,
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'],
label: 'Provider API keys',
tab: 'providers&pview=keys'
},
{ icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' },
{
icon: KeyRound,
keywords: ['api', 'secrets', 'tokens', 'credentials', 'browser', 'search'],
label: 'Tools & Keys',
tab: 'keys&kview=tools'
},
{
icon: Settings2,
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env'],
label: 'Tools & Keys settings',
tab: 'keys&kview=settings'
},
{ 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'],
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`)
}
]
},
{
// Declared before Settings: cmdk keeps group order, so this keeps the
// theme/mode pickers on top for "theme"/"color" queries instead of
// buried under a fuzzy Settings match.
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'
}
]
},
{
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))
}))
]
}
]
}, [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>
)
}

View File

@@ -1,108 +0,0 @@
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { triggerHaptic } from '@/lib/haptics'
interface CronJobActions {
busy?: boolean
isPaused: boolean
title: string
onDelete: () => void
onEdit: () => void
onPauseResume: () => void
onTrigger: () => void
}
interface CronJobActionsMenuProps
extends CronJobActions, Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> {
children: React.ReactNode
}
export function CronJobActionsMenu({
align = 'end',
busy = false,
children,
isPaused,
onDelete,
onEdit,
onPauseResume,
onTrigger,
sideOffset = 6,
title
}: CronJobActionsMenuProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={`Actions for ${title}`}
className="w-44"
sideOffset={sideOffset}
>
<DropdownMenuItem
disabled={busy}
onSelect={() => {
triggerHaptic('selection')
onPauseResume()
}}
>
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
<span>{isPaused ? 'Resume' : 'Pause'}</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={busy}
onSelect={() => {
triggerHaptic('selection')
onTrigger()
}}
>
<Codicon name="zap" size="0.875rem" />
<span>Trigger now</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
triggerHaptic('selection')
onEdit()
}}
>
<Codicon name="edit" size="0.875rem" />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
triggerHaptic('warning')
onDelete()
}}
variant="destructive"
>
<Codicon name="trash" size="0.875rem" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
interface CronJobActionsTriggerProps extends Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'> {
title: string
}
export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) {
return (
<Button
aria-label={`Actions for ${title}`}
className={className}
size="icon-sm"
title="Cron job actions"
variant="ghost"
{...props}
>
<Codicon className="text-muted-foreground" name="ellipsis" size="0.875rem" />
</Button>
)
}

View File

@@ -1,10 +1,9 @@
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 { ConfirmDialog } from '@/components/ui/confirm-dialog'
import {
Dialog,
DialogContent,
@@ -14,7 +13,6 @@ 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 {
@@ -27,13 +25,12 @@ import {
triggerCronJob,
updateCronJob
} from '@/hermes'
import { AlertTriangle, Clock } from '@/lib/icons'
import { AlertTriangle, Clock, Pause, Pencil, Play, Trash2, Zap } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayView } from '../overlays/overlay-view'
import { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu'
import { PageSearchShell } from '../page-search-shell'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
const DEFAULT_DELIVER = 'local'
@@ -89,16 +86,23 @@ const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
}
]
const STATE_VARIANT: Record<string, BadgeProps['variant']> = {
enabled: 'default',
scheduled: 'default',
running: 'default',
const STATE_TONE: Record<string, 'good' | 'muted' | 'warn' | 'bad'> = {
enabled: 'good',
scheduled: 'good',
running: 'good',
paused: 'warn',
disabled: 'muted',
error: 'destructive',
error: 'bad',
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)
@@ -301,29 +305,33 @@ function matchesQuery(job: CronJob, q: string): boolean {
)
}
interface CronViewProps {
onClose: () => void
interface CronViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
}
export function CronView({ onClose }: CronViewProps) {
export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: 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' })
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
const [deleting, setDeleting] = useState(false)
const refresh = useCallback(async () => {
setRefreshing(true)
try {
const result = await getCronJobs()
setJobs(result)
} catch (err) {
notifyError(err, 'Failed to load cron jobs')
} finally {
setRefreshing(false)
}
}, [])
useRefreshHotkey(refresh)
useEffect(() => {
void refresh()
}, [refresh])
@@ -372,6 +380,25 @@ export function CronView({ onClose }: CronViewProps) {
}
}
async function handleConfirmDelete() {
if (!pendingDelete) {
return
}
setDeleting(true)
try {
await deleteCronJob(pendingDelete.id)
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
notify({ kind: 'success', title: 'Cron deleted', message: truncate(jobTitle(pendingDelete), 60) })
setPendingDelete(null)
} catch (err) {
notifyError(err, 'Failed to delete cron job')
} finally {
setDeleting(false)
}
}
async function handleEditorSave(values: EditorValues) {
if (editor.mode === 'create') {
const created = await createCronJob({
@@ -399,96 +426,100 @@ export function CronView({ onClose }: CronViewProps) {
}
return (
<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}
/>
<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 ? (
// Empty state owns the primary "create" CTA — we used to also have
// one in the filters bar but it was redundant. Only show the button
// when there are zero jobs total; the search-empty case ("No
// matches") just asks the user to broaden their query.
<EmptyState
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
description={
totalCount === 0
? 'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.'
: 'Try a broader search query.'
}
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>
)}
{!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
// matches") just asks the user to broaden their query.
<EmptyState
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
description={
totalCount === 0
? 'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.'
: 'Try a broader search query.'
}
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
/>
) : (
<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 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} />
<ConfirmDialog
busyLabel="Deleting…"
confirmLabel="Delete"
description={
pendingDelete ? (
<>
This will remove{' '}
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span> permanently.
It will stop firing immediately.
</>
) : null
}
destructive
doneLabel="Deleted"
onClose={() => setPendingDelete(null)}
onConfirm={async () => {
if (!pendingDelete) {
return
}
await deleteCronJob(pendingDelete.id)
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
notify({ kind: 'success', message: truncate(jobTitle(pendingDelete), 60), title: 'Cron deleted' })
}}
open={pendingDelete !== null}
title="Delete cron job?"
/>
</OverlayView>
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete cron job?</DialogTitle>
<DialogDescription>
{pendingDelete ? (
<>
This will remove{' '}
<span className="font-medium text-foreground">{truncate(jobTitle(pendingDelete), 60)}</span>{' '}
permanently. It will stop firing immediately.
</>
) : null}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
Cancel
</Button>
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
{deleting ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageSearchShell>
)
}
@@ -516,20 +547,14 @@ 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 rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
className="min-w-0 cursor-pointer rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
onClick={onEdit}
type="button"
>
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">{jobTitle(job)}</span>
<Badge className="capitalize" variant={STATE_VARIANT[state] ?? 'muted'}>
{state}
</Badge>
{deliver && deliver !== DEFAULT_DELIVER && (
<Badge className="capitalize" variant="muted">
{deliver}
</Badge>
)}
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{state}</StatePill>
{deliver && deliver !== DEFAULT_DELIVER && <StatePill tone="muted">{deliver}</StatePill>}
</div>
{hasName && prompt && <p className="mt-1 truncate text-xs text-muted-foreground">{truncate(prompt, 120)}</p>}
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-[0.68rem] text-muted-foreground">
@@ -548,27 +573,57 @@ function CronJobRow({
)}
</button>
<div className="flex shrink-0 items-center">
<CronJobActionsMenu
busy={busy}
isPaused={isPaused}
onDelete={onDelete}
onEdit={onEdit}
onPauseResume={onPauseResume}
onTrigger={onTrigger}
title={jobTitle(job)}
<div className="flex shrink-0 items-center gap-0.5">
<IconAction
aria-label={isPaused ? 'Resume cron' : 'Pause cron'}
disabled={busy}
onClick={onPauseResume}
title={isPaused ? 'Resume' : 'Pause'}
>
<CronJobActionsTrigger
className="text-muted-foreground hover:text-foreground"
onClick={event => event.stopPropagation()}
title={jobTitle(job)}
/>
</CronJobActionsMenu>
{isPaused ? <Play className="size-3.5" /> : <Pause className="size-3.5" />}
</IconAction>
<IconAction aria-label="Trigger now" disabled={busy} onClick={onTrigger} title="Trigger now">
<Zap className="size-3.5" />
</IconAction>
<IconAction aria-label="Edit cron" onClick={onEdit} title="Edit">
<Pencil className="size-3.5" />
</IconAction>
<IconAction
aria-label="Delete cron"
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
title="Delete"
>
<Trash2 className="size-3.5" />
</IconAction>
</div>
</div>
)
}
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"
variant="ghost"
{...props}
>
{children}
</Button>
)
}
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,
@@ -713,7 +768,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 id="cron-frequency">
<SelectTrigger className="h-9 rounded-md" id="cron-frequency">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -728,7 +783,7 @@ function CronEditorDialog({
<Field htmlFor="cron-deliver" label="Deliver to">
<Select onValueChange={setDeliver} value={deliver}>
<SelectTrigger id="cron-deliver">
<SelectTrigger className="h-9 rounded-md" id="cron-deliver">
<SelectValue />
</SelectTrigger>
<SelectContent>

View File

@@ -11,11 +11,9 @@ import { Pane, PaneMain } from '@/components/pane-shell'
import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getSessionMessages, listAllProfileSessions, type SessionInfo } from '../hermes'
import { getSessionMessages, listSessions } from '../hermes'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import { toggleCommandPalette } from '../store/command-palette'
import {
$panesFlipped,
$pinnedSessionIds,
$sessionsLimit,
bumpSessionsLimit,
@@ -25,11 +23,9 @@ import {
pinSession,
SIDEBAR_DEFAULT_WIDTH,
SIDEBAR_MAX_WIDTH,
SIDEBAR_SESSIONS_PAGE_SIZE,
unpinSession
} from '../store/layout'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import { $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
import {
$activeSessionId,
$currentCwd,
@@ -38,7 +34,7 @@ import {
$selectedStoredSessionId,
$sessions,
$workingSessionIds,
mergeSessionPage,
mergeWorkingSessions,
sessionPinId,
setAwaitingResponse,
setBusy,
@@ -47,7 +43,6 @@ import {
setCurrentModel,
setCurrentProvider,
setMessages,
setSessionProfileTotals,
setSessions,
setSessionsLoading,
setSessionsTotal
@@ -63,7 +58,6 @@ 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'
@@ -101,26 +95,6 @@ const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).P
const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView }))
const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView }))
// Rows a session refresh must preserve even if the aggregator omits them:
// in-flight first turns (message_count 0), pinned rows aged off the page, and
// the actively-viewed chat (its "working" flag clears a beat before the
// aggregator sees the persisted row). Pass `scope` to only keep the active row
// when it belongs to the profile being paged.
function sessionsToKeep(scope?: string): Set<string> {
const keep = new Set<string>([...$workingSessionIds.get(), ...$pinnedSessionIds.get()])
const active = $selectedStoredSessionId.get()
if (active) {
const session = scope ? $sessions.get().find(s => s.id === active) : null
if (!scope || !session || normalizeProfileKey(session.profile) === scope) {
keep.add(active)
}
}
return keep
}
export function DesktopController() {
const queryClient = useQueryClient()
const location = useLocation()
@@ -138,7 +112,6 @@ 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}`
@@ -152,11 +125,9 @@ export function DesktopController() {
closeOverlayToPreviousRoute,
commandCenterInitialSection,
commandCenterOpen,
cronOpen,
currentView,
openAgents,
openCommandCenterSection,
profilesOpen,
settingsOpen,
toggleCommandCenter
} = useOverlayRouting()
@@ -224,31 +195,6 @@ export function DesktopController() {
}
}, [])
// Global chrome shortcuts (plain Cmd/Ctrl, no alt/shift): Cmd+K / Cmd+P →
// command palette (the composer's "drain next queued" moved to Cmd+Shift+K),
// Cmd+. → command center (sessions / system / usage).
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) {
return
}
const key = event.key.toLowerCase()
if (key === 'k' || key === 'p') {
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
@@ -259,15 +205,16 @@ export function DesktopController() {
// Require at least one message so abandoned/empty "Untitled" drafts (one
// was created per TUI/desktop launch before the lazy-create fix) don't
// clutter the sidebar.
// Unified cross-profile list (served read-only off each profile's
// state.db; no per-profile backend is spawned). Single-profile users get
// the same rows tagged profile="default".
const result = await listAllProfileSessions(limit, 1)
const result = await listSessions(limit, 1)
if (refreshSessionsRequestRef.current === requestId) {
setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep()))
// 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()))
setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
setSessionProfileTotals(result.profile_totals ?? {})
}
} finally {
if (refreshSessionsRequestRef.current === requestId) {
@@ -281,21 +228,6 @@ export function DesktopController() {
void refreshSessions()
}, [refreshSessions])
// ALL-profiles view pages one profile at a time: fetch that profile's next
// page and merge it in place, leaving every other profile's rows untouched.
const loadMoreSessionsForProfile = useCallback(async (profile: string) => {
const key = normalizeProfileKey(profile)
const inKey = (s: SessionInfo) => normalizeProfileKey(s.profile) === key
const loaded = $sessions.get().filter(inKey).length
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key)
const keep = sessionsToKeep(key)
setSessions(prev => [...prev.filter(s => !inKey(s)), ...mergeSessionPage(prev.filter(inKey), result.sessions, keep)])
const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length
setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) }))
}, [])
const toggleSelectedPin = useCallback(() => {
const sessionId = $selectedStoredSessionId.get()
@@ -352,7 +284,7 @@ export function DesktopController() {
})
const openProviderSettings = useCallback(() => {
navigate(`${SETTINGS_ROUTE}?tab=providers`)
navigate(`${SETTINGS_ROUTE}?tab=keys`)
}, [navigate])
const modelMenuContent = useMemo(
@@ -385,11 +317,9 @@ export function DesktopController() {
return
}
const storedProfile = $sessions.get().find(session => session.id === storedSessionId)?.profile
for (let index = 0; index < Math.max(1, attempts); index += 1) {
try {
const latest = await getSessionMessages(storedSessionId, storedProfile)
const latest = await getSessionMessages(storedSessionId)
updateSessionState(
runtimeSessionId,
state => ({
@@ -483,8 +413,6 @@ 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)
@@ -492,20 +420,6 @@ export function DesktopController() {
return () => window.removeEventListener('keydown', onKeyDown)
}, [startFreshSessionDraft])
// A profile switch/create drops to a fresh new-session draft so the previously
// open session doesn't bleed across contexts. Skip the initial value.
const freshSessionRequest = useStore($freshSessionRequest)
const lastFreshRef = useRef(freshSessionRequest)
useEffect(() => {
if (freshSessionRequest === lastFreshRef.current) {
return
}
lastFreshRef.current = freshSessionRequest
startFreshSessionDraft()
}, [freshSessionRequest, startFreshSessionDraft])
const composer = useComposerActions({
activeSessionId,
currentCwd,
@@ -558,7 +472,6 @@ export function DesktopController() {
busyRef,
createBackendSessionForSend,
handleSkinCommand,
refreshSessions,
requestGateway,
selectedStoredSessionIdRef,
startFreshSessionDraft,
@@ -581,7 +494,6 @@ export function DesktopController() {
useEffect(() => {
if (gatewayState === 'open') {
void refreshCurrentModel()
void refreshActiveProfile()
void refreshSessions().catch(() => undefined)
}
}, [gatewayState, refreshCurrentModel, refreshSessions])
@@ -612,9 +524,7 @@ export function DesktopController() {
inferenceStatus,
modelMenuContent,
openAgents,
freshDraftReady,
openCommandCenterSection,
requestGateway,
statusSnapshot,
toggleCommandCenter
})
@@ -624,7 +534,6 @@ export function DesktopController() {
currentView={currentView}
onArchiveSession={sessionId => void archiveSession(sessionId)}
onDeleteSession={sessionId => void removeSession(sessionId)}
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
onLoadMoreSessions={loadMoreSessions}
onNavigate={selectSidebarItem}
onNewSessionInWorkspace={startSessionInWorkspace}
@@ -652,7 +561,6 @@ export function DesktopController() {
<UpdatesOverlay />
<GatewayConnectingOverlay />
<BootFailureOverlay />
<CommandPalette />
{settingsOpen && (
<Suspense fallback={null}>
@@ -681,6 +589,7 @@ export function DesktopController() {
initialSection={commandCenterInitialSection}
onClose={closeOverlayToPreviousRoute}
onDeleteSession={removeSession}
onNavigateRoute={path => navigate(path)}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/>
</Suspense>
@@ -691,18 +600,6 @@ export function DesktopController() {
<AgentsView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{cronOpen && (
<Suspense fallback={null}>
<CronView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
{profilesOpen && (
<Suspense fallback={null}>
<ProfilesView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
</>
)
@@ -714,7 +611,7 @@ export function DesktopController() {
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
onAttachDroppedItems={composer.attachDroppedItems}
onAttachImageBlob={composer.attachImageBlob}
onBranchInNewChat={branchInNewChat}
onBranchInNewChat={messageId => void branchInNewChat(messageId)}
onCancel={cancelRun}
onDeleteSelectedSession={() => {
if (selectedStoredSessionId) {
@@ -741,52 +638,12 @@ 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}
@@ -798,7 +655,7 @@ export function DesktopController() {
maxWidth={SIDEBAR_MAX_WIDTH}
minWidth={SIDEBAR_DEFAULT_WIDTH}
resizable
side={sidebarSide}
side="left"
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
>
{sidebar}
@@ -831,8 +688,25 @@ export function DesktopController() {
}
path="artifacts"
/>
<Route element={null} path="cron" />
<Route element={null} path="profiles" />
<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="settings" />
<Route element={null} path="command-center" />
<Route element={null} path="agents" />
@@ -841,13 +715,35 @@ export function DesktopController() {
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="*" />
</Routes>
</PaneMain>
{/*
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}
<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>
</AppShell>
)
}

View File

@@ -2,7 +2,6 @@ import { useEffect, useRef } from 'react'
import type { HermesConnection } from '@/global'
import { HermesGateway } from '@/hermes'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import {
$desktopBoot,
applyDesktopBootProgress,
@@ -10,27 +9,9 @@ import {
failDesktopBoot,
setDesktopBootStep
} from '@/store/boot'
import {
$gateway,
closeSecondaryGateways,
configureGatewayRegistry,
ensureGatewayForProfile,
pruneSecondaryGateways,
reconnectSecondaryGateways,
reportPrimaryGatewayState,
setPrimaryGateway,
touchSecondaryGateways
} from '@/store/gateway'
import { setGateway } from '@/store/gateway'
import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
import {
$attentionSessionIds,
$connection,
$sessions,
$workingSessionIds,
setConnection,
setSessionsLoading
} from '@/store/session'
import { $connection, setConnection, setGatewayState, setSessionsLoading } from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
interface GatewayBootOptions {
@@ -82,114 +63,6 @@ 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
// Surface "sign in again" once per disconnect episode, not on every backoff
// tick — a stale OAuth ticket fails every attempt and would otherwise stack
// identical error toasts (and their haptics). Reset on the next clean open.
let reauthNotified = false
// 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($activeGatewayProfile.get())
if (cancelled) {
return
}
publish(conn)
// Re-mint the WS URL before reconnecting. OAuth tickets are single-use
// with a short TTL, so the ticket baked into the cached conn.wsUrl is
// dead on every reconnect after the initial boot — reusing it surfaces
// as an opaque "Could not connect to Hermes gateway". resolveGatewayWsUrl
// mints a fresh ticket (or throws a reauth error in OAuth mode rather
// than connecting with a stale one). For local/token gateways the URL
// carries a long-lived token and the re-mint is a cheap no-op.
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await gateway.connect(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 (err) {
// OAuth session expired mid-reconnect: surface the actionable "sign in
// again" message once instead of silently looping the backoff against a
// ticket that can never succeed. Transport failures fall through to the
// backoff in the finally block below.
if (!cancelled && isGatewayReauthRequired(err) && !reauthNotified) {
reauthNotified = true
notifyError(err, 'Gateway sign-in required')
}
} 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
reconnectSecondaryGateways()
if (!gatewayOpen()) {
void attemptReconnect()
}
}
const offBootProgress = desktop.onBootProgress(payload => applyDesktopBootProgress(payload))
void desktop
.getBootProgress()
@@ -204,71 +77,11 @@ export function useGatewayBoot({
const gateway = new HermesGateway()
callbacksRef.current.onGatewayReady(gateway)
setPrimaryGateway(gateway, normalizeProfileKey($activeGatewayProfile.get()))
// Secondary (background-profile) sockets funnel into the same handler.
configureGatewayRegistry({ onEvent: event => callbacksRef.current.handleGatewayEvent(event) })
const offState = gateway.onState(st => {
// Mirror to the composer only while the primary is the active profile —
// a background secondary reconnect mustn't flip the foreground state.
reportPrimaryGatewayState(st)
if (st === 'open') {
reconnectAttempt = 0
reauthNotified = false
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()
}
})
setGateway(gateway)
const offState = gateway.onState(st => void setGatewayState(st))
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)
// Keep live pool backends alive while this window is open (the main process
// can't observe the direct renderer↔backend WS). No-op for the primary.
const keepaliveTimer = setInterval(() => {
touchActiveGatewayBackend()
touchSecondaryGateways()
}, 60_000)
// Bound concurrency cost to live work: keep a background socket only while
// its profile has a running (working) or blocked (needs-input) session.
// Once that profile goes idle its socket is dropped and its backend is free
// to idle-reap. The active profile is always spared.
const recomputeKeptGateways = () => {
const live = new Set([...$workingSessionIds.get(), ...$attentionSessionIds.get()])
const keep = new Set<string>()
for (const session of $sessions.get()) {
if (live.has(session.id)) {
keep.add(normalizeProfileKey(session.profile))
}
}
pruneSecondaryGateways(keep)
}
const offWorking = $workingSessionIds.subscribe(() => recomputeKeptGateways())
const offAttention = $attentionSessionIds.subscribe(() => recomputeKeptGateways())
const offActiveProfile = $activeGatewayProfile.subscribe(() => recomputeKeptGateways())
const offWindowState = desktop.onWindowStateChanged?.(payload => {
const current = $connection.get()
@@ -304,31 +117,12 @@ export function useGatewayBoot({
progress: 95
})
publish(conn)
// Mint a fresh WS URL right before connecting. For OAuth gateways the
// ticket is single-use with a short TTL, so the ticket baked into
// conn.wsUrl is stale; resolveGatewayWsUrl() re-mints it and, on
// failure, throws a reauth error rather than connecting with a dead
// ticket (which would surface as an opaque "connection closed").
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await gateway.connect(wsUrl)
await gateway.connect(conn.wsUrl)
if (cancelled) {
return
}
// Record which profile the primary (window) backend booted as, so
// same-profile resumes are no-op swaps and any reconnect targets the
// right backend. Best-effort: a missing preference means "default".
try {
const pref = await desktop.profile?.get?.()
const profileKey = (pref?.profile ?? '').trim() || 'default'
$activeGatewayProfile.set(profileKey)
setPrimaryGateway(gateway, profileKey)
void ensureGatewayForProfile(profileKey)
} catch {
$activeGatewayProfile.set('default')
}
setDesktopBootStep({
phase: 'renderer.config',
message: 'Loading Hermes settings',
@@ -347,7 +141,6 @@ export function useGatewayBoot({
})
await callbacksRef.current.refreshSessions()
completeDesktopBoot()
bootCompleted = true
} catch (err) {
if (!cancelled) {
const message = err instanceof Error ? err.message : String(err)
@@ -362,25 +155,15 @@ export function useGatewayBoot({
return () => {
cancelled = true
clearReconnectTimer()
clearInterval(keepaliveTimer)
offWorking()
offAttention()
offActiveProfile()
window.removeEventListener('online', onOnline)
document.removeEventListener('visibilitychange', onVisible)
offPowerResume?.()
offState()
offEvent()
offExit()
offWindowState?.()
offBootProgress()
closeSecondaryGateways()
gateway.close()
publish(null)
callbacksRef.current.onGatewayReady(null)
setPrimaryGateway(null)
$gateway.set(null)
setGateway(null)
}
}, [])
}

View File

@@ -2,9 +2,6 @@ import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef } from 'react'
import type { HermesGateway } from '@/hermes'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import { $gateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gateway'
import { $activeGatewayProfile } from '@/store/profile'
import { $gatewayState, setConnection } from '@/store/session'
export function useGatewayRequest() {
@@ -17,25 +14,11 @@ export function useGatewayRequest() {
const gatewayStateRef = useRef(gatewayState)
const reconnectingRef = useRef<Promise<HermesGateway | null> | null>(null)
// Holds the reauth error from the most recent failed reconnect so
// requestGateway can surface the gateway's "session expired, sign in again"
// message instead of the opaque "connection closed" that triggered the retry.
const reauthErrorRef = useRef<unknown>(null)
useEffect(() => {
gatewayStateRef.current = gatewayState
}, [gatewayState])
// Track the active gateway (primary or a background profile's socket) so
// outbound requests and overlay props always target the focused profile.
useEffect(
() =>
$gateway.subscribe(gateway => {
gatewayRef.current = gateway as HermesGateway | null
}),
[]
)
const ensureGatewayOpen = useCallback(async () => {
const existing = gatewayRef.current
@@ -58,29 +41,14 @@ export function useGatewayRequest() {
return null
}
reauthErrorRef.current = null
try {
// Reconnect to whichever profile the gateway is currently routed to (not
// always the primary), so a sleep/wake reconnect keeps the user on the
// profile they were chatting in.
const conn = await desktop.getConnection($activeGatewayProfile.get())
const conn = await desktop.getConnection()
connectionRef.current = conn
setConnection(conn)
// Re-mint the WS URL before reconnecting. OAuth tickets are single-use
// and short-lived, so the cached conn.wsUrl ticket is dead here;
// resolveGatewayWsUrl() throws a reauth error in OAuth mode rather than
// connecting with a stale ticket. Stash it so requestGateway can show
// the actionable "sign in again" message.
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await existing.connect(wsUrl)
await existing.connect(conn.wsUrl)
return existing
} catch (error) {
if (isGatewayReauthRequired(error)) {
reauthErrorRef.current = error
}
} catch {
connectionRef.current = null
setConnection(null)
@@ -110,21 +78,9 @@ export function useGatewayRequest() {
throw error
}
// Primary keeps the OAuth-aware reconnect (remote gateways re-mint a
// single-use ticket); background profiles are always local pool
// backends, so the registry handles their reconnect with no reauth.
const recovered = isActivePrimary() ? await ensureGatewayOpen() : await ensureActiveGatewayOpen()
const recovered = await ensureGatewayOpen()
if (!recovered) {
// Prefer the reauth error from the failed reconnect (OAuth session
// expired) over the generic transport error that triggered the retry.
const reauthError = reauthErrorRef.current
reauthErrorRef.current = null
if (reauthError) {
throw reauthError
}
throw error
}

View File

@@ -1,45 +0,0 @@
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])
}

View File

@@ -1,13 +0,0 @@
// 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)]'

View File

@@ -3,7 +3,6 @@ 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'
@@ -18,11 +17,8 @@ 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 { CREDENTIAL_CONTROL_CLASS } from '../settings/credential-key-ui'
import { ListRow } from '../settings/primitives'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { PlatformAvatar } from './platform-icon'
@@ -45,11 +41,11 @@ const STATE_LABELS: Record<string, string> = {
startup_failed: 'Startup failed'
}
const TONE_VARIANT: Record<StatusTone, BadgeProps['variant']> = {
good: 'default',
muted: 'muted',
warn: 'warn',
bad: 'destructive'
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 HINT_BY_STATE: Record<string, string> = {
@@ -110,47 +106,6 @@ const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: str
help: 'first, all, or off.',
advanced: true
},
DISCORD_ALLOW_ALL_USERS: {
label: 'Allow all Discord users',
help: 'Development only. When true, anyone can DM the bot without an allowlist.',
advanced: true
},
DISCORD_HOME_CHANNEL: {
label: 'Home channel ID',
help: 'Channel where the bot sends proactive messages (cron output, reminders).',
advanced: true
},
DISCORD_HOME_CHANNEL_NAME: {
label: 'Home channel name',
help: 'Display name for the home channel in logs and status output.',
advanced: true
},
BLUEBUBBLES_ALLOW_ALL_USERS: {
label: 'Allow all iMessage users',
help: 'When true, skip the BlueBubbles allowlist.',
advanced: true
},
MATTERMOST_ALLOW_ALL_USERS: {
label: 'Allow all Mattermost users',
advanced: true
},
MATTERMOST_HOME_CHANNEL: {
label: 'Home channel',
advanced: true
},
QQ_ALLOW_ALL_USERS: {
label: 'Allow all QQ users',
advanced: true
},
QQBOT_HOME_CHANNEL: {
label: 'QQ home channel',
help: 'Default channel or group for cron delivery.',
advanced: true
},
QQBOT_HOME_CHANNEL_NAME: {
label: 'QQ home channel name',
advanced: true
},
SLACK_BOT_TOKEN: {
label: 'Slack bot token',
help: 'Starts with xoxb-. Found under OAuth & Permissions after installing your Slack app.',
@@ -258,8 +213,6 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
}
}, [])
useRefreshHotkey(() => void refreshPlatforms())
useEffect(() => {
void refreshPlatforms()
}, [refreshPlatforms])
@@ -390,15 +343,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 p-2">
<aside className="min-h-0 overflow-y-auto border-b border-(--ui-stroke-tertiary) p-2 lg:border-b-0 lg:border-r">
<ul className="space-y-1">
{visiblePlatforms.map(platform => (
<li key={platform.id}>
@@ -453,8 +406,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-row-active-background) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground'
? 'bg-(--ui-bg-tertiary) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
onClick={onSelect}
type="button"
@@ -529,7 +482,7 @@ function PlatformDetail({
{introCopy(platform)}
</p>
<div className="mt-3">
<Button asChild size="sm" variant="textStrong">
<Button asChild size="sm" variant="outline">
<a href={platform.docs_url} rel="noreferrer" target="_blank">
Open setup guide
<ExternalLink className="size-3.5" />
@@ -540,7 +493,7 @@ function PlatformDetail({
<section>
<SectionTitle>Required</SectionTitle>
<div className="mt-3 grid gap-1">
<div className="mt-3 space-y-4">
{requiredFields.length > 0 ? (
requiredFields.map(field => (
<MessagingField
@@ -563,7 +516,7 @@ function PlatformDetail({
{optionalFields.length > 0 && (
<section>
<SectionTitle>Recommended</SectionTitle>
<div className="mt-3 grid gap-1">
<div className="mt-3 space-y-4">
{optionalFields.map(field => (
<MessagingField
edits={edits}
@@ -589,7 +542,7 @@ function PlatformDetail({
<DisclosureCaret open={showAdvanced} size="0.875rem" />
</button>
{showAdvanced && (
<div className="mt-3 grid gap-1">
<div className="mt-3 space-y-4">
{advancedFields.map(field => (
<MessagingField
edits={edits}
@@ -607,15 +560,19 @@ function PlatformDetail({
</div>
</div>
<footer className="bg-(--ui-chat-surface-background) px-5 py-2.5">
<footer className="border-t border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-5 py-2.5">
<div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2">
<Switch
aria-label={platform.enabled ? `Disable ${platform.name}` : `Enable ${platform.name}`}
checked={platform.enabled}
disabled={saving === `enabled:${platform.id}`}
onCheckedChange={onToggle}
size="xs"
/>
<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>
<div className="ml-auto flex items-center gap-2">
{hasEdits && <span className="text-xs text-muted-foreground">Unsaved changes</span>}
@@ -683,48 +640,45 @@ function MessagingField({
saving: string | null
}) {
const copy = fieldCopy(field)
const fieldId = `messaging-field-${field.key}`
return (
<ListRow
action={
<div className="flex items-center gap-2">
<Input
className={CREDENTIAL_CONTROL_CLASS}
id={fieldId}
onChange={event => onEdit(field.key, event.target.value)}
placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder}
type={field.is_password ? 'password' : 'text'}
value={edits[field.key] || ''}
/>
{field.url && (
<Button asChild className="size-8 shrink-0" title="Open docs" variant="ghost">
<a href={field.url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3.5" />
</a>
</Button>
)}
{field.is_set && (
<Button
className="size-8 shrink-0"
disabled={saving === `clear:${field.key}`}
onClick={() => onClear(field.key)}
title={`Clear ${field.key}`}
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
)}
</div>
}
description={copy.help}
title={
<span className="flex flex-wrap items-center gap-2">
<label htmlFor={fieldId}>{copy.label}</label>
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">Saved</span>}
</span>
}
/>
<div className="space-y-1.5">
<div className="flex flex-wrap items-baseline gap-2">
<label className="text-sm font-medium text-foreground" htmlFor={`messaging-field-${field.key}`}>
{copy.label}
</label>
{field.is_set && <span className="text-[0.66rem] font-medium text-primary">Saved</span>}
</div>
<div className="flex items-center gap-2">
<Input
className="h-9 rounded-lg font-mono text-sm"
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}
type={field.is_password ? 'password' : 'text'}
value={edits[field.key] || ''}
/>
{field.url && (
<Button asChild size="icon-sm" title="Open docs" variant="ghost">
<a href={field.url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3.5" />
</a>
</Button>
)}
{field.is_set && (
<Button
disabled={saving === `clear:${field.key}`}
onClick={() => onClear(field.key)}
size="icon-sm"
title={`Clear ${field.key}`}
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
)}
</div>
{copy.help && <p className="text-xs leading-5 text-muted-foreground">{copy.help}</p>}
</div>
)
}
@@ -744,13 +698,27 @@ function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) {
function StatePill({ children, tone }: { children: string; tone: StatusTone }) {
return (
<Badge variant={TONE_VARIANT[tone]}>
<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]
)}
>
<StatusDot tone={tone} />
{children}
</Badge>
</span>
)
}
function SetupPill({ active, children }: { active: boolean; children: string }) {
return <Badge variant={active ? 'default' : 'muted'}>{children}</Badge>
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>
)
}

View File

@@ -1,3 +1,5 @@
import type { ComponentType, SVGProps } from 'react'
import {
SiApple,
SiBilibili,
@@ -12,7 +14,6 @@ import {
SiWechat,
SiWhatsapp
} from '@icons-pack/react-simple-icons'
import type { ComponentType, SVGProps } from 'react'
import { Globe, Link as LinkIcon, MessageSquareText } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -68,7 +69,10 @@ export function PlatformAvatar({ className, platformId, platformName }: Platform
if (!spec) {
return (
<span aria-hidden="true" className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}>
<span
aria-hidden="true"
className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}
>
{platformName.charAt(0).toUpperCase()}
</span>
)

View File

@@ -0,0 +1,77 @@
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)}
/>
)
}

View File

@@ -3,8 +3,6 @@ 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
@@ -24,9 +22,6 @@ interface OverlayNavItemProps {
active: boolean
icon: IconComponent
label: string
// Renders as an indented child of another nav item: smaller icon and a
// lighter active state so it never competes with the boxed parent item.
nested?: boolean
onClick: () => void
trailing?: ReactNode
}
@@ -48,9 +43,7 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
return (
<aside
className={cn(
// 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)]',
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 py-3',
className
)}
>
@@ -61,41 +54,23 @@ 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 pb-3 pt-[calc(var(--titlebar-height)+1rem)]',
PAGE_INSET_X,
className
)}
>
{children}
</main>
<main className={cn('flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent p-3', className)}>{children}</main>
)
}
export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) {
export function OverlayNavItem({ active, icon: Icon, label, onClick, trailing }: OverlayNavItemProps) {
return (
<button
className={cn(
'flex h-7 w-full items-center justify-start gap-2 rounded-md border px-2 text-left text-[length:var(--conversation-text-font-size)] font-normal transition-colors',
nested
? active
? 'border-transparent bg-(--chrome-action-hover) font-medium text-foreground'
: 'border-transparent bg-transparent text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
: active
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground'
: 'border-transparent bg-transparent text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
active
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground'
: 'border-transparent bg-transparent text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
onClick={onClick}
type="button"
>
<Icon
className={cn(
'shrink-0',
nested ? 'size-3.5' : 'size-4',
active ? 'text-foreground/80' : 'text-muted-foreground/80'
)}
/>
<Icon className={cn('size-4 shrink-0', active ? 'text-foreground/80' : 'text-muted-foreground/80')} />
<span className="min-w-0 flex-1 truncate">{label}</span>
{trailing}
</button>

View File

@@ -64,26 +64,23 @@ 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(0.5rem+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(1rem+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)] -translate-y-1/2 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)] 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]"
onClick={closeOverlay}
size="icon-titlebar"
size="icon"
variant="ghost"
>
<Codicon name="close" size="1rem" />
</Button>
</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 className={cn('min-h-0 flex flex-1 flex-col pt-(--titlebar-height)', contentClassName)}>{children}</div>
</div>
</div>
)

View File

@@ -1,30 +1,26 @@
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 (
@@ -33,38 +29,29 @@ export function PageSearchShell({
className={cn('flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)', className)}
>
{/*
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.
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).
*/}
{/*
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 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}
</div>
<div className="min-h-0 flex-1 overflow-hidden bg-(--ui-chat-surface-background)">{children}</div>
</section>

View File

@@ -1,158 +0,0 @@
import { useEffect, useState } from 'react'
import { ActionStatus } from '@/components/ui/action-status'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { createProfile, updateProfileSoul } from '@/hermes'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
export const PROFILE_NAME_HINT =
'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.'
export function isValidProfileName(name: string): boolean {
return PROFILE_NAME_RE.test(name.trim())
}
// Self-contained create flow (name + clone toggle + optional SOUL.md). Owns the
// createProfile/updateProfileSoul calls so every caller just refreshes/selects
// via onCreated. SOUL left blank keeps the cloned/blank persona untouched.
export function CreateProfileDialog({
onClose,
onCreated,
open
}: {
onClose: () => void
onCreated?: (name: string) => Promise<void> | void
open: boolean
}) {
const [name, setName] = useState('')
const [cloneFromDefault, setCloneFromDefault] = useState(true)
const [soul, setSoul] = useState('')
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
const [error, setError] = useState<null | string>(null)
useEffect(() => {
if (!open) {
return
}
setName('')
setCloneFromDefault(true)
setSoul('')
setError(null)
setStatus('idle')
}, [open])
const trimmed = name.trim()
const invalid = trimmed !== '' && !isValidProfileName(trimmed)
const busy = status === 'saving' || status === 'done'
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!trimmed || invalid) {
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
return
}
setStatus('saving')
setError(null)
try {
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
if (soul.trim()) {
await updateProfileSoul(trimmed, soul)
}
await onCreated?.(trimmed)
setStatus('done')
window.setTimeout(onClose, 800)
} catch (err) {
setStatus('idle')
setError(err instanceof Error ? err.message : 'Failed to create profile')
}
}
return (
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>New profile</DialogTitle>
<DialogDescription>
Profiles are independent Hermes environments: separate config, skills, and SOUL.md.
</DialogDescription>
</DialogHeader>
<form className="grid gap-4" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-name">
Name
</label>
<Input
aria-invalid={invalid}
autoFocus
id="new-profile-name"
onChange={event => setName(event.target.value)}
placeholder="my-profile"
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{PROFILE_NAME_HINT}
</p>
</div>
<label className="flex cursor-pointer select-none items-start gap-2.5 px-0.5 py-1">
<Checkbox
checked={cloneFromDefault}
className="mt-0.5 shrink-0"
onCheckedChange={checked => setCloneFromDefault(checked === true)}
/>
<span className="grid gap-0.5 leading-snug">
<span className="text-sm font-medium">Clone from default</span>
<span className="text-xs text-muted-foreground">
Copy config, skills, and SOUL.md from your default profile.
</span>
</span>
</label>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-soul">
SOUL.md <span className="font-normal text-muted-foreground"> optional</span>
</label>
<Textarea
className="min-h-28 font-mono text-xs leading-5"
id="new-profile-soul"
onChange={event => setSoul(event.target.value)}
placeholder={`The system prompt / persona for this profile.\nLeave blank to keep the ${cloneFromDefault ? 'cloned' : 'empty'} default.`}
value={soul}
/>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={busy || !trimmed || invalid} type="submit">
<ActionStatus busy="Creating…" done="Created" idle="Create profile" state={status} />
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,58 +0,0 @@
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { deleteProfile } from '@/hermes'
import { $activeGatewayProfile, normalizeProfileKey, selectProfile, setActiveProfile } from '@/store/profile'
// Thin wrapper over ConfirmDialog: owns the deleteProfile call, inherits
// Enter-to-confirm + busy/done/error from the shared dialog. The single choke
// point for every delete entry point (rail + Profiles view).
export function DeleteProfileDialog({
profile,
onClose,
onDeleted,
open
}: {
profile: { name: string; path: string } | null
onClose: () => void
onDeleted?: () => Promise<void> | void
open: boolean
}) {
return (
<ConfirmDialog
busyLabel="Deleting…"
confirmLabel="Delete"
description={
profile ? (
<>
This will delete <span className="font-medium text-foreground">{profile.name}</span> and remove its{' '}
<span className="font-mono text-xs">{profile.path}</span> directory. This cannot be undone.
</>
) : null
}
destructive
doneLabel="Deleted"
onClose={onClose}
onConfirm={async () => {
if (!profile) {
return
}
// Deleting the profile the live gateway is on strands it on a dead
// backend. Capture that before the delete; reset *after* the host's
// onDeleted refresh so our reset is the last write — a refreshActiveProfile
// racing the (still-dying) backend can't clobber the pill back to it.
const wasActive = normalizeProfileKey(profile.name) === normalizeProfileKey($activeGatewayProfile.get())
await deleteProfile(profile.name)
await onDeleted?.()
if (wasActive) {
// Swap gateway/sidebar to default and set the pill now — the primary
// backend is always default, so this is correct, not just optimistic.
selectProfile('default')
setActiveProfile('default')
}
}}
open={open}
title="Delete profile?"
/>
)
}

View File

@@ -1,74 +1,68 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { ActionStatus } from '@/components/ui/action-status'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Tip } from '@/components/ui/tooltip'
import { createProfile, getProfiles, getProfileSoul, type ProfileInfo, updateProfileSoul } from '@/hermes'
import { AlertTriangle, Save, Users } from '@/lib/icons'
import { profileColor } from '@/lib/profile-color'
import {
createProfile,
deleteProfile,
getProfiles,
getProfileSetupCommand,
getProfileSoul,
type ProfileInfo,
renameProfile,
updateProfileSoul
} from '@/hermes'
import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $activeProfile, switchProfile } from '@/store/profile'
import { notify, notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayMain, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { titlebarHeaderBaseClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
import { CreateProfileDialog } from './create-profile-dialog'
import { DeleteProfileDialog } from './delete-profile-dialog'
import { RenameProfileDialog } from './rename-profile-dialog'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
// Pick a free "<source>-copy" name for a duplicated profile, appending a numeric
// suffix when the base is taken. Source is truncated to leave room for the
// suffix and to stay within the 64-char profile-name limit.
function uniqueCloneName(source: string, existing: Set<string>): string {
const base = `${source}-copy`.slice(0, 58)
const PROFILE_NAME_HINT = 'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.'
if (!existing.has(base)) {
return base
}
for (let i = 2; i < 1000; i++) {
const candidate = `${base}-${i}`
if (!existing.has(candidate)) {
return candidate
}
}
return `${base}-${Date.now()}`
function isValidProfileName(name: string): boolean {
return PROFILE_NAME_RE.test(name.trim())
}
// Three-state affordance shared by every save/create/rename/delete button:
// spinner while pending, a check on success, then back to the idle icon+label.
interface ProfilesViewProps {
onClose: () => void
interface ProfilesViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
setTitlebarToolGroup?: SetTitlebarToolGroup
}
export function ProfilesView({ onClose }: ProfilesViewProps) {
export function ProfilesView({
setStatusbarItemGroup: _setStatusbarItemGroup,
setTitlebarToolGroup,
...props
}: 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 [pendingRename, setPendingRename] = useState<null | ProfileInfo>(null)
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
const [loadError, setLoadError] = useState<null | string>(null)
const [deleting, setDeleting] = useState(false)
const refresh = useCallback(async () => {
setRefreshing(true)
try {
const { profiles: list } = await getProfiles()
setProfiles(list)
setLoadError(null)
setSelectedName(current => {
if (current && list.some(p => p.name === current)) {
return current
@@ -77,17 +71,34 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
return list.find(p => p.is_default)?.name ?? list[0]?.name ?? null
})
} catch (err) {
setLoadError(err instanceof Error ? err.message : 'Failed to load profiles')
setProfiles(prev => prev ?? [])
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
@@ -96,269 +107,251 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
return profiles.find(p => p.name === selectedName) ?? profiles[0] ?? null
}, [profiles, selectedName])
const handleClone = useCallback(
async (source: ProfileInfo) => {
const existing = new Set((profiles ?? []).map(p => p.name))
const target = uniqueCloneName(source.name, existing)
const handleCreate = useCallback(
async (name: string, cloneFromDefault: boolean) => {
const trimmed = name.trim()
try {
await createProfile({ name: target, clone_from: source.name })
setSelectedName(target)
await refresh()
} catch (err) {
setLoadError(err instanceof Error ? err.message : `Failed to duplicate ${source.name}`)
if (!isValidProfileName(trimmed)) {
throw new Error(PROFILE_NAME_HINT)
}
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
notify({ kind: 'success', title: 'Profile created', message: trimmed })
setSelectedName(trimmed)
await refresh()
},
[profiles, refresh]
[refresh]
)
const handleMakeDefault = useCallback(async (profile: ProfileInfo) => {
try {
// Relaunches the backend under this profile's HERMES_HOME and reloads the
// window, so control normally doesn't return here.
await switchProfile(profile.name)
} catch (err) {
setLoadError(err instanceof Error ? err.message : `Failed to switch to ${profile.name}`)
const handleRename = useCallback(
async (from: string, to: string): Promise<void> => {
const target = to.trim()
if (target === from) {
return
}
if (!isValidProfileName(target)) {
throw new Error(PROFILE_NAME_HINT)
}
await renameProfile(from, target)
notify({ kind: 'success', title: 'Profile renamed', message: `${from}${target}` })
setSelectedName(target)
await refresh()
},
[refresh]
)
const handleConfirmDelete = useCallback(async () => {
if (!pendingDelete) {
return
}
}, [])
setDeleting(true)
try {
await deleteProfile(pendingDelete.name)
notify({ kind: 'success', title: 'Profile deleted', message: pendingDelete.name })
setPendingDelete(null)
setSelectedName(null)
await refresh()
} catch (err) {
notifyError(err, 'Failed to delete profile')
} finally {
setDeleting(false)
}
}, [pendingDelete, refresh])
return (
<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>
{loadError && (
<div className="mb-1 flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-2 py-1.5 text-[0.66rem] text-destructive">
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
<span>{loadError}</span>
</div>
)}
{profiles.map(profile => (
<ProfileRow
active={selected?.name === profile.name}
key={profile.name}
onClone={() => void handleClone(profile)}
onDelete={() => setPendingDelete(profile)}
onMakeDefault={() => void handleMakeDefault(profile)}
onRename={() => setPendingRename(profile)}
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>
<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>
<OverlayMain className="px-0">
{selected ? (
<ProfileDetail key={selected.name} 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>
<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>
)}
</OverlayMain>
</OverlaySplitLayout>
)}
<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>
</div>
)}
</main>
</div>
)}
</div>
<CreateProfileDialog
onClose={() => setCreateOpen(false)}
onCreated={async name => {
setSelectedName(name)
await refresh()
}}
onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)}
open={createOpen}
/>
<RenameProfileDialog
currentName={pendingRename?.name ?? ''}
onClose={() => setPendingRename(null)}
onRenamed={async name => {
setSelectedName(name)
await refresh()
}}
open={pendingRename !== null}
/>
<DeleteProfileDialog
onClose={() => setPendingDelete(null)}
onDeleted={async () => {
setSelectedName(null)
await refresh()
}}
open={pendingDelete !== null}
profile={pendingDelete}
/>
</OverlayView>
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete profile?</DialogTitle>
<DialogDescription>
{pendingDelete ? (
<>
This will delete <span className="font-medium text-foreground">{pendingDelete.name}</span> and remove
its <span className="font-mono text-xs">{pendingDelete.path}</span> directory. This cannot be undone.
</>
) : null}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button disabled={deleting} onClick={() => setPendingDelete(null)} variant="outline">
Cancel
</Button>
<Button disabled={deleting} onClick={() => void handleConfirmDelete()} variant="destructive">
{deleting ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
)
}
function ProfileRow({
active,
onClone,
onDelete,
onMakeDefault,
onRename,
onSelect,
profile
}: {
active: boolean
onClone: () => void
onDelete: () => void
onMakeDefault: () => void
onRename: () => void
onSelect: () => void
profile: ProfileInfo
}) {
const running = useStore($activeProfile)
const isRunning = profile.name === running
function ProfileRow({ active, onSelect, profile }: { active: boolean; onSelect: () => void; profile: ProfileInfo }) {
return (
<div
<button
className={cn(
'group relative flex items-center rounded-md border transition-colors',
active
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)'
: 'border-transparent hover:bg-(--chrome-action-hover)'
'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'
)}
onClick={onSelect}
type="button"
>
<button
className={cn(
'flex min-w-0 flex-1 flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left text-[length:var(--conversation-text-font-size)] transition-colors',
active ? 'text-foreground' : 'text-(--ui-text-secondary) group-hover:text-foreground'
)}
onClick={onSelect}
type="button"
>
<span className="flex w-full items-center gap-1.5 pr-6">
{profile.is_default ? null : (
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: profileColor(profile.name) ?? 'var(--ui-text-quaternary)' }}
/>
)}
<span className="truncate text-sm font-medium">{profile.name}</span>
{isRunning && (
<Tip label="Current default profile">
<Codicon className="shrink-0 text-(--ui-accent)" name="pass-filled" size="0.75rem" />
</Tip>
)}
</span>
<span className="text-[0.66rem] text-muted-foreground">
{isRunning ? 'default · ' : ''}
{profile.skill_count} {profile.skill_count === 1 ? 'skill' : 'skills'}
</span>
</button>
<ProfileActionsMenu
isRunning={isRunning}
onClone={onClone}
onDelete={onDelete}
onMakeDefault={onMakeDefault}
onRename={onRename}
profile={profile}
>
<Button
aria-label={`Actions for ${profile.name}`}
className="absolute right-1 top-1 size-6 bg-transparent text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground data-[state=open]:opacity-100"
size="icon-xs"
title="Profile actions"
variant="ghost"
>
<Codicon name="ellipsis" size="0.875rem" />
</Button>
</ProfileActionsMenu>
</div>
<span className="flex w-full items-center justify-between gap-2">
<span className="truncate text-sm font-medium">{profile.name}</span>
{profile.is_default && <span className="text-[0.6rem] text-primary">default</span>}
</span>
<span className="text-[0.66rem] text-muted-foreground">
{profile.skill_count} {profile.skill_count === 1 ? 'skill' : 'skills'}
{profile.has_env ? ' · env' : ''}
</span>
</button>
)
}
function ProfileActionsMenu({
children,
isRunning,
onClone,
function ProfileDetail({
onDelete,
onMakeDefault,
onRename,
profile
}: {
children: React.ReactNode
isRunning: boolean
onClone: () => void
onDelete: () => void
onMakeDefault: () => void
onRename: () => void
onRename: (newName: string) => Promise<void>
profile: ProfileInfo
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent align="end" aria-label={`Actions for ${profile.name}`} className="w-44" sideOffset={6}>
<DropdownMenuItem disabled={isRunning} onSelect={onMakeDefault}>
<Codicon name="pass" size="0.875rem" />
<span>{isRunning ? 'Current default' : 'Make default'}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{!profile.is_default && (
<DropdownMenuItem onSelect={onRename}>
<Codicon name="edit" size="0.875rem" />
<span>Rename</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={onClone}>
<Codicon name="copy" size="0.875rem" />
<span>Duplicate</span>
</DropdownMenuItem>
{!profile.is_default && (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={onDelete}
variant="destructive"
>
<Codicon name="trash" size="0.875rem" />
<span>Delete</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
const [renameOpen, setRenameOpen] = useState(false)
const [copying, setCopying] = useState(false)
const handleCopySetup = useCallback(async () => {
setCopying(true)
try {
const { command } = await getProfileSetupCommand(profile.name)
await navigator.clipboard.writeText(command)
notify({ kind: 'success', title: 'Setup command copied', message: command })
} catch (err) {
notifyError(err, 'Failed to copy setup command')
} finally {
setCopying(false)
}
}, [profile.name])
function ProfileDetail({ profile }: { profile: ProfileInfo }) {
return (
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<header className="space-y-3">
<div className="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 && <Badge>Default</Badge>}
<div className="flex flex-wrap items-start justify-between gap-3">
<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>
)}
</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">
{!profile.is_default && (
<Button onClick={() => setRenameOpen(true)} size="sm" variant="outline">
<Pencil />
Rename
</Button>
)}
<Button disabled={copying} onClick={() => void handleCopySetup()} size="sm" variant="outline">
<Terminal />
{copying ? 'Copying...' : 'Copy setup'}
</Button>
{!profile.is_default && (
<Button
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
size="sm"
variant="ghost"
>
<Trash2 />
Delete
</Button>
)}
</div>
<Tip label={profile.path}>
<p className="mt-1 font-mono text-[0.7rem] text-muted-foreground">{profile.path}</p>
</Tip>
</div>
<dl className="grid gap-2 text-xs sm:grid-cols-2">
<dl className="grid gap-2 rounded-lg border border-border/40 bg-background/70 px-3 py-3 text-xs sm:grid-cols-2">
<DetailRow label="Model">
{profile.model ? (
<>
@@ -376,6 +369,16 @@ function ProfileDetail({ profile }: { profile: ProfileInfo }) {
<SoulEditor profileName={profile.name} />
</div>
</div>
<RenameProfileDialog
currentName={profile.name}
onClose={() => setRenameOpen(false)}
onRename={async newName => {
await onRename(newName)
setRenameOpen(false)
}}
open={renameOpen}
/>
</div>
)
}
@@ -384,7 +387,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-xs text-foreground">{children}</dd>
<dd className="text-sm text-foreground">{children}</dd>
</div>
)
}
@@ -393,16 +396,14 @@ function SoulEditor({ profileName }: { profileName: string }) {
const [content, setContent] = useState('')
const [original, setOriginal] = useState('')
const [loading, setLoading] = useState(true)
const [status, setStatus] = useState<'idle' | 'saved' | 'saving'>('idle')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
const requestRef = useRef<string>(profileName)
const savedTimerRef = useRef<null | number>(null)
useEffect(() => {
requestRef.current = profileName
setLoading(true)
setError(null)
setStatus('idle')
setContent('')
setOriginal('')
@@ -426,37 +427,21 @@ function SoulEditor({ profileName }: { profileName: string }) {
})()
}, [profileName])
useEffect(
() => () => {
if (savedTimerRef.current !== null) {
window.clearTimeout(savedTimerRef.current)
}
},
[]
)
const dirty = content !== original
const isEmpty = !content.trim()
const saving = status === 'saving'
async function handleSave() {
setStatus('saving')
setSaving(true)
setError(null)
if (savedTimerRef.current !== null) {
window.clearTimeout(savedTimerRef.current)
}
try {
await updateProfileSoul(profileName, content)
setOriginal(content)
setStatus('saved')
savedTimerRef.current = window.setTimeout(() => {
setStatus(current => (current === 'saved' ? 'idle' : current))
}, 2200)
notify({ kind: 'success', title: 'SOUL.md saved', message: profileName })
} catch (err) {
setStatus('idle')
setError(err instanceof Error ? err.message : 'Failed to save SOUL.md')
} finally {
setSaving(false)
}
}
@@ -473,7 +458,9 @@ function SoulEditor({ profileName }: { profileName: string }) {
</div>
{loading ? (
<PageLoader className="min-h-44" label="Loading SOUL.md" />
<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>
) : (
<Textarea
className="min-h-72 font-mono text-xs leading-5"
@@ -491,17 +478,230 @@ function SoulEditor({ profileName }: { profileName: string }) {
)}
<div className="flex justify-end">
<Button disabled={loading || saving || !dirty} onClick={() => void handleSave()} size="sm">
<ActionStatus
busy="Saving…"
done="Saved"
idle="Save SOUL.md"
idleIcon={<Save />}
state={saving ? 'saving' : status === 'saved' && !dirty ? 'done' : 'idle'}
/>
<Button disabled={!dirty || saving || loading} onClick={() => void handleSave()} size="sm">
<Save />
{saving ? 'Saving...' : 'Save SOUL.md'}
</Button>
</div>
</section>
)
}
function CreateProfileDialog({
onClose,
onCreate,
open
}: {
onClose: () => void
onCreate: (name: string, cloneFromDefault: boolean) => Promise<void>
open: boolean
}) {
const [name, setName] = useState('')
const [cloneFromDefault, setCloneFromDefault] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
useEffect(() => {
if (!open) {
return
}
setName('')
setCloneFromDefault(true)
setError(null)
setSaving(false)
}, [open])
const trimmed = name.trim()
const invalid = trimmed !== '' && !isValidProfileName(trimmed)
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!trimmed || invalid) {
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
return
}
setSaving(true)
setError(null)
try {
await onCreate(trimmed, cloneFromDefault)
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create profile')
} finally {
setSaving(false)
}
}
return (
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>New profile</DialogTitle>
<DialogDescription>
Profiles are independent Hermes environments: separate config, skills, and SOUL.md.
</DialogDescription>
</DialogHeader>
<form className="grid gap-4" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="new-profile-name">
Name
</label>
<Input
aria-invalid={invalid}
autoFocus
id="new-profile-name"
onChange={event => setName(event.target.value)}
placeholder="my-profile"
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{PROFILE_NAME_HINT}
</p>
</div>
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-sm">
<input
checked={cloneFromDefault}
className="size-4 accent-primary"
onChange={event => setCloneFromDefault(event.target.checked)}
type="checkbox"
/>
<span>
<span className="font-medium">Clone from default</span>
<span className="ml-2 text-xs text-muted-foreground">
Copy config, skills, and SOUL.md from your default profile.
</span>
</span>
</label>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
Cancel
</Button>
<Button disabled={saving || !trimmed || invalid} type="submit">
{saving ? 'Creating...' : 'Create profile'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function RenameProfileDialog({
currentName,
onClose,
onRename,
open
}: {
currentName: string
onClose: () => void
onRename: (newName: string) => Promise<void>
open: boolean
}) {
const [name, setName] = useState(currentName)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
useEffect(() => {
if (!open) {
return
}
setName(currentName)
setError(null)
setSaving(false)
}, [currentName, open])
const trimmed = name.trim()
const unchanged = trimmed === currentName
const invalid = trimmed !== '' && !unchanged && !isValidProfileName(trimmed)
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (unchanged) {
onClose()
return
}
if (!trimmed || invalid) {
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
return
}
setSaving(true)
setError(null)
try {
await onRename(trimmed)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to rename profile')
} finally {
setSaving(false)
}
}
return (
<Dialog onOpenChange={value => !value && !saving && onClose()} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Rename profile</DialogTitle>
<DialogDescription>
Renaming updates the profile directory and any wrapper scripts in{' '}
<span className="font-mono">~/.local/bin</span>.
</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="rename-profile-name">
New name
</label>
<Input
aria-invalid={invalid}
autoFocus
id="rename-profile-name"
onChange={event => setName(event.target.value)}
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{PROFILE_NAME_HINT}
</p>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button disabled={saving} onClick={onClose} type="button" variant="outline">
Cancel
</Button>
<Button disabled={saving || invalid || unchanged} type="submit">
{saving ? 'Renaming...' : 'Rename'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,121 +0,0 @@
import { useEffect, useState } from 'react'
import { ActionStatus } from '@/components/ui/action-status'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { renameProfile } from '@/hermes'
import { AlertTriangle } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { isValidProfileName, PROFILE_NAME_HINT } from './create-profile-dialog'
// Self-contained rename (owns the renameProfile call) so every caller just
// reacts via onRenamed. Unchanged name is a no-op close.
export function RenameProfileDialog({
currentName,
onClose,
onRenamed,
open
}: {
currentName: string
onClose: () => void
onRenamed?: (name: string) => Promise<void> | void
open: boolean
}) {
const [name, setName] = useState(currentName)
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
const [error, setError] = useState<null | string>(null)
useEffect(() => {
if (!open) {
return
}
setName(currentName)
setError(null)
setStatus('idle')
}, [currentName, open])
const trimmed = name.trim()
const unchanged = trimmed === currentName
const invalid = trimmed !== '' && !unchanged && !isValidProfileName(trimmed)
const busy = status === 'saving' || status === 'done'
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (unchanged) {
onClose()
return
}
if (!trimmed || invalid) {
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
return
}
setStatus('saving')
setError(null)
try {
await renameProfile(currentName, trimmed)
await onRenamed?.(trimmed)
setStatus('done')
window.setTimeout(onClose, 800)
} catch (err) {
setStatus('idle')
setError(err instanceof Error ? err.message : 'Failed to rename profile')
}
}
return (
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Rename profile</DialogTitle>
<DialogDescription>
Renaming updates the profile directory and any wrapper scripts in{' '}
<span className="font-mono">~/.local/bin</span>.
</DialogDescription>
</DialogHeader>
<form className="grid gap-3" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="rename-profile-name">
New name
</label>
<Input
aria-invalid={invalid}
autoFocus
id="rename-profile-name"
onChange={event => setName(event.target.value)}
value={name}
/>
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
{PROFILE_NAME_HINT}
</p>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<DialogFooter>
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={busy || invalid || unchanged} type="submit">
<ActionStatus busy="Renaming…" done="Renamed" idle="Rename" state={status} />
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,7 +1,6 @@
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'
@@ -122,7 +121,11 @@ export function ProjectTree({
}
function TreeSizingState() {
return <PageLoader aria-label="Loading files" className="min-h-24 px-3" />
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>
)
}
function ProjectTreeRow({

View File

@@ -5,10 +5,8 @@ import { ErrorBoundary } from '@/components/error-boundary'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
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'
@@ -33,14 +31,17 @@ interface RightSidebarTab {
}
const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
{ id: 'files', label: 'File system', icon: 'list-tree' },
{ id: 'files', label: 'File system', icon: 'files' },
{ 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
@@ -52,17 +53,8 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
.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
@@ -94,17 +86,14 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
}
}
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={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)]'
)}
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)"
>
<RightSidebarChrome activeTab={effectiveTab} branch={currentBranch} tabs={tabs} />
@@ -146,27 +135,26 @@ function RightSidebarChrome({
}) {
return (
<header className="shrink-0 bg-transparent text-[0.75rem]">
<div className="flex items-center gap-2 px-2.5 py-1">
<div className="flex items-center gap-2 border-b border-(--ui-stroke-tertiary) px-2.5 py-1">
<nav aria-label="Right sidebar panels" className="flex min-w-0 items-center gap-1">
{tabs.map(tab => (
<Tip key={tab.id} label={tab.label}>
<Button
aria-label={tab.label}
aria-pressed={tab.id === activeTab}
className={cn(
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
)}
onClick={() => setRightSidebarTab(tab.id)}
size="icon-xs"
variant="ghost"
>
<Codicon name={tab.icon} size="0.875rem" />
</Button>
</Tip>
<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'
)}
data-active={tab.id === activeTab}
key={tab.id}
onClick={() => setRightSidebarTab(tab.id)}
title={tab.label}
type="button"
>
<Codicon name={tab.icon} size="0.875rem" />
</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" />
@@ -187,11 +175,8 @@ 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 =
'text-sidebar-foreground/70 hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-sidebar-ring'
'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'
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`
@@ -217,30 +202,20 @@ function FilesystemTab({
return (
<div className="group/project-header flex min-h-0 flex-1 flex-col">
<RightSidebarSectionHeader>
<Tip label={hasCwd ? `${cwd} — click to change folder` : 'Open a folder'}>
<button
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
onClick={() => void onChangeFolder()}
type="button"
>
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</button>
</Tip>
<Button
aria-label="Refresh tree"
className={HEADER_ACTION_CLASS}
disabled={!hasCwd || loading}
onClick={onRefresh}
size="icon-xs"
variant="ghost"
<button
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
onClick={() => void onChangeFolder()}
title={hasCwd ? `${cwd} — click to change folder` : 'Open a folder'}
type="button"
>
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
</Button>
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
</button>
<Button
aria-label="Open folder"
className={HEADER_ACTION_CLASS}
onClick={() => void onChangeFolder()}
size="icon-xs"
size="icon"
title={hasCwd ? 'Open a different folder' : 'Open a folder'}
variant="ghost"
>
<Codicon name="folder-opened" size="0.8125rem" />
@@ -250,11 +225,23 @@ function FilesystemTab({
className={HEADER_ACTION_REVEAL_CLASS}
disabled={!hasCwd || !canCollapse}
onClick={onCollapseAll}
size="icon-xs"
size="icon"
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}
@@ -274,7 +261,7 @@ function FilesystemTab({
}
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
return <div className="flex h-7 shrink-0 items-center px-2.5">{children}</div>
return <div className="flex h-7 shrink-0 items-center px-2">{children}</div>
}
interface FileTreeBodyProps {

View File

@@ -5,7 +5,6 @@ import { useStore } from '@nanostores/react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import { $terminalTakeover, setRightSidebarTab, setTerminalTakeover } from '../store'
@@ -32,7 +31,6 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
if (takeover) {
setRightSidebarTab('terminal')
}
setTerminalTakeover(!takeover)
}
@@ -40,18 +38,17 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
<div className="flex h-8 shrink-0 items-center gap-2 px-2.5">
<SidebarPanelLabel className="text-white!">{shellName}</SidebarPanelLabel>
<Tip label={label}>
<Button
aria-label={label}
className="ml-auto size-6 rounded-md text-white!"
onClick={toggleTakeover}
size="icon"
type="button"
variant="ghost"
>
<Codicon name={takeover ? 'screen-normal' : 'screen-full'} size="0.875rem" />
</Button>
</Tip>
<Button
aria-label={label}
className="ml-auto size-6 rounded-md text-white!"
onClick={toggleTakeover}
size="icon"
title={label}
type="button"
variant="ghost"
>
<Codicon name={takeover ? 'screen-normal' : 'screen-full'} size="0.875rem" />
</Button>
</div>
<div className="relative min-h-0 flex-1 bg-[#002b36] p-2">
{status === 'starting' && (

View File

@@ -1,10 +1,9 @@
import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { TERMINAL_BG } from './selection'
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
import { TerminalTab } from './index'
import { TERMINAL_BG } from './selection'
/**
* One xterm Terminal mounted at the layout root and CSS-overlayed onto
@@ -22,17 +21,11 @@ export function TerminalSlot({ className = SLOT_CLASS }: { className?: string })
useEffect(() => {
const el = ref.current
if (!el) {
return
}
if (!el) return
$slot.set(el)
return () => {
if ($slot.get() === el) {
$slot.set(null)
}
if ($slot.get() === el) $slot.set(null)
}
}, [])
@@ -62,7 +55,6 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
useLayoutEffect(() => {
if (!slot) {
setRect(null)
return
}
@@ -80,17 +72,13 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
if (!sameRect(prev, next)) {
prev = next
setRect(next)
if (next.width > 0 && next.height > 0) {
setReady(true)
}
if (next.width > 0 && next.height > 0) setReady(true)
}
frame = requestAnimationFrame(tick)
}
tick()
return () => cancelAnimationFrame(frame)
}, [slot])

View File

@@ -96,18 +96,11 @@ interface UseTerminalSessionOptions {
}
function transferHasDropCandidates(t: DataTransfer): boolean {
if (t.types?.includes(HERMES_PATHS_MIME)) {
return true
}
if ((t.files?.length ?? 0) > 0) {
return true
}
if (t.types?.includes(HERMES_PATHS_MIME)) return true
if ((t.files?.length ?? 0) > 0) return true
for (let i = 0; i < (t.items?.length ?? 0); i += 1) {
if (t.items[i]?.kind === 'file') {
return true
}
if (t.items[i]?.kind === 'file') return true
}
return false
@@ -115,38 +108,22 @@ function transferHasDropCandidates(t: DataTransfer): boolean {
function collectDroppedPaths(t: DataTransfer): string[] {
const seen = new Set<string>()
const push = (value: unknown) => {
if (typeof value !== 'string') {
return
}
if (typeof value !== 'string') return
const path = value.trim()
if (path) {
seen.add(path)
}
if (path) seen.add(path)
}
try {
const raw = t.getData(HERMES_PATHS_MIME)
if (raw) {
for (const entry of JSON.parse(raw) as { path?: unknown }[]) {
push(entry?.path)
}
}
if (raw) for (const entry of JSON.parse(raw) as { path?: unknown }[]) push(entry?.path)
} catch {
// Malformed in-app drag payload — fall through to OS files.
}
const getPath = window.hermesDesktop?.getPathForFile
const addFile = (file: File | null) => {
if (!file || !getPath) {
return
}
if (!file || !getPath) return
try {
push(getPath(file))
} catch {
@@ -154,16 +131,10 @@ function collectDroppedPaths(t: DataTransfer): string[] {
}
}
for (let i = 0; i < (t.files?.length ?? 0); i += 1) {
addFile(t.files.item(i))
}
for (let i = 0; i < (t.files?.length ?? 0); i += 1) addFile(t.files.item(i))
for (let i = 0; i < (t.items?.length ?? 0); i += 1) {
const item = t.items[i]
if (item?.kind === 'file') {
addFile(item.getAsFile())
}
if (item?.kind === 'file') addFile(item.getAsFile())
}
return [...seen]
@@ -171,15 +142,8 @@ function collectDroppedPaths(t: DataTransfer): string[] {
function quotePathForShell(path: string, shellName: string): string {
const shell = shellName.toLowerCase()
if (shell.includes('powershell') || shell.includes('pwsh')) {
return `'${path.replace(/'/g, "''")}'`
}
if (shell.includes('cmd')) {
return `"${path.replace(/"/g, '""')}"`
}
if (shell.includes('powershell') || shell.includes('pwsh')) return `'${path.replace(/'/g, "''")}'`
if (shell.includes('cmd')) return `"${path.replace(/"/g, '""')}"`
return `'${path.replace(/'/g, "'\\''")}'`
}
@@ -286,14 +250,12 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
webgl.onContextLoss(() => webgl.dispose())
term.loadAddon(webgl)
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
}
const onDragOver = (e: DragEvent) => {
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
return
}
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) return
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
@@ -301,19 +263,11 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
const onDrop = (e: DragEvent) => {
const id = sessionIdRef.current
if (!id || !e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
return
}
if (!id || !e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) return
e.preventDefault()
e.stopPropagation()
const paths = collectDroppedPaths(e.dataTransfer)
if (!paths.length) {
return
}
if (!paths.length) return
void terminalApi.write(id, `${paths.map(p => quotePathForShell(p, shellNameRef.current)).join(' ')} `)
term.focus()
triggerHaptic('selection')
@@ -351,18 +305,11 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
// synchronously while sibling panes are mid-transition (e.g. file browser
// collapsing to 0px) crashes the WebGL renderer mid texture-atlas rebuild.
let pendingFrame = 0
const scheduleResize = () => {
if (pendingFrame) {
return
}
if (pendingFrame) return
pendingFrame = window.requestAnimationFrame(() => {
pendingFrame = 0
if (!disposed) {
fitAndResize()
}
if (!disposed) fitAndResize()
})
}
@@ -370,10 +317,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
resizeObserver.observe(host)
cleanup.push(() => {
resizeObserver.disconnect()
if (pendingFrame) {
window.cancelAnimationFrame(pendingFrame)
}
if (pendingFrame) window.cancelAnimationFrame(pendingFrame)
})
const dataDisposable = term.onData(data => {

Some files were not shown because too many files have changed in this diff Show More