Merge remote-tracking branch 'origin/main' into ethie/pm-clean

Conflicts resolved toward the branch: PM owns dependency preparation, the
Windows shim re-exec/hand-off path stays retired (main's shim-parent wait,
gateway-resume env token and update_cmd_deps tests dropped), docs describe
the PM update flow. The docker workflow parks install-stamp.json around the
toolchain step instead of deleting it so tests/docker can compare provenance.
This commit is contained in:
ethernet
2026-09-19 13:45:07 -04:00
248 changed files with 7070 additions and 5114 deletions
+3 -1
View File
@@ -133,7 +133,9 @@ async function main() {
if (failed.length > 0) {
for (const r of failed) console.error(`::error::${r.unit.pkg} :: ${r.unit.script} failed`)
console.error(`::error::${failed.length} of ${results.length} checks failed`)
process.exit(1)
// Not process.exit(): it drops what stdout still buffers, which is the failing check's output.
process.exitCode = 1
return
}
console.log(`\nall ${results.length} checks passed`)
}
+7 -3
View File
@@ -204,9 +204,10 @@ jobs:
# ---------------------------------------------------------------------
# The stamp above marks the checkout as a docker distribution, and PM
# then expects the image's packaged runtime. The runner is not the
# image: drop it before provisioning the test toolchain.
- name: Remove the image install stamp from the checkout
run: rm -f install-stamp.json
# image: park the stamp while the test toolchain is provisioned, then
# put it back — tests/docker compares the image's provenance against it.
- name: Park the image install stamp while provisioning the runner toolchain
run: mv install-stamp.json "$RUNNER_TEMP/install-stamp.json"
- name: Set up locked Python and test dependencies
uses: ./.github/actions/setup-pm
@@ -214,6 +215,9 @@ jobs:
extras: '["dev"]'
prune-python-cache: true
- name: Restore the image install stamp for the docker tests
run: mv "$RUNNER_TEMP/install-stamp.json" install-stamp.json
- name: Run docker integration tests
env:
# Skip rebuild; use the image already loaded by the build step.
+10 -1
View File
@@ -1910,6 +1910,11 @@ def _enforce_minimum_context(agent):
# Reject windows below the 64K floor needed for reliable tool-calling; an explicit
# positive model.context_length on LM Studio is allowed below the floor.
_ctx = getattr(agent.context_compressor, "context_length", 0)
# A local Ollama server serves num_ctx, not the GGUF's advertised window: a Modelfile or
# model.ollama_num_ctx at 64K+ is a usable window even when the metadata says 40K (#100437).
# Only a local endpoint can honour num_ctx, so a stale override never admits a hosted model.
if agent._ollama_num_ctx and agent.base_url and is_local_endpoint(agent.base_url):
_ctx = max(_ctx or 0, agent._ollama_num_ctx)
_allow_lmstudio_explicit_below_floor = (
str(agent.provider or "").strip().lower() == "lmstudio"
and isinstance(agent._config_context_length, int)
@@ -2036,6 +2041,9 @@ def _configure_ollama_num_ctx(agent, _model_cfg, _config_context_length):
"Ollama num_ctx: will request %d tokens (model max from /api/show)",
agent._ollama_num_ctx,
)
def _clamp_compressor_to_ollama_num_ctx(agent):
# Recalibrate the compressor to the served window: every request runs at num_ctx, so a
# trigger derived from the probed model window could sit above it and never fire.
# A config that sets only model.ollama_num_ctx (without model.context_length) previously left the
@@ -2324,11 +2332,12 @@ def init_agent(
agent, _agent_cfg, base_url
)
_build_context_engine(agent, _agent_cfg, cs, _custom_providers, _effective_context_length, session_db)
_configure_ollama_num_ctx(agent, _model_cfg, _config_context_length)
_enforce_minimum_context(agent)
_warn_nonagentic_hermes_model(agent)
_inject_context_engine_tools(agent)
_init_usage_state(agent)
_configure_ollama_num_ctx(agent, _model_cfg, _config_context_length)
_clamp_compressor_to_ollama_num_ctx(agent)
_emit_compression_summary(agent, cs)
_snapshot_primary_runtime(agent)
+52
View File
@@ -2961,6 +2961,58 @@ def looks_like_codex_intermediate_ack(
)
# Degenerate-final detector (#103483): after real tool work a text stop whose ENTIRE answer is a
# fragment — a stray wrong-script word ("пар" in an English conversation), a token starting
# mid-punctuation ("?warming up") — is a provider-side collapse, not an answer, yet the loop
# accepted it and the turn reported completed. Shape alone cannot PROVE a collapse, so this is
# deliberately narrower than "short": a terse legitimate answer ("42", "SQLite", "report.csv",
# "€12.50", "你好。", "Done.", ":8080", "да" to a Russian prompt) never matches, English-script
# fragments ("the", "ing") are knowingly not covered, and the re-prompt it triggers asks for the
# same answer again if it was complete. ``turn_finalizer._SENTENCE_END`` encodes a sibling
# "≤ 24 chars, no terminal" heuristic for the finish explainer.
_DEGENERATE_FINAL_MAX_CHARS = 24
_SENTENCE_TERMINALS = (".", "!", "?", "\u3002", "\uff01", "\uff1f")
# Punctuation no answer begins with when a letter follows ("?warming"); "$5", "#123", "-1",
# "/tmp", ".env", "(a)", ":8080", ":)", ";;" all stay answers.
_DEGENERATE_LEADING_PUNCT = "?!,;:)]}"
def looks_like_degenerate_final(text: str, user_message: Any = None) -> bool:
"""Whether a text stop reads as a collapsed fragment rather than a (terse) answer.
"Wrong script" is judged against the conversation: when the user's own message carries
non-ASCII letters, a terse non-Latin reply ("是", "Готово") is an answer, not a collapse.
"""
t = (text or "").strip()
if not t or len(t) > _DEGENERATE_FINAL_MAX_CHARS or t.endswith(_SENTENCE_TERMINALS):
return False
if t[0] in _DEGENERATE_LEADING_PUNCT and len(t) > 1 and t[1].isalpha():
return True
if not any(ch.isalpha() for ch in t) or any(ch.isascii() and ch.isalnum() for ch in t):
return False
from agent.codex_responses_adapter import _summarize_user_message_for_log
user_text = _summarize_user_message_for_log(user_message) if user_message else ""
return not any(ch.isalpha() and not ch.isascii() for ch in user_text)
def tool_results_this_turn(messages: List[Dict[str, Any]]) -> int:
"""Tool-result rows after the most recent user row — whether the turn did real tool work.
ANY user row ends the window, the continuation nudges included: that is what bounds the
degenerate-final guard to one re-prompt per collapse. Skipping synthetic user rows here
would turn it into a two-nudge loop.
"""
count = 0
for msg in reversed(messages or ()):
if not isinstance(msg, dict):
continue
if msg.get("role") == "user":
break
if msg.get("role") == "tool":
count += 1
return count
# Narrow "trailing continue-intent" detector for the stall guard (agent.stall_guards): only the
# message TAIL announcing a next action, so mid-sentence "I will" never trips it.
_TRAILING_CONTINUE_INTENT_RE = re.compile(
+143 -7
View File
@@ -17,6 +17,7 @@ import json
import logging
import os
import platform
import re
import secrets
import subprocess
import threading
@@ -41,6 +42,10 @@ _OAUTH_TOKEN_URLS = [
_OAUTH_TOKEN_USER_AGENT = "axios/1.7.9"
_OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
_OAUTH_SCOPES = "org:create_api_key user:profile user:inference"
# Claude Code's macOS Keychain entry (generic password). Hermes reads it
# (_read_claude_code_credentials_from_keychain) and, since #98334, mirrors the
# refresh write into it so the two stores stop diverging on a single-use rotation.
_CLAUDE_CODE_KEYCHAIN_SERVICE = "Claude Code-credentials"
def _getenv(name: str, default: str = "") -> str:
@@ -206,27 +211,103 @@ def _claude_oauth_record(data: Any, source: str) -> Optional[Dict[str, Any]]:
}
def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
"""Read the "Claude Code-credentials" macOS Keychain entry (Claude Code >=2.1.114)."""
_KEYCHAIN_ATTR = r'(?:0x(?P<hex>[0-9A-Fa-f]+)\b.*|"(?P<text>.*)")'
def _decode_keychain_attr(match: Optional["re.Match[str]"]) -> str:
"""``security`` prints an attribute as ``"text"`` when it is plain printable ASCII and as
``0x<HEX> "<octal-escaped echo>"`` otherwise; the quoted form is NOT escaped (an embedded
``"`` appears raw), so the text group must run to the last quote on the line."""
if match is None:
return ""
if match.group("hex"):
try:
return bytes.fromhex(match.group("hex")).decode("utf-8")
except ValueError:
return ""
return match.group("text") or ""
def _find_claude_code_keychain_item() -> Optional[tuple[str, Dict[str, Any]]]:
"""``(account, payload)`` of the ``Claude Code-credentials`` login Keychain item, or None.
One ``find-generic-password -g`` call: attributes on stdout, ``password: …`` on stderr. The
account matters because ``add-generic-password -U`` matches on account AND service — writing
under another account would create a second item instead of updating the one Claude Code reads.
"""
if platform.system() != "Darwin":
return None
try:
result = subprocess.run(
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
["security", "find-generic-password", "-s", _CLAUDE_CODE_KEYCHAIN_SERVICE, "-g"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, stdin=subprocess.DEVNULL,
)
except (OSError, subprocess.TimeoutExpired):
return None
if result.returncode != 0:
return None
account = _decode_keychain_attr(re.search(r'^\s*"acct"<blob>=' + _KEYCHAIN_ATTR + r"\s*$", result.stdout, re.M))
raw = _decode_keychain_attr(re.search(r"^password: " + _KEYCHAIN_ATTR + r"\s*$", result.stderr, re.M))
if not account or not raw:
return None
try:
payload = json.loads(raw)
except ValueError:
return None
return (account, payload) if isinstance(payload, dict) else None
def _read_claude_code_keychain_payload() -> Optional[Dict[str, Any]]:
"""Raw ``{"claudeAiOauth": {...}, ...}`` payload from the macOS Keychain, or None.
Returns the full entry (not the normalised credential record) so a refresh
write can merge the rotated token triple over the existing metadata
(``subscriptionType`` / ``rateLimitTier`` / ``scopes``) instead of clobbering it.
"""
if platform.system() != "Darwin":
return None
try:
result = subprocess.run(
["security", "find-generic-password", "-s", _CLAUDE_CODE_KEYCHAIN_SERVICE, "-w"],
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5, stdin=subprocess.DEVNULL,
)
except (OSError, subprocess.TimeoutExpired):
logger.debug("Keychain: security command not available or timed out")
return None
if result.returncode != 0:
logger.debug("Keychain: no entry found for 'Claude Code-credentials'")
logger.debug("Keychain: no entry found for %r", _CLAUDE_CODE_KEYCHAIN_SERVICE)
return None
raw = result.stdout.strip()
if not raw:
return None
try:
return _claude_oauth_record(json.loads(raw), "macos_keychain") if raw else None
payload = json.loads(raw)
except json.JSONDecodeError:
logger.debug("Keychain: credentials payload is not valid JSON")
return None
return payload if isinstance(payload, dict) else None
def _keychain_mirror_command(account: str, payload: Dict[str, Any]) -> tuple[list[str], str]:
"""``(argv, stdin)`` that updates the Claude Code Keychain item with ``payload``.
The command line goes to ``security -i`` on stdin, with the secret hex-encoded (``-X``):
a bare ``-w`` prompts twice on /dev/tty when a terminal exists (hangs the CLI) and, with
no terminal, reads only the first line and stores an EMPTY password when the confirmation
read hits EOF — either way the live token must never sit on argv.
"""
def quoted(value: str) -> str: # the ``security -i`` tokenizer: double quotes, backslash escapes
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
encoded = json.dumps(payload, separators=(",", ":"), ensure_ascii=True).encode("utf-8").hex()
line = f"add-generic-password -U -a {quoted(account)} -s {quoted(_CLAUDE_CODE_KEYCHAIN_SERVICE)} -X {encoded}\n"
return ["security", "-i"], line
def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
"""Read the "Claude Code-credentials" macOS Keychain entry (Claude Code >=2.1.114)."""
payload = _read_claude_code_keychain_payload()
return _claude_oauth_record(payload, "macos_keychain") if payload else None
def claude_code_credentials_path() -> Path:
@@ -403,7 +484,10 @@ def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:
# The POST spent ``refresh_token``; this write is the commit step. On failure, fail closed and
# mark the pre-rotation pair as spent.
try:
_write_claude_code_credentials(refreshed["access_token"], refreshed["refresh_token"], refreshed["expires_at_ms"])
_write_claude_code_credentials(
refreshed["access_token"], refreshed["refresh_token"], refreshed["expires_at_ms"],
spent_refresh_token=refresh_token,
)
except Exception as e:
logger.error(
"Anthropic OAuth refresh rotated the single-use token but could not "
@@ -425,7 +509,8 @@ def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:
def _write_claude_code_credentials(
access_token: str, refresh_token: str, expires_at_ms: int, *, scopes: Optional[list] = None
access_token: str, refresh_token: str, expires_at_ms: int, *, scopes: Optional[list] = None,
spent_refresh_token: str = "",
) -> None:
"""Commit refreshed credentials to ~/.claude/.credentials.json; ``CredentialPersistError`` on any failure (a
corrupt existing file included). *scopes* (or the previously stored scopes) are persisted because Claude Code
@@ -443,6 +528,57 @@ def _write_claude_code_credentials(
oauth_data["scopes"] = existing["claudeAiOauth"]["scopes"]
existing["claudeAiOauth"] = oauth_data
_commit_private_json(cred_path, existing, "credentials")
_mirror_claude_code_credentials_to_keychain(
access_token, refresh_token, expires_at_ms, spent_refresh_token=spent_refresh_token)
def _merge_keychain_credential_payload(
existing_payload: Dict[str, Any], access_token: str, refresh_token: str, expires_at_ms: int
) -> Dict[str, Any]:
"""Rotate the ``claudeAiOauth`` token triple over the existing Keychain payload,
preserving its metadata (``subscriptionType`` / ``rateLimitTier`` / ``scopes``).
Pure and host-agnostic so the merge semantics are unit-testable without a Keychain.
"""
merged = dict(existing_payload)
oauth = dict(existing_payload.get("claudeAiOauth") or {})
oauth.update({"accessToken": access_token, "refreshToken": refresh_token, "expiresAt": expires_at_ms})
merged["claudeAiOauth"] = oauth
return merged
def _mirror_claude_code_credentials_to_keychain(
access_token: str, refresh_token: str, expires_at_ms: int, *, spent_refresh_token: str
) -> None:
"""After a Hermes refresh, write the rotated pair into the Claude Code Keychain item too (#98334).
Claude Code on macOS reads the login Keychain first. Refresh tokens are single-use, so a refresh
that only updates the file leaves the Keychain holding a spent token and Claude Code logs itself
out. Only the item that held the pair we just spent is updated — a different pair there means a
different login (``CLAUDE_CONFIG_DIR``) or a rotation Claude Code already made, and clobbering it
would be the bug in the other direction. Best-effort: never raises, never creates an item.
"""
if platform.system() != "Darwin":
return
try:
item = _find_claude_code_keychain_item()
if item is None:
return
account, existing = item
oauth = existing.get("claudeAiOauth")
if not isinstance(oauth, dict) or oauth.get("refreshToken") != spent_refresh_token:
logger.debug("Keychain mirror skipped: item does not hold the pair that was just rotated")
return
argv, line = _keychain_mirror_command(
account, _merge_keychain_credential_payload(existing, access_token, refresh_token, expires_at_ms))
result = subprocess.run(
argv, input=line, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10,
)
except Exception as e: # the file commit already succeeded; a Keychain hiccup must not fail the rotation
logger.debug("Keychain mirror skipped (%s)", e)
return
if result.returncode != 0:
logger.debug("Keychain mirror failed (rc=%s): %s", result.returncode, (result.stderr or "").strip()[:200])
# ── Resolution ──
+5 -5
View File
@@ -3771,15 +3771,15 @@ Write only the summary body. Do not include any preamble or prefix."""
text = _content_text_for_contains(message.get("content")).strip()
# Recovery nudges are scaffolding, not human turns; lazy import avoids an import cycle.
from agent.conversation_loop import (
_CODEX_ACK_CONTINUATION_NUDGE, _CODEX_INCOMPLETE_NUDGE, _DROPPED_TOOLCALL_NUDGE_CONTENT,
_EMPTY_TOOL_RESPONSE_NUDGE, _LENGTH_CONTINUATION_DROPPED_TOOLS_PREFIX, _LENGTH_CONTINUATION_NETWORK_STUB,
_LENGTH_CONTINUATION_OUTPUT_LIMIT,
_CODEX_ACK_CONTINUATION_NUDGE, _CODEX_INCOMPLETE_NUDGE, _DEGENERATE_FINAL_NUDGE,
_DROPPED_TOOLCALL_NUDGE_CONTENT, _EMPTY_TOOL_RESPONSE_NUDGE, _LENGTH_CONTINUATION_DROPPED_TOOLS_PREFIX,
_LENGTH_CONTINUATION_NETWORK_STUB, _LENGTH_CONTINUATION_OUTPUT_LIMIT,
)
return text in {
COMPRESSION_CONTINUATION_USER_CONTENT, _LEGACY_COMPRESSION_CONTINUATION_USER_CONTENT,
MAX_ITERATIONS_SUMMARY_REQUEST, _CODEX_INCOMPLETE_NUDGE, _CODEX_ACK_CONTINUATION_NUDGE,
_DROPPED_TOOLCALL_NUDGE_CONTENT, _EMPTY_TOOL_RESPONSE_NUDGE, _LENGTH_CONTINUATION_NETWORK_STUB,
_LENGTH_CONTINUATION_OUTPUT_LIMIT,
_DEGENERATE_FINAL_NUDGE, _DROPPED_TOOLCALL_NUDGE_CONTENT, _EMPTY_TOOL_RESPONSE_NUDGE,
_LENGTH_CONTINUATION_NETWORK_STUB, _LENGTH_CONTINUATION_OUTPUT_LIMIT,
} or text.startswith((
_BACKGROUND_PROCESS_NOTIFICATION_PREFIX, TODO_INJECTION_HEADER + "\n", _LENGTH_CONTINUATION_DROPPED_TOOLS_PREFIX,
))
+8
View File
@@ -870,6 +870,14 @@ _CODEX_ACK_CONTINUATION_NUDGE = (
"after completing the task.]"
)
# Re-prompt after a collapsed fragment ended a turn that had done real tool work (#103483). Asks
# for the same answer again when it WAS complete, so a false positive costs one call, never the answer.
_DEGENERATE_FINAL_NUDGE = (
"[System: Your previous message ended the turn with a fragment that is not a usable answer. "
"If the task is unfinished, continue it and then give the complete answer. If that fragment "
"WAS your complete answer, send it again exactly as before.]"
)
# Re-prompt for finish_reason="tool_calls" with empty tool_calls (an interrupt mid-retry can persist it).
_DROPPED_TOOLCALL_NUDGE_CONTENT = (
"Your previous turn indicated a tool call but none was included. Do not narrate a plan or "
+1 -1
View File
@@ -1372,7 +1372,7 @@ class CredentialPool(CredentialPoolAdminMixin, CredentialPoolModelCooldownMixin)
from agent import anthropic_credentials as ac
args = (refreshed["access_token"], refreshed["refresh_token"], refreshed["expires_at_ms"])
if entry.source == "claude_code":
ac._write_claude_code_credentials(*args)
ac._write_claude_code_credentials(*args, spent_refresh_token=entry.refresh_token or "")
else:
ac._write_hermes_oauth_credentials(*args)
except Exception as wexc:
+15 -1
View File
@@ -63,6 +63,20 @@ def get_service() -> Optional[LSPService]:
return _active(_service)
def release_workspace(path: str) -> int:
"""Shut down the LSP clients serving ``path`` (a worktree about to be removed) in every started
service, without shutting down unrelated workspaces. Never creates a service. Returns the count."""
with _service_lock:
services = [svc for svc in (_service, *_services_by_home.values()) if svc is not None]
released = 0
for svc in services:
try:
released += svc.release_workspace(path)
except Exception as e: # noqa: BLE001
logger.debug("LSP workspace release failed for %s: %s", path, e)
return released
def shutdown_service() -> None:
"""Tear down every LSP service that was started. Idempotent."""
global _service
@@ -86,4 +100,4 @@ def _atexit_shutdown() -> None:
logger.debug("atexit LSP shutdown failed: %s", e)
__all__ = ["get_service", "shutdown_service", "LSPService"]
__all__ = ["get_service", "release_workspace", "shutdown_service", "LSPService"]
+21 -5
View File
@@ -56,20 +56,37 @@ def run_lsp_command(args: argparse.Namespace) -> int:
return 130
def _all_servers() -> list:
"""Config-declared servers (``lsp.servers.<id>.extensions``) ahead of the built-in registry."""
from agent.lsp.servers import SERVERS, custom_servers
from hermes_cli.config import load_config_readonly
try:
lsp_cfg = load_config_readonly().get("lsp") or {}
except Exception: # noqa: BLE001 — a broken config still lists the built-ins
lsp_cfg = {}
return [*custom_servers(lsp_cfg.get("servers") if isinstance(lsp_cfg, dict) else None), *SERVERS]
def _status_for(server_id: str) -> str:
import os
from agent.lsp.install import detect_status
from agent.lsp.servers import SERVERS, ServerContext
custom = next((s for s in _all_servers() if s.server_id == server_id and s not in SERVERS), None)
if custom is not None: # no install recipe: installed iff the configured command resolves
cwd = os.getcwd()
return "installed" if custom.build_spawn(cwd, ServerContext(cwd, install_strategy="manual")) else "manual-only"
return detect_status(_recipe_pkg_for(server_id))
def _cmd_status(emit_json: bool) -> int:
from agent.lsp import get_service
from agent.lsp.servers import SERVERS
servers = _all_servers()
svc = get_service()
info = svc.get_status() if svc is not None else {"enabled": False}
if emit_json:
import json
registry = [{"server_id": s.server_id, "extensions": list(s.extensions), "description": s.description,
"binary_status": _status_for(s.server_id)} for s in SERVERS]
"binary_status": _status_for(s.server_id)} for s in servers]
sys.stdout.write(json.dumps({"service": info, "registry": registry}, indent=2) + "\n")
return 0
@@ -91,7 +108,7 @@ def _cmd_status(emit_json: bool) -> int:
if backend_warnings := _backend_warnings():
out += ["", "Backend warnings", "================"] + [f" ! {line}" for line in backend_warnings]
out += ["", "Registered Servers", "=================="]
for s in SERVERS:
for s in servers:
status = _status_for(s.server_id)
ext_summary = ", ".join(list(s.extensions)[:5])
if len(s.extensions) > 5:
@@ -104,8 +121,7 @@ def _cmd_status(emit_json: bool) -> int:
def _cmd_list(installed_only: bool) -> int:
from agent.lsp.servers import SERVERS
for s in SERVERS:
for s in _all_servers():
status = _status_for(s.server_id)
if not (installed_only and status != "installed"):
sys.stdout.write(f"{s.server_id:24s} [{status:11s}] {','.join(s.extensions)}\n")
+37 -2
View File
@@ -10,6 +10,7 @@ it), and ``ContentModified`` (-32801) errors are retried with exponential backof
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import sys
@@ -33,10 +34,15 @@ DIAGNOSTICS_DOCUMENT_WAIT = 5.0
DIAGNOSTICS_FULL_WAIT = 10.0
DIAGNOSTICS_REQUEST_TIMEOUT = 3.0
PUSH_DEBOUNCE = 0.15
SHUTDOWN_GRACE = 1.0 # seconds between SIGTERM and SIGKILL
SHUTDOWN_GRACE = 1.0 # seconds after `exit` before SIGTERM, and between SIGTERM and SIGKILL
# Retry policy for transient ContentModified errors: 0.5, 1.0, 2.0s.
MAX_CONTENT_MODIFIED_RETRIES = 3
RETRY_BASE_DELAY = 0.5
# Cap on tracked documents: each _DocState pins the file's full text here AND the server mirrors
# every open document, so an uncapped dict pins everything a long session ever touched on both
# sides of the pipe until the idle reaper kills the whole client (#62950). 64 covers an active
# edit loop's working set; evicted files are didClose'd and re-didOpen'ed on their next touch.
MAX_TRACKED_FILES = 64
_WRITE_ERRORS = (BrokenPipeError, ConnectionResetError, OSError)
_LIVE_STATES = {"starting", "running"}
@@ -283,6 +289,16 @@ class LSPClient:
"workspace/didChangeWorkspaceFolders", {"event": {"added": [_folder(root)], "removed": []}},
)
async def remove_workspace_folder(self, root: str) -> None:
"""Detach ``root`` from a running multi-root server (a removed worktree) so the process keeps
serving its sibling roots instead of being torn down with them. Idempotent."""
if root not in self.workspace_folders:
return
self.workspace_folders.remove(root)
await self._send_notification(
"workspace/didChangeWorkspaceFolders", {"event": {"added": [], "removed": [_folder(root)]}},
)
async def _initialize(self) -> None:
params = {
"rootUri": file_uri(self.workspace_root), "rootPath": self.workspace_root, "processId": os.getpid(),
@@ -299,7 +315,8 @@ class LSPClient:
await self._send_notification("workspace/didChangeConfiguration", {"settings": self._init_options})
async def shutdown(self) -> None:
"""Best-effort graceful shutdown: ``shutdown`` + ``exit``, then SIGTERM/SIGKILL. Idempotent."""
"""Best-effort graceful shutdown: ``shutdown`` + ``exit``, wait ``SHUTDOWN_GRACE`` for the
server to honour ``exit``, then SIGTERM/SIGKILL. Idempotent."""
if self._stopping:
return
self._stopping = True
@@ -313,6 +330,11 @@ class LSPClient:
await self._send_notification("exit", None)
except Exception: # noqa: BLE001
pass
# Signalling right after ``exit`` races the server's own exit: needless SIGTERM
# noise for well-behaved servers and, on Darwin, a reaped-and-reused PID target.
if (proc := self._proc) is not None and proc.returncode is None:
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(proc.wait(), timeout=SHUTDOWN_GRACE)
finally:
self._state = "stopped"
await self._cleanup_process()
@@ -491,12 +513,16 @@ class LSPClient:
)
if doc is None:
# Fresh state: anything a pre-open push stashed under this path (relatedDocuments spillover) is discarded.
self._docs.pop(abs_path, None)
self._docs[abs_path] = _DocState(version=0, text=text)
await self._send_notification(
"textDocument/didOpen",
{"textDocument": {"uri": uri, "languageId": language_id, "version": 0, "text": text}},
)
await self._evict_lru_docs()
return 0
# pop + reinsert refreshes LRU recency (dicts are insertion-ordered).
self._docs[abs_path] = self._docs.pop(abs_path)
change: Dict[str, Any] = {"text": text}
if self._sync_kind == 2:
change["range"] = {"start": {"line": 0, "character": 0}, "end": _end_position(doc.text)}
@@ -513,6 +539,15 @@ class LSPClient:
)
return new_version
async def _evict_lru_docs(self) -> None:
"""Drop least-recently-touched documents beyond MAX_TRACKED_FILES; didClose the ones the server
has open so it releases its mirror too (version -1 entries were never opened)."""
while len(self._docs) > MAX_TRACKED_FILES:
old_path, old = next(iter(self._docs.items()))
del self._docs[old_path]
if old.version >= 0:
await self._send_notification("textDocument/didClose", {"textDocument": {"uri": file_uri(old_path)}})
async def save_file(self, path: str) -> None:
"""Send didSave for ``path``. Some linters re-scan only on save."""
if self.is_running:
+18 -2
View File
@@ -6,7 +6,9 @@ skipped, repeat "no project root" / "server unavailable"); INFO for once-per-ses
transitions (first ``active for <root>``, first ``no project root`` per file) and every
diagnostic event; WARNING for action-required failures (first ``server unavailable`` per
(server_id, binary), every timeout / unexpected error). Dedup uses module-level sets bounded
by the distinct pairs touched in one process — a bounded LRU would re-fire suppressed lines.
by the distinct pairs touched in one process, each capped at ``_ANNOUNCE_CAP`` keys: past it the
bucket resets and the first-seen line re-fires once, so per-file keys can't grow for the life of
a gateway process (#62950).
"""
from __future__ import annotations
@@ -20,6 +22,7 @@ from typing import List, Tuple
event_log = logging.getLogger("hermes.lint.lsp")
_announce_lock = threading.Lock()
_ANNOUNCE_CAP = 512
_announced_active: set = set() # keys: (server_id, workspace_root)
_announced_unavailable: set = set() # keys: (server_id, binary_path_or_name)
_announced_no_root: set = set() # keys: (server_id, file_path)
@@ -32,7 +35,9 @@ def _short_path(file_path: str) -> str:
return file_path
try:
rel = os.path.relpath(file_path)
except ValueError:
except (ValueError, OSError):
# Different drive (ValueError) or the process cwd was removed (OSError from getcwd):
# a log-line shortener must never turn a delivered diagnostic into a swallowed error.
return file_path
return file_path if rel.startswith(".." + os.sep) or rel == ".." else rel
@@ -45,6 +50,8 @@ def _emit_once(bucket: set, key: Tuple, server_id: str, level: int, first: str,
"""Log *first* at *level* the first time *key* is seen, *repeat* at DEBUG thereafter."""
with _announce_lock:
is_first = key not in bucket
if is_first and len(bucket) >= _ANNOUNCE_CAP:
bucket.clear()
bucket.add(key)
_emit(server_id, level if is_first else logging.DEBUG, first if is_first else repeat)
@@ -116,6 +123,15 @@ def log_reaped(keys: List[Tuple[str, str]], idle_timeout: float) -> None:
_emit("reaper", logging.INFO, f"reaped {len(keys)} idle client(s) after {idle_timeout:.0f}s: {summary}")
def log_released(keys: List[Tuple[str, str]], reason: str) -> None:
"""Clients were shut down because their workspace went away (worktree released or root deleted).
INFO, one line per event; forgets the ``log_active`` announcement like :func:`log_reaped`."""
with _announce_lock:
_announced_active.difference_update(keys)
summary = ", ".join(f"{sid} ({root})" for sid, root in keys)
_emit("reaper", logging.INFO, f"released {len(keys)} client(s) ({reason}): {summary}")
def reset_announce_caches() -> None:
"""Test-only: clear the dedup caches. Production code never calls this."""
with _announce_lock:
+86 -22
View File
@@ -36,6 +36,10 @@ def _manual(bin_name: str) -> Dict[str, Any]:
return _recipe("manual", "", bin_name)
# TypeScript 7+ is the Go-native port and ships no ``lib/tsserver.js`` /
# ``lib/typescript.js``, so JS-based servers cannot load it as their SDK.
TYPESCRIPT_SDK_PKG = "typescript@6"
# Recipe key → {strategy, pkg, bin[, extra_pkgs]}. After install we look for
# ``bin`` in ``<HERMES_HOME>/lsp/bin/`` first, then on PATH. ``extra_pkgs``
# are sibling npm packages a server needs in the same node_modules tree.
@@ -43,8 +47,12 @@ INSTALL_RECIPES: Dict[str, Dict[str, Any]] = {
"pyright": _npm("pyright", "pyright-langserver"),
# tsserver must be importable from the same node_modules tree or
# initialize() fails with "Could not find a valid TypeScript installation".
"typescript-language-server": _npm("typescript-language-server", "typescript-language-server", extra_pkgs=["typescript"]),
"@vue/language-server": _npm("@vue/language-server", "vue-language-server"),
"typescript-language-server": _npm("typescript-language-server", "typescript-language-server", extra_pkgs=[TYPESCRIPT_SDK_PKG]),
# 3.x forwards every TypeScript request to a client-hosted tsserver
# (``tsserver/request`` tunnel) that a generic LSP client does not run, so
# it never publishes diagnostics; 2.x self-hosts TypeScript from
# ``initializationOptions.typescript.tsdk`` (see servers._spawn_vue).
"@vue/language-server": _npm("@vue/language-server@2", "vue-language-server", extra_pkgs=[TYPESCRIPT_SDK_PKG]),
"svelte-language-server": _npm("svelte-language-server", "svelteserver"),
"@astrojs/language-server": _npm("@astrojs/language-server", "astro-ls"),
"yaml-language-server": _npm("yaml-language-server", "yaml-language-server"),
@@ -57,6 +65,8 @@ INSTALL_RECIPES: Dict[str, Dict[str, Any]] = {
"rust-analyzer": _manual("rust-analyzer"),
"clangd": _manual("clangd"),
"lua-language-server": _manual("lua-language-server"),
# laravel-lsp ships via composer (`composer global require laravel/lsp`), not npm.
"laravel-lsp": _manual("laravel-lsp"),
# PowerShellEditorServices is a release-zip bundle driven by pwsh; we probe
# the host so `hermes lsp status` reports its presence.
"powershell": _manual("pwsh"),
@@ -81,24 +91,40 @@ def hermes_lsp_bin_dir() -> Path:
return p
def _native_binary_candidates(base: Path) -> list[Path]:
"""Return platform-native executable candidates for a staged binary (``base`` plus Windows wrappers)."""
if not _is_windows():
def _native_binary_candidates(base: Path, *, is_windows: Optional[bool] = None) -> list[Path]:
"""Return platform-native executable candidates for a staged binary, most runnable first.
On Windows the ``.cmd``/``.exe``/``.bat`` wrappers come BEFORE the bare name: npm writes a
POSIX ``#!/bin/sh`` shim under the bare name next to its ``.cmd``, ``os.access(X_OK)`` is
always true there, and ``CreateProcess`` on the shim fails with WinError 193. The bare name
stays as a last resort for genuinely extension-less executables.
"""
if not (_is_windows() if is_windows is None else is_windows):
return [base]
cands: Dict[str, Path] = {}
for c in (base, *(Path(str(base) + s) for s in _WINDOWS_WRAPPER_SUFFIXES)):
for c in (*(Path(str(base) + s) for s in _WINDOWS_WRAPPER_SUFFIXES), base):
cands.setdefault(str(c).lower(), c)
return list(cands.values())
def _first_existing(*bases: Path) -> Optional[Path]:
def _first_existing(*bases: Path, is_windows: Optional[bool] = None) -> Optional[Path]:
"""First platform-native candidate of any ``base`` that exists on disk."""
return next((c for base in bases for c in _native_binary_candidates(base) if c.exists()), None)
return next((c for base in bases for c in _native_binary_candidates(base, is_windows=is_windows) if c.exists()), None)
def _existing_binary(name: str) -> Optional[str]:
"""Probe the staging dir + PATH for a binary named ``name``."""
for staged in _native_binary_candidates(hermes_lsp_bin_dir() / name):
def _npm_bin_dir() -> Path:
"""npm's own ``node_modules/.bin`` under the staging tree, where its ``%~dp0``-relative wrappers work."""
return hermes_lsp_bin_dir().parent / "node_modules" / ".bin"
def _existing_binary(name: str, *, is_windows: Optional[bool] = None) -> Optional[str]:
"""Probe the staging dir (+ npm's bin dir on Windows) then PATH for a binary named ``name``.
``is_windows`` overrides the host check so the Windows resolution is testable as data on every lane.
"""
win = _is_windows() if is_windows is None else is_windows
bases = [hermes_lsp_bin_dir() / name] + ([_npm_bin_dir() / name] if win else [])
for staged in (c for base in bases for c in _native_binary_candidates(base, is_windows=win)):
if staged.exists() and os.access(staged, os.X_OK):
return str(staged)
if any(r.get("strategy") == "pip" and r.get("bin") == name for r in INSTALL_RECIPES.values()):
@@ -111,7 +137,7 @@ def _existing_binary(name: str) -> Optional[str]:
else:
if binary is not None:
return str(binary)
suffixes = ("", *_WINDOWS_WRAPPER_SUFFIXES) if _is_windows() else ("",)
suffixes = (*_WINDOWS_WRAPPER_SUFFIXES, "") if win else ("",)
return next((p for s in suffixes if (p := shutil.which(f"{name}{s}"))), None)
@@ -159,7 +185,9 @@ def _run_installer(tool: str, pkg: str, cmd: list, *, timeout: int, env: Optiona
timeout=timeout, env=env, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags(),
)
if proc.returncode != 0:
logger.warning("[install] %s install failed for %s: %s", tool, pkg, proc.stderr.strip()[:500])
# pnpm reports ERR_PNPM_* on stdout with an empty stderr; log whichever stream carries the reason.
detail = (proc.stderr.strip() or proc.stdout.strip())[:500]
logger.warning("[install] %s install failed for %s: %s", tool, pkg, detail)
return False
except (subprocess.TimeoutExpired, OSError) as e:
logger.warning("[install] %s install errored for %s: %s", tool, pkg, e)
@@ -182,22 +210,58 @@ def _link_into_bin(target: Path) -> str:
return str(link if link.exists() else target)
# Node package manager → argv that installs into ``<staging>/node_modules`` (``lsp.package_manager``).
# Every manager keeps the staging-dir semantics: nothing touches the user's project or global tree.
_NODE_PM_ARGV: Dict[str, Callable[[str], list]] = {
"npm": lambda staging: ["install", "--prefix", staging, "--silent", "--no-fund", "--no-audit"],
"pnpm": lambda staging: ["add", "--dir", staging],
# Global ``--cwd`` (before the command) is accepted by both Yarn Classic and Yarn Berry; Berry's
# default PnP linker writes no ``node_modules/.bin``, so the staging dir needs ``nodeLinker: node-modules``.
"yarn": lambda staging: ["--cwd", staging, "add"],
}
def _node_package_manager() -> Optional[str]:
"""``lsp.package_manager`` from config (npm default); an unknown value fails closed (``None``)."""
try:
from hermes_cli.config import load_config_readonly
lsp_cfg = load_config_readonly().get("lsp") or {}
except Exception: # noqa: BLE001 — installer must not die on a broken config; npm is the historical default
return "npm"
pm = str(lsp_cfg.get("package_manager") or "npm").strip().lower() if isinstance(lsp_cfg, dict) else "npm"
if pm not in _NODE_PM_ARGV:
# Fail closed: a typo must not silently bypass a pnpm/yarn supply-chain policy by running npm.
logger.warning("[install] lsp.package_manager=%r is not one of %s; skipping install", pm, sorted(_NODE_PM_ARGV))
return None
return pm
def _install_npm(pkg: str, bin_name: str, extra_pkgs: Optional[list] = None) -> Optional[str]:
"""``npm install --prefix <staging>`` then link ``node_modules/.bin/<bin_name>`` into ``lsp/bin/``."""
npm = find_node_executable("npm")
if npm is None:
logger.info("[install] cannot install %s: no usable npm found", pkg)
"""Install with the configured Node package manager into ``<staging>`` and link
``node_modules/.bin/<bin_name>`` into ``lsp/bin/``."""
pm = _node_package_manager()
if pm is None:
return None
# Managed Node first: $HERMES_HOME/node isn't on an arbitrary process's
# PATH, so a bare which() would miss the Node that Hermes installed.
pm_bin = find_node_executable(pm)
if pm_bin is None:
# Deliberately no silent fallback to npm: a pnpm/yarn choice is usually a supply-chain policy.
logger.warning("[install] cannot install %s: lsp.package_manager is %r but no usable %s was found "
"(install it, or set lsp.package_manager: npm)", pkg, pm, pm)
return None
staging = hermes_lsp_bin_dir().parent # <HERMES_HOME>/lsp/
install_targets = [pkg] + list(extra_pkgs or [])
logger.info("[install] npm install --prefix %s %s", staging, " ".join(install_targets))
cmd = [npm, "install", "--prefix", str(staging), "--silent", "--no-fund", "--no-audit", *install_targets]
if not _run_installer("npm", pkg, cmd, timeout=300, env=with_hermes_node_path()):
cmd = [pm_bin, *_NODE_PM_ARGV[pm](str(staging)), *install_targets]
logger.info("[install] %s %s", pm, " ".join(cmd[1:]))
if not _run_installer(pm, pkg, cmd, timeout=300, env=with_hermes_node_path()):
return None
found = _first_existing(staging / "node_modules" / ".bin" / bin_name)
if found is not None:
return _link_into_bin(found)
logger.warning("[install] npm install for %s succeeded but bin %s not found", pkg, bin_name)
# npm's Windows wrappers resolve their payload via ``%~dp0\..\<pkg>``, so a copy or symlink
# in ``lsp/bin/`` points at nothing; use them where npm put them (``_existing_binary`` probes there).
return str(found) if _is_windows() and found.suffix.lower() in (".cmd", ".bat") else _link_into_bin(found)
logger.warning("[install] %s install for %s succeeded but bin %s not found", pm, pkg, bin_name)
return None
+97 -13
View File
@@ -20,12 +20,13 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
from agent.lsp import eventlog
from agent.lsp.client import DIAGNOSTICS_DOCUMENT_WAIT, LSPClient, _diagnostic_key as _diag_key
from agent.lsp.servers import ServerContext, ServerDef, find_server_for_file, language_id_for
from agent.lsp.servers import SERVERS, ServerContext, ServerDef, custom_servers, find_server_for_file, language_id_for
from agent.lsp.workspace import clear_cache, resolve_workspace_for_file
logger = logging.getLogger("agent.lsp.manager")
DEFAULT_IDLE_TIMEOUT = 600 # seconds; servers idle for >10min get reaped
_DELTA_BASELINE_CAP = 256 # per-file pre-write snapshots; paths never written again would otherwise live forever (#62950)
MIN_IDLE_TIMEOUT = 30 # floor for config values; must exceed any per-op wait budget
_Key = Tuple[str, str]
@@ -103,6 +104,7 @@ class LSPService:
init_overrides: Optional[Dict[str, Dict[str, Any]]] = None,
disabled_servers: Optional[List[str]] = None,
idle_timeout: float = DEFAULT_IDLE_TIMEOUT,
extra_servers: Optional[List[ServerDef]] = None,
) -> None:
self._enabled = enabled
self._wait_mode = wait_mode if wait_mode in {"document", "full"} else "document"
@@ -113,6 +115,7 @@ class LSPService:
self._init_overrides = init_overrides or {}
self._disabled_servers = set(disabled_servers or [])
self._idle_timeout = idle_timeout
self._extra_servers: List[ServerDef] = list(extra_servers or [])
self._loop = _BackgroundLoop()
if self._enabled:
@@ -165,8 +168,18 @@ class LSPService:
if isinstance(c.get("initialization_options"), dict)},
disabled_servers=[n for n, c in servers.items() if c.get("disabled")],
idle_timeout=idle_timeout,
extra_servers=custom_servers(servers),
)
def _server_for(self, file_path: str) -> Optional[ServerDef]:
"""Config-declared servers first (they may claim an extension ahead of a built-in), then the registry."""
extra = find_server_for_file(file_path, self._extra_servers) if self._extra_servers else None
return extra or find_server_for_file(file_path)
def handles_extension(self, ext: str) -> bool:
"""True iff a config-declared or built-in server claims ``ext`` (pre-write capture decision)."""
return any(ext.lower() in s.extensions for s in (*self._extra_servers, *SERVERS))
# ---- public API ----
def is_active(self) -> bool:
@@ -190,7 +203,7 @@ class LSPService:
def enabled_for(self, file_path: str) -> bool:
"""True iff LSP should run for this file: registered non-disabled server, git workspace,
and pair not broken (a failed server costs nothing until ``hermes lsp restart`` / exit)."""
srv = find_server_for_file(file_path) if self._enabled else None
srv = self._server_for(file_path) if self._enabled else None
if srv is None or srv.server_id in self._disabled_servers:
return False
key = self._broken_key(srv, file_path)
@@ -202,14 +215,24 @@ class LSPService:
if not self.enabled_for(file_path):
return
try:
# Outer budget must exceed the inner wait or a slow-but-alive server gets falsely marked broken.
t = max(8.0, self._wait_timeout + 3.0)
# Outer join budget must exceed the inner wait or a slow-but-alive server gets falsely
# marked broken; it is a ceiling only — the inner wait returns as soon as it completes.
t = max(DIAGNOSTICS_DOCUMENT_WAIT + 3.0, self._wait_timeout + 3.0)
diags = self._loop.run(self._snapshot_async(file_path), timeout=t)
except Exception as e: # noqa: BLE001
logger.debug("baseline snapshot failed for %s: %s", file_path, e)
self._mark_broken_for_file(file_path, e)
diags = []
self._delta_baseline[os.path.abspath(file_path)] = diags or []
self._set_delta_baseline(os.path.abspath(file_path), diags or [])
def _set_delta_baseline(self, abs_path: str, diags: _Diags) -> None:
"""Store a baseline, refreshing recency (pop + reinsert) so eviction tracks write order.
Callers run on arbitrary threads; the multi-step mutation needs the lock (callers don't hold it)."""
with self._state_lock:
self._delta_baseline.pop(abs_path, None)
self._delta_baseline[abs_path] = diags
while len(self._delta_baseline) > _DELTA_BASELINE_CAP:
del self._delta_baseline[next(iter(self._delta_baseline))]
def get_diagnostics_sync(
self, file_path: str, *, delta: bool = True, timeout: Optional[float] = None,
@@ -224,7 +247,7 @@ class LSPService:
"""
if not self.enabled_for(file_path):
return []
server_id = find_server_for_file(file_path).server_id # enabled_for guarantees a match
server_id = self._server_for(file_path).server_id # enabled_for guarantees a match
try:
t = timeout if timeout is not None else self._wait_timeout + 2.0
diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t)
@@ -268,7 +291,7 @@ class LSPService:
except Exception: # noqa: BLE001
fresh = []
if fresh:
self._delta_baseline[abs_path] = fresh
self._set_delta_baseline(abs_path, fresh)
return diags
def _mark_broken_for_file(self, file_path: str, exc: BaseException) -> None:
@@ -276,7 +299,7 @@ class LSPService:
The outer ``_loop.run`` timeout cancels the in-flight spawn before ``_get_or_spawn`` could record
the failure; without this every later write would re-pay the full timeout. Also kills any
half-initialized client and logs the failure once."""
srv = find_server_for_file(file_path)
srv = self._server_for(file_path)
key = self._broken_key(srv, file_path) if srv is not None else None
if key is None:
return
@@ -332,17 +355,18 @@ class LSPService:
"""Open + wait for FRESH diagnostics: ``[]`` = checked clean, ``None`` = no verdict in budget.
Callers must not substitute stale data for either. ``snapshot`` mode
(pre-write baseline) skips didSave and uses the default wait budget.
(pre-write baseline) skips didSave; both modes wait at most ``lsp.wait_timeout``.
"""
client = await self._get_or_spawn(file_path)
if client is None:
return None
try:
version = await client.open_file(file_path, language_id=language_id_for(file_path))
srv = self._server_for(file_path)
version = await client.open_file(file_path, language_id=language_id_for(file_path, srv))
if not snapshot:
await client.save_file(file_path)
fresh = await client.wait_for_diagnostics(
file_path, version, mode=self._wait_mode, timeout=None if snapshot else self._wait_timeout,
file_path, version, mode=self._wait_mode, timeout=self._wait_timeout,
)
except Exception as e: # noqa: BLE001
if snapshot:
@@ -355,7 +379,7 @@ class LSPService:
async def _current_diags_async(self, file_path: str) -> _Diags:
ws, gated = resolve_workspace_for_file(file_path)
srv = find_server_for_file(file_path)
srv = self._server_for(file_path)
if not (ws and gated and srv):
return []
# Same key _get_or_spawn() stored under: single-root servers live under their
@@ -368,7 +392,7 @@ class LSPService:
return list(client.diagnostics_for(file_path, fresh_only=True)) if client else []
async def _get_or_spawn(self, file_path: str) -> Optional[LSPClient]:
srv = find_server_for_file(file_path)
srv = self._server_for(file_path)
if srv is None:
return None
if srv.server_id in self._disabled_servers:
@@ -481,6 +505,66 @@ class LSPService:
if clients:
eventlog.log_reaped([(c.server_id, c.workspace_root) for c in clients], self._idle_timeout)
await asyncio.gather(*(client.shutdown() for client in clients), return_exceptions=True)
# Externally deleted project roots (rm -rf, a worktree removed by another process) never go
# idle from the server's point of view — tsserver keeps its multi-GiB heap for a tree that is gone.
await self._detach_roots(lambda folder: not os.path.isdir(folder), reason="workspace root deleted")
def release_workspace(self, workspace_root: str) -> int:
"""Shut down the clients serving ``workspace_root`` or any root beneath it; returns how many.
Called by the worktree cleanup paths BEFORE ``git worktree remove`` so a gateway that outlives the
session does not keep the language server (and its stdio pipes) alive for a tree that no longer
exists. Multi-root servers only drop the folder. Idempotent; best-effort — never blocks removal.
"""
if not self._enabled:
return 0
root = os.path.abspath(workspace_root)
try:
return self._loop.run(self._release_async(root), timeout=15.0)
except Exception as e: # noqa: BLE001
logger.debug("LSP release of %s failed: %s", root, e)
return 0
async def _release_async(self, root: str) -> int:
def _under(path: str) -> bool:
return path == root or path.startswith(root + os.sep)
# A spawn in flight would insert its client AFTER we detach; let it land first (same loop, so
# once the future resolves the client is in ``_clients`` and the pass below sees it).
with self._state_lock:
pending = [fut for fut in self._spawning.values() if not fut.done()]
self._broken = {key for key in self._broken if not _under(key[1])}
for path in [p for p in self._delta_baseline if _under(p)]:
del self._delta_baseline[path]
if pending:
await asyncio.wait(pending, timeout=10.0)
released = await self._detach_roots(_under, reason="workspace released")
clear_cache()
return released
async def _detach_roots(self, is_gone: Callable[[str], bool], *, reason: str) -> int:
"""Shared teardown primitive for :meth:`release_workspace` and the reaper.
Clients whose every workspace folder ``is_gone`` are detached under ``_state_lock`` and shut down;
multi-root clients that still serve other folders only drop the gone ones. Returns the number of
clients shut down.
"""
with self._state_lock:
dead_keys = [key for key, c in self._clients.items() if all(map(is_gone, c.workspace_folders))]
clients = [self._clients.pop(key) for key in dead_keys]
for key in dead_keys:
self._last_used.pop(key, None)
trims = [(c, [f for f in c.workspace_folders if is_gone(f)]) for c in self._clients.values()]
for client, folders in trims:
for folder in folders:
try:
await client.remove_workspace_folder(folder)
except Exception as e: # noqa: BLE001
logger.debug("LSP folder removal for %s failed: %s", folder, e)
if clients:
eventlog.log_released([(c.server_id, c.workspace_root) for c in clients], reason)
await asyncio.gather(*(client.shutdown() for client in clients), return_exceptions=True)
return len(clients)
async def _shutdown_async(self) -> None:
if (reaper := self._idle_reaper_task) is not None:
+127 -13
View File
@@ -34,7 +34,7 @@ _EXTS_BY_LANGUAGE: Dict[str, Sequence[str]] = {
"csharp": (".cs", ".csx"), "fsharp": (".fs", ".fsi", ".fsx"),
"swift": (".swift",), "java": (".java",), "kotlin": (".kt", ".kts"),
"yaml": (".yaml", ".yml"), "json": (".json",), "jsonc": (".jsonc",),
"lua": (".lua",), "php": (".php",), "prisma": (".prisma",), "dart": (".dart",),
"lua": (".lua",), "php": (".php",), "blade": (".blade.php",), "prisma": (".prisma",), "dart": (".dart",),
"ocaml": (".ml", ".mli"),
"shellscript": (".sh", ".bash", ".zsh"),
"terraform": (".tf", ".tfvars"),
@@ -75,6 +75,8 @@ class ServerDef:
# Server handles ``workspace/didChangeWorkspaceFolders``: one process serves every project root
# (git worktrees included) as extra workspaceFolders instead of one process per root.
multi_root: bool = False
# didOpen languageId; "" = derive from LANGUAGE_BY_EXT (custom servers name theirs in config).
language_id: str = ""
def matches(self, file_path: str) -> bool:
return _file_ext_or_basename(file_path) in self.extensions
@@ -92,10 +94,16 @@ class ServerContext:
# ---- helpers ----
# Multi-part extensions that name a different language than their last segment: ``os.path.splitext``
# would reduce ``home.blade.php`` to ``.php`` and hand Blade templates to the plain-PHP server.
_COMPOUND_EXTS = (".blade.php",)
def _file_ext_or_basename(path: str) -> str:
"""Lower-cased extension, or the full basename for extensionless files (``Dockerfile``)."""
"""Lower-cased extension (compound ones first), or the full basename for extensionless files (``Dockerfile``)."""
base = os.path.basename(path)
return os.path.splitext(base)[1].lower() or base
lower = base.lower()
return next((c for c in _COMPOUND_EXTS if lower.endswith(c)), None) or os.path.splitext(base)[1].lower() or base
def _which(*names: str) -> Optional[str]:
@@ -158,9 +166,11 @@ def _spawn_pyright(root: str, ctx: ServerContext) -> Optional[SpawnSpec]:
bin_path = _find_binary(ctx, "pyright", ("pyright-langserver", "pyright"), "pyright")
if bin_path is None:
return None
# If we got the cli ``pyright``, the langserver is its sibling.
if os.path.basename(bin_path) in {"pyright", "pyright.exe"}:
sibling = os.path.join(os.path.dirname(bin_path), "pyright-langserver")
# If we got the cli ``pyright``, the langserver is its sibling — same suffix, since on Windows
# the bare sibling is npm's unrunnable POSIX shim.
stem, suffix = os.path.splitext(os.path.basename(bin_path))
if stem == "pyright":
sibling = os.path.join(os.path.dirname(bin_path), f"pyright-langserver{suffix}")
if os.path.exists(sibling):
bin_path = sibling
# Point pyright at the project venv; its default "python on PATH" rarely is.
@@ -203,6 +213,67 @@ def _spawn_bash_ls(root: str, ctx: ServerContext) -> Optional[SpawnSpec]:
return _make_spec(root, ctx, "bash-language-server", [bin_path, "start"])
_VUE_REINSTALL = (
"delete <HERMES_HOME>/lsp/node_modules/@vue and <HERMES_HOME>/lsp/bin/vue-language-server*, "
"then run: hermes lsp install vue-language-server"
)
_VUE_TUNNEL_MSG = (
"vue-language-server: the installed @vue/language-server is 3.x, which only works behind a client-hosted "
f"tsserver tunnel Hermes does not run — no diagnostics will arrive. Reinstall the self-hosting 2.x line: {_VUE_REINSTALL}"
)
_VUE_TSDK_MSG = (
"vue-language-server: no JavaScript TypeScript SDK (typescript/lib/typescript.js) next to the server or under "
f"the project's node_modules — diagnostics are skipped. Reinstall (the recipe co-installs one): {_VUE_REINSTALL}"
)
def _node_modules_trees(bin_path: str, root: str) -> List[str]:
"""``node_modules`` trees that may hold the Vue server and its TypeScript SDK:
the launcher's own tree (symlinks resolved), Hermes staging, then the project's."""
from agent.lsp.install import hermes_lsp_bin_dir
trees = [str(hermes_lsp_bin_dir().parent / "node_modules"), os.path.join(root, "node_modules")]
real = os.path.realpath(bin_path)
marker = f"{os.sep}node_modules{os.sep}"
if (idx := real.rfind(marker)) >= 0:
trees.insert(0, real[: idx + len(marker) - 1])
return trees
def _vue_server_major(trees: Sequence[str]) -> int:
"""Major version of the first ``@vue/language-server`` found in ``trees``; 0 when unreadable."""
import json
for tree in trees:
try:
with open(os.path.join(tree, "@vue", "language-server", "package.json"), encoding="utf-8") as fh:
return int(str(json.load(fh).get("version", "")).split(".")[0])
except (OSError, ValueError):
continue
return 0
def _typescript_sdk_dir(trees: Sequence[str]) -> Optional[str]:
"""First ``typescript/lib`` in ``trees`` holding a JS ``typescript.js`` (TypeScript 7+ ships none)."""
cands = (os.path.join(tree, "typescript", "lib") for tree in trees)
return next((c for c in cands if os.path.isfile(os.path.join(c, "typescript.js"))), None)
def _spawn_vue(root: str, ctx: ServerContext) -> Optional[SpawnSpec]:
"""Spawn @vue/language-server 2.x self-hosting TypeScript (``hybridMode`` off, explicit ``tsdk``)."""
bin_path = _find_binary(ctx, "vue-language-server", ("vue-language-server",), "@vue/language-server")
if bin_path is None:
return None
trees = _node_modules_trees(bin_path, root)
if _vue_server_major(trees) >= 3:
_warn_once("vue-tunnel", _VUE_TUNNEL_MSG)
return None
tsdk = _typescript_sdk_dir(trees)
if tsdk is None:
_warn_once("vue-tsdk", _VUE_TSDK_MSG)
return None
return _make_spec(root, ctx, "vue-language-server", [bin_path, "--stdio"],
{"typescript": {"tsdk": tsdk}, "vue": {"hybridMode": False}})
def _find_pses_bundle(ctx: ServerContext) -> Optional[str]:
"""Locate the PowerShellEditorServices bundle dir (release zip, manual install). Resolution order:
``lsp.servers.powershell.command[0]`` when a directory, ``init_overrides["powershell"]["bundlePath"]``,
@@ -296,7 +367,7 @@ SERVERS: List[ServerDef] = [
"JavaScript/TypeScript — typescript-language-server", resolve_root=_root_typescript,
which=("typescript-language-server",), args=("--stdio",), install_pkg="typescript-language-server", seed=True),
_server("vue-language-server", (".vue",), "Vue.js — @vue/language-server", resolve_root=_root_typescript,
args=("--stdio",), install_pkg="@vue/language-server"),
build_spawn=_spawn_vue),
_server("svelte-language-server", (".svelte",), "Svelte — svelte-language-server", resolve_root=_root_typescript,
which=("svelteserver", "svelte-language-server"), args=("--stdio",), install_pkg="svelte-language-server"),
_server("astro-language-server", (".astro",), "Astro — @astrojs/language-server", resolve_root=_root_typescript,
@@ -312,6 +383,9 @@ SERVERS: List[ServerDef] = [
_server("lua-language-server", (".lua",), "Lua — lua-language-server",
markers=[".luarc.json", ".luarc.jsonc", ".luacheckrc", ".stylua.toml", "stylua.toml", "selene.toml", "selene.yml"],
install_pkg="lua-language-server"),
# Before intelephense: Blade templates are Laravel's, plain .php stays with intelephense.
_server("laravel-lsp", (".blade.php",), "Laravel Blade — laravel-lsp (manual: composer global require laravel/lsp)",
markers=["artisan", "composer.json", "composer.lock"], args=("lsp",)),
_server("intelephense", (".php",), "PHP — intelephense", markers=["composer.json", "composer.lock", ".php-version"],
args=("--stdio",), install_pkg="intelephense", base_init={"telemetry": {"enabled": False}}),
_server("ocaml-lsp", (".ml", ".mli"), "OCaml — ocaml-lsp", markers=["dune-project", "dune-workspace", ".merlin", "opam"],
@@ -348,14 +422,54 @@ SERVERS: List[ServerDef] = [
]
def find_server_for_file(file_path: str) -> Optional[ServerDef]:
"""Return the registry entry that handles ``file_path``, or None."""
return next((srv for srv in SERVERS if srv.matches(file_path)), None)
def find_server_for_file(file_path: str, servers: Optional[Sequence[ServerDef]] = None) -> Optional[ServerDef]:
"""Return the first entry of ``servers`` (default: the built-in registry) that handles ``file_path``."""
return next((srv for srv in (SERVERS if servers is None else servers) if srv.matches(file_path)), None)
def language_id_for(path: str) -> str:
"""Return the LSP languageId to send in didOpen for ``path``."""
def language_id_for(path: str, srv: Optional[ServerDef] = None) -> str:
"""Return the LSP languageId to send in didOpen for ``path`` (a custom server's own id wins)."""
if srv is not None and srv.language_id:
return srv.language_id
return LANGUAGE_BY_EXT.get(_file_ext_or_basename(path), "plaintext")
__all__ = ["ServerDef", "ServerContext", "SpawnSpec", "SERVERS", "find_server_for_file", "language_id_for", "LANGUAGE_BY_EXT"]
def _custom_spawn(server_id: str, command: Sequence[str]) -> _SpawnFn:
"""Spawn builder for a config-declared server: ``command[0]`` is a path or a PATH lookup, no auto-install."""
def build(root: str, ctx: ServerContext) -> Optional[SpawnSpec]:
bin_path = _which(os.path.expanduser(command[0]))
if bin_path is None:
_warn_once(f"custom:{server_id}", f"lsp.servers.{server_id}: command {command[0]!r} not found on PATH — server skipped")
return None
return _make_spec(root, ctx, server_id, [bin_path, *command[1:]])
return build
def custom_servers(servers_cfg: Any) -> List[ServerDef]:
"""``lsp.servers`` entries that declare ``extensions`` and name no built-in server are user-declared
servers (issue #100257). They go AHEAD of the built-ins so a custom entry can claim an extension;
malformed entries are logged and skipped so one typo never disables the rest of the subsystem."""
if not isinstance(servers_cfg, dict):
return []
builtin = {s.server_id for s in SERVERS}
out: List[ServerDef] = []
for server_id, cfg in servers_cfg.items():
if server_id in builtin or not isinstance(cfg, dict) or "extensions" not in cfg:
continue
command, exts, markers = cfg.get("command"), cfg.get("extensions"), cfg.get("root_markers")
if not (isinstance(command, list) and command and all(isinstance(c, str) and c for c in command)
and isinstance(exts, list) and exts and all(isinstance(e, str) and e for e in exts)):
logger.warning("lsp.servers.%s: custom server needs command: [bin, ...args] and extensions: [.ext, ...] — ignored", server_id)
continue
out.append(ServerDef(
str(server_id), tuple(e.lower() if e.startswith(".") else e for e in exts),
_markers_root([str(m) for m in markers] if isinstance(markers, list) and markers else None),
_custom_spawn(str(server_id), command),
description=str(cfg.get("description") or f"{server_id} — custom (lsp.servers)"),
language_id=str(cfg.get("language_id") or ""),
))
return out
__all__ = ["ServerDef", "ServerContext", "SpawnSpec", "SERVERS", "custom_servers", "find_server_for_file",
"language_id_for", "LANGUAGE_BY_EXT"]
+17 -6
View File
@@ -15,8 +15,10 @@ from typing import Iterable, Iterator, Optional, Tuple
logger = logging.getLogger("agent.lsp.workspace")
# Cache: start dir → (worktree_root, is_git) so repeated calls don't re-stat. Cleared on shutdown.
# Cache: start dir → (worktree_root, is_git) so repeated calls don't re-stat. Cleared on shutdown; capped
# because every distinct file dir a long gateway session touches lands here (#62950).
_workspace_cache: dict = {}
_WORKSPACE_CACHE_CAP = 512
# Walk cap: the deepest reasonable monorepo is well under 64 levels; bounds a
# pathological cwd or symlink cycle even though parent-equality normally stops us.
@@ -60,16 +62,18 @@ def find_git_worktree(start: str) -> Optional[str]:
cached = _workspace_cache.get(str(start_path))
if cached is not None:
return cached[0]
resolved = None
for cur in _walk_up(start_path):
try:
if (cur / ".git").exists():
resolved = str(cur)
_workspace_cache[str(start_path)] = (resolved, True)
return resolved
break
except OSError:
break # permission error on a parent dir — bail out cleanly
_workspace_cache[str(start_path)] = (None, False)
return None
_workspace_cache[str(start_path)] = (resolved, resolved is not None)
if len(_workspace_cache) > _WORKSPACE_CACHE_CAP:
_workspace_cache.clear() # a stat cache: resetting is a few re-stats, and one atomic op is thread-safe
return resolved
def is_inside_workspace(path: str, workspace_root: str) -> bool:
@@ -133,7 +137,14 @@ def resolve_workspace_for_file(file_path: str, *, cwd: Optional[str] = None) ->
"""Return ``(workspace_root, gated_in)`` for a file. The cwd's worktree wins when the file is
inside it; otherwise the file's own worktree is the fallback anchor (monorepos / unrelated
checkouts). ``(None, False)`` when neither is in a git worktree."""
cwd_root = find_git_worktree(cwd or os.getcwd())
try:
cwd_anchor = cwd or os.getcwd()
except OSError:
# The process cwd was removed underneath us (a scratch workspace cleaned up at
# card completion); getcwd keeps raising even after the path is recreated, so
# there is simply no cwd anchor — fall through to the file's own worktree.
cwd_anchor = None
cwd_root = find_git_worktree(cwd_anchor) if cwd_anchor else None
if cwd_root is not None and is_inside_workspace(file_path, cwd_root):
return cwd_root, True
file_root = find_git_worktree(file_path)
+39 -5
View File
@@ -56,7 +56,8 @@ def finish_text_response(
iteration-limit summarization; the final message is appended and flushed only after the
stop gates accept it."""
from agent.conversation_loop import (
_CODEX_ACK_CONTINUATION_NUDGE, _DROPPED_TOOLCALL_NUDGE_CONTENT, _join_truncated_parts
_CODEX_ACK_CONTINUATION_NUDGE, _DEGENERATE_FINAL_NUDGE, _DROPPED_TOOLCALL_NUDGE_CONTENT,
_join_truncated_parts
)
def _verdict(action: str, result: Optional[Dict[str, Any]] = None) -> FinalResponseVerdict:
@@ -142,7 +143,8 @@ def finish_text_response(
# delivery channel (gateway status message / CLI print). NEVER appended to messages/api_messages:
# conversation context and the cached prompt prefix stay byte-identical.
from agent.agent_runtime_helpers import (
intent_ack_continuation_mode, promoted_reasoning_announces_action, trailing_continue_intent
intent_ack_continuation_mode, looks_like_degenerate_final, promoted_reasoning_announces_action,
tool_results_this_turn, trailing_continue_intent,
)
_ack_mode = intent_ack_continuation_mode(agent)
@@ -162,7 +164,23 @@ def finish_text_response(
or (bool(_promoted) and promoted_reasoning_announces_action(_stall_text))
)
)
if _stall_continue_intent or (
# Degenerate-final guard (#103483): the turn did real tool work and then stopped on a
# fragment. Same scope knob and the SAME bounded counter as the ack continuation; the nudge
# row itself closes the tool-work window, so a second fragment ends the turn as the answer.
_tool_rows = tool_results_this_turn(messages)
_degenerate_final = (
bool(getattr(agent, "_stall_guards", True))
and _ack_mode != "off"
and codex_ack_continuations < 2
and _tool_rows > 0
and looks_like_degenerate_final(_stall_text, user_message=user_message)
)
# Precedence: an announced next action outranks the fragment shape; the codex ack is last.
if _stall_continue_intent:
_continuation_kind = "stall"
elif _degenerate_final:
_continuation_kind = "degenerate"
elif (
_ack_mode != "off"
and agent.valid_tool_names
and codex_ack_continuations < 2
@@ -171,12 +189,22 @@ def finish_text_response(
require_workspace=(_ack_mode == "codex_only"),
)
):
if _stall_continue_intent:
_continuation_kind = "ack"
else:
_continuation_kind = None
if _continuation_kind:
if _continuation_kind == "stall":
logger.info(
"Stall guard: turn ending on trailing continue-"
"intent with no tool calls — re-prompting to act "
"(%d/2)", codex_ack_continuations + 1,
)
elif _continuation_kind == "degenerate":
logger.warning(
"Degenerate final: %d-char fragment %r ended the turn after %d tool result(s) — "
"re-prompting (%d/2)", len(_stall_text), _stall_text[:40], _tool_rows,
codex_ack_continuations + 1,
)
codex_ack_continuations += 1
interim_msg = agent._build_assistant_message(assistant_message, "incomplete")
if _promoted:
@@ -185,7 +213,13 @@ def finish_text_response(
interim_msg["api_content"] = final_response
append_message(messages, interim_msg)
agent._emit_interim_assistant_message(interim_msg)
append_message(messages, {"role": "user", "content": _CODEX_ACK_CONTINUATION_NUDGE})
append_message(messages, {
"role": "user",
"content": (
_DEGENERATE_FINAL_NUDGE if _continuation_kind == "degenerate"
else _CODEX_ACK_CONTINUATION_NUDGE
),
})
agent._session_messages = messages
# An acknowledgment is non-final: its text must not suppress iteration-limit
# summarization if the continuation exhausts budget.
+1 -2
View File
@@ -232,8 +232,7 @@ blurred backdrop.
Empty lists hide their search field.
- **`SegmentedControl`** — the choice control for small mutually-exclusive sets
(color mode, tool-call display, usage period). Replaces radio piles and
pill rows. `iconOnly` renders compact icon buttons with label tooltips and
accessible names; use `codiconIcon()` for Codicon options.
pill rows.
- **`Switch`** (`size="xs"`) — bare, with `aria-label`. No bordered text wrapper.
- **`FanMenu`** (`src/components/ui/fan-menu.tsx`) — one hub control that
fans sibling toggles out on hover: `direction` `vertical` | `horizontal`
+1 -1
View File
@@ -6128,7 +6128,7 @@ async function showPluginCompatNoticeOnce() {
})
if (response === 0) {
handleDeepLink(`${HERMES_PROTOCOL}://open/skills?tab=plugins`)
handleDeepLink(`${HERMES_PROTOCOL}://open/capabilities?tab=plugins`)
}
} finally {
try {
+1 -1
View File
@@ -147,7 +147,7 @@ export function hermesApi<T>(request: HermesApiRequest): Promise<T> {
//
// A profile is not a machine-global name — it belongs to ONE gateway. The
// Capabilities surface can be pointed at any (connection, profile) pair
// (SkillsView's scope selector, Bot Mode's fixedProfile/fixedConnection), so
// (CapabilitiesView's scope selector, Bot Mode's fixedProfile/fixedConnection), so
// its REST helpers accept either the legacy string form or an explicit scope
// object:
//
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { QueryClientProvider } from '@tanstack/react-query'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import type * as ReactRouterDom from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -9,10 +9,6 @@ import type * as HermesApi from '@/hermes'
import { queryClient } from '@/lib/query-client'
import type * as HubActions from '@/store/hub-actions'
import { parseCatalog } from './catalog-data'
import { SkillCatalog } from './skill-catalog'
import { $catalogCardView } from './store'
const getSkills = vi.fn()
const getToolsets = vi.fn()
const setSkillEnabled = vi.fn()
@@ -22,8 +18,9 @@ const selectToolsetProvider = vi.fn()
const getUsageAnalytics = vi.fn()
const getProfiles = vi.fn()
const getSkillContent = vi.fn()
const getOfficialSkills = vi.fn()
// Partial mock: keep the real module (SkillsView pulls in @/store/profile,
// Partial mock: keep the real module (CapabilitiesView pulls in @/store/profile,
// whose import-time subscription calls setApiRequestProfile) and stub only the
// calls we assert on. Args are forwarded so the per-profile scope arg is
// observable.
@@ -38,7 +35,8 @@ vi.mock('@/hermes', async importOriginal => ({
selectToolsetProvider: (toolset: string, provider: string) => selectToolsetProvider(toolset, provider),
getUsageAnalytics: (days: number, profile?: null | string) => getUsageAnalytics(days, profile),
getProfiles: () => getProfiles(),
getSkillContent: (name: string, profile?: null | string) => getSkillContent(name, profile)
getSkillContent: (name: string, profile?: null | string) => getSkillContent(name, profile),
getOfficialSkills: (profile?: null | string) => getOfficialSkills(profile)
}))
// Notifications hit nanostores/timers we don't care about here.
@@ -48,7 +46,7 @@ vi.mock('@/store/notifications', () => ({
}))
// The catalog Install button routes through the hub action pipeline — stub the
// action entrypoint (real module kept: SkillsView reads $hubActions and the
// action entrypoint (real module kept: CapabilitiesView reads $hubActions and the
// query keys from it).
vi.mock('@/store/hub-actions', async importOriginal => ({
...(await importOriginal<typeof HubActions>()),
@@ -67,7 +65,7 @@ vi.mock('react-router', async importOriginal => ({
// Import at module scope (after the hoisted vi.mock calls) so the heavy
// component-tree transform is paid during collection, not billed against the
// first test's testTimeout — same flake class as messaging/index.test.tsx.
const { SkillsView } = await import('./index')
const { CapabilitiesView } = await import('./index')
function toolset(overrides: Record<string, unknown> = {}) {
return {
@@ -86,10 +84,10 @@ async function renderSkills() {
let result: ReturnType<typeof render>
await act(async () => {
result = render(
// SkillsView reads skills/toolsets via useQuery, so it needs a provider.
// CapabilitiesView reads skills/toolsets via useQuery, so it needs a provider.
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/skills?tab=toolsets']}>
<SkillsView />
<MemoryRouter initialEntries={['/capabilities?tab=toolsets']}>
<CapabilitiesView />
</MemoryRouter>
</QueryClientProvider>
)
@@ -99,13 +97,12 @@ async function renderSkills() {
}
beforeEach(() => {
// Scope/install cases exercise the retained list layout; cards have dedicated coverage.
$catalogCardView.set(false)
getSkills.mockResolvedValue([])
getToolsets.mockResolvedValue([toolset()])
setToolsetEnabled.mockResolvedValue({ ok: true, name: 'web', enabled: false })
getToolsetConfig.mockResolvedValue({ has_category: true, active_provider: null, providers: [] })
getUsageAnalytics.mockResolvedValue({ tools: [] })
getOfficialSkills.mockResolvedValue({ skills: [] })
getSkillContent.mockResolvedValue({
name: 'web-research',
path: '/skills/web-research/SKILL.md',
@@ -119,17 +116,16 @@ beforeEach(() => {
afterEach(() => {
cleanup()
vi.clearAllMocks()
vi.unstubAllGlobals()
// Shared singleton client — drop cached skills/toolsets so each test refetches.
queryClient.clear()
})
// SkillsView is a heavy module (import cost now paid at module scope above,
// CapabilitiesView is a heavy module (import cost now paid at module scope above,
// during collection) but the file still legitimately runs ~14s on CI runners —
// right against the global 15s per-test budget, so slow runners cascade-fail
// all 11 tests (2× in a row on PR #93612, plus a main run the same hour).
// Give this file headroom; the tests are not slow individually.
describe('SkillsView toolset management', { timeout: 60_000 }, () => {
describe('CapabilitiesView toolset management', { timeout: 60_000 }, () => {
it('renders a switch for each toolset and toggles it off', async () => {
await renderSkills()
@@ -184,8 +180,8 @@ describe('SkillsView toolset management', { timeout: 60_000 }, () => {
await act(async () => {
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/skills?tab=toolsets']}>
<SkillsView />
<MemoryRouter initialEntries={['/capabilities?tab=toolsets']}>
<CapabilitiesView />
</MemoryRouter>
</QueryClientProvider>
)
@@ -229,8 +225,8 @@ describe('SkillsView toolset management', { timeout: 60_000 }, () => {
await act(async () => {
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/skills?tab=skills']}>
<SkillsView />
<MemoryRouter initialEntries={['/capabilities?tab=skills']}>
<CapabilitiesView />
</MemoryRouter>
</QueryClientProvider>
)
@@ -272,8 +268,8 @@ describe('SkillsView toolset management', { timeout: 60_000 }, () => {
await act(async () => {
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/skills?tab=skills']}>
<SkillsView />
<MemoryRouter initialEntries={['/capabilities?tab=skills']}>
<CapabilitiesView />
</MemoryRouter>
</QueryClientProvider>
)
@@ -288,190 +284,65 @@ describe('SkillsView toolset management', { timeout: 60_000 }, () => {
expect(await screen.findByText(/Deep research steps/)).toBeTruthy()
})
it.each(['web-research', 'official/research/web-research'])(
'disables catalog installation when the installed list contains %s',
async installedName => {
const { installHubSkill } = await import('@/store/hub-actions')
queryClient.setQueryData(['public-catalog', 'skills'], parseCatalog('skills', [{
name: 'web-research',
identifier: 'official/research/web-research',
source: 'official',
category: 'research',
description: 'Research the web'
}]))
it('hub picker refuses to reinstall an already-installed skill', async () => {
const { notify } = await import('@/store/notifications')
const { EmbeddedHubPicker } = await import('./skills/embedded-hub-picker')
render(
<QueryClientProvider client={queryClient}>
<SkillCatalog installedNames={new Set([installedName])} profile="researcher" />
</QueryClientProvider>
)
render(<EmbeddedHubPicker installedNames={new Set(['web-research'])} profile={null} />)
fireEvent.click(screen.getByRole('button', { name: /^web-research/ }))
const installed = screen.getByRole('button', { name: 'Installed' })
expect((installed as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(installed)
expect(installHubSkill).not.toHaveBeenCalled()
}
)
it.each([
{
source: 'github',
identifier: 'github:example/skills/research/community-research',
expectedIdentifier: 'github:example/skills/research/community-research'
},
{
source: 'clawhub',
identifier: 'community-research',
expectedIdentifier: 'clawhub/community-research'
},
{
source: 'clawhub',
identifier: 'clawhub/community-research',
expectedIdentifier: 'clawhub/community-research'
}
])('installs $identifier with its source-qualified target in the pinned connection and profile', async ({ source, identifier, expectedIdentifier }) => {
const { installHubSkill } = await import('@/store/hub-actions')
const entry = {
name: 'community-research',
identifier,
source,
category: 'research',
description: 'Community research workflow'
}
queryClient.setQueryData(['public-catalog', 'skills'], parseCatalog('skills', [
{ ...entry, name: 'other-skill', source: 'github', identifier: 'github:example/skills/other-skill' },
entry
]))
// The picker is expanded by default — the hub iframe is live on mount.
expect(document.querySelector('iframe')).toBeTruthy()
await act(async () => {
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/skills']}>
<SkillsView embedded fixedConnection="homelab" fixedProfile="researcher" />
</MemoryRouter>
</QueryClientProvider>
window.dispatchEvent(
new MessageEvent('message', {
data: { type: 'hermes-skill-pick', name: 'web-research', identifier: 'web-research' },
origin: 'https://hermes-agent.nousresearch.com'
})
)
})
fireEvent.click(screen.getByRole('button', { name: 'Browse' }))
fireEvent.click(await screen.findByRole('button', { name: /^community-research/ }))
expect(screen.getByRole('heading', { name: entry.name })).toBeTruthy()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Install' }))
})
expect(installHubSkill).toHaveBeenCalledExactlyOnceWith(expectedIdentifier, {
connectionId: 'homelab', profile: 'researcher'
})
})
it('keeps a pending install tied to its entry and scope when the pinned target changes', async () => {
const { installHubSkill } = await import('@/store/hub-actions')
const identifier = 'clawhub/community-research'
queryClient.setQueryData(['public-catalog', 'skills'], parseCatalog('skills', [
{ name: 'community-research', identifier: 'community-research', source: 'clawhub' },
{ name: 'other-skill', identifier: 'official/research/other-skill', source: 'optional' }
]))
let finishFirst!: () => void
let finishSecond!: () => void
const firstInstall = new Promise<void>(resolve => { finishFirst = resolve })
const secondInstall = new Promise<void>(resolve => { finishSecond = resolve })
vi.mocked(installHubSkill)
.mockReturnValueOnce(firstInstall)
.mockReturnValueOnce(secondInstall)
const scopedView = (connectionId: string, profile: string) => (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/skills']}>
<SkillsView embedded fixedConnection={connectionId} fixedProfile={profile} />
</MemoryRouter>
</QueryClientProvider>
// Refused with an informational toast, no install action spawned.
await waitFor(() =>
expect(vi.mocked(notify)).toHaveBeenCalledWith(
expect.objectContaining({ title: '"web-research" is already installed' })
)
)
const view = render(scopedView('homelab', 'researcher'))
fireEvent.click(screen.getByRole('button', { name: 'Browse' }))
fireEvent.click(await screen.findByRole('button', { name: /^community-research/ }))
fireEvent.click(screen.getByRole('button', { name: 'Install' }))
const pending = screen.getByRole<HTMLButtonElement>('button', { name: 'Installing...' })
expect(pending.disabled).toBe(true)
expect(pending.querySelector('svg.animate-spin')).not.toBeNull()
fireEvent.click(pending)
expect(installHubSkill).toHaveBeenCalledExactlyOnceWith(identifier, {
connectionId: 'homelab', profile: 'researcher'
})
fireEvent.click(screen.getByRole('button', { name: /^other-skill/ }))
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Install' }).disabled).toBe(false)
fireEvent.click(screen.getByRole('button', { name: /^community-research/ }))
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Installing...' }).disabled).toBe(true)
view.rerender(scopedView('other-gateway', 'writer'))
fireEvent.click(await screen.findByRole('button', { name: /^community-research/ }))
const nextInstall = screen.getByRole<HTMLButtonElement>('button', { name: 'Install' })
expect(nextInstall.disabled).toBe(false)
fireEvent.click(nextInstall)
expect(installHubSkill).toHaveBeenNthCalledWith(2, identifier, {
connectionId: 'other-gateway', profile: 'writer'
})
await act(async () => {
finishFirst()
await firstInstall
})
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Installing...' }).disabled).toBe(true)
await act(async () => {
finishSecond()
await secondInstall
})
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Install' }).disabled).toBe(false)
expect(installHubSkill).toHaveBeenCalledTimes(2)
})
it('loads the public catalog only on Browse and reuses it across skills and tools tab switches', async () => {
const fetchCatalog = vi.fn().mockResolvedValue({
ok: true,
json: async () => [{
name: 'catalog-research',
identifier: 'official/research/catalog-research',
source: 'official',
category: 'research',
description: 'Research from the public snapshot'
}]
})
vi.stubGlobal('fetch', fetchCatalog)
it('mounts the hub iframe lazily and keeps it (hidden) across tab switches', async () => {
// On a non-Skills tab the docs-site iframe must not exist at all — an
// eagerly mounted hub is exactly the Capabilities lag bug.
await renderSkills() // ?tab=toolsets
await screen.findByRole('switch', { name: 'Turn Web Search toolset off' })
expect(fetchCatalog).not.toHaveBeenCalled()
expect(document.querySelector('iframe')).toBeNull()
cleanup()
// Embedded mode drives tabs through local state (the route hooks are
// mocked here), starting on Skills: the picker mounts with the tab.
await act(async () => {
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/skills']}>
<SkillsView embedded />
<MemoryRouter initialEntries={['/capabilities']}>
<CapabilitiesView embedded />
</MemoryRouter>
</QueryClientProvider>
)
})
expect(screen.queryByRole('heading', { name: 'catalog-research' })).toBeNull()
expect(fetchCatalog).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Browse' }))
expect(await screen.findByRole('heading', { name: 'catalog-research' })).toBeTruthy()
expect(fetchCatalog).toHaveBeenCalledTimes(1)
expect(fetchCatalog.mock.calls[0][0]).toMatch(/\/docs\/api\/skills\.json$/)
const iframe = document.querySelector('iframe')
expect(iframe).toBeTruthy()
expect(iframe!.closest('section')!.classList.contains('hidden')).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'Installed' }))
expect(screen.queryByRole('heading', { name: 'catalog-research' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Browse' }))
expect(await screen.findByRole('heading', { name: 'catalog-research' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /^Tools/ }))
await screen.findByRole('switch', { name: 'Turn Web Search toolset off' })
expect(screen.queryByRole('heading', { name: 'catalog-research' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /^Skills/ }))
expect(await screen.findByRole('heading', { name: 'catalog-research' })).toBeTruthy()
expect(fetchCatalog).toHaveBeenCalledTimes(1)
// Switch to Tools → the iframe STAYS mounted (no docs-site reload on the
// next visit) but its section is fully hidden, so nothing from the hub
// can paint over the toolsets UI.
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Tools/ }))
})
const kept = document.querySelector('iframe')
expect(kept).toBeTruthy()
expect(kept!.closest('section')!.classList.contains('hidden')).toBe(true)
})
it('shows a vision explainer that deep-links to Settings → Models', async () => {
@@ -510,8 +381,8 @@ describe('SkillsView toolset management', { timeout: 60_000 }, () => {
await act(async () => {
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/skills']}>
<SkillsView embedded fixedConnection="homelab" fixedProfile="inbox-bot" />
<MemoryRouter initialEntries={['/capabilities']}>
<CapabilitiesView embedded fixedConnection="homelab" fixedProfile="inbox-bot" />
</MemoryRouter>
</QueryClientProvider>
)
@@ -574,8 +445,13 @@ describe('SkillsView toolset management', { timeout: 60_000 }, () => {
}
})
it('offers optional skills through Browse and guards installed skills using the scoped installed list', async () => {
it('lists the built-in optional-skills catalog with Install buttons that route through the hub pipeline', async () => {
// The full official catalog renders BELOW the installed list; each row
// carries an Install button (no toggle until installed) that routes
// through the standard hub action pipeline scoped to the Capabilities
// profile. Already-installed catalog entries are filtered out.
const { installHubSkill } = await import('@/store/hub-actions')
getSkills.mockResolvedValue([
{
name: 'web-research',
@@ -586,54 +462,62 @@ describe('SkillsView toolset management', { timeout: 60_000 }, () => {
provenance: 'bundled'
}
])
queryClient.setQueryData(['public-catalog', 'skills'], parseCatalog('skills', [
{
name: 'gif-search',
description: 'Search GIFs',
source: 'optional',
installIdentifier: 'official/gifs/gif-search',
category: 'gifs',
tags: ['gifs']
},
{
name: 'web-research',
description: 'Research the web',
source: 'optional',
identifier: 'official/research/web-research',
category: 'research'
}
]))
getOfficialSkills.mockResolvedValue({
skills: [
{
name: 'gif-search',
description: 'Search GIFs',
identifier: 'official/gifs/gif-search',
category: 'gifs',
installed: false,
tags: ['gifs']
},
{
name: 'web-research',
description: 'already here under a different source',
identifier: 'official/research/web-research',
category: 'research',
installed: false,
tags: []
},
{
name: 'ascii-art',
description: 'ASCII art',
identifier: 'official/creative/ascii-art',
category: 'creative',
installed: true,
tags: []
}
]
})
await act(async () => {
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/skills?tab=skills']}>
<SkillsView fixedProfile="researcher" />
<MemoryRouter initialEntries={['/capabilities?tab=skills']}>
<CapabilitiesView />
</MemoryRouter>
</QueryClientProvider>
)
})
expect(await screen.findByRole('switch', { name: 'web-research' })).toBeTruthy()
expect(screen.queryByRole('button', { name: /^gif-search/ })).toBeNull()
expect(screen.queryByRole('button', { name: 'Install' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Browse' }))
fireEvent.click(await screen.findByRole('button', { name: /^web-research/ }))
// Catalog section header + the one genuinely-available row. Rows already
// installed (lock flag OR name collision with the installed list) are gone.
expect(await screen.findByText('Available to install')).toBeTruthy()
expect(await screen.findByText('gif-search')).toBeTruthy()
expect(screen.queryByText('ascii-art')).toBeNull()
// Browse keeps installed entries discoverable, but never reinstallable.
const detail = within(screen.getByRole('main'))
const installed = detail.getByRole<HTMLButtonElement>('button', { name: 'Installed' })
expect(installed.disabled).toBe(true)
fireEvent.click(installed)
expect(installHubSkill).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: /^gif-search/ }))
expect(detail.getByRole('heading', { name: 'gif-search' })).toBeTruthy()
expect(screen.queryByRole('switch')).toBeNull()
// The installed skill still shows its toggle; the catalog row shows
// Install instead of a switch.
expect(screen.getByRole('switch', { name: 'web-research' })).toBeTruthy()
const install = screen.getByRole('button', { name: 'Install' })
await act(async () => {
fireEvent.click(detail.getByRole('button', { name: 'Install' }))
fireEvent.click(install)
})
expect(installHubSkill).toHaveBeenCalledExactlyOnceWith('official/gifs/gif-search', 'researcher')
await waitFor(() =>
expect(vi.mocked(installHubSkill)).toHaveBeenCalledWith('official/gifs/gif-search', expect.anything())
)
})
})
+217
View File
@@ -0,0 +1,217 @@
import type * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { useI18n } from '@/i18n'
import { queryClient } from '@/lib/query-client'
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
import { useStoreSelector } from '@/lib/use-session-slice'
import { $gateway } from '@/store/gateway'
import { OFFICIAL_SKILLS_KEY } from '@/store/hub-actions'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PanelEmpty } from '../overlays/panel'
import { PageSearchShell } from '../page-search-shell'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { McpTab } from './mcp/mcp-tab'
import { PluginsTab } from './plugins/plugins-tab'
import { CapabilityScopeSelector, useCapabilityScope } from './scope-selector'
import { EmbeddedHubPicker } from './skills/embedded-hub-picker'
import { SKILLS_QUERY_KEY, skillSearchTerms, useSkillsQuery } from './skills/skills-data'
import { SkillsTab } from './skills/skills-tab'
import { refreshToolCalls } from './toolsets/tool-calls'
import { TOOLSETS_QUERY_KEY, toolsetSearchTerms, useToolsetsQuery, visibleToolsetCount } from './toolsets/toolsets-data'
import { ToolsetsTab } from './toolsets/toolsets-tab'
// Skills Hub browsing lives inside the Skills tab. Legacy `?tab=hub`
// links fall back to 'skills' via useRouteEnumParam.
const CAPABILITY_MODES = ['skills', 'toolsets', 'mcp', 'plugins'] as const
type CapabilityMode = (typeof CAPABILITY_MODES)[number]
interface CapabilitiesViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
/** Embedded mode (plugin dialogs — e.g. Bot Mode's Advanced section): tab
* state lives in local React state instead of the route's `?tab=` param,
* so an embedding dialog never fights the page router. */
embedded?: boolean
/** Pin the WHOLE view to one profile: the scope selector is hidden and
* every tab reads/writes THAT profile. This is the plugin door — Bot Mode
* renders the real Capabilities surface pinned to a bot. */
fixedProfile?: string
/** Pin the view to a REGISTERED gateway connection alongside `fixedProfile`:
* every read/write routes to that machine's backend instead of the active
* gateway. `''`/`'local'` mean the local pool. This is Bot Mode's
* remote-target door — a bot living on another registered gateway gets the
* live surface pointed at ITS backend. Ignored without `fixedProfile`. */
fixedConnection?: string
}
/** The Capabilities page SHELL: tab selection, the search header, the profile /
* connection scope, the refresh hotkey, and the dispatch to one tab component
* per tab. Each tab owns its list, detail pane and writes; the two installed
* lists are fetched here because the tab pills count them for the tab the user
* is NOT on. */
export function CapabilitiesView({
embedded = false,
fixedConnection,
fixedProfile,
setStatusbarItemGroup: _setStatusbarItemGroup,
...props
}: CapabilitiesViewProps) {
const { t } = useI18n()
// Both hooks run unconditionally (rules of hooks); embedded picks the local
// one so tab clicks inside a dialog don't rewrite the page URL.
const routeTab = useRouteEnumParam('tab', CAPABILITY_MODES, 'skills')
const localTab = useState<CapabilityMode>('skills')
const [mode, setMode] = embedded ? localTab : routeTab
// $gateway only feeds the MCP tab — gate the subscription so Skills/Toolsets
// tabs don't re-render on connect/disconnect/reconnect.
const gateway = useStoreSelector($gateway, g => (mode === 'mcp' ? g : null))
const [query, setQuery] = useState('')
// Keep the docs iframe alive after the first Skills visit.
const [hubMounted, setHubMounted] = useState(mode === 'skills')
if (mode === 'skills' && !hubMounted) {
setHubMounted(true)
}
const scope = useCapabilityScope({ fixedConnection, fixedProfile })
// The two installed lists the tab pills count. They are fetched here, as a
// pair, because the counts stay live for the tab the user is NOT on.
const { data: skills, isError: skillsFailed, error: skillsError } = useSkillsQuery(scope.profile)
const { data: toolsets, isError: toolsetsFailed } = useToolsetsQuery(scope.profile)
const installedSkillNames = useMemo(() => new Set((skills ?? []).map(skill => skill.name)), [skills])
const refreshCapabilities = useCallback(async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: SKILLS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: TOOLSETS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: OFFICIAL_SKILLS_KEY })
])
invalidateSlashCompletions()
void refreshToolCalls(scope.profile)
}, [scope.profile])
useRefreshHotkey(refreshCapabilities)
// Rotating placeholder nudges from the user's own data — teach that search
// understands categories and tool names, not just titles.
const searchHints = useMemo(() => {
if (mode === 'skills' && skills?.length) {
return skillSearchTerms(skills).map(term => t.common.tryHint(term))
}
if (mode === 'toolsets' && toolsets?.length) {
return toolsetSearchTerms(toolsets).map(term => t.common.tryHint(term))
}
return undefined
}, [mode, skills, t, toolsets])
// MCP and Plugins load independently of the installed Skills/Tools lists.
const gated = mode === 'toolsets' || mode === 'skills'
const pending = gated && !(skills && toolsets)
const loadGate = !pending ? null : skillsFailed || toolsetsFailed ? (
<PanelEmpty
action={
<Button onClick={() => void refreshCapabilities()} size="sm">
{t.skills.refresh}
</Button>
}
description={skillsError instanceof Error ? skillsError.message : undefined}
icon="error"
title={t.skills.skillsLoadFailed}
/>
) : (
<PageLoader label={t.skills.loading} />
)
// One entry per tab. Each is keyed on the scope so switching profile or
// connection is a fresh tab — never one profile's selection, open editor or
// pending install left standing over another profile's backend.
const tabContent = {
// The gateway instance backs ONLY the live `reload.mcp` RPC, and it is the
// ACTIVE gateway's socket — for a scope pinned to a different backend that
// RPC would hot-reload the wrong machine's MCP servers, so it is withheld
// (config edits still apply on that backend's next session).
mcp: () => (
<McpTab gateway={scope.crossBackend ? null : gateway} key={`mcp-${scope.key}`} profile={scope.profile} />
),
// Agent plugins for the scoped profile (selector in the section header),
// app-level desktop plugins, and the docs catalog picker underneath.
plugins: () => (
<PluginsTab
key={`plugins-${scope.key}`}
profile={scope.profile}
scopeLabel={scope.label}
scopeSelector={scope.options.length > 1 ? <CapabilityScopeSelector compact scope={scope} /> : undefined}
/>
),
skills: () => (
<SkillsTab
key={`skills-${scope.key}`}
onRefresh={() => void refreshCapabilities()}
profile={scope.profile}
query={query}
skills={skills ?? []}
/>
),
toolsets: () => (
<ToolsetsTab key={`toolsets-${scope.key}`} profile={scope.profile} query={query} toolsets={toolsets ?? []} />
)
} satisfies Record<CapabilityMode, () => React.ReactNode>
return (
<PageSearchShell
{...props}
activeTab={mode}
onSearchChange={setQuery}
onTabChange={id => setMode(id as CapabilityMode)}
// MCP manages a handful of entries with the editor right there —
// searching it is noise.
searchHidden={mode === 'mcp' || mode === 'plugins'}
searchHints={searchHints}
searchPlaceholder={mode === 'skills' ? t.skills.searchSkills : t.skills.searchToolsets}
searchValue={query}
tabs={[
{ id: 'skills', label: t.skills.tabSkills, meta: skills?.length ?? null },
{ id: 'toolsets', label: t.skills.tabToolsets, meta: toolsets ? visibleToolsetCount(toolsets) : null },
{ id: 'mcp', label: t.skills.tabMcp },
{ id: 'plugins', label: t.skills.tabPlugins }
]}
>
{/* One shared column: the scope selector sits above whichever tab is
active, so Skills / Tools / MCP all read and write the SAME selected
profile. */}
<div className="flex h-full flex-col">
{mode !== 'plugins' && <CapabilityScopeSelector scope={scope} />}
<div className="flex min-h-0 flex-1 flex-col">
<div className={mode === 'skills' ? 'min-h-40 flex-1 overflow-hidden' : 'min-h-0 flex-1'}>
{loadGate ?? tabContent[mode]()}
</div>
{hubMounted && (
<EmbeddedHubPicker hidden={mode !== 'skills'} installedNames={installedSkillNames} profile={scope.profile} />
)}
</div>
</div>
</PageSearchShell>
)
}
// Feature-detection flag for plugins (Bot Mode): TRUE means this build's
// CapabilitiesView routes `fixedConnection` to the pinned connection's backend.
// Older builds export CapabilitiesView WITHOUT the prop — passing it there would
// silently read/write the ACTIVE gateway under the remote bot's profile name,
// which is exactly the wrong-machine bug the prop exists to prevent. A static
// property is probe-able without rendering.
CapabilitiesView.supportsFixedConnection = true as const
@@ -45,13 +45,13 @@ import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { $activeSessionId } from '@/store/session'
import { hermesConfigCacheWriter, useHermesConfigRecord } from '../hooks/use-config-record'
import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
import { useProfileSwitchLatch } from '../hooks/use-profile-switch-latch'
import { DetailPane, ICON_BUTTON, MASTER_DETAIL_WIDE_COLS } from '../master-detail'
import { PanelAddButton, PanelEmpty } from '../overlays/panel'
import { prettyName } from '../settings/helpers'
import { useDeepLinkHighlight } from '../settings/use-deep-link-highlight'
import { hermesConfigCacheWriter, useHermesConfigRecord } from '../../hooks/use-config-record'
import { useOnProfileSwitch } from '../../hooks/use-on-profile-switch'
import { useProfileSwitchLatch } from '../../hooks/use-profile-switch-latch'
import { DetailPane, ICON_BUTTON, MASTER_DETAIL_WIDE_COLS } from '../../master-detail'
import { PanelAddButton, PanelEmpty } from '../../overlays/panel'
import { prettyName } from '../../settings/helpers'
import { useDeepLinkHighlight } from '../../settings/use-deep-link-highlight'
// The editor always speaks the ecosystem's mcp.json document format — names
// are the JSON keys, transport is inferred from `command` vs `url` — so any
@@ -0,0 +1,456 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $pluginRecords } from '@/contrib/plugins-store'
import { $agentPlugins, $agentPluginsStatus } from '@/store/agent-plugins'
import { $paneHeightOverride, setPaneHeightOverride } from '@/store/panes'
import { $pluginInstallRequest, closePluginInstallRequest } from '@/store/plugin-install-request'
import { $connection } from '@/store/session'
import { PluginsTab } from './plugins-tab'
const requestGateway = vi.fn(async () => ({ plugins: [] }))
const connectionFixture = {
baseUrl: 'http://localhost',
isFullscreen: false,
logs: [],
nativeOverlayWidth: 0,
token: '',
windowButtonPosition: null,
wsUrl: ''
}
vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({
useGatewayRequest: () => ({ requestGateway })
}))
describe('PluginsTab', () => {
beforeEach(() => {
$pluginRecords.set({})
$agentPlugins.set([])
$agentPluginsStatus.set('ready')
closePluginInstallRequest()
requestGateway.mockClear()
})
afterEach(() => {
cleanup()
$connection.set(null)
})
it('lists the scoped profile agent plugins with toggles', () => {
$agentPlugins.set([
{
description: 'A test plugin',
key: 'demo-plugin',
name: 'demo-plugin',
source: 'git',
status: 'enabled',
version: '1.0.0'
}
])
render(<PluginsTab profile="workbot" />)
expect(screen.getByText('demo-plugin')).toBeTruthy()
expect(screen.getByRole('switch', { name: 'Agent: demo-plugin' }).getAttribute('aria-checked')).toBe('true')
})
it('hides bundled plugins (managed from their own surfaces)', () => {
$agentPlugins.set([
{
description: '',
key: 'image_gen/fal',
name: 'fal',
source: 'bundled',
status: 'enabled',
version: ''
}
])
render(<PluginsTab profile={null} />)
expect(screen.queryByText('fal')).toBeNull()
expect(screen.getByText(/No plugins yet/)).toBeTruthy()
})
// A desktop half can only be copied out of a backend that runs on THIS
// machine; against a remote one the reconcile is a structural no-op, so the
// row must say so instead of pending forever (#114079).
it('marks a remote-backend desktop half unavailable instead of forever copying', () => {
$connection.set({ ...connectionFixture, mode: 'remote' })
$agentPlugins.set([
{
description: '',
has_desktop_half: true,
key: 'nous-prices',
name: 'nous-prices',
source: 'catalog',
status: 'enabled',
version: '1'
}
])
render(<PluginsTab profile={null} />)
const detail = within(screen.getByRole('row', { name: /^nous-prices/ }))
expect(detail.getByText('unavailable (remote backend)')).toBeTruthy()
expect(detail.queryByText('copying…')).toBeNull()
})
it('keeps the pending desktop-half state on a local backend', () => {
$agentPlugins.set([
{
description: '',
has_desktop_half: true,
key: 'nous-prices',
name: 'nous-prices',
source: 'catalog',
status: 'enabled',
version: '1'
}
])
render(<PluginsTab profile={null} />)
const detail = within(screen.getByRole('row', { name: /^nous-prices/ }))
expect(detail.getByText('copying…')).toBeTruthy()
expect(detail.queryByText('unavailable (remote backend)')).toBeNull()
})
it('renders a unified package as ONE row with a Desktop switch and an Agent switch', () => {
$pluginRecords.set({
media: { id: 'media', name: 'Media Studio', kind: 'disk', status: 'loaded', packageName: 'hermes-media-studio' }
})
$agentPlugins.set([
{
description: '',
key: 'hermes-media-studio',
name: 'hermes-media-studio',
source: 'git',
status: 'disabled',
version: '1'
}
])
render(<PluginsTab profile="workbot" scopeLabel="workbot" />)
expect(screen.getAllByTestId(/^plugin-row-/)).toHaveLength(1)
expect(screen.getByText('Agent + Desktop')).toBeTruthy()
expect(screen.getByRole('switch', { name: 'Desktop: Media Studio' }).getAttribute('aria-checked')).toBe('true')
expect(screen.getByRole('switch', { name: 'Agent: Media Studio' }).getAttribute('aria-checked')).toBe('false')
expect(screen.getAllByText('Agent in workbot').length).toBeGreaterThan(0)
})
it('offers "Install here" for a desktop half whose agent half is not in the selected profile', async () => {
$pluginRecords.set({
media: {
id: 'media',
name: 'Media Studio',
kind: 'disk',
status: 'loaded',
packageName: 'hermes-media-studio',
packageOrigin: { repo: 'https://github.com/NousResearch/hermes-media-studio.git', sha: 'abc' }
}
})
render(<PluginsTab profile="workbot" scopeLabel="workbot" />)
expect(screen.queryByRole('switch', { name: /^Agent:/ })).toBeNull()
screen.getByRole('button', { name: 'Install here' }).click()
// Pre-filled from the package marker: repo + pinned sha, agent half only.
await waitFor(() => {
expect($pluginInstallRequest.get()).toMatchObject({
legacyHint: 'agent',
profile: 'workbot',
repo: 'https://github.com/NousResearch/hermes-media-studio.git',
sha: 'abc'
})
})
})
it('disables "Install here" when the package has no known origin (hand-copied folder)', () => {
$pluginRecords.set({
media: { id: 'media', name: 'Media Studio', kind: 'disk', status: 'loaded', packageName: 'hermes-media-studio' }
})
render(<PluginsTab profile="workbot" scopeLabel="workbot" />)
expect((screen.getByRole('button', { name: 'Install here' }) as HTMLButtonElement).disabled).toBe(true)
})
it('loads the plugin list scoped to the selected profile', () => {
render(<PluginsTab profile="workbot" />)
expect(requestGateway).toHaveBeenCalledWith(
'plugins.manage',
expect.objectContaining({ action: 'list', profile: 'workbot' })
)
})
it('opens the dual-target install modal from a catalog pick message', async () => {
render(<PluginsTab profile="workbot" />)
window.dispatchEvent(
new MessageEvent('message', {
data: {
name: 'weather-plugin',
repo: 'https://github.com/example/weather-plugin',
sha: 'a'.repeat(40),
subdir: '',
tier: 'community',
type: 'hermes-plugin-pick'
},
origin: 'https://hermes-agent.nousresearch.com'
})
)
await waitFor(() => {
const request = $pluginInstallRequest.get()
expect(request).not.toBeNull()
expect(request?.catalogName).toBe('weather-plugin')
expect(request?.repo).toBe('https://github.com/example/weather-plugin')
expect(request?.profile).toBe('workbot')
expect(request?.sha).toBe('a'.repeat(40))
})
})
it('ignores pick messages from foreign origins', () => {
render(<PluginsTab profile={null} />)
window.dispatchEvent(
new MessageEvent('message', {
data: {
name: 'evil-plugin',
repo: 'https://github.com/evil/evil-plugin',
type: 'hermes-plugin-pick'
},
origin: 'https://evil.example.com'
})
)
expect($pluginInstallRequest.get()).toBeNull()
})
it('toggles by canonical key through plugins.manage', async () => {
$agentPlugins.set([
{
description: '',
key: 'image_gen/legacy',
name: 'Legacy plugin',
source: 'user',
status: 'disabled',
version: '0.20.0'
}
])
requestGateway.mockResolvedValueOnce({
ok: true,
plugin: { key: 'image_gen/legacy', name: 'Legacy plugin', status: 'enabled' }
} as never)
render(<PluginsTab profile={null} />)
screen.getByRole('switch', { name: 'Agent: Legacy plugin' }).click()
await waitFor(() =>
expect(requestGateway).toHaveBeenCalledWith(
'plugins.manage',
expect.objectContaining({ action: 'toggle', key: 'image_gen/legacy', enable: true })
)
)
})
it('renders keyless rows read-only (no name-addressed toggle RPC)', () => {
// Name-addressed toggles flip every same-named plugin across category
// dirs — pre-contract-v6 rows must never reach the RPC.
$agentPlugins.set([
{
description: 'Returned by a pre-key backend',
name: 'Legacy plugin',
source: 'user',
status: 'disabled',
version: '0.20.0'
}
])
render(<PluginsTab profile={null} />)
const toggle = screen.getByRole('switch', { name: 'Agent: Legacy plugin' })
expect(toggle.hasAttribute('disabled') || toggle.getAttribute('aria-disabled') === 'true').toBe(true)
toggle.click()
expect(requestGateway).not.toHaveBeenCalledWith('plugins.manage', expect.objectContaining({ action: 'toggle' }))
})
it('appends the subdir fragment for multi-plugin repos', async () => {
render(<PluginsTab profile={null} />)
window.dispatchEvent(
new MessageEvent('message', {
data: {
name: 'nested-plugin',
repo: 'https://github.com/example/plugins-monorepo',
subdir: 'nested-plugin',
type: 'hermes-plugin-pick'
},
origin: 'https://hermes-agent.nousresearch.com'
})
)
await waitFor(() => {
expect($pluginInstallRequest.get()?.repo).toBe('https://github.com/example/plugins-monorepo#nested-plugin')
})
})
})
describe('PluginsTab catalog UX', () => {
beforeEach(() => {
$agentPlugins.set([])
$agentPluginsStatus.set('ready')
closePluginInstallRequest()
requestGateway.mockClear()
setPaneHeightOverride('capabilities-plugin-catalog', undefined)
})
afterEach(cleanup)
it('grows the catalog when its top-edge sash is dragged up, and resets on double-click', () => {
// jsdom has no layout: give the Capabilities column a real height so the
// "never crush the lists above" clamp has something to clamp against.
const clientHeight = vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(900)
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 1000 })
render(<PluginsTab profile={null} />)
const sash = screen.getByTestId('plugin-catalog-sash')
fireEvent.pointerDown(sash, { button: 0, clientY: 600 })
fireEvent.pointerMove(window, { clientY: 400 })
fireEvent.pointerUp(window)
// Default 380px + 200px of upward drag (clamped only by window/column size).
expect($paneHeightOverride('capabilities-plugin-catalog').get()).toBe(580)
fireEvent.doubleClick(sash)
expect($paneHeightOverride('capabilities-plugin-catalog').get()).toBeUndefined()
clientHeight.mockRestore()
})
it('shows an Update chip when the catalog pin moved past the installed SHA', () => {
$agentPlugins.set([
{
catalog_name: 'demo-weather',
catalog_sha: 'b'.repeat(40),
catalog_tier: 'community',
description: '',
installed_sha: 'a'.repeat(40),
key: 'demo-weather',
name: 'demo-weather',
source: 'git',
status: 'enabled',
update_available: true,
version: '1.0.0'
}
])
render(<PluginsTab profile={null} />)
expect(screen.getByRole('button', { name: `Update to ${'b'.repeat(8)}` })).toBeTruthy()
})
it('re-pins through plugins.manage update when the chip is clicked', async () => {
$agentPlugins.set([
{
catalog_name: 'demo-weather',
catalog_sha: 'b'.repeat(40),
catalog_tier: 'community',
description: '',
installed_sha: 'a'.repeat(40),
key: 'demo-weather',
name: 'demo-weather',
source: 'git',
status: 'enabled',
update_available: true,
version: '1.0.0'
}
])
requestGateway.mockResolvedValue({ ok: true, unchanged: false, plugins: [] } as never)
render(<PluginsTab profile="workbot" />)
screen.getByRole('button', { name: `Update to ${'b'.repeat(8)}` }).click()
await waitFor(() =>
expect(requestGateway).toHaveBeenCalledWith(
'plugins.manage',
expect.objectContaining({ action: 'update', name: 'demo-weather', profile: 'workbot' })
)
)
})
it('refuses a catalog pick that is already installed and current', async () => {
$agentPlugins.set([
{
catalog_name: 'demo-weather',
description: '',
installed_sha: 'a'.repeat(40),
key: 'demo-weather',
name: 'demo-weather',
source: 'git',
status: 'enabled',
update_available: false,
version: '1.0.0'
}
])
render(<PluginsTab profile={null} />)
window.dispatchEvent(
new MessageEvent('message', {
data: {
name: 'demo-weather',
repo: 'https://github.com/example/demo-weather',
type: 'hermes-plugin-pick'
},
origin: 'https://hermes-agent.nousresearch.com'
})
)
// The modal must NOT open — the pick is refused with a toast.
await new Promise(resolve => setTimeout(resolve, 20))
expect($pluginInstallRequest.get()).toBeNull()
})
it('still opens the modal for an installed pick when an update is available', async () => {
$agentPlugins.set([
{
catalog_name: 'demo-weather',
description: '',
installed_sha: 'a'.repeat(40),
key: 'demo-weather',
name: 'demo-weather',
source: 'git',
status: 'enabled',
update_available: true,
version: '1.0.0'
}
])
render(<PluginsTab profile={null} />)
window.dispatchEvent(
new MessageEvent('message', {
data: {
name: 'demo-weather',
repo: 'https://github.com/example/demo-weather',
type: 'hermes-plugin-pick'
},
origin: 'https://hermes-agent.nousresearch.com'
})
)
await waitFor(() => expect($pluginInstallRequest.get()).not.toBeNull())
})
})
@@ -0,0 +1,655 @@
import { useStore } from '@nanostores/react'
import {
memo,
type ReactNode,
type PointerEvent as ReactPointerEvent,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Switch } from '@/components/ui/switch'
import { Tip } from '@/components/ui/tooltip'
import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store'
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import type { ProfileScope } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { FolderOpen, Loader2, Monitor, Package, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$agentPluginBusy,
$agentPlugins,
$agentPluginsError,
$agentPluginsStatus,
type AgentPluginRow,
type GatewayRequest,
isDesktopRelevantPlugin,
loadAgentPlugins,
toggleAgentPlugin,
updateAgentPlugin
} from '@/store/agent-plugins'
import { notify, notifyError } from '@/store/notifications'
import { $paneHeightOverride, setPaneHeightOverride } from '@/store/panes'
import { openPluginInstallRequest } from '@/store/plugin-install-request'
import { $connection } from '@/store/session'
import { PanelEmpty } from '../../overlays/panel'
import { Pill } from '../../settings/primitives'
import { useDeepLinkHighlight } from '../../settings/use-deep-link-highlight'
import { mergePluginPackages, type PackageKind, type PluginPackage } from './plugin-packages'
// The REAL Plugin Catalog page (docs site) embedded as a one-click picker —
// the same pattern as the Skills tab's EmbeddedHubPicker. `?embed=picker`
// hides the docs chrome and adds "+ Add to this Agent" per card, which posts
// { type: 'hermes-plugin-pick', name, repo, sha, subdir, tier, installCmd }
// to the parent window. We validate the origin and open the shared
// dual-target install modal (agent half → catalog-pinned install into the
// scoped profile; desktop half → this app), so unified packages install both
// halves in one flow.
const CATALOG_ORIGIN = 'https://hermes-agent.nousresearch.com'
const CATALOG_PICKER_URL = `${CATALOG_ORIGIN}/docs/plugins?embed=picker`
// Catalog viewport: persisted through the shared pane store, dragged from the
// section's TOP edge ("pull the catalog up"), clamped so neither the catalog
// nor the plugin list above can vanish. Same contract as EmbeddedHubPicker.
const CATALOG_PANE_ID = 'capabilities-plugin-catalog'
const CATALOG_DEFAULT_PX = 380
const CATALOG_MIN_PX = 120
const CATALOG_MAX_VH = 0.75
const CATALOG_COLLAPSED_PX = 4
const CATALOG_LIST_RESERVED_PX = 176
interface PluginPickMessage {
installCmd?: string
name?: string
repo?: string
sha?: string
subdir?: string
tier?: string
type?: string
}
/** Deep-link anchor for a package row (`/capabilities?tab=plugins&plugin=<key>`).
* Accepts the agent key, the agent name, or the desktop record id. */
export const pluginElementId = (target: string) => `plugin-${target}`
/** Derive the bare profile name a `plugins.manage` call should target. */
function profileParam(scope: ProfileScope): null | string {
if (!scope) {
return null
}
return typeof scope === 'string' ? scope : (scope.profile ?? null)
}
function reveal(file: string) {
void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined)
}
async function revealPluginsDir() {
try {
// Electron owns the app-level plugin root — deriving it from the backend's
// hermes_home breaks against a remote backend (#66899).
const dir = await window.hermesDesktop?.desktopPluginsRoot?.()
if (!dir) {
notifyError('Desktop plugins are unavailable', 'Could not resolve the plugins folder')
return
}
const result = await window.hermesDesktop?.openDir?.(dir)
if (result && !result.ok) {
notifyError(result.error ?? 'unknown error', 'Could not open the plugins folder')
}
} catch (err) {
notifyError(err, 'Could not resolve the plugins folder')
}
}
/** Copy any changed unified desktop halves into the app root FIRST, then
* rescan the root — a concurrent scan would read the pre-copy state. */
async function rescanAll(requestGateway: GatewayRequest, scope: null | string) {
await window.hermesDesktop?.reconcileDesktopPlugins?.().catch(() => undefined)
await discoverRuntimePlugins()
await loadAgentPlugins(requestGateway, scope)
}
/** Open the dual-target install modal pre-filled to install ONLY the agent
* half of a unified package into the scoped profile (the desktop half is
* already here). Provenance comes from the package marker Electron stamped
* when it copied the half out (catalog sidecar or git remote). */
function installAgentHalfHere(record: PluginRecord, profile: null | string) {
const origin = record.packageOrigin
if (!origin?.repo) {
return
}
openPluginInstallRequest({
catalogName: origin.catalogName,
legacyHint: 'agent',
profile,
repo: origin.repo,
sha: origin.sha
})
}
function KindBadge({ kind }: { kind: PackageKind }) {
const { t } = useI18n()
const p = t.skills.plugins
return (
<span className="inline-flex items-center gap-1 rounded border border-(--ui-stroke-tertiary) px-1.5 py-px text-[0.65rem] text-(--ui-text-tertiary)">
{kind !== 'desktop' && <Package aria-hidden className="size-3" />}
{kind !== 'agent' && <Monitor aria-hidden className="size-3" />}
{kind === 'both' ? p.kindBoth : kind === 'agent' ? p.kindAgent : p.kindDesktop}
</span>
)
}
/** Provenance pill: where the package came from. */
function ProvenancePill({ pkg }: { pkg: PluginPackage }) {
const { t } = useI18n()
const p = t.skills.plugins
if (pkg.agent?.catalog_name) {
return (
<Tip label={p.catalogProvenance(pkg.agent.installed_sha?.slice(0, 8) ?? '')}>
<span>
<Pill>{pkg.agent.catalog_tier === 'official' ? p.tierOfficial : p.tierCommunity}</Pill>
</span>
</Tip>
)
}
if (pkg.agent?.pinned_sha) {
return (
<Tip label={p.pinnedProvenance(pkg.agent.pinned_sha.slice(0, 8))}>
<span>
<Pill>
<span className="font-mono">{p.pinnedBadge(pkg.agent.pinned_sha.slice(0, 8))}</span>
</Pill>
</span>
</Tip>
)
}
if (pkg.agent) {
return <Pill>{pkg.agent.source}</Pill>
}
if (pkg.desktop) {
return <Pill>{t.settings.plugins.kinds[pkg.desktop.kind]}</Pill>
}
return null
}
/** Column widths shared by the header and every row so the two control
* columns line up down the page like a table. */
const HALF_COL = 'flex w-36 shrink-0 items-center gap-1.5'
/** One control cell; `label` is the accessible name for screen readers only
* (the visible column label lives once, in the header). */
function HalfCell({ label, children }: { label: string; children: ReactNode }) {
return (
<div aria-label={label} className={HALF_COL} role="cell">
{children}
</div>
)
}
function Dash() {
return (
<span aria-hidden className="w-9 text-center text-(--ui-text-quaternary)">
—
</span>
)
}
function PackageRow({
pkg,
scope,
scopeLabel,
busy,
onAgentToggle,
onAgentUpdate
}: {
pkg: PluginPackage
scope: null | string
scopeLabel: string
busy: boolean
onAgentToggle: (row: AgentPluginRow, enable: boolean) => void
onAgentUpdate: (row: AgentPluginRow) => void
}) {
const { t } = useI18n()
const p = t.skills.plugins
const d = t.settings.plugins
const desktop = pkg.desktop
const agent = pkg.agent
const desktopOn = desktop ? desktop.status !== 'disabled' : false
const agentOn = agent?.status === 'enabled'
const agentToggleable = Boolean(agent?.key)
// Electron's desktop-half reconcile only walks THIS machine's homes, so a
// package installed on a remote backend can never materialize here (#114079).
const remoteBackend = useStore($connection)?.mode === 'remote'
return (
<div
className="flex items-center gap-3 border-b border-(--ui-stroke-tertiary) px-3 py-2.5 last:border-b-0"
data-testid={`plugin-row-${pkg.key}`}
id={pluginElementId(agent?.key ?? agent?.name ?? desktop?.id ?? pkg.key)}
role="row"
>
<div className="flex min-w-0 flex-1 items-start gap-2" role="cell">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
<span>{pkg.name}</span>
{agent?.version && <span className="text-(--ui-text-quaternary)">v{agent.version}</span>}
<KindBadge kind={pkg.kind} />
<ProvenancePill pkg={pkg} />
{agent?.portable && <Pill>{p.portableBadge}</Pill>}
{desktop?.status === 'error' && <Pill tone="primary">{d.failed}</Pill>}
</div>
{(desktop?.status === 'error' ? desktop.error : pkg.description) && (
<div
className={cn(
'mt-0.5 text-[length:var(--conversation-caption-font-size)] break-words',
desktop?.status === 'error' ? 'text-(--ui-danger,#f87171)' : 'text-(--ui-text-tertiary)'
)}
>
{desktop?.status === 'error' ? desktop.error : pkg.description}
</div>
)}
</div>
{/* Fixed slot so the switch column stays straight whether or not
this row has a folder to reveal (bundled plugins have none). */}
<span className="flex size-7 shrink-0 items-center justify-center">
{desktop?.file && (
<Tip label={d.reveal}>
<Button onClick={() => reveal(desktop.file!)} size="icon" variant="ghost">
<Codicon name="folder-opened" size="0.85rem" />
</Button>
</Tip>
)}
</span>
</div>
{/* The two halves. Desktop is app-level and reads the same whichever
profile is selected; Agent follows the selector. A half the package
lacks shows a dash; a half it has but which is missing on this side
shows the install affordance. */}
<HalfCell label={p.halfDesktop}>
{desktop ? (
<Switch
aria-label={`${p.halfDesktop}: ${pkg.name}`}
checked={desktopOn}
onCheckedChange={on => {
triggerHaptic('selection')
void setPluginEnabled(desktop.id, on)
}}
/>
) : pkg.desktopMissing ? (
<Tip label={remoteBackend ? p.desktopHalfRemoteTip : p.desktopHalfPendingTip}>
<span className="text-[0.65rem] text-(--ui-text-tertiary)">
{remoteBackend ? p.desktopHalfRemote : p.desktopHalfPending}
</span>
</Tip>
) : (
<Dash />
)}
</HalfCell>
<HalfCell label={p.halfAgentIn(scopeLabel)}>
{agent ? (
<>
{agent.update_available && (
<Button
className="h-5 px-1.5 text-[0.65rem]"
disabled={busy}
onClick={() => onAgentUpdate(agent)}
size="xs"
variant="outline"
>
{p.updateToPin(agent.catalog_version ?? agent.catalog_sha?.slice(0, 8) ?? '')}
</Button>
)}
{busy && <Loader2 className="size-3.5 animate-spin text-(--ui-text-tertiary)" />}
{agentToggleable ? (
<Switch
aria-label={`${p.halfAgent}: ${pkg.name}`}
checked={agentOn}
disabled={busy}
onCheckedChange={on => onAgentToggle(agent, on)}
/>
) : (
<Tip label={p.legacyBackend}>
<span>
<Switch aria-label={`${p.halfAgent}: ${pkg.name}`} checked={agentOn} disabled />
</span>
</Tip>
)}
</>
) : pkg.agentMissingInProfile && desktop ? (
<Tip label={desktop.packageOrigin?.repo ? p.installAgentHereTip(scopeLabel) : p.installAgentHereNoOrigin}>
<span>
<Button
className="h-5 px-1.5 text-[0.65rem]"
disabled={!desktop.packageOrigin?.repo}
onClick={() => installAgentHalfHere(desktop, scope)}
size="xs"
variant="outline"
>
{p.installAgentHere}
</Button>
</span>
</Tip>
) : (
<Dash />
)}
</HalfCell>
</div>
)
}
/** THE plugins surface: one row per package. Each row shows its Desktop half
* (this app — the same for every profile, gateway, or machine) and its Agent
* half (the selected profile's backend). Discovery sits underneath: the live
* catalog picker plus Install from Git for anything not in the catalog. */
export const PluginsTab = memo(function PluginsTab({
profile,
scopeSelector,
scopeLabel
}: {
profile: ProfileScope
/** The Capabilities profile selector; rendered in the Agent column header so
* it visibly governs only that column. */
scopeSelector?: ReactNode
/** Display name of the selected profile for the Agent column label. */
scopeLabel?: string
}) {
const { t } = useI18n()
const p = t.skills.plugins
const d = t.settings.plugins
const { requestGateway } = useGatewayRequest()
const desktopRecords = useStore($pluginRecords)
const agentRows = useStore($agentPlugins)
const status = useStore($agentPluginsStatus)
const error = useStore($agentPluginsError)
const busyKey = useStore($agentPluginBusy)
const scope = profileParam(profile)
const label = scopeLabel ?? scope ?? t.skills.plugins.defaultProfile
useEffect(() => {
void loadAgentPlugins(requestGateway, scope)
}, [requestGateway, scope])
const packages = useMemo(
() => mergePluginPackages(Object.values(desktopRecords), agentRows.filter(isDesktopRelevantPlugin)),
[agentRows, desktopRecords]
)
useDeepLinkHighlight({ param: 'plugin', ready: () => true, elementId: pluginElementId })
// Catalog picker viewport (persisted height, collapse toggle, top-edge sash).
const heightOverride = useStore($paneHeightOverride(CATALOG_PANE_ID))
const height = heightOverride ?? CATALOG_DEFAULT_PX
const open = height > CATALOG_COLLAPSED_PX
const [pickerMounted, setPickerMounted] = useState(open)
const [dragging, setDragging] = useState(false)
const sectionRef = useRef<HTMLElement>(null)
if (open && !pickerMounted) {
setPickerMounted(true)
}
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return
}
event.preventDefault()
const startY = event.clientY
const startHeight = height
const column = sectionRef.current?.parentElement
const columnMax = column ? column.clientHeight - CATALOG_LIST_RESERVED_PX : Number.POSITIVE_INFINITY
const max = Math.max(CATALOG_MIN_PX, Math.round(Math.min(window.innerHeight * CATALOG_MAX_VH, columnMax)))
setDragging(true)
const onMove = (move: globalThis.PointerEvent) => {
setPaneHeightOverride(
CATALOG_PANE_ID,
Math.round(Math.min(max, Math.max(CATALOG_MIN_PX, startHeight + (startY - move.clientY))))
)
}
const onUp = () => {
window.removeEventListener('pointermove', onMove)
setDragging(false)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp, { once: true })
}
useEffect(() => {
if (!open) {
return undefined
}
const onMessage = (event: MessageEvent) => {
if (event.origin !== CATALOG_ORIGIN) {
return
}
const data = event.data as null | PluginPickMessage
if (!data || data.type !== 'hermes-plugin-pick' || !data.name || !data.repo) {
return
}
const existing = $agentPlugins.get().find(row => row.catalog_name === data.name || row.name === data.name)
if (existing && !existing.update_available) {
notify({ kind: 'success', message: p.alreadyInstalled(String(data.name)) })
return
}
openPluginInstallRequest({
catalogName: String(data.name),
profile: scope,
repo: data.subdir ? `${String(data.repo)}#${String(data.subdir)}` : String(data.repo),
sha: data.sha ? String(data.sha) : undefined
})
}
window.addEventListener('message', onMessage)
return () => window.removeEventListener('message', onMessage)
}, [open, p, scope])
const agentBusy = (row: AgentPluginRow) => busyKey === (row.key ?? row.name) || busyKey === row.name
return (
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-32 flex-1 overflow-y-auto">
{/* Header: what the two columns mean, and the controls that act on
the whole page (install, folder, rescan). */}
<div className="flex flex-wrap items-start justify-between gap-3 px-3 pt-3 pb-2">
<p className="min-w-0 flex-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{p.pageBlurb}
</p>
<div className="flex shrink-0 items-center gap-1">
<Button
onClick={() => openPluginInstallRequest({ profile: scope, repo: '' })}
size="sm"
type="button"
variant="secondary"
>
{d.installModal.installFromGit}
</Button>
<Tip label={d.openFolder}>
<Button
aria-label={d.openFolder}
onClick={() => void revealPluginsDir()}
size="icon"
type="button"
variant="ghost"
>
<FolderOpen className="size-3.5" />
</Button>
</Tip>
<Tip label={d.rescan}>
<Button
aria-label={d.rescan}
onClick={() => {
triggerHaptic('selection')
void rescanAll(requestGateway, scope)
}}
size="icon"
type="button"
variant="ghost"
>
<RefreshCw className="size-3.5" />
</Button>
</Tip>
</div>
</div>
{status === 'error' ? (
<PanelEmpty
action={
<Button onClick={() => void loadAgentPlugins(requestGateway, scope)} size="sm">
{t.skills.refresh}
</Button>
}
description={error ?? undefined}
icon="error"
title={p.loadFailed}
/>
) : packages.length === 0 && status === 'ready' ? (
<p className="px-3 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{p.emptyAll} {p.emptyHint}
</p>
) : (
<div className="flex flex-col" role="table">
{/* Column header: the visible labels for the two control columns,
aligned with the cells below. The profile selector sits INSIDE
the Agent header so it visibly governs only that column. */}
<div
className="flex items-center gap-3 border-y border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 text-[0.68rem] text-(--ui-text-tertiary)"
role="row"
>
<div className="min-w-0 flex-1" role="columnheader" />
<div className={HALF_COL} role="columnheader">
<Monitor aria-hidden className="size-3.5 shrink-0" />
<Tip label={p.halfDesktopHint}>
<span className="font-medium">{p.halfDesktop}</span>
</Tip>
</div>
<div className={HALF_COL} role="columnheader">
<Package aria-hidden className="size-3.5 shrink-0" />
{scopeSelector ?? <span className="truncate font-medium">{p.halfAgentIn(label)}</span>}
</div>
</div>
{packages.map(pkg => (
<PackageRow
busy={pkg.agent ? agentBusy(pkg.agent) : false}
key={pkg.key}
onAgentToggle={(row, enable) => {
if (!row.key) {
return
}
void toggleAgentPlugin(requestGateway, row.key, enable, p.toggleFailed(row.name), scope)
}}
onAgentUpdate={row => {
void updateAgentPlugin(requestGateway, row.name, p.updateFailed(row.name), scope).then(applied => {
if (applied) {
notify({ kind: 'success', message: p.updated(row.name) })
void rescanAll(requestGateway, scope)
}
})
}}
pkg={pkg}
scope={scope}
scopeLabel={label}
/>
))}
</div>
)}
</div>
<section
className="relative flex min-h-9 flex-col overflow-hidden border-t border-(--ui-stroke-secondary)"
ref={sectionRef}
>
<div
className="group/catsash absolute inset-x-0 top-0 z-10 h-1 -translate-y-1/2 cursor-row-resize"
data-testid="plugin-catalog-sash"
onDoubleClick={() => setPaneHeightOverride(CATALOG_PANE_ID, undefined)}
onPointerDown={startDrag}
>
<div
className={cn(
'absolute inset-x-0 top-1/2 h-px -translate-y-1/2 transition-colors',
dragging ? 'bg-(--ui-stroke-secondary)' : 'group-hover/catsash:bg-(--ui-stroke-secondary)'
)}
/>
</div>
<div className="flex shrink-0 items-center justify-between px-3 py-1.5">
<span className="text-[0.62rem] font-medium tracking-wide uppercase text-(--ui-text-quaternary)">
{p.catalogTitle}
</span>
<Button onClick={() => setPaneHeightOverride(CATALOG_PANE_ID, open ? 0 : undefined)} size="xs" variant="text">
{open ? p.catalogHide : p.catalogBrowse}
</Button>
</div>
{pickerMounted && (
<div className={cn('flex min-h-0 flex-col gap-1 px-3 pb-2', !open && 'hidden')}>
<div
style={{
border: '1px solid var(--ui-stroke-secondary)',
borderRadius: 8,
flex: `0 1 ${height}px`,
maxWidth: '100%',
minHeight: 0,
minWidth: 320,
overflow: 'hidden',
position: 'relative',
width: '100%'
}}
>
<iframe
sandbox="allow-scripts allow-same-origin"
src={CATALOG_PICKER_URL}
style={{
background: 'transparent',
border: 'none',
height: '133.34%',
pointerEvents: dragging ? 'none' : 'auto',
transform: 'scale(0.75)',
transformOrigin: 'top left',
width: '133.34%'
}}
title={p.catalogTitle}
/>
</div>
<p className="shrink-0 px-1 text-[0.65rem] leading-4 text-(--ui-text-quaternary)">{p.catalogHint}</p>
</div>
)}
</section>
</div>
)
})
@@ -0,0 +1,60 @@
import type { ReactNode } from 'react'
import { useI18n } from '@/i18n'
import { ListStripButton } from '../master-detail'
import { PanelEmpty } from '../overlays/panel'
// The chrome the Skills and Tools tabs share: one inspector header, one empty
// state and one sort control, so switching between them never jumps.
// Shared inspector header — mirrors Messaging's PlatformDetail so Skills and
// Tools share one title/description block and tab switches don't jump.
export function DetailHeader({
description,
pills,
title
}: {
description: ReactNode
pills?: ReactNode
title: string
}) {
return (
<header>
<div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="min-w-0 truncate text-[0.9375rem] font-semibold tracking-tight">{title}</h3>
{pills}
</div>
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{description}
</p>
</header>
)
}
// Full-bleed empty state, matching the MCP tab (spans both columns, not a
// cramped note in the left rail). Query-aware, and says "tools" not the
// internal "toolsets".
export function CapabilityEmpty({ noun, query }: { noun: string; query: string }) {
const { t } = useI18n()
const q = query.trim()
return (
<div className="flex h-full min-h-0 flex-1">
<PanelEmpty
description={q ? t.skills.emptyNothingMatches(q) : t.skills.emptyNoneAvailable(noun)}
icon="search"
title={t.skills.emptyNoneFound(noun)}
/>
</div>
)
}
/** Most-used / least-used flip for a list strip. */
export function SortButton({ desc, onFlip }: { desc: boolean; onFlip: () => void }) {
const { t } = useI18n()
return (
<ListStripButton onClick={onFlip}>{desc ? t.skills.sortMostUsedDesc : t.skills.sortLeastUsedAsc}</ListStripButton>
)
}
@@ -0,0 +1,216 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { DesktopRosterAgent } from '@/global'
import { getProfiles, type ProfileScope, profileScopeKey } from '@/hermes'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { activeGatewayConnectionId } from '@/store/gateway'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
interface ScopeOption {
key: string
label: string
value: string
}
export interface CapabilityScope {
/** Which profile's Skills/Tools/MCP config every tab reads and writes — and
* on WHICH gateway. */
profile: ProfileScope
/** Cache-key form of `profile`: every scoped query key ends with it, and the
* tabs are keyed on it so a scope change is a fresh tab. */
key: string
/** Scoped to a DIFFERENT backend than the window's active gateway? The MCP
* tab's live-reload RPC rides the active gateway socket, which would reload
* the wrong machine — it is withheld for cross-backend scopes. */
crossBackend: boolean
/** Display name of the selected profile (the Plugins tab's Agent column). */
label?: string
options: ScopeOption[]
value: string
onChange: (value: string) => void
}
/**
* The Capabilities scope: which profile's config the page is editing, and on
* which gateway. A profile belongs to one gateway, so on a multi-connection
* desktop the selector offers every (connection, profile) pair from the union
* agent roster and an explicit pick routes every read/write to that machine's
* backend. Defaults to the app-wide active profile. A `fixedProfile` (+
* optional `fixedConnection`) pins the scope outright, selector hidden.
*/
export function useCapabilityScope({
fixedConnection,
fixedProfile
}: {
fixedConnection?: string
fixedProfile?: string
}): CapabilityScope {
const activeProfile = useStore($activeGatewayProfile)
const [scopeOverride, setScopeOverride] = useState<null | string | { connectionId: string; profile: string }>(null)
const scopeProfile: ProfileScope = useMemo(
() =>
fixedProfile
? fixedConnection
? { connectionId: fixedConnection, profile: fixedProfile }
: fixedProfile
: (scopeOverride ?? activeProfile ?? null),
[activeProfile, fixedConnection, fixedProfile, scopeOverride]
)
const scopeKey = profileScopeKey(scopeProfile)
// The registry connection an explicit override pins — null for the ambient
// active-profile path and for fixed-profile pins without a connection.
const scopeConnectionId =
scopeProfile && typeof scopeProfile === 'object' ? (scopeProfile.connectionId ?? '').trim() || 'local' : null
const { data: profilesData } = useQuery({
queryKey: ['capabilities-profiles'],
queryFn: () => getProfiles(),
staleTime: 60_000,
// Pinned scope never shows the selector, so don't fetch the roster for it.
enabled: !fixedProfile
})
// v2 multi-connection registry: cheap local IPC to learn whether more than
// one gateway is registered. Only then is the (heavier) union agent roster
// fetched to feed the selector — single-connection setups keep the exact
// legacy profiles list. Both feature-detected for older Electron mains.
const registryBridge = window.hermesDesktop?.connections
const rosterBridge = window.hermesDesktop?.getAgentRoster
const { data: registryData } = useQuery({
queryKey: ['capabilities-connections-registry'],
queryFn: () => registryBridge!.list(),
staleTime: 60_000,
enabled: !fixedProfile && Boolean(registryBridge) && Boolean(rosterBridge)
})
const multiConnection = (registryData?.connections.length ?? 0) > 1
const { data: rosterData } = useQuery({
queryKey: ['capabilities-agent-roster'],
queryFn: () => rosterBridge!(),
staleTime: 60_000,
enabled: !fixedProfile && multiConnection
})
// An app-wide profile switch drops any scope override — the user just
// changed what "here" means, and a stale override pointing at the previous
// selection would be surprising.
useOnProfileSwitch(() => setScopeOverride(null))
// Scope-selector rows. Multi-connection desktops list every reachable
// (connection, profile) agent from the union roster — the selected profile
// is configured ON ITS OWN GATEWAY. Otherwise the legacy per-profile list.
const options: ScopeOption[] = useMemo(() => {
if (multiConnection && rosterData?.agents?.length) {
const activeId = activeGatewayConnectionId() ?? 'local'
return rosterData.agents.map((agent: DesktopRosterAgent) => ({
key: `${agent.connectionId}::${agent.profile}`,
label:
agent.connectionId === activeId
? `${agent.profile} — ${agent.connectionLabel} (current)`
: `${agent.profile} — ${agent.connectionLabel}`,
value: `${agent.connectionId}::${agent.profile}`
}))
}
return (profilesData?.profiles ?? []).map(p => ({
key: p.name,
label: p.is_default ? 'Hermes (default)' : p.name,
value: p.name
}))
}, [multiConnection, profilesData, rosterData])
// The selector's current value must match one option's value exactly. On the
// roster path an ambient (non-override) scope is the active gateway's
// profile, which lives at `activeConnectionId::profile`.
const value = useMemo(() => {
if (scopeProfile && typeof scopeProfile === 'object') {
return `${(scopeProfile.connectionId ?? '').trim() || 'local'}::${scopeProfile.profile ?? ''}`
}
if (multiConnection && rosterData?.agents?.length) {
return `${activeGatewayConnectionId() ?? 'local'}::${normalizeProfileKey(scopeProfile)}`
}
return scopeProfile ?? ''
}, [multiConnection, rosterData, scopeProfile])
// Selector option values are `connectionId::profile` on multi-connection
// desktops (the roster path) and bare profile names otherwise, so one
// handler decodes both.
const onChange = (picked: string) => {
const sep = picked.indexOf('::')
// Roster picks (`connectionId::profile`) stay objects — a `local::` pick
// must PIN the local pool even while a remote gateway is active. Legacy
// bare-name picks stay strings so cache keys and routing are unchanged.
const next: ProfileScope =
sep >= 0 ? { connectionId: picked.slice(0, sep), profile: picked.slice(sep + 2) } : picked
if (profileScopeKey(next) === scopeKey) {
return
}
setScopeOverride(next as string | { connectionId: string; profile: string })
}
return {
crossBackend: scopeConnectionId !== null && scopeConnectionId !== (activeGatewayConnectionId() ?? 'local'),
key: scopeKey,
label: options.find(option => option.value === value)?.label,
onChange,
options,
profile: scopeProfile,
value
}
}
/**
* Scope selector, shown above EVERY Capabilities tab (Skills, Tools, MCP,
* Plugins). Lets the user configure ANY profile's capabilities — on any
* registered gateway — without switching the whole app. Only meaningful with
* >1 option; hidden otherwise to avoid clutter.
*/
export function CapabilityScopeSelector({
compact = false,
scope
}: {
/** In Plugins, the selector belongs only to the Agent column. */
compact?: boolean
scope: CapabilityScope
}) {
const { t } = useI18n()
if (scope.options.length <= 1) {
return null
}
return (
<div className={cn('flex min-w-0 items-center gap-2', compact ? 'flex-1' : 'border-b border-(--ui-stroke-secondary) px-3 py-2')}>
{!compact && <span className="text-[0.7rem] font-medium text-(--ui-text-tertiary)">{t.skills.configuringProfile}</span>}
<Select onValueChange={scope.onChange} value={scope.value}>
<SelectTrigger className={cn('text-xs', compact ? 'h-6 w-full max-w-64 px-2' : 'h-7 w-56')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{scope.options.map(option => (
<SelectItem key={option.key} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
@@ -0,0 +1,249 @@
import { useStore } from '@nanostores/react'
import { memo, type PointerEvent as ReactPointerEvent, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import type { ProfileScope } from '@/hermes'
import { useI18n } from '@/i18n'
import { Loader2 } from '@/lib/icons'
import { useStoreSelector } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { $hubActions, installHubSkill, notifyHubActionFailed, UPDATE_ALL_KEY, updateHubSkills } from '@/store/hub-actions'
import { notify, notifyError } from '@/store/notifications'
import { $paneHeightOverride, setPaneHeightOverride } from '@/store/panes'
// The REAL Skills Hub page (docs site) embedded as a one-click picker — the
// same trick the Bot Mode agent editor uses. `?embed=picker` hides the docs
// chrome and adds a "+ Add to this Agent" button per card, which posts
// { type: 'hermes-skill-pick', name, identifier, installCmd, source }
// to the parent window. We validate the origin and route the install through
// the standard hub action pipeline (background action + tailed log + Skills
// list invalidation), scoped to the Capabilities profile selector.
const HUB_ORIGIN = 'https://hermes-agent.nousresearch.com'
const HUB_PICKER_URL = `${HUB_ORIGIN}/docs/skills?embed=picker`
// Hub viewport height: persisted through the shared pane store (same one the
// terminal/editor panes use), dragged from the section's TOP edge — "pull the
// hub up" — clamped so neither the hub nor the skills list above vanishes.
const HUB_PANE_ID = 'capabilities-hub'
const HUB_DEFAULT_PX = 380
const HUB_MIN_PX = 120
const HUB_MAX_VH = 0.75
// Collapse threshold, mirroring DetailPane: a persisted height at/below this
// reads as "collapsed to the header" (the toggle stores 0).
const HUB_COLLAPSED_PX = 4
// Room the sash must always leave for the content ABOVE the picker (the
// installed-skills list plus its strip) so dragging the hub up can never
// crush the list to zero and shove its chrome under the hub header.
const HUB_LIST_RESERVED_PX = 176
interface SkillPickMessage {
identifier?: string
installCmd?: string
name?: string
source?: string
type?: string
}
interface EmbeddedHubPickerProps {
/** Kept mounted but fully hidden (display:none). The Capabilities view uses
* this to preserve the loaded hub iframe across tab switches — a plain
* unmount would reload the whole docs site on every return to Skills. */
hidden?: boolean
/** Names of skills already installed in the scoped profile — a pick that
* matches is refused with a toast instead of re-running the install. */
installedNames: ReadonlySet<string>
/** Capabilities profile-scope override — installs land in THIS profile;
* undefined/null targets the app-wide active profile. */
profile?: ProfileScope
}
/** The Skills Hub browser for the Skills tab: a resizable iframe of the live
* hub where every card installs with one click. Expanded by default —
* discovery IS the point — with a collapse toggle (persisted, like every
* other pane) and an update-all action. Memoized: the iframe must not sit in
* the parent's keystroke/re-render path. */
export const EmbeddedHubPicker = memo(function EmbeddedHubPicker({
hidden = false,
installedNames,
profile
}: EmbeddedHubPickerProps) {
const { t } = useI18n()
const h = t.skills.hub
// Subscribe to the ONE flag this header renders, not the whole action map —
// $hubActions churns on every tailed log line during an install.
const updating = useStoreSelector($hubActions, actions => actions[UPDATE_ALL_KEY]?.running ?? false)
// Collapse state rides the same persisted height override the sash writes
// (0 = collapsed to the header), so "Hide the hub browser" survives tab
// switches and restarts instead of re-expanding — and re-loading the docs
// site — on every visit. Same contract as DetailPane.
const heightOverride = useStore($paneHeightOverride(HUB_PANE_ID))
const height = heightOverride ?? HUB_DEFAULT_PX
const open = height > HUB_COLLAPSED_PX
const [dragging, setDragging] = useState(false)
const sectionRef = useRef<HTMLElement>(null)
// Top-edge sash: dragging UP grows the hub (shrinking the skills list above,
// which is the flex-1 sibling). Same gesture as DetailPane / the shell's
// bottom panes; double-click resets to the default height. The iframe gets
// pointer-events disabled for the duration or it swallows the pointermoves.
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return
}
event.preventDefault()
const startY = event.clientY
const startHeight = height
// Clamp against the actual Capabilities column, not just the window: the
// hub may never grow past "column minus the list's reserved strip", so
// the installed list always keeps real height and its header/footer can't
// end up sharing pixels with the hub header.
const column = sectionRef.current?.parentElement
const columnMax = column ? column.clientHeight - HUB_LIST_RESERVED_PX : Number.POSITIVE_INFINITY
const max = Math.max(HUB_MIN_PX, Math.round(Math.min(window.innerHeight * HUB_MAX_VH, columnMax)))
setDragging(true)
const onMove = (move: globalThis.PointerEvent) => {
setPaneHeightOverride(
HUB_PANE_ID,
Math.round(Math.min(max, Math.max(HUB_MIN_PX, startHeight + (startY - move.clientY))))
)
}
const onUp = () => {
window.removeEventListener('pointermove', onMove)
setDragging(false)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp, { once: true })
}
// Picker messages from the embedded hub page. Origin-checked; installs route
// through the same store pipeline the hub rows use, so the action log,
// optimistic flips, and Skills-list refresh all come for free.
useEffect(() => {
if (!open) {
return undefined
}
const onMessage = (event: MessageEvent) => {
if (event.origin !== HUB_ORIGIN) {
return
}
const data = event.data as SkillPickMessage | null
if (!data || data.type !== 'hermes-skill-pick' || !data.name) {
return
}
const target = String(data.identifier || data.name)
const label = String(data.name)
// Already installed in this scope → tell the user, don't reinstall.
if (installedNames.has(label) || installedNames.has(target)) {
notify({ kind: 'success', title: h.alreadyInstalled(label), message: '' })
return
}
notify({ kind: 'success', title: h.installStarted(label), message: h.actionLog })
void installHubSkill(target, profile).catch(err => notifyHubActionFailed(err, h.actionFailed, label, profile))
}
window.addEventListener('message', onMessage)
return () => window.removeEventListener('message', onMessage)
}, [h, installedNames, open, profile])
const updateAll = () => {
notify({ kind: 'success', title: h.updateStarted, message: h.actionLog })
void updateHubSkills(profile).catch(err => notifyError(err, h.actionFailed))
}
return (
<section
className={cn(
// Shrinkable (no shrink-0) + overflow-hidden: the picker is a flex
// child of the Capabilities column. Before, its fixed-height viewport
// made the section's min-content height rigid, so a short window (or a
// tall persisted drag height) starved the installed list to 0px and
// the list's strip/footer painted straight over this header. Now the
// section clips its own content and gives height back to the list;
// min-h keeps the header row itself always visible.
'relative flex min-h-9 flex-col overflow-hidden border-t border-(--ui-stroke-secondary)',
hidden && 'hidden'
)}
ref={sectionRef}
>
{/* Top-edge drag sash — pull the whole hub section up/down. */}
<div
className="group/hubsash absolute inset-x-0 top-0 z-10 h-1 -translate-y-1/2 cursor-row-resize"
onDoubleClick={() => setPaneHeightOverride(HUB_PANE_ID, undefined)}
onPointerDown={startDrag}
>
<div
className={cn(
'absolute inset-x-0 top-1/2 h-px -translate-y-1/2 transition-colors',
dragging ? 'bg-(--ui-stroke-secondary)' : 'group-hover/hubsash:bg-(--ui-stroke-secondary)'
)}
/>
</div>
<div className="flex shrink-0 items-center justify-between px-3 py-1.5">
<span className="text-[0.7rem] font-medium text-(--ui-text-tertiary)">{h.pickerTitle}</span>
<div className="flex items-center gap-1">
<Button disabled={updating} onClick={updateAll} size="xs" variant="text">
{updating && <Loader2 className="size-3 animate-spin" />}
{updating ? h.updating : h.updateAll}
</Button>
<Button onClick={() => setPaneHeightOverride(HUB_PANE_ID, open ? 0 : undefined)} size="xs" variant="text">
{open ? h.pickerHide : h.pickerBrowse}
</Button>
</div>
</div>
{open && (
<div className="flex min-h-0 flex-col gap-1 px-3 pb-2">
{/* Resizable viewport: height comes from the top-edge drag sash
above (persisted; double-click resets). flex-basis instead of a
hard height so a short window shrinks the hub viewport rather
than letting it spill over the list. The iframe is rendered
oversized and scaled DOWN (133% × 0.75) so the hub page starts
zoomed out — the cross-origin page itself can't be styled, but
scaling the frame is ours. */}
<div
style={{
border: '1px solid var(--ui-stroke-secondary)',
borderRadius: 8,
flex: `0 1 ${height}px`,
maxWidth: '100%',
minHeight: 0,
minWidth: 320,
overflow: 'hidden',
position: 'relative',
width: '100%'
}}
>
<iframe
sandbox="allow-scripts allow-same-origin"
src={HUB_PICKER_URL}
style={{
background: 'transparent',
border: 'none',
height: '133.34%',
// While the sash drags, the cross-origin iframe must not eat
// the pointermove stream.
pointerEvents: dragging ? 'none' : 'auto',
transform: 'scale(0.75)',
transformOrigin: 'top left',
width: '133.34%'
}}
title={h.pickerTitle}
/>
</div>
<p className="shrink-0 px-1 text-[0.65rem] leading-4 text-(--ui-text-quaternary)">{h.pickerHint}</p>
</div>
)}
</section>
)
})
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { parseFrontmatter } from './frontmatter'
describe('parseFrontmatter', () => {
it('splits the leading YAML block from the body', () => {
const { body, meta } = parseFrontmatter('---\nname: web-research\nversion: 1.2.0\n---\n\n# Web Research\n\nSteps.')
expect(meta).toEqual([
['name', 'web-research'],
['version', '1.2.0']
])
expect(body.trim()).toBe('# Web Research\n\nSteps.')
})
it('keeps a nested block with its key, de-indented one level', () => {
const { meta } = parseFrontmatter('---\nallowed-tools:\n - Read\n - Write\nname: x\n---\nbody')
expect(meta).toEqual([
['allowed-tools', '- Read\n- Write'],
['name', 'x']
])
})
it('returns the whole content as body when there is no frontmatter', () => {
expect(parseFrontmatter('# Just a skill')).toEqual({ body: '# Just a skill', meta: [] })
})
})
@@ -0,0 +1,39 @@
// Frontmatter parse for display: the YAML block between the leading `---`
// fences, flattened to top-level `key: value` rows (nested blocks render as
// their raw indented text). Display-only — never fed back to the backend.
export function parseFrontmatter(content: string): { body: string; meta: [string, string][] } {
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content)
if (!match) {
return { body: content, meta: [] }
}
const meta: [string, string][] = []
let currentKey: null | string = null
let block: string[] = []
const flush = () => {
if (currentKey !== null) {
meta.push([currentKey, block.join('\n').trim()])
}
currentKey = null
block = []
}
for (const line of match[1].split(/\r?\n/)) {
const kv = /^(\w[\w-]*):\s?(.*)$/.exec(line)
if (kv) {
flush()
currentKey = kv[1]
block = kv[2] ? [kv[2]] : []
} else if (currentKey !== null) {
block.push(line.replace(/^ {2}/, ''))
}
}
flush()
return { body: content.slice(match[0].length), meta }
}
@@ -0,0 +1,83 @@
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { Button } from '@/components/ui/button'
import { CountSkeleton } from '@/components/ui/skeleton'
import { previewSkillHub, type ProfileScope, profileScopeKey } from '@/hermes'
import { useI18n } from '@/i18n'
import { Loader2 } from '@/lib/icons'
import type { OfficialSkillInfo } from '@/types/hermes'
import { PanelPill } from '../../overlays/panel'
import { asText, prettyName } from '../../settings/helpers'
import { DetailHeader } from '../primitives'
import { parseFrontmatter } from './frontmatter'
// Detail pane for a not-yet-installed catalog skill.
export function OfficialSkillDetail({
installing,
onInstall,
profile,
skill
}: {
installing: boolean
onInstall: () => void
profile?: ProfileScope
skill: OfficialSkillInfo
}) {
const { t } = useI18n()
const previewQuery = useQuery({
queryKey: ['official-skill-preview', skill.identifier, profileScopeKey(profile)],
queryFn: () => previewSkillHub(skill.identifier, profile),
staleTime: 5 * 60_000,
retry: false
})
const parsed = useMemo(
() => (previewQuery.data?.skill_md ? parseFrontmatter(previewQuery.data.skill_md) : null),
[previewQuery.data]
)
return (
<>
<DetailHeader
description={asText(skill.description) || t.skills.noDescription}
pills={
<>
<PanelPill>{prettyName(skill.category)}</PanelPill>
<PanelPill tone="muted">{t.skills.officialPill}</PanelPill>
</>
}
title={skill.name}
/>
<div className="flex items-center gap-2">
<Button disabled={installing} onClick={onInstall} size="xs" variant="textStrong">
{installing && <Loader2 className="size-3 animate-spin" />}
{installing ? t.skills.hub.installing : t.skills.hub.install}
</Button>
</div>
{parsed && parsed.meta.length > 0 && (
<div className="grid gap-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3">
{parsed.meta.map(([key, value]) => (
<div className="flex gap-2 text-[0.68rem] leading-4" key={key}>
<span className="w-24 shrink-0 font-medium text-(--ui-text-tertiary)">{key}</span>
<span className="min-w-0 whitespace-pre-wrap break-words text-(--ui-text-secondary)">{value}</span>
</div>
))}
</div>
)}
{previewQuery.isLoading ? (
<CountSkeleton />
) : parsed ? (
<pre
className="overflow-auto whitespace-pre-wrap wrap-break-word rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 font-mono text-[0.68rem] leading-relaxed"
data-selectable-text="true"
>
{parsed.body.trim() || t.skills.noDescription}
</pre>
) : null}
</>
)
}
@@ -0,0 +1,95 @@
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { Button } from '@/components/ui/button'
import { CountSkeleton } from '@/components/ui/skeleton'
import { getSkillContent, type ProfileScope, profileScopeKey } from '@/hermes'
import { useI18n } from '@/i18n'
import type { SkillInfo } from '@/types/hermes'
import { PanelPill } from '../../overlays/panel'
import { asText, prettyName } from '../../settings/helpers'
import { DetailHeader } from '../primitives'
import { parseFrontmatter } from './frontmatter'
import { categoryFor } from './skills-data'
export function SkillDetail({
onArchive,
onEdit,
profile,
skill
}: {
onArchive: () => void
onEdit: () => void
profile?: ProfileScope
skill: SkillInfo
}) {
const { t } = useI18n()
// Only learned/local skills are the user's to rewrite or archive — bundled
// and hub skills are managed by their sources.
const editable = skill.provenance === 'agent'
// The FULL skill — frontmatter metadata + complete SKILL.md body — for any
// provenance, scoped to the Capabilities profile selector. The row list only
// carries name/description; the pane shows the whole thing.
const contentQuery = useQuery({
queryKey: ['skill-content', skill.name, profileScopeKey(profile)],
queryFn: () => getSkillContent(skill.name, profile),
staleTime: 60_000
})
const parsed = useMemo(
() => (contentQuery.data ? parseFrontmatter(contentQuery.data.content) : null),
[contentQuery.data]
)
return (
<>
<DetailHeader
description={asText(skill.description) || t.skills.noDescription}
pills={
<>
<PanelPill>{prettyName(categoryFor(skill))}</PanelPill>
{skill.provenance && skill.provenance !== 'bundled' && (
<PanelPill tone={skill.provenance === 'agent' ? 'good' : 'muted'}>
{t.skills.provenance[skill.provenance]}
</PanelPill>
)}
</>
}
title={skill.name}
/>
{editable && (
<div className="flex items-center gap-2">
<Button onClick={onEdit} size="xs" variant="text">
{t.skills.edit}
</Button>
<Button className="text-destructive hover:text-destructive" onClick={onArchive} size="xs" variant="text">
{t.skills.archive}
</Button>
</div>
)}
{parsed && parsed.meta.length > 0 && (
<div className="grid gap-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3">
{parsed.meta.map(([key, value]) => (
<div className="flex gap-2 text-[0.68rem] leading-4" key={key}>
<span className="w-24 shrink-0 font-medium text-(--ui-text-tertiary)">{key}</span>
<span className="min-w-0 whitespace-pre-wrap break-words text-(--ui-text-secondary)">{value}</span>
</div>
))}
</div>
)}
{contentQuery.isLoading ? (
<CountSkeleton />
) : parsed ? (
<pre
className="overflow-auto whitespace-pre-wrap wrap-break-word rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 font-mono text-[0.68rem] leading-relaxed"
data-selectable-text="true"
>
{parsed.body.trim() || t.skills.noDescription}
</pre>
) : null}
</>
)
}
@@ -0,0 +1,76 @@
import { useQuery } from '@tanstack/react-query'
import { getSkills, type ProfileScope, profileScopeKey } from '@/hermes'
import { normalize } from '@/lib/text'
import type { OfficialSkillInfo, SkillInfo } from '@/types/hermes'
import { asText, includesQuery } from '../../settings/helpers'
// Skills live in the RQ cache so switching tabs/pages paints the cached list
// instantly (no reload flash) and mount only fires a deduped background
// refetch. A profile swap globally invalidates (see store/profile), so this
// plain key refetches against the new backend automatically.
// `store/hub-actions` invalidates it after a hub (un)install — it imports this
// constant rather than re-spelling the key.
export const SKILLS_QUERY_KEY = ['skills-list'] as const
/** The list key for one scope: the plain key plus the Capabilities scope key,
* so every scoped profile keeps its own cached copy (prefix invalidations
* still match). */
export const skillsQueryKey = (profile: ProfileScope) => [...SKILLS_QUERY_KEY, profileScopeKey(profile)]
export function useSkillsQuery(profile: ProfileScope) {
return useQuery({
queryKey: skillsQueryKey(profile),
queryFn: () => getSkills(profile),
staleTime: 0
})
}
export const usageOf = (skill: SkillInfo): number => (typeof skill.usage === 'number' ? skill.usage : 0)
export const categoryFor = (skill: SkillInfo): string => asText(skill.category) || 'general'
// Catalog rows have no usage yet — plain A–Z, with tags as searchable metadata.
export function filteredOfficial(skills: OfficialSkillInfo[], query: string): OfficialSkillInfo[] {
const q = normalize(query)
return skills
.filter(
skill =>
!q ||
includesQuery(skill.name, q) ||
includesQuery(skill.description, q) ||
includesQuery(skill.category, q) ||
skill.tags.some(tag => includesQuery(tag, q))
)
.sort((a, b) => asText(a.name).localeCompare(asText(b.name)))
}
export function filteredSkills(skills: SkillInfo[], query: string, desc: boolean): SkillInfo[] {
const q = normalize(query)
const sign = desc ? 1 : -1
return skills
.filter(
skill =>
!q || includesQuery(skill.name, q) || includesQuery(skill.description, q) || includesQuery(skill.category, q)
)
.sort((a, b) => sign * (usageOf(b) - usageOf(a)) || asText(a.name).localeCompare(asText(b.name)))
}
/** The user's busiest categories, for the search field's rotating placeholder
* nudges — they teach that search understands categories, not just titles. */
export function skillSearchTerms(skills: SkillInfo[]): string[] {
const counts = new Map<string, number>()
for (const skill of skills) {
const key = categoryFor(skill)
counts.set(key, (counts.get(key) || 0) + 1)
}
return [...counts.entries()]
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([category]) => category.toLowerCase())
}
@@ -0,0 +1,381 @@
import { compactNumber } from '@hermes/shared'
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { type ReactNode, useCallback, useMemo, useState } from 'react'
import { ArchiveSkillConfirmDialog } from '@/app/learning/archive-skill-confirm-dialog'
import { CodeEditor } from '@/components/chat/code-editor'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { editLearningNode, getLearningNode, getOfficialSkills, type ProfileScope, profileScopeKey, setSkillEnabled } from '@/hermes'
import { useI18n } from '@/i18n'
import { Loader2 } from '@/lib/icons'
import { Codecs, persistentAtom } from '@/lib/persisted'
import { queryClient } from '@/lib/query-client'
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
import { useStoreSelector } from '@/lib/use-session-slice'
import { $hubActions, installHubSkill, notifyHubActionFailed, OFFICIAL_SKILLS_KEY } from '@/store/hub-actions'
import { notify, notifyError } from '@/store/notifications'
import type { OfficialSkillInfo, SkillInfo } from '@/types/hermes'
import {
CapRow,
DetailColumn,
DetailPane,
ListColumn,
ListStrip,
ListStripMenu,
type ListStripMenuToggle,
MasterDetail
} from '../../master-detail'
import { prettyName } from '../../settings/helpers'
import { CapabilityEmpty, SortButton } from '../primitives'
import { OfficialSkillDetail } from './official-skill-detail'
import { SkillDetail } from './skill-detail'
import { categoryFor, filteredOfficial, filteredSkills, skillsQueryKey, usageOf } from './skills-data'
// Sort direction for the Skills list — persisted so the tab remembers
// most/least-used across navigations and restarts.
const $skillsSortDesc = persistentAtom('hermes.desktop.capabilities.skillsSortDesc', true, Codecs.bool)
// Row subtitle: category, with non-default origins badged.
function skillSubtitle(skill: SkillInfo): ReactNode {
const category = prettyName(categoryFor(skill))
const provenance = skill.provenance
return (
<>
<span className="truncate">{category}</span>
{provenance === 'agent' && (
<Badge className="shrink-0 normal-case" variant="default">
learned
</Badge>
)}
{provenance === 'hub' && (
<Badge className="shrink-0 normal-case" variant="muted">
hub
</Badge>
)}
</>
)
}
interface SkillsTabProps {
/** The scope's skill list, straight from the shell's query. */
skills: SkillInfo[]
/** The (connection, profile) scope every read and write routes to. */
profile: ProfileScope
query: string
/** Page-level refresh: a saved skill edit reloads the same way the refresh
* hotkey does, counts and slash completions included. */
onRefresh: () => void
}
/** The Skills tab: installed skills, official optional skills, and learned-skill editing. */
export function SkillsTab({ onRefresh, profile, query, skills }: SkillsTabProps) {
const { t } = useI18n()
const skillsSortDesc = useStore($skillsSortDesc)
const [bulkBusy, setBulkBusy] = useState(false)
const [selectedSkill, setSelectedSkill] = useState<string | null>(null)
const [selectedOfficial, setSelectedOfficial] = useState<string | null>(null)
const { data: officialData } = useQuery({
queryKey: [...OFFICIAL_SKILLS_KEY, profileScopeKey(profile)],
queryFn: () => getOfficialSkills(profile),
staleTime: 60_000,
retry: false
})
// Learned/local skills are editable + archivable, mirroring the memory
// graph (same /api/learning/node endpoints — delete archives, restorable
// via `hermes curator restore`).
const [skillEditor, setSkillEditor] = useState<null | { content: string; name: string }>(null)
const [skillDraft, setSkillDraft] = useState('')
const [skillSaving, setSkillSaving] = useState(false)
const [archiveTarget, setArchiveTarget] = useState<null | string>(null)
// Optimistic write-through against the scoped Skills key: toggles/bulk/
// archive repaint instantly; the next background refetch reconciles.
const setSkills = useCallback(
(fn: (cur: SkillInfo[] | undefined) => SkillInfo[] | undefined) =>
queryClient.setQueryData<SkillInfo[]>(skillsQueryKey(profile), prev => fn(prev) ?? prev),
[profile]
)
const visibleSkills = useMemo(() => filteredSkills(skills, query, skillsSortDesc), [query, skills, skillsSortDesc])
// Installed-name set stays unfiltered so search cannot make a skill look absent.
const installedSkillNames = useMemo(() => new Set(skills.map(s => s.name)), [skills])
const visibleOfficial = useMemo(() => {
const catalog = (officialData?.skills ?? []).filter(skill => !skill.installed && !installedSkillNames.has(skill.name))
return filteredOfficial(catalog, query)
}, [installedSkillNames, officialData, query])
const runningInstallKey = useStoreSelector($hubActions, actions =>
Object.keys(actions)
.filter(key => actions[key]?.running)
.sort()
.join('|')
)
const runningInstalls = useMemo(() => new Set(runningInstallKey.split('|').filter(Boolean)), [runningInstallKey])
// Keep a valid selection: fall back to the first visible row when the
// current selection is filtered out (or nothing is selected yet).
const activeSkill = useMemo(
() => visibleSkills.find(s => s.name === selectedSkill) ?? visibleSkills[0] ?? null,
[selectedSkill, visibleSkills]
)
const activeOfficial = useMemo(
() => visibleOfficial.find(skill => skill.identifier === selectedOfficial) ?? null,
[selectedOfficial, visibleOfficial]
)
function handleInstallOfficial(skill: OfficialSkillInfo) {
notify({ kind: 'success', title: t.skills.hub.installStarted(skill.name), message: t.skills.hub.actionLog })
void installHubSkill(skill.identifier, profile).catch(err =>
notifyHubActionFailed(err, t.skills.hub.actionFailed, skill.name, profile)
)
}
async function handleToggleSkill(skill: SkillInfo, enabled: boolean) {
setSkills(current => current?.map(row => (row.name === skill.name ? { ...row, enabled } : row)) ?? current)
try {
await setSkillEnabled(skill.name, enabled, profile)
// A disabled skill loses its `/name` command, so the composer's cached
// `/` list has to be dropped along with the row repaint.
invalidateSlashCompletions()
} catch (err) {
setSkills(
current => current?.map(row => (row.name === skill.name ? { ...row, enabled: !enabled } : row)) ?? current
)
notifyError(err, t.skills.failedToUpdate(skill.name))
}
}
// Sequential on purpose: each toggle is a config read-modify-write on the
// backend; parallel calls would race the disabled-list save.
async function bulkApply(targets: SkillInfo[], enabled: boolean) {
if (bulkBusy || targets.length === 0) {
return
}
setBulkBusy(true)
let done = 0
try {
for (const row of targets) {
await setSkillEnabled(row.name, enabled, profile)
setSkills(cur => cur?.map(r => (r.name === row.name ? { ...r, enabled } : r)) ?? cur)
done += 1
}
notify({ kind: 'success', title: t.skills.bulkUpdated(done), message: '' })
} catch (err) {
notifyError(err, t.skills.failedToUpdate(t.skills.tabSkills))
} finally {
invalidateSlashCompletions()
setBulkBusy(false)
}
}
// Bulk actions ("All" master switch, "Disable unused") and the master-switch
// state target the WHOLE tab, never the search-filtered view — a tab-wide
// control that silently scoped to the current query would be a lie.
const allEnabled = skills.length > 0 && skills.every(s => s.enabled)
// One switch line covering enable-all/disable-all.
const bulkSwitch: ListStripMenuToggle = {
checked: allEnabled,
disabled: bulkBusy,
label: t.skills.all,
onToggle: checked => void bulkApply(skills.filter(row => row.enabled !== checked), checked)
}
// "Never used" = zero recorded activity. The pruning move for a 100+ skill
// install: keep the workhorses, shed the noise.
const disableUnused = () => bulkApply(skills.filter(skill => skill.enabled && usageOf(skill) === 0), false)
const openSkillEditor = async (name: string) => {
try {
const node = await getLearningNode(name, profile)
setSkillEditor({ content: node.content, name })
setSkillDraft(node.content)
} catch (err) {
notifyError(err, name)
}
}
const saveSkillEdit = async () => {
if (!skillEditor) {
return
}
setSkillSaving(true)
try {
await editLearningNode(skillEditor.name, skillDraft, profile)
notify({
kind: 'success',
title: t.skills.skillUpdated,
message: t.skills.appliesToNewSessions(skillEditor.name)
})
setSkillEditor(null)
onRefresh()
} catch (err) {
notifyError(err, skillEditor.name)
} finally {
setSkillSaving(false)
}
}
const skillEditorPane = skillEditor && (
<DetailPane
actions={
<Button disabled={skillSaving} onClick={() => void saveSkillEdit()} size="xs">
{skillSaving ? t.common.saving : t.common.save}
</Button>
}
id="skill-editor"
onClose={() => setSkillEditor(null)}
title={<span className="text-[0.68rem] font-normal text-muted-foreground/60">{skillEditor.name}/SKILL.md</span>}
>
<CodeEditor
filePath="SKILL.md"
initialValue={skillEditor.content}
key={skillEditor.name}
onCancel={() => setSkillEditor(null)}
onChange={setSkillDraft}
onSave={() => void saveSkillEdit()}
/>
</DetailPane>
)
return (
<>
{visibleSkills.length === 0 && visibleOfficial.length === 0 ? (
<CapabilityEmpty noun="skills" query={query} />
) : (
<MasterDetail pane={skillEditorPane} resizeId="capabilities-split" split="wide">
<ListColumn
header={
<ListStrip
left={<SortButton desc={skillsSortDesc} onFlip={() => $skillsSortDesc.set(!$skillsSortDesc.get())} />}
right={
<ListStripMenu
items={[
{
disabled: bulkBusy,
label: t.skills.disableUnused,
onSelect: () => void disableUnused()
}
]}
label={t.skills.tabSkills}
toggle={bulkSwitch}
/>
}
/>
}
>
{visibleSkills.map(skill => (
<CapRow
active={activeOfficial === null && activeSkill?.name === skill.name}
busy={bulkBusy}
enabled={skill.enabled}
key={skill.name}
meta={usageOf(skill) > 0 ? `×${compactNumber(usageOf(skill))}` : undefined}
onSelect={() => {
setSelectedSkill(skill.name)
setSelectedOfficial(null)
}}
onToggle={enabled => void handleToggleSkill(skill, enabled)}
subtitle={skillSubtitle(skill)}
title={skill.name}
toggleLabel={skill.name}
/>
))}
{visibleOfficial.length > 0 && (
<div className="flex h-7 shrink-0 items-end px-2 pb-1 text-[0.62rem] font-medium uppercase tracking-wide text-(--ui-text-quaternary)">
{t.skills.officialCatalog}
</div>
)}
{visibleOfficial.map(skill => {
const installing = runningInstalls.has(skill.identifier)
return (
<CapRow
action={
<Button
disabled={installing}
onClick={() => handleInstallOfficial(skill)}
size="xs"
variant="text"
>
{installing && <Loader2 className="size-3 animate-spin" />}
{installing ? t.skills.hub.installing : t.skills.hub.install}
</Button>
}
active={activeOfficial?.identifier === skill.identifier}
enabled={false}
key={skill.identifier}
onSelect={() => setSelectedOfficial(skill.identifier)}
subtitle={prettyName(skill.category)}
title={skill.name}
/>
)
})}
</ListColumn>
<DetailColumn footer={t.skills.changesApplyNewSessions}>
{activeOfficial ? (
<OfficialSkillDetail
installing={runningInstalls.has(activeOfficial.identifier)}
onInstall={() => handleInstallOfficial(activeOfficial)}
profile={profile}
skill={activeOfficial}
/>
) : (
activeSkill && (
<SkillDetail
onArchive={() => setArchiveTarget(activeSkill.name)}
onEdit={() => void openSkillEditor(activeSkill.name)}
profile={profile}
skill={activeSkill}
/>
)
)}
</DetailColumn>
</MasterDetail>
)}
{archiveTarget && (
<ArchiveSkillConfirmDialog
onApply={() => {
const name = archiveTarget
const snapshot = skills
setSkills(current => current?.filter(skill => skill.name !== name) ?? current)
invalidateSlashCompletions()
if (skillEditor?.name === name) {
setSkillEditor(null)
}
return () => setSkills(() => snapshot)
}}
onClose={() => setArchiveTarget(null)}
onFailure={(err, name) => notifyError(err, name)}
open
profile={profile}
skillId={archiveTarget}
skillName={archiveTarget}
/>
)}
</>
)
}
@@ -0,0 +1,77 @@
import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { useEffect, useState } from 'react'
import { getUsageAnalytics, type ProfileScope, profileScopeKey } from '@/hermes'
// Per-tool call counts come from a 365-day message scan — heavy, and purely
// cosmetic (Toolsets usage badges). Cache the result module-wide with a TTL so
// bouncing between tabs/pages doesn't re-run the scan every time. Keyed by
// the Capabilities scope profile: analytics are profile-scoped, so a scope or
// app-profile switch must not show the previous profile's counts.
const TOOL_CALLS_TTL_MS = 10 * 60 * 1000
const toolCallsCache = new Map<string, { at: number; value: Record<string, number> }>()
// Bumped once a forced pull has landed in the cache, so badges already on
// screen pick the fresh counts up instead of sitting out the TTL.
const $toolCallsRefreshed = atom(0)
async function loadToolCalls(
scopeKey: string,
scopeProfile: ProfileScope,
force = false
): Promise<Record<string, number>> {
const cached = toolCallsCache.get(scopeKey)
if (!force && cached && Date.now() - cached.at < TOOL_CALLS_TTL_MS) {
return cached.value
}
const analytics = await getUsageAnalytics(365, scopeProfile)
const value = Object.fromEntries((analytics.tools ?? []).map(e => [e.tool, e.count]))
toolCallsCache.set(scopeKey, { at: Date.now(), value })
return value
}
/** The page refresh's analytics leg (`useRefreshHotkey`): the one time the TTL
* is bypassed — but only if the badges have been on screen at least once;
* otherwise let the lazy load pick the counts up when Toolsets is first
* shown. */
export async function refreshToolCalls(profile: ProfileScope): Promise<void> {
if (toolCallsCache.size === 0) {
return
}
await loadToolCalls(profileScopeKey(profile), profile, true).catch(() => undefined)
$toolCallsRefreshed.set($toolCallsRefreshed.get() + 1)
}
/**
* tool name -> call count over the analytics window. `null` = still loading
* (badges show skeletons); `{}` = loaded empty / unavailable backend.
*
* Mounting the Toolsets tab is what asks for the scan, so Skills and MCP never
* pay for it and it can't starve the MCP tab's config load. A scope change
* remounts the tab, so a slow load from the previous scope can never land on
* the new one's badges.
*/
export function useToolCalls(profile: ProfileScope): null | Record<string, number> {
const refreshed = useStore($toolCallsRefreshed)
const [toolCalls, setToolCalls] = useState<null | Record<string, number>>(null)
const scopeKey = profileScopeKey(profile)
useEffect(() => {
let cancelled = false
loadToolCalls(scopeKey, profile)
.then(value => !cancelled && setToolCalls(value))
.catch(() => !cancelled && setToolCalls({}))
return () => void (cancelled = true)
}, [profile, refreshed, scopeKey])
return toolCalls
}
@@ -0,0 +1,89 @@
import { compactNumber } from '@hermes/shared'
import { useNavigate } from 'react-router'
import { Button } from '@/components/ui/button'
import { type ProfileScope, profileScopeKey } from '@/hermes'
import { useI18n } from '@/i18n'
import type { ToolsetInfo } from '@/types/hermes'
import { ToolChip } from '../../master-detail'
import { PanelPill } from '../../overlays/panel'
import { SETTINGS_ROUTE } from '../../routes'
import { BrowserRealProfilePanel } from '../../settings/browser-real-profile-panel'
import { ComputerUsePanel } from '../../settings/computer-use-panel'
import { asText, toolNames, toolsetDisplayLabel } from '../../settings/helpers'
import { TerminalBackendPanel } from '../../settings/terminal-backend-panel'
import { ToolsetConfigPanel } from '../../settings/toolset-config-panel'
import { DetailHeader } from '../primitives'
export function ToolsetDetail({
toolset,
toolCalls,
onConfiguredChange,
profile
}: {
toolset: ToolsetInfo
toolCalls: Record<string, number>
onConfiguredChange: () => void
profile?: ProfileScope
}) {
const { t } = useI18n()
const navigate = useNavigate()
const tools = toolNames(toolset)
const label = toolsetDisplayLabel(toolset)
return (
<>
{/* "Configured" as a resting state is noise — only the warn state earns a pill. */}
<DetailHeader
description={asText(toolset.description) || t.skills.noDescription}
pills={!toolset.configured && <PanelPill tone="warn">{t.skills.needsKeys}</PanelPill>}
title={label}
/>
{tools.length > 0 && (
<div className="flex flex-wrap gap-1">
{tools.map(name => (
<ToolChip key={name}>
{name}
{(toolCalls[name] ?? 0) > 0 && (
<span className="ml-1 text-(--ui-text-quaternary)">×{compactNumber(toolCalls[name])}</span>
)}
</ToolChip>
))}
</div>
)}
{toolset.name === 'vision' && (
// Vision has no provider matrix — model resolution runs through the
// auxiliary model config. Point at the actual home (Settings → Models,
// aux "vision" row) via an internal deep link instead of leaving the
// detail pane empty.
<div className="grid gap-1.5">
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{t.skills.visionModelHint}
</p>
<div>
<Button
onClick={() => navigate(`${SETTINGS_ROUTE}?tab=config:model&aux=vision`)}
size="xs"
variant="textStrong"
>
{t.skills.visionModelLink}
</Button>
</div>
</div>
)}
{toolset.name === 'computer_use' && <ComputerUsePanel onConfiguredChange={onConfiguredChange} />}
{/* Real-profile consent toggle ABOVE the backend/provider matrix — the
config option users kept missing because its only GUI home was the
generic Settings → Config editor. */}
{toolset.name === 'browser' && <BrowserRealProfilePanel profile={profile} />}
{toolset.name === 'terminal' && <TerminalBackendPanel onConfiguredChange={onConfiguredChange} />}
<ToolsetConfigPanel
key={`${toolset.name}:${profileScopeKey(profile)}`}
onConfiguredChange={onConfiguredChange}
profile={profile}
toolset={toolset.name}
/>
</>
)
}
@@ -0,0 +1,75 @@
import { useQuery } from '@tanstack/react-query'
import { getToolsets, type ProfileScope, profileScopeKey } from '@/hermes'
import { isDesktopToolsetVisible } from '@/lib/desktop-toolsets'
import { normalize } from '@/lib/text'
import type { ToolsetInfo } from '@/types/hermes'
import { includesQuery, toolNames, toolsetDisplayLabel } from '../../settings/helpers'
// Toolsets live in the RQ cache so switching tabs/pages paints the cached list
// instantly (no reload flash) and mount only fires a deduped background
// refetch. A profile swap globally invalidates (see store/profile), so this
// plain key refetches against the new backend automatically.
export const TOOLSETS_QUERY_KEY = ['toolsets-list'] as const
/** The list key for one scope: the plain key plus the Capabilities scope key,
* so every scoped profile keeps its own cached copy (prefix invalidations
* still match). */
export const toolsetsQueryKey = (profile: ProfileScope) => [...TOOLSETS_QUERY_KEY, profileScopeKey(profile)]
export function useToolsetsQuery(profile: ProfileScope) {
return useQuery({
queryKey: toolsetsQueryKey(profile),
queryFn: () => getToolsets(profile),
staleTime: 0
})
}
export const toolsetCalls = (toolset: ToolsetInfo, toolCalls: Record<string, number>): number =>
toolNames(toolset).reduce((sum, name) => sum + (toolCalls[name] ?? 0), 0)
export function filteredToolsets(
toolsets: ToolsetInfo[],
query: string,
toolCalls: Record<string, number>,
desc: boolean
): ToolsetInfo[] {
const q = normalize(query)
const sign = desc ? 1 : -1
return toolsets
.filter(toolset => {
if (!isDesktopToolsetVisible(toolset.name)) {
return false
}
if (!q) {
return true
}
return (
includesQuery(toolset.name, q) ||
includesQuery(toolsetDisplayLabel(toolset), q) ||
includesQuery(toolset.description, q) ||
toolNames(toolset).some(name => includesQuery(name, q))
)
})
.sort(
(a, b) =>
sign * (toolsetCalls(b, toolCalls) - toolsetCalls(a, toolCalls)) ||
toolsetDisplayLabel(a).localeCompare(toolsetDisplayLabel(b))
)
}
export const visibleToolsetCount = (toolsets: ToolsetInfo[]) =>
toolsets.filter(ts => isDesktopToolsetVisible(ts.name)).length
/** Tool names for the search field's rotating placeholder nudges — they teach
* that search understands tool names, not just titles. */
export function toolsetSearchTerms(toolsets: ToolsetInfo[]): string[] {
return toolsets
.filter(ts => isDesktopToolsetVisible(ts.name) && toolNames(ts).length > 0)
.slice(0, 5)
.map(ts => toolNames(ts)[0])
}
@@ -0,0 +1,192 @@
import { compactNumber } from '@hermes/shared'
import { useStore } from '@nanostores/react'
import { useCallback, useMemo, useState } from 'react'
import { CountSkeleton } from '@/components/ui/skeleton'
import { type ProfileScope, setToolsetEnabled } from '@/hermes'
import { useI18n } from '@/i18n'
import { isDesktopToolsetVisible } from '@/lib/desktop-toolsets'
import { Codecs, persistentAtom } from '@/lib/persisted'
import { queryClient } from '@/lib/query-client'
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
import { notify, notifyError } from '@/store/notifications'
import type { ToolsetInfo } from '@/types/hermes'
import {
CapRow,
DetailColumn,
ListColumn,
ListStrip,
ListStripMenu,
type ListStripMenuToggle,
MasterDetail
} from '../../master-detail'
import { asText, toolNames, toolsetDisplayLabel } from '../../settings/helpers'
import { CapabilityEmpty, SortButton } from '../primitives'
import { useToolCalls } from './tool-calls'
import { ToolsetDetail } from './toolset-detail'
import { filteredToolsets, toolsetCalls, TOOLSETS_QUERY_KEY, toolsetsQueryKey } from './toolsets-data'
// Sort direction for the Tools list — persisted so the tab remembers
// most/least-used across navigations and restarts.
const $toolsetsSortDesc = persistentAtom('hermes.desktop.capabilities.toolsetsSortDesc', true, Codecs.bool)
interface ToolsetsTabProps {
/** The scope's toolset list, straight from the shell's query. */
toolsets: ToolsetInfo[]
/** The (connection, profile) scope every read and write routes to. */
profile: ProfileScope
query: string
}
/** THE Tools tab: the toolset list with its usage badges, and the inspector
* that mounts each toolset's settings panels. */
export function ToolsetsTab({ profile, query, toolsets }: ToolsetsTabProps) {
const { t } = useI18n()
const toolsetsSortDesc = useStore($toolsetsSortDesc)
const toolCalls = useToolCalls(profile)
const [bulkBusy, setBulkBusy] = useState(false)
const [selectedToolset, setSelectedToolset] = useState<string | null>(null)
// Optimistic write-through against the scoped Tools key: toggles repaint
// instantly; the next background refetch reconciles.
const setToolsets = useCallback(
(fn: (cur: ToolsetInfo[] | undefined) => ToolsetInfo[] | undefined) =>
queryClient.setQueryData<ToolsetInfo[]>(toolsetsQueryKey(profile), prev => fn(prev) ?? prev),
[profile]
)
const refreshToolsets = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: TOOLSETS_QUERY_KEY })
}, [])
// Absent counts sort the list A–Z until the analytics scan lands.
const visibleToolsets = useMemo(
() => filteredToolsets(toolsets, query, toolCalls ?? {}, toolsetsSortDesc),
[query, toolCalls, toolsets, toolsetsSortDesc]
)
// Bulk actions and the master-switch state target the WHOLE tab, never the
// search-filtered view — a tab-wide control that silently scoped to the
// current query would be a lie.
const bulkToolsets = useMemo(() => toolsets.filter(ts => isDesktopToolsetVisible(ts.name)), [toolsets])
// Keep a valid selection: fall back to the first visible row when the
// current selection is filtered out (or nothing is selected yet).
const activeToolset = useMemo(
() => visibleToolsets.find(ts => ts.name === selectedToolset) ?? visibleToolsets[0] ?? null,
[selectedToolset, visibleToolsets]
)
// Single toggles are optimistic and silent on success (the row repaints
// immediately — a toast per flip would spam rapid customization). Errors
// revert and notify.
async function handleToggleToolset(toolset: ToolsetInfo, enabled: boolean) {
setToolsets(
current =>
current?.map(row => (row.name === toolset.name ? { ...row, enabled, available: enabled } : row)) ?? current
)
try {
await setToolsetEnabled(toolset.name, enabled, profile)
} catch (err) {
setToolsets(
current =>
current?.map(row => (row.name === toolset.name ? { ...row, enabled: !enabled, available: !enabled } : row)) ??
current
)
notifyError(err, t.skills.failedToUpdate(toolsetDisplayLabel(toolset)))
}
}
// Sequential on purpose: each toggle is a config read-modify-write on the
// backend; parallel calls would race the disabled-list save.
async function bulkApply(targets: ToolsetInfo[], enabled: boolean) {
if (bulkBusy || targets.length === 0) {
return
}
setBulkBusy(true)
let done = 0
try {
for (const row of targets) {
await setToolsetEnabled(row.name, enabled, profile)
setToolsets(cur => cur?.map(r => (r.name === row.name ? { ...r, enabled, available: enabled } : r)) ?? cur)
done += 1
}
notify({ kind: 'success', title: t.skills.bulkUpdated(done), message: '' })
} catch (err) {
notifyError(err, t.skills.failedToUpdate(t.skills.tabToolsets))
} finally {
invalidateSlashCompletions()
setBulkBusy(false)
}
}
// One switch line covering enable-all/disable-all.
const bulkSwitch: ListStripMenuToggle = {
checked: bulkToolsets.length > 0 && bulkToolsets.every(ts => ts.enabled),
disabled: bulkBusy,
label: t.skills.all,
onToggle: checked => void bulkApply(bulkToolsets.filter(row => row.enabled !== checked), checked)
}
if (visibleToolsets.length === 0) {
return <CapabilityEmpty noun="tools" query={query} />
}
return (
<MasterDetail resizeId="capabilities-split" split="wide">
<ListColumn
header={
<ListStrip
left={<SortButton desc={toolsetsSortDesc} onFlip={() => $toolsetsSortDesc.set(!$toolsetsSortDesc.get())} />}
right={<ListStripMenu label={t.skills.tabToolsets} toggle={bulkSwitch} />}
/>
}
>
{visibleToolsets.map(toolset => {
const label = toolsetDisplayLabel(toolset)
const calls = toolCalls ? toolsetCalls(toolset, toolCalls) : null
return (
<CapRow
active={activeToolset?.name === toolset.name}
busy={bulkBusy}
enabled={toolset.enabled}
key={toolset.name}
meta={
calls === null ? (
<CountSkeleton />
) : calls > 0 ? (
`×${compactNumber(calls)}`
) : (
`${toolNames(toolset).length} tools`
)
}
onSelect={() => setSelectedToolset(toolset.name)}
onToggle={checked => void handleToggleToolset(toolset, checked)}
subtitle={asText(toolset.description)}
title={label}
toggleLabel={t.skills.toggleToolset(label, !toolset.enabled)}
/>
)
})}
</ListColumn>
<DetailColumn footer={t.skills.changesApplyNewSessions}>
{activeToolset && (
<ToolsetDetail
onConfiguredChange={refreshToolsets}
profile={profile}
toolCalls={toolCalls ?? {}}
toolset={activeToolset}
/>
)}
</DetailColumn>
</MasterDetail>
)
}
+3 -3
View File
@@ -13,11 +13,11 @@ import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
import { $routeTiles, closeRouteTile, type RouteTile } from '@/store/route-tiles'
import { $routesVersion, ARTIFACTS_ROUTE, contributedRoutes, MESSAGING_ROUTE, ROUTES_AREA, SKILLS_ROUTE } from '../routes'
import { $routesVersion, ARTIFACTS_ROUTE, CAPABILITIES_ROUTE, contributedRoutes, MESSAGING_ROUTE, ROUTES_AREA } from '../routes'
import { paneMirror } from './pane-mirror'
const SkillsView = lazy(async () => ({ default: (await import('../skills')).SkillsView }))
const CapabilitiesView = lazy(async () => ({ default: (await import('../capabilities')).CapabilitiesView }))
const MessagingView = lazy(async () => ({ default: (await import('../messaging')).MessagingView }))
const ArtifactsView = lazy(async () => ({ default: (await import('../artifacts')).ArtifactsView }))
@@ -25,7 +25,7 @@ const ArtifactsView = lazy(async () => ({ default: (await import('../artifacts')
const BUILTIN_PAGES: Record<string, { render: () => ReactNode; title: string }> = {
[ARTIFACTS_ROUTE]: { render: () => <ArtifactsView />, title: 'Artifacts' },
[MESSAGING_ROUTE]: { render: () => <MessagingView />, title: 'Messaging' },
[SKILLS_ROUTE]: { render: () => <SkillsView />, title: 'Capabilities' }
[CAPABILITIES_ROUTE]: { render: () => <CapabilitiesView />, title: 'Capabilities' }
}
/** Humanize a route path into a tab title: `/my-atlas` → `My Atlas`. */
@@ -131,7 +131,7 @@ describe('ChatSidebar navigation activity', () => {
})
for (const [pathname, currentView, label] of [
['/skills', 'skills', 'Capabilities'],
['/capabilities', 'capabilities', 'Capabilities'],
['/messaging', 'messaging', 'Messaging'],
['/artifacts', 'artifacts', 'Artifacts'],
['/cron', 'cron', 'Scheduled jobs']
+6 -6
View File
@@ -137,11 +137,11 @@ import { $sidebarSessionRankIds } from '@/store/sidebar-sort'
import {
type AppView,
ARTIFACTS_ROUTE,
CAPABILITIES_ROUTE,
CRON_ROUTE,
MESSAGING_ROUTE,
SIDEBAR_NAV_AREA,
type SidebarNavContribution,
SKILLS_ROUTE
type SidebarNavContribution
} from '../../routes'
import type { SidebarNavItem } from '../../types'
import { type NewSessionSplitHandler, startNewSessionDrag } from '../new-session-drag'
@@ -207,11 +207,11 @@ const SIDEBAR_NAV: SidebarNavItem[] = [
keybindActionId: 'session.new'
},
{
id: 'skills',
id: 'capabilities',
label: '',
icon: props => <Codicon name="symbol-misc" {...props} />,
route: SKILLS_ROUTE,
keybindActionId: 'nav.skills'
route: CAPABILITIES_ROUTE,
keybindActionId: 'nav.capabilities'
},
{
id: 'messaging',
@@ -1517,7 +1517,7 @@ export function ChatSidebar({
const isInteractive = Boolean(item.action) || Boolean(item.route)
const active =
(item.id === 'skills' && currentView === 'skills') ||
(item.id === 'capabilities' && currentView === 'capabilities') ||
(item.id === 'messaging' && currentView === 'messaging') ||
(item.id === 'artifacts' && currentView === 'artifacts') ||
(item.id === 'cron' && currentView === 'cron') ||
@@ -1,6 +1,6 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type * as Nanostores from 'nanostores'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ProjectDialog } from './project-dialog'
@@ -35,10 +35,11 @@ vi.mock('@/i18n', () => ({
// $projectDialog is a real nanostore atom in the app; recreate it here so
// useStore behaves identically without pulling in the rest of the projects
// store (backend calls, project list, etc.) which is irrelevant to the Tip fix.
// store (backend calls, project list, etc.) which is irrelevant to the dialog
// interactions under test.
// vi.mock factories are hoisted above the rest of the file, so the atom must
// be created inside vi.hoisted to exist by the time the factory runs.
const { $newProjectDropPlacement, $projectDialog } = vi.hoisted(() => {
const { $newProjectDropPlacement, $projectDialog, createProject, enterProject, pickProjectFolder } = vi.hoisted(() => {
const { atom } = require('nanostores') as typeof Nanostores
return {
@@ -46,7 +47,10 @@ const { $newProjectDropPlacement, $projectDialog } = vi.hoisted(() => {
$newProjectDropPlacement: atom<{ anchor: string; before?: null | string; dir: string } | null>(null),
$projectDialog: atom<{ mode: 'create' | 'rename' | 'add-folder'; name?: string; projectId?: string } | null>({
mode: 'create'
})
}),
createProject: vi.fn(),
enterProject: vi.fn(),
pickProjectFolder: vi.fn()
}
})
@@ -56,12 +60,19 @@ vi.mock('@/store/projects', () => ({
addProjectFolder: vi.fn(),
clearNewProjectDropPlacement: vi.fn(),
closeProjectDialog: vi.fn(),
createProject: vi.fn(),
createProject,
enterProject,
generateProjectIdea: vi.fn(),
pickProjectFolder: vi.fn(async () => '/Users/test/my-folder'),
pickProjectFolder,
renameProject: vi.fn()
}))
beforeEach(() => {
vi.clearAllMocks()
createProject.mockResolvedValue({ id: 'p_created' })
pickProjectFolder.mockResolvedValue('/Users/test/my-folder')
})
vi.mock('@/store/notifications', () => ({
notifyError: vi.fn()
}))
@@ -88,6 +99,36 @@ async function fillCreateForm() {
}
describe('ProjectDialog', () => {
it('creates from the folder basename and enters the created project when the name is empty', async () => {
render(<ProjectDialog />)
fireEvent.click(screen.getByRole('button', { name: 'Add folder' }))
await screen.findByDisplayValue('my-folder')
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => {
expect(createProject).toHaveBeenCalledWith(
expect.objectContaining({ folders: ['/Users/test/my-folder'], name: 'my-folder' })
)
expect(enterProject).toHaveBeenCalledWith('p_created')
})
})
it('keeps an explicit project name when a folder is selected', async () => {
render(<ProjectDialog />)
fireEvent.change(screen.getByPlaceholderText('Project name'), { target: { value: 'My project' } })
fireEvent.click(screen.getByRole('button', { name: 'Add folder' }))
await screen.findByRole('button', { name: 'Remove folder' })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() => {
expect(createProject).toHaveBeenCalledWith(expect.objectContaining({ name: 'My project' }))
})
})
it('wraps the "shuffle idea" button in a Tip', () => {
render(<ProjectDialog />)
@@ -28,11 +28,14 @@ import {
clearNewProjectDropPlacement,
closeProjectDialog,
createProject,
enterProject,
generateProjectIdea,
pickProjectFolder,
renameProject
} from '@/store/projects'
import { baseName } from './projects/workspace-groups'
// Single dialog mounted once in the sidebar; it renders create / rename /
// add-folder flows driven by the $projectDialog atom. Folders are chosen via
// the native directory picker (reused from the default-project-dir setting).
@@ -128,6 +131,13 @@ export function ProjectDialog() {
}
setFolders(prev => (prev.includes(dir) ? prev : [...prev, dir]))
// Picking a folder with no name typed names the project after the folder
// (the ⌘O "Open folder…" naming), so one pick + Create is enough. The name
// lands in the input, never in a hidden fallback the user cannot see.
if (mode === 'create') {
setName(prev => prev.trim() || baseName(dir) || prev)
}
} catch (err) {
notifyError(err, p.createFailed)
}
@@ -151,10 +161,19 @@ export function ProjectDialog() {
// The arm is consumed exactly on SUCCESS (before the close): a failed
// create leaves the dialog open for a retry that still lands where it
// was dropped; the open-state effect discards it on cancel/teardown.
await runSubmit(
() => createProject({ dropPlacement, folders, idea: idea.trim() || undefined, name: trimmed, use: true }),
clearNewProjectDropPlacement
)
await runSubmit(async () => {
const created = await createProject({
dropPlacement,
folders,
idea: idea.trim() || undefined,
name: trimmed,
use: true
})
if (created) {
enterProject(created.id)
}
}, clearNewProjectDropPlacement)
}
}
+11 -11
View File
@@ -93,6 +93,7 @@ import { openSession, openSessionIntentFromModifiers } from '../open-session'
import {
AGENTS_ROUTE,
ARTIFACTS_ROUTE,
CAPABILITIES_ROUTE,
COMMAND_CENTER_ROUTE,
CRON_ROUTE,
MESSAGING_ROUTE,
@@ -100,7 +101,6 @@ import {
NEW_CHAT_ROUTE,
PROFILES_ROUTE,
SETTINGS_ROUTE,
SKILLS_ROUTE,
STARMAP_ROUTE
} from '../routes'
import { SECTIONS } from '../settings/constants'
@@ -812,12 +812,12 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) {
run: go(SETTINGS_ROUTE)
},
{
action: 'nav.skills',
action: 'nav.capabilities',
icon: Wrench,
id: 'nav-skills',
keywords: ['skills', 'tools', 'toolsets', 'mcp', 'capabilities'],
label: cc.nav.skills.title,
run: go(SKILLS_ROUTE)
label: cc.nav.capabilities.title,
run: go(CAPABILITIES_ROUTE)
},
{
action: 'nav.messaging',
@@ -1077,7 +1077,7 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) {
// Deep-link straight to a Capabilities sub-tab. The root "Go to" entry only
// lands on the top-level Skills view; typing "mcp"/"tools"/"skills" should
// jump to the exact tab (matches the "not just the top lvl" ask).
const capLabel = t.commandCenter.nav.skills.title
const capLabel = t.commandCenter.nav.capabilities.title
result.push({
heading: capLabel,
@@ -1087,28 +1087,28 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) {
id: 'cap-skills',
keywords: ['skills', 'capabilities'],
label: `${capLabel}: ${t.skills.tabSkills}`,
run: go(`${SKILLS_ROUTE}?tab=skills`)
run: go(`${CAPABILITIES_ROUTE}?tab=skills`)
},
{
icon: SlidersHorizontal,
id: 'cap-toolsets',
keywords: ['tools', 'toolsets', 'capabilities'],
label: `${capLabel}: ${t.skills.tabToolsets}`,
run: go(`${SKILLS_ROUTE}?tab=toolsets`)
run: go(`${CAPABILITIES_ROUTE}?tab=toolsets`)
},
{
icon: Layers3,
id: 'cap-mcp',
keywords: ['mcp', 'servers', 'tools', 'capabilities', 'model context protocol'],
label: `${capLabel}: ${t.skills.tabMcp}`,
run: go(`${SKILLS_ROUTE}?tab=mcp`)
run: go(`${CAPABILITIES_ROUTE}?tab=mcp`)
},
{
icon: Package,
id: 'cap-plugins',
keywords: ['plugins', 'extensions', 'desktop plugins', 'agent plugins', 'catalog', 'addon', 'add-on'],
label: `${capLabel}: ${t.skills.tabPlugins}`,
run: go(`${SKILLS_ROUTE}?tab=plugins`)
run: go(`${CAPABILITIES_ROUTE}?tab=plugins`)
}
]
})
@@ -1195,7 +1195,7 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) {
id: `sp-${entry.id}`,
keywords: [entry.context, entry.description ?? '', ...entry.keywords],
label: entry.label,
run: go(`${SKILLS_ROUTE}?tab=plugins&plugin=${encodeURIComponent(entry.plugin)}`)
run: go(`${CAPABILITIES_ROUTE}?tab=plugins&plugin=${encodeURIComponent(entry.plugin)}`)
}))
})
}
@@ -1215,7 +1215,7 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) {
id: `mcp-${name}`,
keywords: ['mcp', 'server', 'tool'],
label: name,
run: go(`${SKILLS_ROUTE}?tab=mcp&server=${encodeURIComponent(name)}`)
run: go(`${CAPABILITIES_ROUTE}?tab=mcp&server=${encodeURIComponent(name)}`)
}))
})
}
@@ -1,13 +1,9 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setApiRequestConnection, setApiRequestProfile } from '@/hermes'
import { createClientSessionState } from '@/lib/chat-runtime'
import { adoptNewSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/composer'
import { $confirmRequest, runConfirm, settleConfirm } from '@/store/confirm'
import { $hubInstalledOverride } from '@/store/hub-actions'
import { requestMcpInstallFromDeepLink } from '@/store/mcp-deeplink-install'
import { $pluginInstallRequest } from '@/store/plugin-install-request'
import { _resetLegacyDiscardForTests } from '@/store/session'
import { dropSessionState, publishSessionState } from '@/store/session-states'
import type * as WindowsStore from '@/store/windows'
@@ -436,15 +432,15 @@ describe('useDesktopIntegrations', () => {
})
describe('route-scoped restoration', () => {
it('restores a non-session route like /skills', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/skills')
it('restores a non-session route like /capabilities', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/capabilities')
const sessions = [session({ id: 'some-session', profile: 'default' })]
render({ profileReady: true, sessions })
// /skills is not a session route — no ownership validation needed.
expect(navigate).toHaveBeenCalledWith('/skills', { replace: true })
expect(navigate).toHaveBeenCalledWith('/capabilities', { replace: true })
})
it('does NOT restore overlay routes (settings/command-center)', () => {
@@ -589,92 +585,6 @@ describe('useDesktopIntegrations', () => {
})
})
describe('catalog install deep links', () => {
function listen() {
render({ profileReady: true, resumeLastSession: false })
return vi.mocked(window.hermesDesktop.onDeepLink!).mock.calls[0]![0]
}
afterEach(() => {
settleConfirm(false)
$pluginInstallRequest.set(null)
$hubInstalledOverride.set({})
setApiRequestConnection(null)
setApiRequestProfile(null)
})
it('opens the existing plugin confirmation with catalog metadata intact', () => {
const deepLink = listen()
const params = { repo: 'owner/repo#plugin', catalog_name: 'catalog-plugin', sha: 'display-pin' }
deepLink({ kind: 'plugin', name: 'install', params })
expect($pluginInstallRequest.get()).toMatchObject({
repo: params.repo, catalogName: params.catalog_name, sha: params.sha, enable: true, force: false
})
expect(navigate).not.toHaveBeenCalled()
})
it('requires skill confirmation, preserves the request scope, and uses the hub pipeline', async () => {
const api = vi.fn(async (request: { path: string }) => {
if (request.path === '/api/skills/hub/install') {
return { name: 'skill-link-test' }
}
if (request.path.startsWith('/api/actions/skill-link-test/')) {
return { name: 'skill-link-test', running: false, exit_code: 0, lines: ['Installed'], pid: 123 }
}
return {}
})
desktopWindow.hermesDesktop = { ...desktopWindow.hermesDesktop, api } as unknown as Window['hermesDesktop']
const deepLink = listen()
const installs = () => api.mock.calls.filter(([r]) => r.path === '/api/skills/hub/install')
const identifier = 'skills-sh/owner/repo/skill'
const payload = { kind: 'skill', name: 'install', params: { identifier } }
for (const [connection, profile] of [['server-a', 'research'], ['server-b', 'work'], ['server-a', 'research']]) {
setApiRequestConnection(connection)
setApiRequestProfile(profile)
api.mockClear()
$hubInstalledOverride.set({})
act(() => deepLink(payload))
expect($confirmRequest.get()?.title).toBe('Install “skill”?')
expect($confirmRequest.get()?.details).toEqual([
{ label: 'Source', value: identifier },
{ label: 'Install to', value: `${connection} · ${profile}` }
])
expect(installs()).toHaveLength(0)
await act(async () => settleConfirm(false))
expect(installs()).toHaveLength(0)
act(() => deepLink(payload))
await act(async () => runConfirm($confirmRequest.get()!))
expect($confirmRequest.get()?.phase).toBe('done')
settleConfirm(true)
await waitFor(() => expect($hubInstalledOverride.get()[identifier]).toBe(true))
expect(installs()).toEqual([[{
connectionId: connection, profile, priority: 'foreground', path: '/api/skills/hub/install', method: 'POST', body: { identifier }
}]])
expect(api).toHaveBeenCalledWith({
connectionId: connection, profile, priority: 'foreground', path: '/api/actions/skill-link-test/status?lines=200'
})
}
api.mockClear()
act(() => deepLink(payload))
setApiRequestConnection('server-b')
setApiRequestProfile('work')
await expect(runConfirm($confirmRequest.get()!)).rejects.toThrow('The destination changed')
settleConfirm(false)
expect(installs()).toHaveLength(0)
act(() => deepLink({ kind: 'skill', name: 'install', params: {} }))
expect($confirmRequest.get()).toBeNull()
expect(navigate).not.toHaveBeenCalled()
})
})
describe('notification click -> focus-session id translation', () => {
function withFocusSession(): (sessionId: string) => void {
let handler: ((sessionId: string) => void) | undefined
@@ -30,7 +30,6 @@ import {
} from '@/store/session'
import { $botChatScopes, $sessionTiles, storedSessionIdForRuntimeId } from '@/store/session-states'
import { onSessionsChanged } from '@/store/session-sync'
import { requestSkillInstallFromDeepLink } from '@/store/skill-deeplink-install'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '@/store/updates'
import { isBrowserWindow, isHudWindow, isSecondaryWindow } from '@/store/windows'
import type { SessionInfo } from '@/types/hermes'
@@ -298,7 +297,6 @@ export function useDesktopIntegrations({
// - mcp/install?… → pending MCP install (explicit confirm, never auto-install)
// - plugin/install?… (and legacy plugin-agent/plugin-desktop) → plugin install
// modal awaiting explicit confirmation. Never auto-installs.
// - skill/install?identifier=… → confirmation, then the existing hub pipeline
// - blueprint/<name>?… → reviewable /blueprint command in the composer
// - <plugin>/<path>?… → in-app navigate (e.g. index-network/intent/1)
// - open/<path>?… → in-app navigate (generic)
@@ -337,24 +335,12 @@ export function useDesktopIntegrations({
repo: action.repo,
enable: action.enable,
force: action.force,
legacyHint: action.legacyHint,
catalogName: action.catalogName,
sha: action.sha
legacyHint: action.legacyHint
})
return
}
if (action.type === 'skill-install') {
void requestSkillInstallFromDeepLink(action.identifier)
return
}
if (payload.kind === 'skill') {
return
}
// Not a core action — treat as a plugin-scoped or open/ navigation deep
// link (hermes://index-network/intent/1, hermes://open/…). The resolver
// rejects reserved kinds and unsafe paths.
@@ -117,7 +117,7 @@ export function McpInstallDeepLinkDialog() {
setHermesConfigCache(previous => (previous ? { ...previous, mcp_servers: nextServers } : previous))
notify({ kind: 'success', title: m.savedTitle, message: m.savedMessage(trimmedName) })
$mcpInstallRequest.set(null)
navigate(`/skills?tab=mcp&server=${encodeURIComponent(trimmedName)}`)
navigate(`/capabilities?tab=mcp&server=${encodeURIComponent(trimmedName)}`)
} catch (err) {
setError(readableError(err, m.saveFailed).message)
} finally {
+1 -1
View File
@@ -146,7 +146,7 @@ export function useTitlebarToolContributions(side: 'left' | 'right'): TitlebarTo
}
/**
* Bridge a page's `GroupSetter` extension point (SkillsView, MessagingView,
* Bridge a page's `GroupSetter` extension point (CapabilitiesView, MessagingView,
* ChatPreviewRail, …) into the registry: each call replaces the group's items
* as DATA contributions in `<prefix>.<side>`, so page-owned items flow through
* the same pipe plugins use. Setting an empty list clears the group.
@@ -0,0 +1,55 @@
import { cleanup, render, screen } from '@testing-library/react'
import { atom } from 'nanostores'
import { MemoryRouter } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { APP_ROUTES, isOverlayView } from '../routes'
import { ChatRoutesSurface } from './surfaces'
import type { WiringActions } from './types'
vi.mock('@/contrib/react/use-contributions', () => ({ useContributions: vi.fn() }))
vi.mock('@/store/connections', () => ({ $activeConnectionId: atom('local') }))
vi.mock('@/store/gateway', () => ({ $gateway: atom<unknown>(null) }))
vi.mock('@/store/profile', () => ({ $activeGatewayProfile: atom('default') }))
vi.mock('@/store/session', () => ({
$freshDraftReady: atom(false),
$gatewayState: atom('open')
}))
vi.mock('../chat', () => ({ ChatView: () => <div data-testid="chat-view" /> }))
vi.mock('../capabilities', () => ({ CapabilitiesView: () => null }))
vi.mock('../messaging', () => ({ MessagingView: () => null }))
vi.mock('../artifacts', () => ({ ArtifactsView: () => null }))
vi.mock('../chat/sidebar', () => ({ ChatSidebar: () => null }))
vi.mock('../right-sidebar/terminal/chrome', () => ({ TerminalPaneChrome: () => null }))
vi.mock('../shell/hooks/use-status-snapshot', () => ({ useStatusSnapshot: () => ({}) }))
vi.mock('../shell/hooks/use-statusbar-items', () => ({
useStatusbarItems: () => ({ leftStatusbarItems: [], statusbarItems: [] })
}))
vi.mock('../shell/statusbar-controls', () => ({ StatusbarControls: () => null }))
vi.mock('./latest-actions', () => ({ latestChatActions: () => ({}), latestSidebarActions: () => ({}) }))
vi.mock('./panes', () => ({ setStatusbarItemGroup: vi.fn(), useStatusbarContributions: () => [] }))
vi.mock('../shell/model-menu-panel', () => ({ ModelMenuPanel: () => null }))
vi.mock('../shell/reasoning-menu-panel', () => ({ ReasoningMenuPanel: () => null }))
afterEach(cleanup)
// The route table (`APP_ROUTES`) and the `<Route>` list are kept by hand in two
// files. A workspace page whose path is missing from the `<Route>` list matches
// `:sessionId`, so the app opens a chat for a session named after the page.
// Overlays are exempt: they are modal cards over whatever the shell shows.
describe('ChatRoutesSurface and APP_ROUTES', () => {
const pages = APP_ROUTES.filter(route => route.view !== 'chat' && !isOverlayView(route.view))
it.each(pages.map(route => [route.path]))('%s has its own route and never opens as a session', path => {
const actions = { getGateway: () => null } as unknown as WiringActions
render(
<MemoryRouter initialEntries={[path]}>
<ChatRoutesSurface actions={actions} />
</MemoryRouter>
)
expect(screen.queryByTestId('chat-view')).toBeNull()
})
})
+2 -2
View File
@@ -38,7 +38,7 @@ import type { SidebarActions, WiringActions } from './types'
// (agents/settings/…) are the controller's and stay in wiring.tsx.
const ArtifactsView = lazy(async () => ({ default: (await import('../artifacts')).ArtifactsView }))
const MessagingView = lazy(async () => ({ default: (await import('../messaging')).MessagingView }))
const SkillsView = lazy(async () => ({ default: (await import('../skills')).SkillsView }))
const CapabilitiesView = lazy(async () => ({ default: (await import('../capabilities')).CapabilitiesView }))
export function LegacySessionRedirect() {
const { sessionId } = useParams()
@@ -185,7 +185,7 @@ export const ChatRoutesSurface = memo(function ChatRoutesSurface({
<Routes>
<Route element={chatView} index />
<Route element={chatView} path=":sessionId" />
<Route element={page(<SkillsView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="skills" />
<Route element={page(<CapabilitiesView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="capabilities" />
<Route element={page(<MessagingView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="messaging" />
<Route element={page(<ArtifactsView setStatusbarItemGroup={setStatusbarItemGroup} />)} path="artifacts" />
<Route element={null} path="agents" />
@@ -14,7 +14,11 @@ import type { DesktopBootProgress, HermesConnection, HermesWindowState } from '@
import { HermesGateway } from '@/hermes'
import { translateNow } from '@/i18n'
import { desktopDefaultCwd } from '@/lib/desktop-fs'
import { decideLivenessForceClose, LIVENESS_REPROBE_DELAY_MS } from '@/lib/gateway-liveness-policy'
import {
decideLivenessForceClose,
LIVENESS_PROBE_TIMEOUT_MS,
LIVENESS_REPROBE_DELAY_MS
} from '@/lib/gateway-liveness-policy'
import { BACKEND_BOOT_WAIT_TIMEOUT_MS, RECONNECT_ATTEMPT_TIMEOUT_MS, withTimeout } from '@/lib/with-timeout'
import {
$desktopBoot,
@@ -119,7 +123,11 @@ const RECONNECT_ESCALATE_AFTER_MS = 300_000
// TIMEOUT alone no longer tears the socket down mid-turn (#95327): while a
// turn is in flight the first timeout defers behind one bounded re-probe, so
// only a STREAK of unanswered pings rebuilds the transport.
const GATEWAY_LIVENESS_PROBE_TIMEOUT_MS = 5_000
// Renderer twin of the main process's POWER_RESUME_REVALIDATION_HOLDOFF_MS:
// forced wake reconnects (online / power resume) are coalesced into one per
// window instead of tearing down every secondary socket on each signal (#94769).
const WAKE_RECONNECT_HOLDOFF_MS = 15_000
// Bounded self-heal for a failed REMOTE boot (#82679): main classifies every
// fault it can see (via getBootProgress().retryable); the renderer adds the one
@@ -566,7 +574,7 @@ export function useGatewayBoot({
// one inconclusive probe DEFERS the teardown behind a bounded re-probe;
// only an exhausted streak (or no in-flight work) closes.
try {
await gateway.request('ping', {}, GATEWAY_LIVENESS_PROBE_TIMEOUT_MS)
await gateway.request('ping', {}, LIVENESS_PROBE_TIMEOUT_MS)
livenessProbeFailures = 0
} catch (probeErr) {
// A version-skewed backend that predates the ping method answers
@@ -900,6 +908,8 @@ export function useGatewayBoot({
// primary thread or a just-created session's owner hold is bound to
// (#93892).
foregroundScopes: foregroundSessionScopes,
// Defined further down the effect body; read at call time, never during boot.
liveScopes: () => liveWorkScopes(),
onLocalProfileRetired: forgetProfileOnlyRuntimeOwners,
onActiveConnectionChanged: publish,
// Keep $activeGatewayProfile in lockstep with the registry's OWN record
@@ -1001,7 +1011,42 @@ export function useGatewayBoot({
// Wake signals: power resume (macOS/Windows), network coming back, and the
// window regaining focus/visibility. Each nudges an immediate reconnect.
const forceReconnectNow = () => reconnectNow({ forceOpenSocket: true })
//
// Forced reconnects (power resume / 'online') close and redial every open
// secondary socket. Windows fires 'online' on any interface change — VPN
// connects, Wi-Fi blips, virtual adapter enumeration — so an unthrottled
// handler reaped healthy sockets in bursts and the UI remounted on every
// redial: the #94769 flicker loop. Coalesce forced wakes like the main
// process already does for power-resume revalidation
// (POWER_RESUME_REVALIDATION_HOLDOFF_MS): one forced reconnect per
// holdoff window; a socket dropped in between is still picked up by the
// ordinary close/reconnect backoff and by the non-forced focus/visibility
// nudges below.
let lastForcedWakeReconnectAt = 0
const forceReconnectNow = () => {
// reconnectNow no-ops while boot is incomplete or a gateway switch is in
// flight; stamping the holdoff then would burn the window and drop the
// next 'online' (often the one with the network actually back), leaving
// recovery to the backoff loops. Stamp only when it will proceed.
if (cancelled || !bootCompleted || $gatewaySwitching.get()) {
return
}
// Only the destructive half is coalesced: a second wake inside the
// holdoff (macOS fires resume then 'online' seconds apart) still runs
// the cheap, idempotent nudge — primary ping probe, redial of already
// closed secondaries — without touching open sockets.
const now = Date.now()
const forced = now - lastForcedWakeReconnectAt >= WAKE_RECONNECT_HOLDOFF_MS
if (forced) {
lastForcedWakeReconnectAt = now
}
void reconnectNow({ forceOpenSocket: forced })
}
const offPowerResume = desktop.onPowerResume?.(() => void forceReconnectNow())
const offConnectionApplied = desktop.onConnectionApplied?.(() => void softSwitch())
@@ -1100,6 +1145,10 @@ export function useGatewayBoot({
const keepaliveTimer = setInterval(() => {
touchActiveGatewayBackend()
touchSecondaryGateways()
// The pruner is otherwise event-driven: a socket spared by the
// min-lifetime grace with no store change afterwards would hold its
// pool slot forever.
recomputeKeptGateways()
}, 60_000)
// Bound concurrency cost to consumers: keep a background socket while its
@@ -1109,20 +1158,28 @@ export function useGatewayBoot({
// and its backend is free to idle-reap. The active profile is always spared.
// Do not key this off `entry.retained` — that flag only skips dispose-after-
// RPC; idle prune is what reclaims hover-warmed sockets after you leave.
const recomputeKeptGateways = () => {
// Scopes with a running or needs-input session: registry-scoped
// (connectionId, profile) keys plus the bare profile of every live local
// session. Two sources can expose the same profile name (every source has
// a 'default'), so bare profile names can't represent a non-local
// source's liveness without keeping the wrong gateway alive. Feeds the
// pruner's keep-set and the wake probe's in-flight-work signal.
const liveWorkScopes = (): Set<string> => {
const live = new Set([...$workingSessionIds.get(), ...$attentionSessionIds.get()])
// Registry-scoped (connectionId, profile) scopes with live work. Two
// sources can expose the same profile name (every source has a
// 'default'), so bare profile names can't represent a non-local
// source's liveness without keeping the wrong gateway alive.
const keep = new Set([...liveSessionScopes(), ...foregroundSessionScopes()])
const scopes = liveSessionScopes()
for (const session of $sessions.get()) {
if (live.has(session.id)) {
keep.add(normalizeProfileKey(session.profile))
scopes.add(normalizeProfileKey(session.profile))
}
}
return scopes
}
const recomputeKeptGateways = () => {
const keep = new Set([...liveWorkScopes(), ...foregroundSessionScopes()])
for (const scope of openTileGatewayScopes()) {
keep.add(scope)
}
+3 -3
View File
@@ -76,14 +76,14 @@ import {
$workspaceIsPage,
AGENTS_ROUTE,
ARTIFACTS_ROUTE,
CAPABILITIES_ROUTE,
CRON_ROUTE,
MESSAGING_ROUTE,
navigateToWorkspacePage,
NEW_CHAT_ROUTE,
PROFILES_ROUTE,
sessionRoute,
SETTINGS_ROUTE,
SKILLS_ROUTE
SETTINGS_ROUTE
} from '../routes'
export interface KeybindRuntimeDeps {
@@ -211,7 +211,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'nav.commandCenter': deps.toggleCommandCenter,
'nav.settings': () => navigate(SETTINGS_ROUTE),
'nav.profiles': () => navigate(PROFILES_ROUTE),
'nav.skills': () => navigateToWorkspacePage(navigate, SKILLS_ROUTE),
'nav.capabilities': () => navigateToWorkspacePage(navigate, CAPABILITIES_ROUTE),
'nav.messaging': () => navigateToWorkspacePage(navigate, MESSAGING_ROUTE),
'nav.artifacts': () => navigateToWorkspacePage(navigate, ARTIFACTS_ROUTE),
'nav.cron': () => navigate(CRON_ROUTE),
+1 -8
View File
@@ -391,12 +391,6 @@ export function ListStripMenu({
)
}
const LIST_STRIP_LABEL_CLASS = 'text-[0.68rem] font-medium text-muted-foreground/70'
export function ListStripLabel({ children }: { children: ReactNode }) {
return <span className={LIST_STRIP_LABEL_CLASS} role="status">{children}</span>
}
export function ListStripButton({
active,
children,
@@ -411,8 +405,7 @@ export function ListStripButton({
return (
<button
className={cn(
LIST_STRIP_LABEL_CLASS,
'cursor-pointer transition-colors disabled:opacity-40',
'cursor-pointer text-[0.68rem] font-medium transition-colors disabled:opacity-40',
active ? 'text-foreground' : 'text-muted-foreground/70 hover:text-foreground'
)}
disabled={disabled}
@@ -10,7 +10,7 @@ describe('hidesFixedTitlebarClusters', () => {
it('keeps clusters on chat and first-party workspace pages', () => {
expect(hidesFixedTitlebarClusters('chat')).toBe(false)
expect(hidesFixedTitlebarClusters('skills')).toBe(false)
expect(hidesFixedTitlebarClusters('capabilities')).toBe(false)
expect(hidesFixedTitlebarClusters('messaging')).toBe(false)
expect(hidesFixedTitlebarClusters('artifacts')).toBe(false)
})
+8 -8
View File
@@ -12,7 +12,7 @@ export const NEW_CHAT_ROUTE = '/'
export const SETTINGS_ROUTE = '/settings'
export const COMMAND_CENTER_ROUTE = '/command-center'
export const SESSION_IMPORT_ROUTE = '/session-import'
export const SKILLS_ROUTE = '/skills'
export const CAPABILITIES_ROUTE = '/capabilities'
export const MESSAGING_ROUTE = '/messaging'
export const WEBHOOKS_ROUTE = '/webhooks'
export const ARTIFACTS_ROUTE = '/artifacts'
@@ -25,6 +25,7 @@ export type AppView =
| 'session-import'
| 'agents'
| 'artifacts'
| 'capabilities'
| 'chat'
| 'command-center'
| 'cron'
@@ -36,7 +37,6 @@ export type AppView =
| 'messaging'
| 'profiles'
| 'settings'
| 'skills'
| 'starmap'
| 'webhooks'
@@ -44,13 +44,13 @@ export type AppRouteId =
| 'session-import'
| 'agents'
| 'artifacts'
| 'capabilities'
| 'command-center'
| 'cron'
| 'messaging'
| 'new'
| 'profiles'
| 'settings'
| 'skills'
| 'starmap'
| 'webhooks'
@@ -65,7 +65,7 @@ export const APP_ROUTES = [
{ id: 'new', path: NEW_CHAT_ROUTE, view: 'chat' },
{ id: 'settings', path: SETTINGS_ROUTE, view: 'settings' },
{ id: 'command-center', path: COMMAND_CENTER_ROUTE, view: 'command-center' },
{ id: 'skills', path: SKILLS_ROUTE, view: 'skills' },
{ id: 'capabilities', path: CAPABILITIES_ROUTE, view: 'capabilities' },
{ id: 'messaging', path: MESSAGING_ROUTE, view: 'messaging' },
{ id: 'webhooks', path: WEBHOOKS_ROUTE, view: 'webhooks' },
{ id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' },
@@ -158,14 +158,14 @@ export function isOverlayView(view: AppView): boolean {
* Contributed full pages (`extension`) hide the app clusters only while the
* page actually mounts `titleBar.*` chrome — those slots are mount-scoped, so
* a plugin page with no titlebar contribution keeps the app controls.
* First-party workspace pages (skills/messaging/artifacts) keep the clusters. */
* First-party workspace pages (capabilities/messaging/artifacts) keep the clusters. */
export function hidesFixedTitlebarClusters(view: AppView): boolean {
return isOverlayView(view) || view === 'extension'
}
/** The pathname of a router target. Every classifier below reasons about a
* PATH, but callers navigate to full targets (`/skills?tab=mcp`), and an
* unstripped query reaches the session-id parser — `/skills?tab=mcp` reads as
* PATH, but callers navigate to full targets (`/capabilities?tab=mcp`), and an
* unstripped query reaches the session-id parser — `/capabilities?tab=mcp` reads as
* the session `skills?tab=mcp`, so Capabilities classifies as a chat.
* `sessionRoute` percent-encodes ids, so `?`/`#` can only start a query or a
* hash. */
@@ -282,7 +282,7 @@ export function syncWorkspaceRoute(pathname: string): void {
* Navigate to `to`, fronting the workspace pane when it is a page route.
*
* `syncWorkspaceRoute` covers route CHANGES; this covers the RE-CLICK, the one
* case it can't see — hitting Capabilities while already on `/skills` with a
* case it can't see — hitting Capabilities while already on `/capabilities` with a
* tile focused leaves the location untouched, so no effect fires and only an
* imperative reveal brings the page back. Use it wherever a nav affordance can
* be triggered from the page it targets.
@@ -19,6 +19,7 @@ import {
AGENTS_ROUTE,
appViewForPath,
ARTIFACTS_ROUTE,
CAPABILITIES_ROUTE,
CRON_ROUTE,
MESSAGING_ROUTE,
navigateToWorkspacePage,
@@ -28,7 +29,6 @@ import {
routeSessionId,
sessionRoute,
SETTINGS_ROUTE,
SKILLS_ROUTE,
syncWorkspaceRoute
} from './routes'
@@ -68,9 +68,9 @@ afterEach(() => {
describe('routePathname', () => {
it('keeps a bare path and drops a query or hash', () => {
expect(routePathname(SKILLS_ROUTE)).toBe('/skills')
expect(routePathname('/skills?tab=mcp')).toBe('/skills')
expect(routePathname('/skills?tab=mcp&server=ctx7')).toBe('/skills')
expect(routePathname(CAPABILITIES_ROUTE)).toBe('/capabilities')
expect(routePathname('/capabilities?tab=mcp')).toBe('/capabilities')
expect(routePathname('/capabilities?tab=mcp&server=ctx7')).toBe('/capabilities')
expect(routePathname('/settings#keys')).toBe('/settings')
})
@@ -87,9 +87,9 @@ describe('classification of targets carrying a query', () => {
// servers), and Settings redirects old /settings?tab=mcp deep links to the
// last one. Unstripped, they parsed as SESSION ids and read as 'chat'.
it.each([
[`${SKILLS_ROUTE}?tab=skills`, 'skills'],
[`${SKILLS_ROUTE}?tab=toolsets`, 'skills'],
[`${SKILLS_ROUTE}?tab=mcp&server=ctx7`, 'skills'],
[`${CAPABILITIES_ROUTE}?tab=skills`, 'capabilities'],
[`${CAPABILITIES_ROUTE}?tab=toolsets`, 'capabilities'],
[`${CAPABILITIES_ROUTE}?tab=mcp&server=ctx7`, 'capabilities'],
[`${SETTINGS_ROUTE}?tab=keys`, 'settings']
])('%s is not a session route', (to, view) => {
expect(routeSessionId(to)).toBeNull()
@@ -99,14 +99,14 @@ describe('classification of targets carrying a query', () => {
describe('syncWorkspaceRoute', () => {
it('publishes and fronts on a page route', () => {
syncWorkspaceRoute(SKILLS_ROUTE)
syncWorkspaceRoute(CAPABILITIES_ROUTE)
expect($workspaceIsPage.get()).toBe(true)
expect(fronted()).toBe(true)
})
it('fronts on a page route reached with a query', () => {
syncWorkspaceRoute(`${SKILLS_ROUTE}?tab=mcp`)
syncWorkspaceRoute(`${CAPABILITIES_ROUTE}?tab=mcp`)
expect($workspaceIsPage.get()).toBe(true)
expect(fronted()).toBe(true)
@@ -155,13 +155,13 @@ describe('navigateToWorkspacePage', () => {
it('navigates and fronts, so a re-click on the page you are already on still shows it', () => {
const navigate = vi.fn()
navigateToWorkspacePage(navigate, SKILLS_ROUTE)
navigateToWorkspacePage(navigate, CAPABILITIES_ROUTE)
expect(navigate).toHaveBeenCalledWith(SKILLS_ROUTE, undefined)
expect(navigate).toHaveBeenCalledWith(CAPABILITIES_ROUTE, undefined)
expect(fronted()).toBe(true)
})
it.each([`${SKILLS_ROUTE}?tab=skills`, `${SKILLS_ROUTE}?tab=toolsets`, `${SKILLS_ROUTE}?tab=mcp&server=ctx7`])(
it.each([`${CAPABILITIES_ROUTE}?tab=skills`, `${CAPABILITIES_ROUTE}?tab=toolsets`, `${CAPABILITIES_ROUTE}?tab=mcp&server=ctx7`])(
'fronts for the palette target %s',
to => {
navigateToWorkspacePage(vi.fn(), to)
@@ -62,7 +62,7 @@ function rawHashLooksLikeSession(): boolean {
return (
!hash.startsWith('/settings') &&
!hash.startsWith('/skills') &&
!hash.startsWith('/capabilities') &&
!hash.startsWith('/messaging') &&
!hash.startsWith('/artifacts')
)
@@ -4799,10 +4799,10 @@ describe('selectSidebarItem', () => {
await waitFor(() => expect(handle).not.toBeNull())
act(() => {
handle!.selectSidebarItem({ icon: (() => null) as never, id: 'skills', label: 'Capabilities', route: '/skills' })
handle!.selectSidebarItem({ icon: (() => null) as never, id: 'skills', label: 'Capabilities', route: '/capabilities' })
})
expect(navigate).toHaveBeenCalledWith('/skills', undefined)
expect(navigate).toHaveBeenCalledWith('/capabilities', undefined)
expect(noteActiveTreeGroup).toHaveBeenCalledWith(null)
expect(revealTreePane).toHaveBeenCalledWith('workspace')
})
@@ -4,11 +4,11 @@ import { movedSettingsTabRedirect } from './moved-tabs'
describe('movedSettingsTabRedirect', () => {
it('sends the retired Settings plugin/MCP tabs to Capabilities, keeping the row selector', () => {
expect(movedSettingsTabRedirect('?tab=plugins')).toBe('/skills?tab=plugins')
expect(movedSettingsTabRedirect('?tab=plugins')).toBe('/capabilities?tab=plugins')
expect(movedSettingsTabRedirect('?tab=plugins&plugin=demo%2Fplugin')).toBe(
'/skills?tab=plugins&plugin=demo%2Fplugin'
'/capabilities?tab=plugins&plugin=demo%2Fplugin'
)
expect(movedSettingsTabRedirect('?tab=mcp&server=github')).toBe('/skills?tab=mcp&server=github')
expect(movedSettingsTabRedirect('?tab=mcp&server=github')).toBe('/capabilities?tab=mcp&server=github')
})
it('leaves live Settings tabs alone', () => {
+2 -2
View File
@@ -1,4 +1,4 @@
import { SKILLS_ROUTE } from '../routes'
import { CAPABILITIES_ROUTE } from '../routes'
// Settings tabs that now live in Capabilities → the row-selector param each
// carries (`?server=` for MCP, `?plugin=` for Plugins). Old bookmarks and
@@ -19,5 +19,5 @@ export function movedSettingsTabRedirect(search: string): null | string {
const row = params.get(rowParam)
const suffix = row ? `&${rowParam}=${encodeURIComponent(row)}` : ''
return `${SKILLS_ROUTE}?tab=${tab}${suffix}`
return `${CAPABILITIES_ROUTE}?tab=${tab}${suffix}`
}
@@ -27,7 +27,7 @@ import {
import { $activeGatewayProfile } from '@/store/profile'
import { $connection, $gatewayState } from '@/store/session'
import { PluginActions, PluginsTab } from '../skills/plugins-tab'
import { PluginsTab } from '../capabilities/plugins/plugins-tab'
import { PluginInstallModal } from './plugin-install-modal'
@@ -36,9 +36,8 @@ const installDesktopPlugin = vi.fn()
const renderFlow = () =>
render(
<MemoryRouter initialEntries={['/skills?tab=plugins']}>
<MemoryRouter initialEntries={['/capabilities?tab=plugins']}>
<QueryClientProvider client={queryClient}>
<PluginActions profile={null} />
<PluginsTab profile={null} />
<PluginInstallModal />
</QueryClientProvider>
@@ -291,7 +291,7 @@ export function PluginInstallModal() {
closePluginInstallRequest()
// Catalog picks come from Capabilities → Plugins; land back there.
navigate(request.catalogName ? '/skills?tab=plugins' : '/settings?tab=plugins')
navigate(request.catalogName ? '/capabilities?tab=plugins' : '/settings?tab=plugins')
return
}
@@ -21,7 +21,7 @@ import {
type SettingsSearchEntry
} from './settings-search'
/** An installed plugin row, deep-linkable as `/skills?tab=plugins&plugin=<id>`. */
/** An installed plugin row, deep-linkable as `/capabilities?tab=plugins&plugin=<id>`. */
export interface PluginSearchEntry {
context: string
description?: string
+1 -1
View File
@@ -1,4 +1,4 @@
// The `GroupSetter` shape pages take as an extension-point prop (SkillsView,
// The `GroupSetter` shape pages take as an extension-point prop (CapabilitiesView,
// MessagingView, ChatPreviewRail, …). The live implementation is the
// registry-backed `registryGroupSetter` in app/contrib/panes.tsx.
type Side = 'left' | 'right'
@@ -86,7 +86,7 @@ describe('TitlebarControls fixed clusters', () => {
})
it('keeps the app clusters on a first-party workspace page', () => {
renderControls('/skills')
renderControls('/capabilities')
expect(windowControls()).not.toBeNull()
expect(appControls()).not.toBeNull()
@@ -1,29 +0,0 @@
import type { ReactNode } from 'react'
import { TextTab } from '@/components/ui/text-tab'
import { useI18n } from '@/i18n'
export type CapabilityView = 'installed' | 'browse'
interface CapabilityTabsProps {
value: CapabilityView
onChange: (value: CapabilityView) => void
actions?: ReactNode
}
/** Shared Installed / Browse navigation for Skills and Plugins. */
export function CapabilityTabs({ value, onChange, actions }: CapabilityTabsProps) {
const { t } = useI18n()
return (
<div className="flex shrink-0 items-center gap-4 px-3 py-1" data-capability-tabs>
<TextTab active={value === 'installed'} aria-pressed={value === 'installed'} onClick={() => onChange('installed')}>
{t.catalog.installed}
</TextTab>
<TextTab active={value === 'browse'} aria-pressed={value === 'browse'} onClick={() => onChange('browse')}>
{t.catalog.browse}
</TextTab>
{actions && <div className="-my-0.5 -mr-2 ml-auto flex h-6 items-center gap-2">{actions}</div>}
</div>
)
}
@@ -1,144 +0,0 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { queryClient } from '@/lib/query-client'
import { PageSearchShell } from '../page-search-shell'
import { CatalogBrowser } from './catalog-browser'
import { type CatalogKind, parseCatalog } from './catalog-data'
import { $catalogCardView } from './store'
beforeEach(() => {
$catalogCardView.set(true)
})
afterEach(() => {
cleanup()
queryClient.clear()
$catalogCardView.set(true)
})
function setup(kind: CatalogKind) {
const entries = parseCatalog(kind, ['alpha', 'beta'].map(name => ({
name,
identifier: `official/${name}`,
installIdentifier: `official/${name}`,
source: 'official',
tier: 'official',
category: 'research',
description: `${name} research workflow`,
repo: `https://github.com/example/${name}`,
sourceUrl: `https://github.com/example/${name}`,
docsUrl: `https://example.com/${name}`
})))
queryClient.setQueryData(['public-catalog', kind], entries)
const onInstall = vi.fn()
function Harness() {
const [query, setQuery] = useState('')
return (
<QueryClientProvider client={queryClient}>
<PageSearchShell onSearchChange={setQuery} searchPlaceholder="Search catalog" searchValue={query}>
<CatalogBrowser
isInstalled={entry => entry.name === 'beta'}
kind={kind}
onInstall={onInstall}
onQueryChange={setQuery}
query={query}
/>
</PageSearchShell>
</QueryClientProvider>
)
}
return { entries, onInstall, ...render(<Harness />) }
}
describe.each(['skills', 'plugins'] as const)('%s catalog layouts', kind => {
it('defaults to cards with icon-only layout controls and whole-card hover', () => {
const { container } = setup(kind)
const cards = screen.getAllByRole('article')
const cardView = screen.getByRole('button', { name: 'Card view', pressed: true })
expect(cards).toHaveLength(2)
expect(cardView.textContent).toBe('')
expect(cardView.querySelector('.codicon-extensions')).not.toBeNull()
expect(screen.getByRole('button', { name: 'List view' }).querySelector('.codicon-list-unordered')).not.toBeNull()
expect(cards[0].classList.contains('row-hover')).toBe(true)
expect(within(cards[0]).getByRole('button', { name: 'alpha' }).classList.contains('row-hover')).toBe(false)
expect(container.querySelector('[data-catalog-list]')).toBeNull()
expect(within(cards[1]).getByRole<HTMLButtonElement>('button', { name: 'Installed' }).disabled).toBe(true)
})
it('offers installation directly on a card without opening its details', () => {
const { entries, onInstall } = setup(kind)
const card = screen.getAllByRole('article')[0]
fireEvent.click(within(card).getByRole('button', { name: 'Install' }))
expect(onInstall).toHaveBeenCalledExactlyOnceWith(entries[0])
expect(screen.queryByRole('dialog')).toBeNull()
})
it('opens the shared detail dialog with source links and the same install target', async () => {
const { entries, onInstall } = setup(kind)
fireEvent.click(screen.getByRole('button', { name: 'alpha' }))
const dialog = screen.getByRole('dialog', { name: 'alpha' })
expect(within(dialog).getByRole('heading', { name: 'alpha' })).toBeTruthy()
expect(within(dialog).getByRole('link', { name: 'Repository' }).getAttribute('href')).toBe(entries[0].sourceUrl)
expect(within(dialog).getByRole('link', { name: 'Documentation' }).getAttribute('href')).toBe(entries[0].docsUrl)
fireEvent.click(within(dialog).getByRole('button', { name: 'Install' }))
expect(onInstall).toHaveBeenCalledExactlyOnceWith(entries[0])
if (kind === 'plugins') {
// Release the inspector before the existing plugin installation dialog opens.
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
} else {
fireEvent.click(within(dialog).getByRole('button', { name: 'Close' }))
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
}
})
it('preserves the parent search and filter when switching to list and back', async () => {
const { container } = setup(kind)
const search = screen.getByRole<HTMLInputElement>('textbox', { name: 'Search catalog' })
fireEvent.change(search, { target: { value: 'alpha' } })
await waitFor(() => expect(screen.getAllByRole('article')).toHaveLength(1))
fireEvent.click(screen.getByRole('combobox', { name: 'Category' }))
fireEvent.click(screen.getByRole('option', { name: 'Research' }))
fireEvent.click(screen.getByRole('button', { name: 'List view' }))
expect(container.querySelector(`[data-catalog-list="${kind}"]`)).not.toBeNull()
expect(screen.queryByRole('article')).toBeNull()
expect(screen.getByRole('heading', { name: 'alpha' })).toBeTruthy()
expect(screen.getByRole('combobox', { name: 'Category' }).textContent).toContain('Research')
expect(screen.getByRole('textbox', { name: 'Search catalog' })).toBe(search)
expect(search.value).toBe('alpha')
expect(screen.getByRole('status').textContent).toBe('1 result')
fireEvent.click(screen.getByRole('button', { name: 'Card view' }))
expect(screen.getAllByRole('article')).toHaveLength(1)
expect(screen.getByRole('textbox', { name: 'Search catalog' })).toBe(search)
expect(screen.getByRole('combobox', { name: 'Category' }).textContent).toContain('Research')
expect(screen.queryByRole('dialog')).toBeNull()
})
})
it('shares the saved layout choice when moving between Skills and Plugins', () => {
setup('skills')
fireEvent.click(screen.getByRole('button', { name: 'List view' }))
expect($catalogCardView.get()).toBe(false)
cleanup()
const { container } = setup('plugins')
expect(screen.getByRole('button', { name: 'List view', pressed: true })).toBeTruthy()
expect(container.querySelector('[data-catalog-list="plugins"]')).not.toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Card view' }))
expect($catalogCardView.get()).toBe(true)
})
@@ -1,323 +0,0 @@
import { useStore } from '@nanostores/react'
import { memo, type ReactNode, useDeferredValue, 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, codiconIcon } from '@/components/ui/codicon'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { ErrorState } from '@/components/ui/error-state'
import { RowButton } from '@/components/ui/row-button'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useI18n } from '@/i18n'
import { ExternalLink } from '@/lib/external-link'
import { cn } from '@/lib/utils'
import { DetailColumn, ListColumn, ListStrip, ListStripLabel, MasterDetail, ToolChip } from '../master-detail'
import { PanelEmpty } from '../overlays/panel'
import { prettyName } from '../settings/helpers'
import type { CapabilityView } from './capability-tabs'
import { type CatalogEntry, type CatalogKind, useCatalog } from './catalog-data'
import { $catalogCardView } from './store'
interface CatalogBrowserProps {
kind: CatalogKind
query?: string
onQueryChange?: (value: string) => void
view?: CapabilityView
isInstalled: (entry: CatalogEntry) => boolean
onInstall: (entry: CatalogEntry) => void
isInstalling?: (entry: CatalogEntry) => boolean
installedEntries?: CatalogEntry[]
renderInstalledDetail?: (entry: CatalogEntry) => ReactNode
}
const PAGE_SIZE = 60
const ListViewIcon = codiconIcon('list-unordered')
const CardViewIcon = codiconIcon('extensions')
export const CatalogBrowser = memo(function CatalogBrowser({
kind,
isInstalled,
onInstall,
isInstalling,
installedEntries,
renderInstalledDetail,
view = 'browse',
query = '',
onQueryChange
}: CatalogBrowserProps) {
const { t } = useI18n()
const c = t.catalog
const cardView = useStore($catalogCardView)
const { data, isPending, error, refetch } = useCatalog(kind, view === 'browse')
const deferredQuery = useDeferredValue(query.trim().toLowerCase())
const [source, setSource] = useState('all')
const [category, setCategory] = useState('all')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const selectedCardRef = useRef<HTMLButtonElement | null>(null)
const [limit, setLimit] = useState(PAGE_SIZE)
const entries = useMemo(() => {
return view === 'installed' ? (installedEntries ?? []) : (data ?? [])
}, [data, installedEntries, view])
const sources = useMemo(() => [...new Set(entries.map(entry => entry.source))], [entries])
const categories = useMemo(() => {
const values = new Map<string, string>()
for (const entry of entries) {
if (source === 'all' || entry.source === source) {
values.set(entry.category, entry.categoryLabel)
}
}
return [...values].sort((a, b) => a[1].localeCompare(b[1]))
}, [entries, source])
const filtered = useMemo(() => entries.filter(entry =>
(source === 'all' || entry.source === source) &&
(category === 'all' || entry.category === category) &&
(!deferredQuery || entry.search.includes(deferredQuery))
), [entries, source, category, deferredQuery])
const selected = filtered.find(entry => entry.id === selectedId) ?? filtered[0]
const installedDetail = selected ? renderInstalledDetail?.(selected) : null
const resetSelection = () => {
setLimit(PAGE_SIZE)
setSelectedId(null)
setDetailOpen(false)
}
useEffect(() => {
setLimit(PAGE_SIZE)
setSelectedId(null)
setDetailOpen(false)
}, [deferredQuery])
const clearFilters = () => {
onQueryChange?.('')
setSource('all')
setCategory('all')
resetSelection()
}
const installButton = (entry: CatalogEntry) => {
const installed = isInstalled(entry)
const installing = isInstalling?.(entry) ?? false
return (
<Button disabled={installed || installing || (kind === 'skills' && !entry.installIdentifier)} onClick={() => {
if (kind === 'plugins') {
setDetailOpen(false)
}
onInstall(entry)
}} size="sm" variant={installed ? 'secondary' : 'default'}>
<ActionStatus
busy={t.skills.hub.installing}
done={c.installed}
idle={t.skills.hub.install}
idleIcon={<Codicon name="cloud-download" />}
state={installed ? 'done' : installing ? 'saving' : 'idle'}
/>
</Button>
)
}
const metadata = selected ? [
[c.author, selected.author],
[c.source, prettyName(selected.source)],
[c.category, prettyName(selected.categoryLabel)],
[c.version, selected.version],
[c.platforms, selected.platforms.join(', ')],
[c.requires, selected.requiresHermes ? `Hermes ${selected.requiresHermes}` : ''],
[c.pinned, selected.sha ? <code title={selected.sha}>{selected.sha.slice(0, 8)}</code> : '']
] as [string, ReactNode][] : []
const DetailTitle = view === 'browse' && cardView ? DialogTitle : 'h3'
const details = selected ? (
<>
<header className="space-y-3">
{selected.imageUrl && (
<img
alt=""
className="block aspect-[2/1] w-full rounded-md border border-(--ui-border) object-cover"
decoding="async"
loading="lazy"
onError={e => { e.currentTarget.style.display = 'none' }}
referrerPolicy="no-referrer"
src={selected.imageUrl}
/>
)}
<div className="flex items-start gap-3">
<Codicon className="mt-1 shrink-0 text-(--ui-text-tertiary)" name={kind === 'plugins' ? 'extensions' : 'book'} size="1.5rem" />
<div className="min-w-0">
<DetailTitle className="break-words text-lg font-semibold tracking-tight">{selected.name}</DetailTitle>
<p className="mt-1 text-xs text-(--ui-text-tertiary)">{selected.author || prettyName(selected.source)}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{installButton(selected)}
<Badge variant="muted">{prettyName(selected.source)}</Badge>
{selected.stars !== null && <span className="text-xs text-(--ui-text-tertiary)">☆ {selected.stars.toLocaleString()}</span>}
</div>
</header>
<section className="space-y-2">
<h4 className="text-xs font-medium">{c.about}</h4>
<p className="whitespace-pre-wrap break-words text-[length:var(--conversation-caption-font-size)] leading-relaxed text-(--ui-text-secondary)">{selected.description}</p>
{selected.overview && selected.overview !== selected.description && <p className="whitespace-pre-wrap break-words text-[length:var(--conversation-caption-font-size)] leading-relaxed text-(--ui-text-tertiary)">{selected.overview}</p>}
</section>
<dl className="space-y-2 text-[length:var(--conversation-caption-font-size)]">
{metadata.filter(([, value]) => Boolean(value)).map(([label, value]) => <div className="grid grid-cols-[6rem_minmax(0,1fr)] gap-3" key={label}><dt className="text-(--ui-text-tertiary)">{label}</dt><dd className="m-0 break-words">{value}</dd></div>)}
</dl>
{[[c.tools, selected.tools], [c.hooks, selected.hooks], [c.requires, selected.requirements]].map(([label, values]) => (values as string[]).length > 0 && (
<section className="space-y-2" key={label as string}><h4 className="text-xs font-medium">{label as string}</h4><div className="flex flex-wrap gap-1">{(values as string[]).map(value => <ToolChip key={value}>{value}</ToolChip>)}</div></section>
))}
<div className="flex flex-wrap gap-3 text-xs">
{selected.sourceUrl && <ExternalLink href={selected.sourceUrl}>{c.repository}</ExternalLink>}
{selected.docsUrl && <ExternalLink href={selected.docsUrl}>{c.documentation}</ExternalLink>}
</div>
<p className="text-[0.65rem] leading-relaxed text-(--ui-text-quaternary)">{c.installHint}</p>
</>
) : null
return (
<div className="flex h-full min-h-0 min-w-0 flex-col" data-catalog={kind}>
{view === 'browse' && <div className="flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2 px-3 py-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<div className="w-36">
<Select onValueChange={value => { setSource(value); setCategory('all'); resetSelection() }} value={source}>
<SelectTrigger aria-label={c.source}><SelectValue placeholder={c.allSources} /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{c.allSources}</SelectItem>
{sources.map(value => <SelectItem key={value} value={value}>{prettyName(value)}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="w-40">
<Select onValueChange={value => { setCategory(value); resetSelection() }} value={category}>
<SelectTrigger aria-label={c.category}><SelectValue placeholder={c.allCategories} /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{c.allCategories}</SelectItem>
{categories.map(([value, label]) => <SelectItem key={value} value={value || 'uncategorized'}>{prettyName(label)}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<SegmentedControl
className="ml-auto shrink-0"
iconOnly
onChange={layout => {
setDetailOpen(false)
$catalogCardView.set(layout === 'cards')
}}
options={[{ id: 'list', label: c.listView, icon: ListViewIcon }, { id: 'cards', label: c.cardView, icon: CardViewIcon }]}
value={cardView ? 'cards' : 'list'}
/>
</div>}
<div className="min-h-0 flex-1">
{isPending && view === 'browse' && !entries.length ? <PageLoader label={t.skills.loading} /> : error && !entries.length ? (
<div className="grid h-full place-items-center p-5">
<ErrorState description={error.message} title={c.loadFailed}>
<Button onClick={() => void refetch()} size="sm" variant="secondary">{c.retry}</Button>
</ErrorState>
</div>
) : !selected ? (
<PanelEmpty action={<Button onClick={clearFilters} size="sm" variant="secondary">{c.clearFilters}</Button>} description={c.tryAnother} icon="search" title={c.noResults} />
) : view === 'browse' && cardView ? (
<>
<div className="flex h-full min-h-0 flex-col px-3 pb-3" data-catalog-cards={kind}>
<ListStrip left={<ListStripLabel>{c.results(filtered.length)}</ListStripLabel>} />
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-gutter:stable]" key={`${source}:${category}:${deferredQuery}`}>
<div className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,17rem),1fr))] gap-3 py-2">
{filtered.slice(0, limit).map(entry => (
<article className="row-hover flex min-w-0 flex-col overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)" data-catalog-card key={entry.id}>
<RowButton
aria-haspopup="dialog"
aria-label={entry.name}
className="flex min-w-0 flex-1 cursor-pointer flex-col gap-3 p-4 text-left focus-visible:outline-2 focus-visible:outline-primary focus-visible:-outline-offset-2"
onClick={event => {
selectedCardRef.current = event.currentTarget
setSelectedId(entry.id)
setDetailOpen(true)
}}
>
<span className="flex w-full items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<Codicon name={kind === 'plugins' ? 'extensions' : 'book'} size="1.25rem" />
<span className="min-w-0 flex-1 truncate">{prettyName(entry.source)}</span>
{entry.stars !== null && <span className="shrink-0">☆ {entry.stars.toLocaleString()}</span>}
</span>
<span className="line-clamp-2 break-words text-[length:var(--conversation-text-font-size)] font-semibold leading-snug">{entry.name}</span>
<span className="line-clamp-3 text-[length:var(--conversation-caption-font-size)] leading-relaxed text-(--ui-text-secondary)">{entry.description}</span>
<span className="mt-auto w-full truncate pt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{entry.author || prettyName(entry.source)}</span>
</RowButton>
<div className="flex flex-wrap items-center justify-between gap-2 px-4 pb-4">
<Badge variant="muted">{prettyName(entry.categoryLabel)}</Badge>
{installButton(entry)}
</div>
</article>
))}
</div>
{filtered.length > limit && <Button onClick={() => setLimit(value => value + PAGE_SIZE)} size="sm" variant="text">{c.more}</Button>}
</div>
</div>
<Dialog onOpenChange={setDetailOpen} open={detailOpen}>
<DialogContent
aria-describedby={undefined}
bodyClassName="gap-5"
className="max-w-2xl"
onCloseAutoFocus={event => {
event.preventDefault()
selectedCardRef.current?.focus()
}}
>
{details}
</DialogContent>
</Dialog>
</>
) : (
<div className={cn('h-full min-h-0', detailOpen ? '[&_aside]:max-sm:hidden' : '[&_main]:max-sm:hidden')} data-catalog-list={kind}>
<MasterDetail resizeId="capabilities-split" split="wide">
<ListColumn header={<ListStrip left={<ListStripLabel>{c.results(filtered.length)}</ListStripLabel>} />} key={`${source}:${category}:${deferredQuery}`}>
{filtered.slice(0, limit).map(entry => (
<RowButton
aria-pressed={entry.id === selected.id}
className={cn('row-hover flex w-full min-w-0 items-start gap-3 rounded-md px-2 py-2 text-left', entry.id === selected.id && 'bg-(--ui-row-active-background)')}
key={entry.id}
onClick={() => { setSelectedId(entry.id); setDetailOpen(true) }}
>
<Codicon className="mt-0.5 shrink-0 text-(--ui-text-tertiary)" name={kind === 'plugins' ? 'extensions' : 'book'} size="1.1rem" />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="truncate text-[0.78rem] font-medium">{entry.name}</span>
{isInstalled(entry) && <Codicon className="shrink-0 text-(--ui-text-tertiary)" name="check" />}
</span>
<span className="mt-1 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">{entry.description}</span>
<span className="mt-1.5 flex items-center gap-2 text-[0.65rem] text-(--ui-text-quaternary)">
<span className="truncate">{entry.author || prettyName(entry.source)}</span>
{entry.stars !== null && <span className="shrink-0">☆ {entry.stars.toLocaleString()}</span>}
</span>
</span>
</RowButton>
))}
{filtered.length > limit && <Button onClick={() => setLimit(value => value + PAGE_SIZE)} size="sm" variant="text">{c.more}</Button>}
</ListColumn>
<DetailColumn footer={c.snapshotHint}>
<div className="sm:hidden"><Button onClick={() => setDetailOpen(false)} size="sm" variant="text"><Codicon name="arrow-left" />{c.back}</Button></div>
{installedDetail || details}
</DetailColumn>
</MasterDetail>
</div>
)}
</div>
</div>
)
})
@@ -1,136 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fetchCatalog, parseCatalog } from './catalog-data'
afterEach(() => vi.unstubAllGlobals())
describe('public catalog data', () => {
it('preserves every source and distinct install identifier, including same-named skills', () => {
const rows = ['official', 'github', 'community', 'future-source'].map(source => ({
name: 'research',
identifier: `${source}/research`,
source,
author: `${source} maintainer`,
description: `Research from ${source}`,
category: 'research',
categoryLabel: 'Research',
tags: [`${source}-tag`],
envVars: [`${source.toUpperCase()}_KEY`]
}))
// Same source, different identifier is also a distinct install target.
rows.push({ ...rows[0], identifier: 'official/alternate/research' })
const entries = parseCatalog('skills', rows)
expect(entries).toHaveLength(rows.length)
expect(new Set(entries.map(entry => entry.id)).size).toBe(rows.length)
rows.forEach(row => {
const entry = entries.find(candidate => candidate.identifier === row.identifier)
expect(entry).toMatchObject({
name: row.name,
identifier: row.identifier,
source: row.source,
author: row.author,
description: row.description,
categoryLabel: row.categoryLabel,
tags: row.tags,
requirements: row.envVars
})
expect(entry?.search).toContain(row.source)
expect(entry?.search).toContain(row.tags[0])
})
})
it('keeps catalog identity separate from the source-qualified skill install target', () => {
const [entry] = parseCatalog('skills', [{
name: 'Apple Design', identifier: 'apple-design', source: 'ClawHub',
installCmd: 'hermes skills install clawhub/apple-design'
}])
expect(entry.identifier).toBe('apple-design')
expect(entry.installIdentifier).toBe('clawhub/apple-design')
})
it('keeps each plugin tier, pinned install target, and published detail metadata together', () => {
const rows = ['official', 'community', 'future-tier'].map(tier => ({
name: `${tier}-plugin`,
tier,
repo: `https://github.com/example/${tier}-plugins`,
subdir: 'packages/weather',
sha: 'a'.repeat(40),
maintainer: `${tier} maintainer`,
overview: `Overview for ${tier}`,
version: '1.2.3',
requiresHermes: '>=0.17',
platforms: ['macos', 'linux'],
docsUrl: `https://example.com/${tier}/docs`,
capabilities: {
providesTools: [`${tier}_forecast`],
providesHooks: ['on_session_start'],
requiresEnv: ['WEATHER_KEY']
}
}))
const entries = parseCatalog('plugins', rows)
expect(entries).toHaveLength(rows.length)
rows.forEach(row => {
const entry = entries.find(candidate => candidate.name === row.name)
expect(entry).toMatchObject({
name: row.name,
source: row.tier,
repo: row.repo,
sha: row.sha,
subdir: row.subdir,
author: row.maintainer,
overview: row.overview,
version: row.version,
requiresHermes: row.requiresHermes,
platforms: row.platforms,
docsUrl: row.docsUrl,
sourceUrl: row.repo,
tools: row.capabilities.providesTools,
hooks: row.capabilities.providesHooks,
requirements: row.capabilities.requiresEnv
})
expect(entry?.search).toContain(row.capabilities.providesTools[0])
})
})
it.each(['skills', 'plugins'] as const)('fetches only the published %s snapshot and parses it', async kind => {
const rows = [{
name: 'weather',
identifier: 'official/weather',
source: 'official',
tier: 'official',
repo: 'https://github.com/example/weather',
sourceUrl: 'https://github.com/example/weather/blob/main/SKILL.md'
}]
const fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => rows })
vi.stubGlobal('fetch', fetch)
expect(await fetchCatalog(kind)).toEqual(parseCatalog(kind, rows))
expect(fetch).toHaveBeenCalledExactlyOnceWith(
`https://nousresearch.github.io/hermes-agent/docs/api/${kind}.json`,
expect.objectContaining({ credentials: 'omit', signal: expect.any(AbortSignal) })
)
})
it('only renders plugin images hosted on GitHub so the browser never fans out to third-party hosts', () => {
const base = { name: 'x', tier: 'community', repo: 'https://github.com/o/r', sha: 'a'.repeat(40), version: '1.4.0' }
const [github, offhost, http] = parseCatalog('plugins', [
{ ...base, name: 'github', image: 'https://raw.githubusercontent.com/o/r/abc/banner.png' },
{ ...base, name: 'offhost', image: 'https://cdn.example.com/banner.png' },
{ ...base, name: 'http', image: 'http://github.com/o/r/banner.png' }
])
expect(github.imageUrl).toBe('https://raw.githubusercontent.com/o/r/abc/banner.png')
expect(github.version).toBe('1.4.0')
expect(offhost.imageUrl).toBeNull()
expect(http.imageUrl).toBeNull()
})
it('rejects a non-catalog response instead of treating an error payload as an empty catalog', () => {
expect(() => parseCatalog('skills', { error: 'Service unavailable' })).toThrow('Invalid catalog response')
})
})
-157
View File
@@ -1,157 +0,0 @@
import { skillCatalogInstallIdentifier } from '@hermes/shared'
import { useQuery } from '@tanstack/react-query'
export type CatalogKind = 'skills' | 'plugins'
export interface CatalogEntry {
id: string
name: string
description: string
overview: string
category: string
categoryLabel: string
source: string
author: string
identifier: string
installIdentifier?: string | null
repo: string
sha: string
subdir: string
version: string
requiresHermes: string
tags: string[]
platforms: string[]
requirements: string[]
tools: string[]
hooks: string[]
sourceUrl: string | null
docsUrl: string | null
/** GitHub-hosted banner for plugin entries; the only third-party fetch the browser makes. */
imageUrl: string | null
stars: number | null
search: string
}
const DOCS_ORIGIN = 'https://hermes-agent.nousresearch.com'
// The public domain redirects here without CORS headers on the redirect.
// Use the docs' actual static host, not GitHub's API or repository endpoints.
const CATALOG_BASE = 'https://nousresearch.github.io/hermes-agent/docs/api'
const text = (value: unknown): string => typeof value === 'string' ? value : ''
const strings = (value: unknown): string[] => Array.isArray(value) ? value.filter(v => typeof v === 'string') : []
const IMAGE_HOSTS = new Set(['raw.githubusercontent.com', 'github.com'])
/** Mirrors scripts/validate_plugin_catalog.py: https on a GitHub host, else no image. */
export function catalogImageUrl(value: unknown): string | null {
try {
const url = new URL(text(value))
const host = url.hostname.toLowerCase()
return url.protocol === 'https:' && (IMAGE_HOSTS.has(host) || host.endsWith('.githubusercontent.com')) ? url.href : null
} catch {
return null
}
}
function webUrl(value: unknown): string | null {
try {
const url = new URL(text(value))
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null
} catch {
return null
}
}
export function parseCatalog(kind: CatalogKind, data: unknown): CatalogEntry[] {
if (!Array.isArray(data)) {
throw new Error('Invalid catalog response')
}
const entries = new Map<string, CatalogEntry>()
for (const row of data) {
if (!row || typeof row !== 'object' || !text(row.name)) {
continue
}
const name = text(row.name)
const source = text(kind === 'plugins' ? row.tier : row.source)
const identifier = text(row.identifier) || name
const id = `${source}:${identifier}`
const caps = row.capabilities ?? {}
const category = text(row.category) || 'uncategorized'
const tags = strings(row.tags)
const tools = strings(caps.providesTools)
const hooks = strings(caps.providesHooks)
const author = text(row.maintainer ?? row.author)
const description = text(row.description)
entries.set(id, {
id,
name,
description,
overview: text(row.overview),
category,
categoryLabel: text(row.categoryLabel) || category,
source,
author,
identifier,
installIdentifier: kind === 'skills' ? skillCatalogInstallIdentifier({
name, source, identifier: text(row.identifier), installIdentifier: text(row.installIdentifier)
}) : null,
repo: text(row.repo),
sha: text(row.sha),
subdir: text(row.subdir),
version: text(row.version),
requiresHermes: text(row.requiresHermes),
tags,
tools,
hooks,
platforms: strings(row.platforms),
requirements: strings(kind === 'plugins' ? caps.requiresEnv : row.envVars),
sourceUrl: webUrl(row.repo || row.sourceUrl),
docsUrl: webUrl(row.docsUrl) || (text(row.docsPath)
? `${DOCS_ORIGIN}/docs/user-guide/skills/${text(row.docsPath)}`
: null),
imageUrl: kind === 'plugins' ? catalogImageUrl(row.image) : null,
stars: typeof row.stars === 'number' && Number.isFinite(row.stars) ? row.stars : null,
search: [name, description, author, category, row.categoryLabel, source, ...tags, ...tools, ...hooks]
.filter(Boolean).join(' ').toLowerCase()
})
}
return [...entries.values()]
}
export async function fetchCatalog(kind: CatalogKind): Promise<CatalogEntry[]> {
// These are the same published snapshots as the docs galleries. Never fan
// out to repositories, README previews, avatars, or live hub searches.
const response = await fetch(`${CATALOG_BASE}/${kind}.json`, {
credentials: 'omit',
signal: AbortSignal.timeout(60_000)
})
if (!response.ok) {
throw new Error(`Catalog HTTP ${response.status}`)
}
return parseCatalog(kind, await response.json())
}
export function useCatalog(kind: CatalogKind, enabled = true) {
return useQuery({
queryKey: ['public-catalog', kind],
// Re-enabling a mounted query retries errors even with retryOnMount off.
// Keep failures parked until the user explicitly chooses Try again.
enabled: query => enabled && query.state.status !== 'error',
queryFn: () => fetchCatalog(kind),
staleTime: 30 * 60_000,
gcTime: Infinity,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retryOnMount: false,
retry: false
})
}
File diff suppressed because it is too large Load Diff
@@ -1,516 +0,0 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { type ComponentProps, useState } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $pluginRecords } from '@/contrib/plugins-store'
import { queryClient } from '@/lib/query-client'
import { $agentPlugins, $agentPluginsStatus } from '@/store/agent-plugins'
import { $pluginInstallRequest, closePluginInstallRequest } from '@/store/plugin-install-request'
import { $connection } from '@/store/session'
import { PageSearchShell } from '../page-search-shell'
import { CapabilityTabs } from './capability-tabs'
import { parseCatalog } from './catalog-data'
import { PluginActions, PluginsTab } from './plugins-tab'
import { $catalogCardView } from './store'
const requestGateway = vi.fn(async () => ({ plugins: $agentPlugins.get() }))
const connectionFixture = {
baseUrl: 'http://localhost',
isFullscreen: false,
logs: [],
nativeOverlayWidth: 0,
token: '',
windowButtonPosition: null,
wsUrl: ''
}
// SkillsView owns navigation and search; exercise that controlled contract
// with the same primitives instead of giving PluginsTab private controls.
function PluginsHarness({
view: initialView = 'installed',
query: initialQuery = '',
...props
}: ComponentProps<typeof PluginsTab>) {
const [view, setView] = useState(initialView)
const [query, setQuery] = useState(initialQuery)
return (
<PageSearchShell onSearchChange={setQuery} searchPlaceholder="Search plugins" searchValue={query}>
<CapabilityTabs actions={<PluginActions profile={props.profile} />} onChange={setView} value={view} />
<PluginsTab {...props} onQueryChange={setQuery} query={query} view={view} />
</PageSearchShell>
)
}
function renderPlugins(props: ComponentProps<typeof PluginsTab>) {
return render(
<QueryClientProvider client={queryClient}>
<PluginsHarness {...props} />
</QueryClientProvider>
)
}
const weatherEntry = {
name: 'weather-plugin',
repo: 'https://github.com/example/weather-plugin',
sha: 'a'.repeat(40),
subdir: '',
tier: 'community',
category: 'weather',
description: 'Local weather forecasts'
}
function seedCatalog(entries = [weatherEntry]) {
queryClient.setQueryData(['public-catalog', 'plugins'], parseCatalog('plugins', entries))
}
async function selectCatalogEntry(name: string) {
fireEvent.click(screen.getByRole('button', { name: 'Browse' }))
fireEvent.click(await screen.findByRole('button', { name: text => text.startsWith(name) }))
expect(screen.getByRole('heading', { name })).toBeTruthy()
}
afterEach(() => {
cleanup()
$connection.set(null)
queryClient.clear()
vi.unstubAllGlobals()
})
vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({
useGatewayRequest: () => ({ requestGateway })
}))
describe('PluginsTab', () => {
beforeEach(() => {
$pluginRecords.set({})
$agentPlugins.set([])
$agentPluginsStatus.set('ready')
$catalogCardView.set(false)
closePluginInstallRequest()
requestGateway.mockReset()
requestGateway.mockImplementation(async () => ({ plugins: $agentPlugins.get() }))
})
it('lists the scoped profile agent plugins with toggles', () => {
$agentPlugins.set([
{
description: 'A test plugin',
key: 'demo-plugin',
name: 'demo-plugin',
source: 'git',
status: 'enabled',
version: '1.0.0'
}
])
renderPlugins({ profile: 'workbot' })
expect(screen.getByRole('button', { name: /^demo-plugin/, pressed: true })).toBeTruthy()
const detail = within(screen.getByRole('row', { name: /^demo-plugin/ }))
expect(detail.getByRole('switch', { name: 'Agent: demo-plugin' }).getAttribute('aria-checked')).toBe('true')
})
it('hides bundled plugins (managed from their own surfaces)', () => {
$agentPlugins.set([
{
description: '',
key: 'image_gen/fal',
name: 'fal',
source: 'bundled',
status: 'enabled',
version: ''
}
])
renderPlugins({ profile: null })
expect(screen.queryByText('fal')).toBeNull()
expect(screen.queryByRole('row')).toBeNull()
expect(screen.getByText('No matches')).toBeTruthy()
})
// A desktop half can only be copied out of a backend that runs on THIS
// machine; against a remote one the reconcile is a structural no-op, so the
// row must say so instead of pending forever (#114079).
it('marks a remote-backend desktop half unavailable instead of forever copying', () => {
$connection.set({ ...connectionFixture, mode: 'remote' })
$agentPlugins.set([
{
description: '',
has_desktop_half: true,
key: 'nous-prices',
name: 'nous-prices',
source: 'catalog',
status: 'enabled',
version: '1'
}
])
renderPlugins({ profile: null })
const detail = within(screen.getByRole('row', { name: /^nous-prices/ }))
expect(detail.getByText('unavailable (remote backend)')).toBeTruthy()
expect(detail.queryByText('copying…')).toBeNull()
})
it('keeps the pending desktop-half state on a local backend', () => {
$agentPlugins.set([
{
description: '',
has_desktop_half: true,
key: 'nous-prices',
name: 'nous-prices',
source: 'catalog',
status: 'enabled',
version: '1'
}
])
renderPlugins({ profile: null })
const detail = within(screen.getByRole('row', { name: /^nous-prices/ }))
expect(detail.getByText('copying…')).toBeTruthy()
expect(detail.queryByText('unavailable (remote backend)')).toBeNull()
})
it('renders a unified package as ONE row with a Desktop switch and an Agent switch', () => {
$pluginRecords.set({
media: { id: 'media', name: 'Media Studio', kind: 'disk', status: 'loaded', packageName: 'hermes-media-studio' }
})
$agentPlugins.set([
{
description: '',
key: 'hermes-media-studio',
name: 'hermes-media-studio',
source: 'git',
status: 'disabled',
version: '1'
}
])
renderPlugins({ profile: 'workbot', scopeLabel: 'workbot' })
expect(screen.getAllByRole('button', { name: /^Media Studio/ })).toHaveLength(1)
expect(screen.getAllByRole('row')).toHaveLength(1)
const detail = within(screen.getByRole('row', { name: /^Media Studio/ }))
expect(detail.getByText('Agent + Desktop')).toBeTruthy()
expect(detail.getByRole('switch', { name: 'Desktop: Media Studio' }).getAttribute('aria-checked')).toBe('true')
expect(detail.getByRole('switch', { name: 'Agent: Media Studio' }).getAttribute('aria-checked')).toBe('false')
expect(detail.getByRole('cell', { name: 'Agent in workbot' })).toBeTruthy()
})
it('offers "Install here" for a desktop half whose agent half is not in the selected profile', async () => {
$pluginRecords.set({
media: {
id: 'media',
name: 'Media Studio',
kind: 'disk',
status: 'loaded',
packageName: 'hermes-media-studio',
packageOrigin: { repo: 'https://github.com/NousResearch/hermes-media-studio.git', sha: 'abc' }
}
})
renderPlugins({ profile: 'workbot', scopeLabel: 'workbot' })
expect(screen.queryByRole('switch', { name: /^Agent:/ })).toBeNull()
screen.getByRole('button', { name: 'Install here' }).click()
// Pre-filled from the package marker: repo + pinned sha, agent half only.
await waitFor(() => {
expect($pluginInstallRequest.get()).toMatchObject({
legacyHint: 'agent',
profile: 'workbot',
repo: 'https://github.com/NousResearch/hermes-media-studio.git',
sha: 'abc'
})
})
})
it('disables "Install here" when the package has no known origin (hand-copied folder)', () => {
$pluginRecords.set({
media: { id: 'media', name: 'Media Studio', kind: 'disk', status: 'loaded', packageName: 'hermes-media-studio' }
})
renderPlugins({ profile: 'workbot', scopeLabel: 'workbot' })
expect((screen.getByRole('button', { name: 'Install here' }) as HTMLButtonElement).disabled).toBe(true)
})
it('loads the plugin list scoped to the selected profile', () => {
renderPlugins({ profile: 'workbot' })
expect(requestGateway).toHaveBeenCalledWith(
'plugins.manage',
expect.objectContaining({ action: 'list', profile: 'workbot' })
)
})
it('opens the dual-target install modal for the selected catalog entry in the scoped profile', async () => {
seedCatalog([
{ ...weatherEntry, name: 'other-plugin', repo: 'https://github.com/example/other-plugin' },
weatherEntry
])
renderPlugins({ profile: 'workbot' })
await selectCatalogEntry(weatherEntry.name)
expect($pluginInstallRequest.get()).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Install' }))
expect($pluginInstallRequest.get()).toMatchObject({
catalogName: weatherEntry.name,
repo: weatherEntry.repo,
profile: 'workbot',
sha: weatherEntry.sha
})
})
it('toggles by canonical key through plugins.manage', async () => {
$agentPlugins.set([
{
description: '',
key: 'image_gen/legacy',
name: 'Legacy plugin',
source: 'user',
status: 'disabled',
version: '0.20.0'
}
])
requestGateway.mockResolvedValueOnce({
ok: true,
plugin: { key: 'image_gen/legacy', name: 'Legacy plugin', status: 'enabled' }
} as never)
renderPlugins({ profile: null })
screen.getByRole('switch', { name: 'Agent: Legacy plugin' }).click()
await waitFor(() =>
expect(requestGateway).toHaveBeenCalledWith(
'plugins.manage',
expect.objectContaining({ action: 'toggle', key: 'image_gen/legacy', enable: true })
)
)
})
it('renders keyless rows read-only (no name-addressed toggle RPC)', () => {
// Name-addressed toggles flip every same-named plugin across category
// dirs — pre-contract-v6 rows must never reach the RPC.
$agentPlugins.set([
{
description: 'Returned by a pre-key backend',
name: 'Legacy plugin',
source: 'user',
status: 'disabled',
version: '0.20.0'
}
])
renderPlugins({ profile: null })
const toggle = screen.getByRole('switch', { name: 'Agent: Legacy plugin' })
expect(toggle.hasAttribute('disabled') || toggle.getAttribute('aria-disabled') === 'true').toBe(true)
toggle.click()
expect(requestGateway).not.toHaveBeenCalledWith('plugins.manage', expect.objectContaining({ action: 'toggle' }))
})
it('appends the selected subdir for multi-plugin repos without changing its catalog pin', async () => {
const entry = {
...weatherEntry,
name: 'nested-plugin',
repo: 'https://github.com/example/plugins-monorepo',
subdir: 'packages/nested-plugin'
}
seedCatalog([entry])
renderPlugins({ profile: null })
await selectCatalogEntry(entry.name)
fireEvent.click(screen.getByRole('button', { name: 'Install' }))
expect($pluginInstallRequest.get()).toMatchObject({
catalogName: entry.name,
repo: `${entry.repo}#${entry.subdir}`,
profile: null,
sha: entry.sha
})
})
})
describe('PluginsTab catalog UX', () => {
beforeEach(() => {
$agentPlugins.set([])
$agentPluginsStatus.set('ready')
$catalogCardView.set(false)
closePluginInstallRequest()
requestGateway.mockReset()
requestGateway.mockImplementation(async () => ({ plugins: $agentPlugins.get() }))
$pluginRecords.set({})
})
it('fetches only on Browse, with no extra requests for selection, search, or tab bounce', async () => {
const fetchCatalog = vi.fn().mockResolvedValue({
ok: true,
json: async () => [weatherEntry, { ...weatherEntry, name: 'garden-plugin', category: 'garden', description: 'Garden planning' }]
})
vi.stubGlobal('fetch', fetchCatalog)
await act(async () => { renderPlugins({ profile: null }) })
expect(fetchCatalog).not.toHaveBeenCalled()
expect(screen.queryByRole('heading', { name: weatherEntry.name })).toBeNull()
await selectCatalogEntry('garden-plugin')
const search = screen.getByRole<HTMLInputElement>('textbox', { name: 'Search plugins' })
fireEvent.change(search, { target: { value: 'weather' } })
expect(await screen.findByRole('heading', { name: weatherEntry.name })).toBeTruthy()
expect(screen.queryByRole('button', { name: /^garden-plugin/ })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Installed', pressed: false }))
expect(screen.queryByRole('heading', { name: weatherEntry.name })).toBeNull()
// The parent retains the shared search across navigation. Clearing from
// the empty state must propagate through onQueryChange, not local state.
expect(search.value).toBe('weather')
fireEvent.click(screen.getByRole('button', { name: 'Clear filters' }))
expect(search.value).toBe('')
await selectCatalogEntry('garden-plugin')
expect(fetchCatalog).toHaveBeenCalledTimes(1)
expect(fetchCatalog.mock.calls[0][0]).toMatch(/\/docs\/api\/plugins\.json$/)
expect(fetchCatalog.mock.calls[0][1]).toMatchObject({ credentials: 'omit' })
expect($pluginInstallRequest.get()).toBeNull()
})
it('offers an explicit retry after a catalog failure rather than refetching on tab bounce', async () => {
const fetchCatalog = vi.fn()
.mockResolvedValueOnce({ ok: false, status: 503 })
.mockResolvedValue({ ok: true, json: async () => [weatherEntry] })
vi.stubGlobal('fetch', fetchCatalog)
await act(async () => { renderPlugins({ profile: null }) })
fireEvent.click(screen.getByRole('button', { name: 'Browse' }))
expect(await screen.findByText('Catalog HTTP 503')).toBeTruthy()
expect(fetchCatalog).toHaveBeenCalledTimes(1)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /^Installed/ }))
})
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Browse' }))
})
expect(fetchCatalog).toHaveBeenCalledTimes(1)
expect(screen.getByText('Catalog HTTP 503')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
expect(await screen.findByRole('heading', { name: weatherEntry.name })).toBeTruthy()
expect(fetchCatalog).toHaveBeenCalledTimes(2)
})
it('shows an Update chip when the catalog pin moved past the installed SHA', () => {
$agentPlugins.set([
{
catalog_name: 'demo-weather',
catalog_sha: 'b'.repeat(40),
catalog_tier: 'community',
description: '',
installed_sha: 'a'.repeat(40),
key: 'demo-weather',
name: 'demo-weather',
source: 'git',
status: 'enabled',
update_available: true,
version: '1.0.0'
}
])
renderPlugins({ profile: null })
expect(screen.getByRole('button', { name: `Update to ${'b'.repeat(8)}` })).toBeTruthy()
})
it('re-pins through plugins.manage update when the chip is clicked', async () => {
$agentPlugins.set([
{
catalog_name: 'demo-weather',
catalog_sha: 'b'.repeat(40),
catalog_tier: 'community',
description: '',
installed_sha: 'a'.repeat(40),
key: 'demo-weather',
name: 'demo-weather',
source: 'git',
status: 'enabled',
update_available: true,
version: '1.0.0'
}
])
requestGateway.mockResolvedValue({ ok: true, unchanged: false, plugins: [] } as never)
renderPlugins({ profile: 'workbot' })
screen.getByRole('button', { name: `Update to ${'b'.repeat(8)}` }).click()
await waitFor(() =>
expect(requestGateway).toHaveBeenCalledWith(
'plugins.manage',
expect.objectContaining({ action: 'update', name: 'demo-weather', profile: 'workbot' })
)
)
})
it('disables installation of a catalog entry that is already installed and current', async () => {
$agentPlugins.set([
{
catalog_name: 'demo-weather',
description: '',
installed_sha: 'a'.repeat(40),
key: 'demo-weather',
name: 'demo-weather',
source: 'git',
status: 'enabled',
update_available: false,
version: '1.0.0'
}
])
seedCatalog([{ ...weatherEntry, name: 'demo-weather' }])
await act(async () => { renderPlugins({ profile: null }) })
await selectCatalogEntry('demo-weather')
const installed = within(screen.getByRole('main')).getByRole<HTMLButtonElement>('button', { name: 'Installed' })
expect(installed.disabled).toBe(true)
fireEvent.click(installed)
expect($pluginInstallRequest.get()).toBeNull()
})
it('offers catalog installation for an installed entry when an update is available', async () => {
$agentPlugins.set([
{
catalog_name: 'demo-weather',
description: '',
installed_sha: 'a'.repeat(40),
key: 'demo-weather',
name: 'demo-weather',
source: 'git',
status: 'enabled',
update_available: true,
version: '1.0.0'
}
])
const entry = { ...weatherEntry, name: 'demo-weather', sha: 'b'.repeat(40) }
seedCatalog([entry])
await act(async () => { renderPlugins({ profile: null }) })
await selectCatalogEntry(entry.name)
fireEvent.click(screen.getByRole('button', { name: 'Install' }))
expect($pluginInstallRequest.get()).toMatchObject({
catalogName: entry.name,
repo: entry.repo,
profile: null,
sha: entry.sha
})
})
})
-441
View File
@@ -1,441 +0,0 @@
import { useStore } from '@nanostores/react'
import { memo, type ReactNode, useEffect, useMemo } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Switch } from '@/components/ui/switch'
import { Tip } from '@/components/ui/tooltip'
import { $pluginRecords, type PluginRecord, setPluginEnabled } from '@/contrib/plugins-store'
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import type { ProfileScope } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { FolderOpen, Loader2, Monitor, Package, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$agentPluginBusy,
$agentPlugins,
type AgentPluginRow,
type GatewayRequest,
isDesktopRelevantPlugin,
loadAgentPlugins,
toggleAgentPlugin,
updateAgentPlugin
} from '@/store/agent-plugins'
import { notify, notifyError } from '@/store/notifications'
import { openPluginInstallRequest } from '@/store/plugin-install-request'
import { $connection } from '@/store/session'
import { Pill } from '../settings/primitives'
import { useDeepLinkHighlight } from '../settings/use-deep-link-highlight'
import type { CapabilityView } from './capability-tabs'
import { CatalogBrowser } from './catalog-browser'
import { type CatalogEntry, parseCatalog } from './catalog-data'
import { mergePluginPackages, type PackageKind, type PluginPackage } from './plugin-packages'
/** Deep-link anchor for a package row (`/skills?tab=plugins&plugin=<key>`).
* Accepts the agent key, the agent name, or the desktop record id. */
export const pluginElementId = (target: string) => `plugin-${target}`
/** Derive the bare profile name a `plugins.manage` call should target. */
function profileParam(scope: ProfileScope): null | string {
if (!scope) {
return null
}
return typeof scope === 'string' ? scope : (scope.profile ?? null)
}
function reveal(file: string) {
void window.hermesDesktop?.revealPath?.(file)?.catch(() => undefined)
}
async function revealPluginsDir() {
try {
// Electron owns the app-level plugin root — deriving it from the backend's
// hermes_home breaks against a remote backend (#66899).
const dir = await window.hermesDesktop?.desktopPluginsRoot?.()
if (!dir) {
notifyError('Desktop plugins are unavailable', 'Could not resolve the plugins folder')
return
}
const result = await window.hermesDesktop?.openDir?.(dir)
if (result && !result.ok) {
notifyError(result.error ?? 'unknown error', 'Could not open the plugins folder')
}
} catch (err) {
notifyError(err, 'Could not resolve the plugins folder')
}
}
/** Copy any changed unified desktop halves into the app root FIRST, then
* rescan the root — a concurrent scan would read the pre-copy state. */
async function rescanAll(requestGateway: GatewayRequest, scope: null | string) {
await window.hermesDesktop?.reconcileDesktopPlugins?.().catch(() => undefined)
await discoverRuntimePlugins()
await loadAgentPlugins(requestGateway, scope)
}
/** Open the dual-target install modal pre-filled to install ONLY the agent
* half of a unified package into the scoped profile (the desktop half is
* already here). Provenance comes from the package marker Electron stamped
* when it copied the half out (catalog sidecar or git remote). */
function installAgentHalfHere(record: PluginRecord, profile: null | string) {
const origin = record.packageOrigin
if (!origin?.repo) {
return
}
openPluginInstallRequest({
catalogName: origin.catalogName,
legacyHint: 'agent',
profile,
repo: origin.repo,
sha: origin.sha
})
}
function KindBadge({ kind }: { kind: PackageKind }) {
const { t } = useI18n()
const p = t.skills.plugins
return (
<span className="inline-flex items-center gap-1 rounded border border-(--ui-stroke-tertiary) px-1.5 py-px text-[0.65rem] text-(--ui-text-tertiary)">
{kind !== 'desktop' && <Package aria-hidden className="size-3" />}
{kind !== 'agent' && <Monitor aria-hidden className="size-3" />}
{kind === 'both' ? p.kindBoth : kind === 'agent' ? p.kindAgent : p.kindDesktop}
</span>
)
}
/** Provenance pill: where the package came from. */
function ProvenancePill({ pkg }: { pkg: PluginPackage }) {
const { t } = useI18n()
const p = t.skills.plugins
if (pkg.agent?.catalog_name) {
return (
<Tip label={p.catalogProvenance(pkg.agent.installed_sha?.slice(0, 8) ?? '')}>
<span>
<Pill>{pkg.agent.catalog_tier === 'official' ? p.tierOfficial : p.tierCommunity}</Pill>
</span>
</Tip>
)
}
if (pkg.agent?.pinned_sha) {
return (
<Tip label={p.pinnedProvenance(pkg.agent.pinned_sha.slice(0, 8))}>
<span>
<Pill>
<span className="font-mono">{p.pinnedBadge(pkg.agent.pinned_sha.slice(0, 8))}</span>
</Pill>
</span>
</Tip>
)
}
if (pkg.agent) {
return <Pill>{pkg.agent.source}</Pill>
}
if (pkg.desktop) {
return <Pill>{t.settings.plugins.kinds[pkg.desktop.kind]}</Pill>
}
return null
}
/** Controls for one installed plugin half in the detail pane. */
function HalfCell({ label, children }: { label: string; children: ReactNode }) {
return (
<div aria-label={label} className="flex w-full items-center gap-3" role="cell">
<span className="min-w-0 flex-1 text-xs text-(--ui-text-tertiary)">{label}</span>
{children}
</div>
)
}
function Dash() {
return (
<span aria-hidden className="w-9 text-center text-(--ui-text-quaternary)">
—
</span>
)
}
function PackageRow({
pkg,
scope,
scopeLabel,
busy,
onAgentToggle,
onAgentUpdate
}: {
pkg: PluginPackage
scope: null | string
scopeLabel: string
busy: boolean
onAgentToggle: (row: AgentPluginRow, enable: boolean) => void
onAgentUpdate: (row: AgentPluginRow) => void
}) {
const { t } = useI18n()
const p = t.skills.plugins
const d = t.settings.plugins
const desktop = pkg.desktop
const agent = pkg.agent
const desktopOn = desktop ? desktop.status !== 'disabled' : false
const agentOn = agent?.status === 'enabled'
const agentToggleable = Boolean(agent?.key)
// Electron's desktop-half reconcile only walks THIS machine's homes, so a
// package installed on a remote backend can never materialize here (#114079).
const remoteBackend = useStore($connection)?.mode === 'remote'
return (
<div
className="flex flex-col gap-5"
data-testid={`plugin-row-${pkg.key}`}
id={pluginElementId(agent?.key ?? agent?.name ?? desktop?.id ?? pkg.key)}
role="row"
>
<div className="flex w-full min-w-0 flex-1 items-start gap-2" role="cell">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
<span>{pkg.name}</span>
{agent?.version && <span className="text-(--ui-text-quaternary)">v{agent.version}</span>}
<KindBadge kind={pkg.kind} />
<ProvenancePill pkg={pkg} />
{agent?.portable && <Pill>{p.portableBadge}</Pill>}
{desktop?.status === 'error' && <Pill tone="primary">{d.failed}</Pill>}
</div>
{(desktop?.status === 'error' ? desktop.error : pkg.description) && (
<div
className={cn(
'mt-0.5 text-[length:var(--conversation-caption-font-size)] break-words',
desktop?.status === 'error' ? 'text-(--ui-danger,#f87171)' : 'text-(--ui-text-tertiary)'
)}
>
{desktop?.status === 'error' ? desktop.error : pkg.description}
</div>
)}
</div>
{/* Fixed slot so the switch column stays straight whether or not
this row has a folder to reveal (bundled plugins have none). */}
<span className="flex size-7 shrink-0 items-center justify-center">
{desktop?.file && (
<Tip label={d.reveal}>
<Button onClick={() => reveal(desktop.file!)} size="icon" variant="ghost">
<Codicon name="folder-opened" size="0.85rem" />
</Button>
</Tip>
)}
</span>
</div>
{/* The two halves. Desktop is app-level and reads the same whichever
profile is selected; Agent follows the selector. A half the package
lacks shows a dash; a half it has but which is missing on this side
shows the install affordance. */}
<HalfCell label={p.halfDesktop}>
{desktop ? (
<Switch
aria-label={`${p.halfDesktop}: ${pkg.name}`}
checked={desktopOn}
onCheckedChange={on => {
triggerHaptic('selection')
void setPluginEnabled(desktop.id, on)
}}
/>
) : pkg.desktopMissing ? (
<Tip label={remoteBackend ? p.desktopHalfRemoteTip : p.desktopHalfPendingTip}>
<span className="text-[0.65rem] text-(--ui-text-tertiary)">
{remoteBackend ? p.desktopHalfRemote : p.desktopHalfPending}
</span>
</Tip>
) : (
<Dash />
)}
</HalfCell>
<HalfCell label={p.halfAgentIn(scopeLabel)}>
{agent ? (
<>
{agent.update_available && (
<Button
className="h-5 px-1.5 text-[0.65rem]"
disabled={busy}
onClick={() => onAgentUpdate(agent)}
size="xs"
variant="outline"
>
{p.updateToPin(agent.catalog_version ?? agent.catalog_sha?.slice(0, 8) ?? '')}
</Button>
)}
{busy && <Loader2 className="size-3.5 animate-spin text-(--ui-text-tertiary)" />}
{agentToggleable ? (
<Switch
aria-label={`${p.halfAgent}: ${pkg.name}`}
checked={agentOn}
disabled={busy}
onCheckedChange={on => onAgentToggle(agent, on)}
/>
) : (
<Tip label={p.legacyBackend}>
<span>
<Switch aria-label={`${p.halfAgent}: ${pkg.name}`} checked={agentOn} disabled />
</span>
</Tip>
)}
</>
) : pkg.agentMissingInProfile && desktop ? (
<Tip label={desktop.packageOrigin?.repo ? p.installAgentHereTip(scopeLabel) : p.installAgentHereNoOrigin}>
<span>
<Button
className="h-5 px-1.5 text-[0.65rem]"
disabled={!desktop.packageOrigin?.repo}
onClick={() => installAgentHalfHere(desktop, scope)}
size="xs"
variant="outline"
>
{p.installAgentHere}
</Button>
</span>
</Tip>
) : (
<Dash />
)}
</HalfCell>
</div>
)
}
export function PluginActions({ profile }: { profile: ProfileScope }) {
const { t } = useI18n()
const d = t.settings.plugins
const { requestGateway } = useGatewayRequest()
const scope = profileParam(profile)
return <>
<Button className="underline" onClick={() => openPluginInstallRequest({ profile: scope, repo: '' })} size="xs" variant="text">
{d.installModal.installFromGit}
</Button>
<Tip label={d.openFolder}>
<Button aria-label={d.openFolder} onClick={() => void revealPluginsDir()} size="icon-xs" variant="ghost"><FolderOpen /></Button>
</Tip>
<Tip label={d.rescan}>
<Button aria-label={d.rescan} onClick={() => void rescanAll(requestGateway, scope)} size="icon-xs" variant="ghost"><RefreshCw /></Button>
</Tip>
</>
}
/** THE plugins surface: one row per package. Each row shows its Desktop half
* (this app — the same for every profile, gateway, or machine) and its Agent
* half (the selected profile's backend). Browse uses the shared native
* catalog; Install from Git remains available for unlisted packages. */
export const PluginsTab = memo(function PluginsTab({
profile,
scopeLabel,
view = 'installed',
query = '',
onQueryChange
}: {
query?: string
onQueryChange?: (value: string) => void
view?: CapabilityView
profile: ProfileScope
/** Display name of the selected profile for the Agent column label. */
scopeLabel?: string
}) {
const { t } = useI18n()
const p = t.skills.plugins
const { requestGateway } = useGatewayRequest()
const desktopRecords = useStore($pluginRecords)
const agentRows = useStore($agentPlugins)
const busyKey = useStore($agentPluginBusy)
const scope = profileParam(profile)
const label = scopeLabel ?? scope ?? t.skills.plugins.defaultProfile
useEffect(() => {
void loadAgentPlugins(requestGateway, scope)
}, [requestGateway, scope])
const packages = useMemo(
() => mergePluginPackages(Object.values(desktopRecords), agentRows.filter(isDesktopRelevantPlugin)),
[agentRows, desktopRecords]
)
useDeepLinkHighlight({ param: 'plugin', ready: () => true, elementId: pluginElementId })
const agentBusy = (row: AgentPluginRow) => busyKey === (row.key ?? row.name) || busyKey === row.name
const installedEntries = useMemo(() => parseCatalog('plugins', packages.map(pkg => ({
name: pkg.name,
identifier: pkg.agent?.catalog_name ?? pkg.desktop?.packageOrigin?.catalogName ?? pkg.key,
description: pkg.description,
category: pkg.kind === 'desktop' ? 'desktop' : 'general',
tier: pkg.agent?.catalog_tier ?? pkg.agent?.source ?? pkg.desktop?.kind ?? '',
repo: pkg.desktop?.packageOrigin?.repo ?? '',
sha: pkg.agent?.installed_sha ?? pkg.desktop?.packageOrigin?.sha ?? '',
version: pkg.agent?.version ?? ''
}))).map((entry, index) => ({ ...entry, id: `installed:${packages[index].key}` })), [packages])
const packageById = useMemo(() => new Map(packages.map(pkg => [`installed:${pkg.key}`, pkg])), [packages])
const isInstalled = (entry: CatalogEntry) => packageById.has(entry.id) || agentRows.some(row =>
(row.catalog_name === entry.name || row.name === entry.name) && !row.update_available
)
const install = (entry: CatalogEntry) => openPluginInstallRequest({
catalogName: entry.name,
profile: scope,
repo: entry.subdir ? `${entry.repo}#${entry.subdir}` : entry.repo,
sha: entry.sha
})
return (
<div className="flex h-full min-h-0 flex-col">
<CatalogBrowser
installedEntries={installedEntries}
isInstalled={isInstalled}
kind="plugins"
onInstall={install}
onQueryChange={onQueryChange}
query={query}
renderInstalledDetail={entry => {
const pkg = packageById.get(entry.id)
if (!pkg) {
return null
}
return <PackageRow
busy={pkg.agent ? agentBusy(pkg.agent) : false}
key={pkg.key}
onAgentToggle={(row, enable) => {
if (row.key) {
void toggleAgentPlugin(requestGateway, row.key, enable, p.toggleFailed(row.name), scope)
}
}}
onAgentUpdate={row => {
void updateAgentPlugin(requestGateway, row.name, p.updateFailed(row.name), scope).then(applied => {
if (applied) {
notify({ kind: 'success', message: p.updated(row.name) })
void rescanAll(requestGateway, scope)
}
})
}}
pkg={pkg}
scope={scope}
scopeLabel={label}
/>
}}
view={view}
/>
</div>
)
})
@@ -1,46 +0,0 @@
import { useCallback, useState } from 'react'
import type { ProfileScope } from '@/hermes'
import { useI18n } from '@/i18n'
import { installHubSkill, notifyHubActionFailed } from '@/store/hub-actions'
import { notify } from '@/store/notifications'
import { CatalogBrowser } from './catalog-browser'
import type { CatalogEntry } from './catalog-data'
interface SkillCatalogProps {
installedNames: ReadonlySet<string>
profile?: ProfileScope
query?: string
onQueryChange?: (value: string) => void
}
export function SkillCatalog({ installedNames, profile, query = '', onQueryChange }: SkillCatalogProps) {
const { t } = useI18n()
const h = t.skills.hub
const [installing, setInstalling] = useState<ReadonlySet<string>>(new Set())
const isInstalled = useCallback((entry: CatalogEntry) => installedNames.has(entry.name) || installedNames.has(entry.identifier), [installedNames])
const install = (entry: CatalogEntry) => {
if (!entry.installIdentifier || isInstalled(entry) || installing.has(entry.id)) {
return
}
setInstalling(current => new Set(current).add(entry.id))
notify({ kind: 'success', title: h.installStarted(entry.name), message: h.actionLog })
void installHubSkill(entry.installIdentifier, profile)
.catch(err => notifyHubActionFailed(err, h.actionFailed, entry.name, profile))
.finally(() => setInstalling(current => new Set([...current].filter(id => id !== entry.id))))
}
return (
<CatalogBrowser
isInstalled={isInstalled}
isInstalling={entry => installing.has(entry.id)}
kind="skills"
onInstall={install}
onQueryChange={onQueryChange}
query={query}
/>
)
}
-9
View File
@@ -1,9 +0,0 @@
import { Codecs, persistentAtom } from '@/lib/persisted'
// Per-view sort direction for the Capabilities lists — persisted so each tab
// remembers most/least-used across navigations and restarts.
export const $skillsSortDesc = persistentAtom('hermes.desktop.capabilities.skillsSortDesc', true, Codecs.bool)
export const $toolsetsSortDesc = persistentAtom('hermes.desktop.capabilities.toolsetsSortDesc', true, Codecs.bool)
// One browsing layout across Skills and Plugins; Installed keeps its own list.
export const $catalogCardView = persistentAtom('hermes.desktop.capabilities.catalogCardView', true, Codecs.bool)
@@ -1,21 +0,0 @@
import { Button } from '@/components/ui/button'
import type { ProfileScope } from '@/hermes'
import { useI18n } from '@/i18n'
import { useStoreSelector } from '@/lib/use-session-slice'
import { $hubActions, UPDATE_ALL_KEY, updateHubSkills } from '@/store/hub-actions'
import { notify, notifyError } from '@/store/notifications'
export function UpdateSkillsButton({ profile }: { profile?: ProfileScope }) {
const { t } = useI18n()
const h = t.skills.hub
const updating = useStoreSelector($hubActions, actions => actions[UPDATE_ALL_KEY]?.running ?? false)
return (
<Button disabled={updating} onClick={() => {
notify({ kind: 'success', title: h.updateStarted, message: h.actionLog })
void updateHubSkills(profile).catch(err => notifyError(err, h.actionFailed))
}} size="xs" variant="text">
{updating ? h.updating : h.updateAll}
</Button>
)
}
+8 -1
View File
@@ -122,7 +122,14 @@ export interface HandoffFailResponse {
state?: string
}
export type SidebarNavId = 'artifacts' | 'command-center' | 'cron' | 'messaging' | 'new-session' | 'settings' | 'skills'
export type SidebarNavId =
| 'artifacts'
| 'capabilities'
| 'command-center'
| 'cron'
| 'messaging'
| 'new-session'
| 'settings'
export interface SidebarNavItem {
/** Built-in view id, or a contributed row's namespaced contribution id. */
@@ -64,42 +64,6 @@ describe('confirm()', () => {
expect(read()).toBe(false)
})
it('keeps async actions open through progress, inline failure, retry and completion', async () => {
render(<ConfirmHost />)
let fail!: (error: Error) => void
let finish!: () => void
let attempts = 0
let answer: boolean | undefined
const pending = confirm({
title: 'Install “pdf”?',
confirmLabel: 'Install',
busyLabel: 'Installing…',
doneLabel: 'Installed',
details: [{ label: 'Source', value: 'official/productivity/pdf' }],
onConfirm: () => new Promise<void>((resolve, reject) => {
attempts += 1
finish = resolve
fail = reject
})
}).then(value => { answer = value })
expect(await screen.findByText('official/productivity/pdf')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Install' }))
expect(await screen.findByRole('button', { name: 'Installing…' })).toBeTruthy()
expect(answer).toBeUndefined()
expect(await confirm({ title: 'Must not interrupt' })).toBe(false)
await act(async () => fail(new Error('Network unavailable')))
expect(await screen.findByText('Network unavailable')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Install' }))
await act(async () => finish())
expect(await screen.findByRole('button', { name: 'Installed' })).toBeTruthy()
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
await pending
expect(answer).toBe(true)
expect(attempts).toBe(2)
})
it('supersedes an open request, answering the one it replaces no', async () => {
render(<ConfirmHost />)
+7 -19
View File
@@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { $confirmRequest, type PendingConfirm, runConfirm, settleConfirm } from '@/store/confirm'
import { $confirmRequest, type PendingConfirm, settleConfirm } from '@/store/confirm'
// The one mount point for `confirm()` from @/store/confirm. Mounted once at the
// shell, the way NotificationStack backs notify().
@@ -24,29 +24,17 @@ export function ConfirmHost() {
return (
<ConfirmDialog
busyLabel={shown.busyLabel}
cancelLabel={shown.cancelLabel}
confirmLabel={shown.confirmLabel}
description={shown.description}
destructive={shown.destructive}
dismissOnConfirm={!shown.onConfirm}
doneLabel={shown.doneLabel}
key={shown.id}
onClose={() => settleConfirm(shown.phase === 'done', shown)}
onConfirm={() => runConfirm(shown)}
// The caller does the work once it has its answer, so there is nothing
// here to keep the dialog open for.
dismissOnConfirm
onClose={() => settleConfirm(false)}
onConfirm={() => settleConfirm(true)}
open={request !== null}
title={shown.title}
>
{shown.details && (
<dl className="space-y-3 text-xs">
{shown.details.map(({ label, value }) => (
<div className="grid grid-cols-[5rem_minmax(0,1fr)] gap-3" key={label}>
<dt className="text-muted-foreground">{label}</dt>
<dd className="m-0 whitespace-pre-wrap break-words text-foreground">{value}</dd>
</div>
))}
</dl>
)}
</ConfirmDialog>
/>
)
}
@@ -89,7 +89,7 @@ describe('focused chat zone drives the tab verbs', () => {
const model = await import('@/components/pane-shell/tree/model')
const { registry } = await import('@/contrib/registry')
for (const id of ['workspace', 'preview-tile:url:x', 'route-tile:/skills']) {
for (const id of ['workspace', 'preview-tile:url:x', 'route-tile:/capabilities']) {
registry.register({
area: 'panes',
data: id === 'workspace' ? { placement: 'main', uncloseable: true } : { placement: 'main' },
@@ -105,15 +105,15 @@ describe('focused chat zone drives the tab verbs', () => {
tree.declareDefaultTree(
model.split('row', [
model.group(['workspace'], { active: 'workspace', id: 'grp-main' }),
model.group(['preview-tile:url:x', 'route-tile:/skills'], { active: 'preview-tile:url:x', id: 'grp-view' })
model.group(['preview-tile:url:x', 'route-tile:/capabilities'], { active: 'preview-tile:url:x', id: 'grp-view' })
])
)
tree.noteActiveTreeGroup('grp-view')
// ⌃Tab cycles within the preview zone, not main's.
expect(tree.cycleTreeTabInFocusedZone(1)).toBe('route-tile:/skills')
expect(tree.cycleTreeTabInFocusedZone(1)).toBe('route-tile:/capabilities')
expect(tree.$layoutTree.get() && model.findGroup(tree.$layoutTree.get()!, 'grp-view')?.active).toBe(
'route-tile:/skills'
'route-tile:/capabilities'
)
// ⌘W closes the zone's active tab through its registered closer.
@@ -21,7 +21,6 @@ interface ConfirmDialogProps {
onConfirm: () => Promise<void> | void
title: ReactNode
description?: ReactNode
children?: ReactNode
confirmLabel?: string
busyLabel?: string
doneLabel?: string
@@ -49,7 +48,6 @@ export function ConfirmDialog({
onConfirm,
title,
description,
children,
confirmLabel,
busyLabel,
doneLabel,
@@ -155,7 +153,6 @@ export function ConfirmDialog({
{description ? <DialogDescription className="whitespace-pre-line">{description}</DialogDescription> : null}
</DialogHeader>
{children}
{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" />
@@ -1,51 +0,0 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { codiconIcon } from './codicon'
import { SegmentedControl } from './segmented-control'
const options = [
{ id: 'list', label: 'List view', icon: codiconIcon('list-unordered') },
{ id: 'cards', label: 'Card view', icon: codiconIcon('extensions') }
]
afterEach(cleanup)
describe('SegmentedControl', () => {
it('preserves visible labels by default', () => {
render(<SegmentedControl onChange={vi.fn()} options={options} value="cards" />)
expect(screen.getByRole('button', { name: 'Card view', pressed: true }).textContent).toBe('Card view')
expect(screen.getByRole('button', { name: 'List view', pressed: false }).textContent).toBe('List view')
})
it('keeps accessible labels and selection behavior for icon-only controls', () => {
const onChange = vi.fn()
render(<SegmentedControl iconOnly onChange={onChange} options={options} value="cards" />)
const list = screen.getByRole('button', { name: 'List view', pressed: false })
const cards = screen.getByRole('button', { name: 'Card view', pressed: true })
expect(list.textContent).toBe('')
expect(cards.textContent).toBe('')
expect(list.querySelector('.codicon-list-unordered')).not.toBeNull()
expect(cards.querySelector('.codicon-extensions')).not.toBeNull()
fireEvent.click(list)
expect(onChange).toHaveBeenCalledExactlyOnceWith('list')
})
it('retains a visible label when an icon-only option has no icon', () => {
render(<SegmentedControl iconOnly onChange={vi.fn()} options={[{ id: 'list', label: 'List view' }]} value="list" />)
expect(screen.getByRole('button', { name: 'List view' }).textContent).toBe('List view')
})
it('does not change a disabled icon-only control', () => {
const onChange = vi.fn()
render(<SegmentedControl disabled iconOnly onChange={onChange} options={options} value="cards" />)
const list = screen.getByRole<HTMLButtonElement>('button', { name: 'List view' })
expect(list.disabled).toBe(true)
fireEvent.click(list)
expect(onChange).not.toHaveBeenCalled()
})
})
@@ -1,4 +1,3 @@
import { Tip } from '@/components/ui/tooltip'
import type { IconComponent } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -13,8 +12,6 @@ interface SegmentedControlProps<T extends string> {
value: T
onChange: (id: T) => void
className?: string
/** Icon buttons retain localized labels for tooltips and screen readers. */
iconOnly?: boolean
/** Dims the whole track and blocks selection (e.g. gated behind a prerequisite). */
disabled?: boolean
}
@@ -27,7 +24,6 @@ interface SegmentedControlProps<T extends string> {
export function SegmentedControl<T extends string>({
className,
disabled = false,
iconOnly = false,
onChange,
options,
value
@@ -43,14 +39,12 @@ export function SegmentedControl<T extends string>({
{options.map(({ id, label, icon: Icon }) => {
const active = value === id
const button = (
return (
<button
aria-label={iconOnly ? label : undefined}
aria-pressed={active}
className={cn(
'flex items-center justify-center gap-1 rounded-[3px] px-2.5 py-0.5 text-[0.6875rem] font-medium transition-colors disabled:cursor-default',
active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
iconOnly && Icon && 'size-6 p-0 text-sm'
active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
disabled={disabled}
key={id}
@@ -58,11 +52,9 @@ export function SegmentedControl<T extends string>({
type="button"
>
{Icon && <Icon className="size-3" />}
{(!iconOnly || !Icon) && label}
{label}
</button>
)
return iconOnly && Icon ? <Tip key={id} label={label}>{button}</Tip> : button
})}
</div>
)
+3 -43
View File
@@ -8,46 +8,6 @@ export const ar = defineLocale({
close: 'إغلاق'
},
catalog: {
listView: 'عرض القائمة',
cardView: 'عرض البطاقات',
installTitle: (name: string) => `تثبيت «${name}»؟`,
installDescription: 'ستتوفر هذه المهارة في الجلسات الجديدة. ثبّت من المصادر التي تثق بها فقط.',
installTo: 'التثبيت في',
thisComputer: 'هذا الكمبيوتر',
installing: 'جارٍ التثبيت…',
installComplete: (name: string) => `تم تثبيت «${name}»`,
destinationChanged: 'تغيرت وجهة التثبيت. أغلق هذا الحوار وافتح رابط التثبيت مجددًا.',
browse: 'تصفح',
installed: 'المثبتة',
searchSkills: 'البحث في المهارات',
searchPlugins: 'البحث في الإضافات',
allSources: 'كل المصادر',
allCategories: 'كل الفئات',
about: 'نبذة',
author: 'المؤلف',
source: 'المصدر',
category: 'الفئة',
version: 'الإصدار',
platforms: 'المنصات',
requires: 'المتطلبات',
tools: 'الأدوات',
hooks: 'الخطافات',
repository: 'المستودع',
documentation: 'التوثيق',
noResults: 'لا توجد نتائج مطابقة',
tryAnother: 'جرّب بحثًا آخر أو امسح عوامل التصفية.',
clearFilters: 'مسح عوامل التصفية',
loadFailed: 'تعذر تحميل الكتالوج',
retry: 'حاول مرة أخرى',
more: 'عرض المزيد',
pinned: 'التزام تمت مراجعته',
snapshotHint: 'من كتالوج Hermes. لا يتصل التصفح بمستودعات المصدر مطلقًا.',
installHint: 'راجع المصدر قبل التثبيت. تسري التغييرات على الجلسات الجديدة.',
results: (count: number) => `النتائج: ${count.toLocaleString('ar')}`,
back: 'العودة إلى النتائج'
},
sessionImport: {
title: 'المتابعة من تطبيق آخر',
subtitle: 'انقل محادثة إلى Hermes وتابع من حيث توقفت.',
@@ -331,7 +291,7 @@ export const ar = defineLocale({
'nav.commandCenter': 'فتح مركز الأوامر',
'nav.settings': 'فتح الإعدادات',
'nav.profiles': 'فتح الملفات الشخصية',
'nav.skills': 'فتح المهارات',
'nav.capabilities': 'فتح المهارات',
'nav.messaging': 'فتح المراسلة',
'nav.artifacts': 'فتح العناصر',
'nav.cron': 'فتح المهام المجدولة',
@@ -1333,7 +1293,7 @@ export const ar = defineLocale({
title: 'الإعدادات',
detail: 'تكوين Hermes desktop'
},
skills: {
capabilities: {
title: 'المهارات والأدوات',
detail: 'تفعيل المهارات ومجموعات الأدوات والمزوّدين'
},
@@ -1926,7 +1886,7 @@ export const ar = defineLocale({
},
nav: {
'new-session': 'جلسة جديدة',
skills: 'المهارات',
capabilities: 'المهارات',
messaging: 'المراسلة',
artifacts: 'العناصر',
cron: 'المهام المجدولة'
+3 -42
View File
@@ -9,45 +9,6 @@ export const en: Translations = {
copyUrl: 'Copy link',
close: 'Close'
},
catalog: {
listView: 'List view',
cardView: 'Card view',
installTitle: (name: string) => `Install “${name}”?`,
installDescription: 'This skill will be available in new sessions. Only install sources you trust.',
installTo: 'Install to',
thisComputer: 'This computer',
installing: 'Installing…',
installComplete: (name: string) => `“${name}” installed`,
destinationChanged: 'The destination changed. Close this dialog and open the install link again.',
browse: 'Browse',
installed: 'Installed',
searchSkills: 'Search skills',
searchPlugins: 'Search plugins',
allSources: 'All sources',
allCategories: 'All categories',
about: 'About',
author: 'Author',
source: 'Source',
category: 'Category',
version: 'Version',
platforms: 'Platforms',
requires: 'Requires',
tools: 'Tools',
hooks: 'Hooks',
repository: 'Repository',
documentation: 'Documentation',
noResults: 'No matches',
tryAnother: 'Try another search or clear your filters.',
clearFilters: 'Clear filters',
loadFailed: 'Could not load the catalog',
retry: 'Try again',
more: 'Show more',
pinned: 'Reviewed commit',
snapshotHint: 'From the Hermes catalog. Browsing never contacts source repositories.',
installHint: 'Review the source before installing. Changes apply to new sessions.',
results: (count: number) => `${count.toLocaleString()} result${count === 1 ? '' : 's'}`,
back: 'Back to results'
},
connectors: {
title: 'Connect your apps',
connect: 'Connect',
@@ -432,7 +393,7 @@ export const en: Translations = {
'nav.commandCenter': 'Open command center',
'nav.settings': 'Open settings',
'nav.profiles': 'Open profiles',
'nav.skills': 'Open skills',
'nav.capabilities': 'Open skills',
'nav.messaging': 'Open messaging',
'nav.artifacts': 'Open artifacts',
'nav.cron': 'Open scheduled jobs',
@@ -2037,7 +1998,7 @@ export const en: Translations = {
nav: {
newChat: { title: 'New session', detail: 'Start a fresh session' },
settings: { title: 'Settings', detail: 'Configure Hermes desktop' },
skills: { title: 'Capabilities', detail: 'Skills, tools, MCP servers, and plugins' },
capabilities: { title: 'Capabilities', detail: 'Skills, tools, MCP servers, and plugins' },
messaging: { title: 'Messaging', detail: 'Set up Telegram, Slack, Discord, and more' },
artifacts: { title: 'Artifacts', detail: 'Browse generated outputs' }
},
@@ -2757,7 +2718,7 @@ export const en: Translations = {
profileRail: 'Profile rail',
nav: {
'new-session': 'New session',
skills: 'Capabilities',
capabilities: 'Capabilities',
messaging: 'Messaging',
artifacts: 'Artifacts',
cron: 'Scheduled jobs'
+2 -41
View File
@@ -9,45 +9,6 @@ export const ja = defineLocale({
copyUrl: 'リンクをコピー',
close: '閉じる'
},
catalog: {
listView: 'リスト表示',
cardView: 'カード表示',
installTitle: (name: string) => `「${name}」をインストールしますか?`,
installDescription: 'このスキルは新しいセッションで利用できます。信頼できる提供元からのみインストールしてください。',
installTo: 'インストール先',
thisComputer: 'このコンピューター',
installing: 'インストール中…',
installComplete: (name: string) => `「${name}」をインストールしました`,
destinationChanged: 'インストール先が変更されました。このダイアログを閉じ、インストールリンクを開き直してください。',
browse: '閲覧',
installed: 'インストール済み',
searchSkills: 'スキルを検索',
searchPlugins: 'プラグインを検索',
allSources: 'すべての提供元',
allCategories: 'すべてのカテゴリ',
about: '概要',
author: '作者',
source: '提供元',
category: 'カテゴリ',
version: 'バージョン',
platforms: '対応プラットフォーム',
requires: '必要なもの',
tools: 'ツール',
hooks: 'フック',
repository: 'リポジトリ',
documentation: 'ドキュメント',
noResults: '一致する項目がありません',
tryAnother: '別の検索を試すか、フィルターをクリアしてください。',
clearFilters: 'フィルターをクリア',
loadFailed: 'カタログを読み込めませんでした',
retry: '再試行',
more: 'さらに表示',
pinned: 'レビュー済みコミット',
snapshotHint: 'Hermesカタログの情報です。閲覧時に提供元のリポジトリへ接続することはありません。',
installHint: 'インストール前にソースを確認してください。変更は新しいセッションに適用されます。',
results: (count: number) => `${count.toLocaleString('ja')}件の結果`,
back: '結果に戻る'
},
sessionImport: {
title: '別のアプリから続ける',
subtitle: '会話をHermesに取り込み、続きを始めましょう。',
@@ -1676,7 +1637,7 @@ export const ja = defineLocale({
nav: {
newChat: { title: '新しいセッション', detail: '新しいセッションを開始' },
settings: { title: '設定', detail: 'Hermes デスクトップを設定' },
skills: { title: 'スキルとツール', detail: 'スキル、ツールセット、プロバイダーを有効化' },
capabilities: { title: 'スキルとツール', detail: 'スキル、ツールセット、プロバイダーを有効化' },
messaging: { title: 'メッセージング', detail: 'Telegram、Slack、Discord などを設定' },
artifacts: { title: 'アーティファクト', detail: '生成された出力を閲覧' }
},
@@ -2256,7 +2217,7 @@ export const ja = defineLocale({
},
nav: {
'new-session': '新しいセッション',
skills: 'スキルとツール',
capabilities: 'スキルとツール',
messaging: 'メッセージング',
artifacts: 'アーティファクト',
cron: 'スケジュール済みジョブ'
+3 -43
View File
@@ -24,46 +24,6 @@ const RU_NOUN = (count: number | string, one: string, few: string, many: string)
}
export const ru = defineLocale({
catalog: {
listView: 'Список',
cardView: 'Карточки',
installTitle: (name: string) => `Установить «${name}»?`,
installDescription: 'Навык будет доступен в новых сессиях. Устанавливайте только из источников, которым доверяете.',
installTo: 'Установить в',
thisComputer: 'Этот компьютер',
installing: 'Установка…',
installComplete: (name: string) => `«${name}» установлен`,
destinationChanged: 'Место установки изменилось. Закройте диалог и снова откройте ссылку установки.',
browse: 'Обзор',
installed: 'Установленные',
searchSkills: 'Поиск навыков',
searchPlugins: 'Поиск плагинов',
allSources: 'Все источники',
allCategories: 'Все категории',
about: 'Описание',
author: 'Автор',
source: 'Источник',
category: 'Категория',
version: 'Версия',
platforms: 'Платформы',
requires: 'Требования',
tools: 'Инструменты',
hooks: 'Хуки',
repository: 'Репозиторий',
documentation: 'Документация',
noResults: 'Совпадений нет',
tryAnother: 'Попробуйте другой запрос или сбросьте фильтры.',
clearFilters: 'Сбросить фильтры',
loadFailed: 'Не удалось загрузить каталог',
retry: 'Попробовать снова',
more: 'Показать ещё',
pinned: 'Проверенный коммит',
snapshotHint: 'Из каталога Hermes. При просмотре нет обращений к исходным репозиториям.',
installHint: 'Проверьте исходный код перед установкой. Изменения применяются к новым сессиям.',
results: (count: number) =>
`${count.toLocaleString('ru')} ${RU_PLURAL(count, 'результат', 'результата', 'результатов')}`,
back: 'Назад к результатам'
},
sessionImport: {
title: 'Продолжить из другого приложения',
subtitle: 'Перенесите разговор в Hermes и продолжите с того места, где остановились.',
@@ -345,7 +305,7 @@ export const ru = defineLocale({
'nav.commandCenter': 'Открыть центр команд',
'nav.settings': 'Открыть настройки',
'nav.profiles': 'Открыть профили',
'nav.skills': 'Открыть навыки',
'nav.capabilities': 'Открыть навыки',
'nav.messaging': 'Открыть мессенджеры',
'nav.artifacts': 'Открыть артефакты',
'nav.cron': 'Открыть запланированные задачи',
@@ -1838,7 +1798,7 @@ export const ru = defineLocale({
nav: {
newChat: { title: 'Новый сеанс', detail: 'Начать новый сеанс' },
settings: { title: 'Настройки', detail: 'Настройка Hermes desktop' },
skills: { title: 'Возможности', detail: 'Навыки, инструменты и MCP-серверы' },
capabilities: { title: 'Возможности', detail: 'Навыки, инструменты и MCP-серверы' },
messaging: { title: 'Сообщения', detail: 'Настройка Telegram, Slack, Discord и других' },
artifacts: { title: 'Артефакты', detail: 'Просмотр сгенерированных результатов' }
},
@@ -2509,7 +2469,7 @@ export const ru = defineLocale({
},
nav: {
'new-session': 'Новый сеанс',
skills: 'Возможности',
capabilities: 'Возможности',
messaging: 'Сообщения',
artifacts: 'Артефакты',
cron: 'Запланированные задачи'
+1 -40
View File
@@ -65,45 +65,6 @@ export interface Translations {
copyUrl: string
close: string
};
catalog: {
listView: string
cardView: string
installTitle: (name: string) => string
installDescription: string
installTo: string
thisComputer: string
installing: string
installComplete: (name: string) => string
destinationChanged: string
browse: string
installed: string
searchSkills: string
searchPlugins: string
allSources: string
allCategories: string
about: string
author: string
source: string
category: string
version: string
platforms: string
requires: string
tools: string
hooks: string
repository: string
documentation: string
noResults: string
tryAnother: string
clearFilters: string
loadFailed: string
retry: string
more: string
pinned: string
snapshotHint: string
installHint: string
results: (count: number) => string
back: string
};
connectors: {
title: string
connect: string
@@ -1812,7 +1773,7 @@ export interface Translations {
archivedChats: string
sections: Record<'maintenance' | 'sessions' | 'system' | 'usage', string>
sectionDescriptions: Record<'maintenance' | 'sessions' | 'system' | 'usage', string>
nav: Record<'newChat' | 'settings' | 'skills' | 'messaging' | 'artifacts', { title: string; detail: string }>
nav: Record<'newChat' | 'settings' | 'capabilities' | 'messaging' | 'artifacts', { title: string; detail: string }>
sectionEntries: Record<'sessions' | 'system' | 'usage', { title: string; detail: string }>
providerNavigate: string
providerSessions: string
+2 -41
View File
@@ -9,45 +9,6 @@ export const zhHant = defineLocale({
copyUrl: '複製連結',
close: '關閉'
},
catalog: {
listView: '清單檢視',
cardView: '卡片檢視',
installTitle: (name: string) => `安裝「${name}」?`,
installDescription: '此技能將於新的工作階段中可用。請僅安裝可信來源的內容。',
installTo: '安裝至',
thisComputer: '這部電腦',
installing: '正在安裝…',
installComplete: (name: string) => `已安裝「${name}」`,
destinationChanged: '安裝目標已變更。請關閉此對話框並重新開啟安裝連結。',
browse: '瀏覽',
installed: '已安裝',
searchSkills: '搜尋技能',
searchPlugins: '搜尋外掛',
allSources: '所有來源',
allCategories: '所有分類',
about: '簡介',
author: '作者',
source: '來源',
category: '分類',
version: '版本',
platforms: '支援的平台',
requires: '相依項目',
tools: '工具',
hooks: '掛鉤',
repository: '程式碼儲存庫',
documentation: '文件',
noResults: '沒有符合的項目',
tryAnother: '請嘗試其他搜尋或清除篩選條件。',
clearFilters: '清除篩選條件',
loadFailed: '無法載入目錄',
retry: '再試一次',
more: '顯示更多',
pinned: '已審核的提交',
snapshotHint: '內容來自 Hermes 目錄。瀏覽時不會連線至來源程式碼儲存庫。',
installHint: '安裝前請檢查原始碼。變更將於新的工作階段生效。',
results: (count: number) => `${count.toLocaleString('zh-Hant')} 個結果`,
back: '返回結果'
},
sessionImport: {
title: '從其他應用程式繼續',
subtitle: '將對話匯入 Hermes,接著上次的進度繼續。',
@@ -1688,7 +1649,7 @@ export const zhHant = defineLocale({
nav: {
newChat: { title: '新工作階段', detail: '開始新的工作階段' },
settings: { title: '設定', detail: '設定 Hermes 桌面端' },
skills: { title: '技能與工具', detail: '啟用技能、工具集和提供方' },
capabilities: { title: '技能與工具', detail: '啟用技能、工具集和提供方' },
messaging: { title: '訊息平台', detail: '設定 Telegram、Slack、Discord 等' },
artifacts: { title: '成品', detail: '瀏覽產生的輸出' }
},
@@ -2243,7 +2204,7 @@ export const zhHant = defineLocale({
},
nav: {
'new-session': '新工作階段',
skills: '技能與工具',
capabilities: '技能與工具',
messaging: '訊息平台',
artifacts: '成品',
cron: '排程工作'
+3 -42
View File
@@ -9,45 +9,6 @@ export const zh = defineLocale({
copyUrl: '复制链接',
close: '关闭'
},
catalog: {
listView: '列表视图',
cardView: '卡片视图',
installTitle: (name: string) => `安装“${name}”?`,
installDescription: '此技能将在新会话中可用。请仅安装可信来源的内容。',
installTo: '安装到',
thisComputer: '此电脑',
installing: '正在安装…',
installComplete: (name: string) => `已安装“${name}”`,
destinationChanged: '安装目标已更改。请关闭此对话框并重新打开安装链接。',
browse: '浏览',
installed: '已安装',
searchSkills: '搜索技能',
searchPlugins: '搜索插件',
allSources: '所有来源',
allCategories: '所有分类',
about: '简介',
author: '作者',
source: '来源',
category: '分类',
version: '版本',
platforms: '支持的平台',
requires: '依赖项',
tools: '工具',
hooks: '钩子',
repository: '代码仓库',
documentation: '文档',
noResults: '没有匹配项',
tryAnother: '请尝试其他搜索或清除筛选条件。',
clearFilters: '清除筛选条件',
loadFailed: '无法加载目录',
retry: '重试',
more: '显示更多',
pinned: '已审核的提交',
snapshotHint: '内容来自 Hermes 目录。浏览时不会连接来源代码仓库。',
installHint: '安装前请检查源代码。更改将在新会话中生效。',
results: (count: number) => `${count.toLocaleString('zh')} 个结果`,
back: '返回结果'
},
connectors: {
title: '连接你的应用',
connect: '连接',
@@ -393,7 +354,7 @@ export const zh = defineLocale({
'nav.commandCenter': '打开命令中心',
'nav.settings': '打开设置',
'nav.profiles': '打开配置',
'nav.skills': '打开技能',
'nav.capabilities': '打开技能',
'nav.messaging': '打开消息',
'nav.artifacts': '打开制品',
'nav.cron': '打开定时任务',
@@ -2190,7 +2151,7 @@ export const zh = defineLocale({
nav: {
newChat: { title: '新建会话', detail: '开始一个新会话' },
settings: { title: '设置', detail: '配置 Hermes 桌面端' },
skills: { title: '技能与工具', detail: '启用技能、工具集与提供方' },
capabilities: { title: '技能与工具', detail: '启用技能、工具集与提供方' },
messaging: { title: '消息平台', detail: '配置 Telegram、Slack、Discord 等' },
artifacts: { title: '产物', detail: '浏览生成的输出' }
},
@@ -2895,7 +2856,7 @@ export const zh = defineLocale({
},
nav: {
'new-session': '新建会话',
skills: '技能与工具',
capabilities: '技能与工具',
messaging: '消息平台',
artifacts: '产物',
cron: '定时任务'
@@ -1,37 +0,0 @@
import { pluginCatalogInstallUrl, skillCatalogInstallIdentifier, skillCatalogInstallUrl } from '@hermes/shared'
import { describe, expect, it } from 'vitest'
import { resolveDeepLinkAction } from './deeplink-routes'
function route(href: string) {
const url = new URL(href)
return resolveDeepLinkAction({ kind: url.hostname, name: url.pathname.slice(1), params: Object.fromEntries(url.searchParams) })
}
describe('public catalog install links', () => {
it.each([
[{ name: 'Apple Design', source: 'ClawHub', identifier: 'apple-design' }, 'clawhub/apple-design'],
[{ name: 'Apple Design', source: 'ClawHub', identifier: 'clawhub/apple-design' }, 'clawhub/apple-design'],
[{ name: 'pdf', source: 'Anthropic', identifier: 'anthropics/skills/skills/pdf' }, 'anthropics/skills/skills/pdf'],
[{ name: 'pdf', source: 'optional' }, 'official/pdf'],
[{ name: 'pdf', source: 'built-in', installIdentifier: 'NousResearch/hermes-agent/skills/productivity/pdf' }, 'NousResearch/hermes-agent/skills/productivity/pdf'],
[{ name: 'A name', source: 'future-source', identifier: 'provider/path?mode=one&two#readme' }, 'provider/path?mode=one&two#readme']
])('preserves the source-specific target for %o', (entry, identifier) => {
expect(skillCatalogInstallIdentifier(entry)).toBe(identifier)
expect(route(skillCatalogInstallUrl(entry)!)).toEqual({ type: 'skill-install', identifier })
})
it('omits bundled install links when an older snapshot has no exact target', () => {
expect(skillCatalogInstallUrl({ name: 'pdf', source: 'built-in' })).toBeNull()
})
it('forwards catalog identity, display pin and encoded subdirectory to the plugin confirmation', () => {
const plugin = { name: 'weather', repo: 'https://github.com/owner/plugins', subdir: 'packages/weather', sha: 'a'.repeat(40) }
expect(route(pluginCatalogInstallUrl(plugin))).toEqual({
type: 'plugin-install', repo: `${plugin.repo}#${plugin.subdir}`, catalogName: plugin.name,
sha: plugin.sha, enable: true, force: false, legacyHint: null
})
})
})
@@ -19,30 +19,6 @@ describe('resolveDeepLinkAction', () => {
})
})
it('preserves catalog provenance without treating the display pin as an install target', () => {
const params = {
repo: 'https://github.com/owner/repo#plugins/example',
catalog_name: 'example',
sha: '0123456789abcdef0123456789abcdef01234567',
enable: '1'
}
const url = new URL(`hermes://plugin/install?${new URLSearchParams(params)}`)
expect(resolveDeepLinkAction({
kind: url.hostname,
name: url.pathname.slice(1),
params: Object.fromEntries(url.searchParams)
})).toEqual({
type: 'plugin-install',
repo: params.repo,
catalogName: params.catalog_name,
sha: params.sha,
enable: true,
force: false,
legacyHint: null
})
})
it('routes legacy plugin-agent alias', () => {
expect(
resolveDeepLinkAction({
@@ -53,30 +29,6 @@ describe('resolveDeepLinkAction', () => {
).toMatchObject({ type: 'plugin-install', legacyHint: 'agent' })
})
it('only routes skill installs with an explicit, unchanged identifier', () => {
const identifier = 'skills-sh/owner/repo/a skill?mode=one&two#readme'
const url = new URL(`hermes://skill/install?${new URLSearchParams({ identifier })}`)
expect(resolveDeepLinkAction({
kind: url.hostname,
name: url.pathname.slice(1),
params: Object.fromEntries(url.searchParams)
})).toEqual({ type: 'skill-install', identifier })
const invalidParams: Record<string, string>[] = [
{},
{ identifier: '' },
{ identifier: ' ' },
{ identifier: ' official/skill ' }
]
for (const params of invalidParams) {
expect(resolveDeepLinkAction({ kind: 'skill', name: 'install', params })).toEqual({ type: 'ignore' })
}
expect(resolveDeepLinkAction({ kind: 'skill', name: 'remove', params: { identifier } })).toEqual({ type: 'ignore' })
expect(resolveDeepLinkAction({ kind: 'plugin', name: 'install', params: {} })).toEqual({ type: 'ignore' })
})
it('routes blueprint composer inserts', () => {
expect(
resolveDeepLinkAction({
+3 -24
View File
@@ -7,16 +7,7 @@ export interface DeepLinkPayload {
}
export type DeepLinkAction =
| {
type: 'plugin-install'
repo: string
enable: boolean
force: boolean
legacyHint: PluginInstallLegacyHint
catalogName?: string
sha?: string
}
| { type: 'skill-install'; identifier: string }
| { type: 'plugin-install'; repo: string; enable: boolean; force: boolean; legacyHint: PluginInstallLegacyHint }
| { type: 'composer-blueprint'; name: string; params: Record<string, string> }
| { type: 'ignore' }
@@ -39,17 +30,7 @@ export function resolveDeepLinkAction(payload: DeepLinkPayload | null | undefine
return { type: 'composer-blueprint', name: payload.name, params: payload.params || {} }
}
if (payload.kind === 'skill') {
const identifier = payload.params?.identifier
return payload.name === 'install' && identifier && identifier === identifier.trim()
? { type: 'skill-install', identifier }
: { type: 'ignore' }
}
const repo = (
payload.params?.repo || payload.params?.identifier || (payload.kind !== 'plugin' ? payload.name : '') || ''
).trim()
const repo = (payload.params?.repo || payload.params?.identifier || payload.name || '').trim()
if (payload.kind === 'plugin' && payload.name === 'install' && repo) {
return {
@@ -57,9 +38,7 @@ export function resolveDeepLinkAction(payload: DeepLinkPayload | null | undefine
repo,
enable: truthyParam(payload.params?.enable, true),
force: truthyParam(payload.params?.force, false),
legacyHint: null,
catalogName: payload.params?.catalog_name || undefined,
sha: payload.params?.sha || undefined
legacyHint: null
}
}
@@ -34,6 +34,9 @@
* (mirroring gateway-liveness usage in use-gateway-boot).
*/
/** Ping budget for a liveness probe (primary and secondary sockets share it). */
export const LIVENESS_PROBE_TIMEOUT_MS = 5_000
/** Consecutive unanswered probes tolerated while work is in flight. */
export const LIVENESS_PROBE_FAILURE_STREAK = 2
@@ -59,6 +59,5 @@ describe('pathFromHermesDeepLink', () => {
it('ignores reserved kinds', () => {
expect(pathFromHermesDeepLink('blueprint', 'morning-brief')).toBeNull()
expect(pathFromHermesDeepLink('plugin', 'install')).toBeNull()
expect(pathFromHermesDeepLink('skill', 'install')).toBeNull()
})
})
+1 -2
View File
@@ -26,8 +26,7 @@ const RESERVED_DEEP_LINK_KINDS = new Set([
'plugin',
'plugin-agent',
'plugin-desktop',
'settings',
'skill'
'settings'
])
function appendSearch(path: string, params: URLSearchParams | Record<string, string> | undefined): string {
+1 -1
View File
@@ -109,7 +109,7 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
{ id: 'nav.commandCenter', category: 'navigation', defaults: ['mod+.'] },
{ id: 'nav.settings', category: 'navigation', defaults: ['mod+,'] },
{ id: 'nav.profiles', category: 'navigation', defaults: [] },
{ id: 'nav.skills', category: 'navigation', defaults: [] },
{ id: 'nav.capabilities', category: 'navigation', defaults: [] },
{ id: 'nav.messaging', category: 'navigation', defaults: [] },
{ id: 'nav.artifacts', category: 'navigation', defaults: [] },
{ id: 'nav.cron', category: 'navigation', defaults: [] },
+1 -1
View File
@@ -150,7 +150,7 @@ describe('actionAllowedInInput', () => {
expect(actionAllowedInInput('session.prev', 'ctrl+shift+tab')).toBe(true)
expect(actionAllowedInInput('nav.commandPalette', 'mod+k')).toBe(true)
expect(actionAllowedInInput('view.findInPage', 'mod+f')).toBe(true)
expect(actionAllowedInInput('nav.skills', 'mod+k')).toBe(true)
expect(actionAllowedInInput('nav.capabilities', 'mod+k')).toBe(true)
expect(actionAllowedInInput('view.showTerminal', 'ctrl+`')).toBe(true)
expect(actionAllowedInInput('profile.next', 'mod+shift+]')).toBe(true)

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