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:
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
//
|
||||
|
||||
+106
-222
@@ -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())
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
+7
-7
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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']
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />)
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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: 'المهام المجدولة'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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: 'スケジュール済みジョブ'
|
||||
|
||||
@@ -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: 'Запланированные задачи'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '排程工作'
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: [] },
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user