mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-04 17:17:56 +08:00
Compare commits
5 Commits
claude-cod
...
remove-bre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a346903d2 | ||
|
|
05c01af68c | ||
|
|
eb40402420 | ||
|
|
8fa9e6c013 | ||
|
|
de45b9529d |
2
.envrc
2
.envrc
@@ -1,4 +1,4 @@
|
||||
watch_file pyproject.toml uv.lock
|
||||
watch_file pyproject.toml uv.lock hermes
|
||||
watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
|
||||
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix nix/hermes-agent.nix nix/desktop.nix
|
||||
|
||||
|
||||
15
.github/workflows/docker.yml
vendored
15
.github/workflows/docker.yml
vendored
@@ -178,9 +178,6 @@ jobs:
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=()
|
||||
@@ -188,8 +185,9 @@ jobs:
|
||||
args+=("${IMAGE_NAME}@sha256:${digest_file}")
|
||||
done
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE_NAME}:${RELEASE_TAG}" \
|
||||
-t "${IMAGE_NAME}:${TAG}" \
|
||||
"${args[@]}"
|
||||
else
|
||||
docker buildx imagetools create \
|
||||
@@ -197,14 +195,15 @@ jobs:
|
||||
-t "${IMAGE_NAME}:latest" \
|
||||
"${args[@]}"
|
||||
fi
|
||||
|
||||
- name: Inspect image
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}"
|
||||
docker buildx imagetools inspect "${IMAGE_NAME}:${{ github.event.release.tag_name }}"
|
||||
else
|
||||
docker buildx imagetools inspect "${IMAGE_NAME}:main"
|
||||
fi
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
|
||||
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
@@ -98,8 +98,6 @@ jobs:
|
||||
echo "base ty: $(wc -c < .lint-reports/base/ty.json) bytes"
|
||||
|
||||
- name: Generate diff summary
|
||||
env:
|
||||
HEAD_REF: ${{ inputs.event_name == 'pull_request' && github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
python scripts/lint_diff.py \
|
||||
--base-ruff .lint-reports/base/ruff.json \
|
||||
@@ -107,7 +105,7 @@ jobs:
|
||||
--base-ty .lint-reports/base/ty.json \
|
||||
--head-ty .lint-reports/head/ty.json \
|
||||
--base-ref "${{ steps.base.outputs.ref }}" \
|
||||
--head-ref "$HEAD_REF" \
|
||||
--head-ref "${{ inputs.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \
|
||||
--output .lint-reports/summary.md
|
||||
cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
|
||||
164
.github/workflows/upload_to_pypi.yml
vendored
164
.github/workflows/upload_to_pypi.yml
vendored
@@ -1,164 +0,0 @@
|
||||
name: Publish to PyPI
|
||||
|
||||
# Triggered by CalVer tag pushes from scripts/release.py (e.g. v2026.5.15)
|
||||
# Can also be triggered manually from the Actions tab as an escape hatch.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v20*" # CalVer tags: v2026.5.15, v2026.5.15.2, etc.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm_tag:
|
||||
description: "Tag to publish (e.g. v2026.5.15). Must already exist."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
# Restrict default token to read-only; each job escalates as needed.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Prevent overlapping publishes (e.g. two same-day tags pushed quickly).
|
||||
concurrency:
|
||||
group: pypi-publish
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build distribution 📦
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
# On workflow_dispatch, check out the confirmed tag.
|
||||
ref: ${{ inputs.confirm_tag || github.ref }}
|
||||
fetch-tags: true
|
||||
|
||||
- name: Validate tag exists
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
if ! git tag -l "${{ inputs.confirm_tag }}" | grep -q .; then
|
||||
echo "::error::Tag '${{ inputs.confirm_tag }}' does not exist in the repo"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Build web dashboard
|
||||
run: cd web && npm ci && npm run build
|
||||
|
||||
- name: Build TUI bundle
|
||||
run: cd ui-tui && npm ci && npm run build
|
||||
|
||||
- name: Bundle TUI into hermes_cli
|
||||
run: |
|
||||
mkdir -p hermes_cli/tui_dist
|
||||
cp ui-tui/dist/entry.js hermes_cli/tui_dist/entry.js
|
||||
|
||||
- name: Verify frontend assets exist
|
||||
run: |
|
||||
test -f hermes_cli/web_dist/index.html || { echo "ERROR: web_dist not built"; exit 1; }
|
||||
test -f hermes_cli/tui_dist/entry.js || { echo "ERROR: tui_dist not built"; exit 1; }
|
||||
|
||||
- name: Bundle install scripts into wheel
|
||||
run: |
|
||||
mkdir -p hermes_cli/scripts
|
||||
cp scripts/install.sh hermes_cli/scripts/install.sh
|
||||
cp scripts/install.ps1 hermes_cli/scripts/install.ps1
|
||||
|
||||
- name: Build wheel and sdist
|
||||
run: uv build --sdist --wheel
|
||||
|
||||
- name: Upload distribution artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
||||
publish:
|
||||
name: Publish to PyPI
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/p/hermes-agent
|
||||
permissions:
|
||||
id-token: write # OIDC trusted publishing
|
||||
|
||||
steps:
|
||||
- name: Download distribution artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
sign:
|
||||
name: Sign and attach to GitHub Release
|
||||
# Only runs on tag pushes — release.py creates the GitHub Release,
|
||||
# and workflow_dispatch won't have a matching release to attach to.
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # attach assets to the existing release
|
||||
id-token: write # sigstore signing
|
||||
|
||||
steps:
|
||||
- name: Download distribution artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
||||
- name: Wait for GitHub Release to exist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
# release.py creates the GitHub Release after pushing the tag,
|
||||
# but this workflow starts from the tag push — wait for it.
|
||||
run: |
|
||||
for i in $(seq 1 30); do
|
||||
if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "Release $GITHUB_REF_NAME found"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for release... ($i/30)"
|
||||
sleep 10
|
||||
done
|
||||
echo "::warning::Release $GITHUB_REF_NAME not found after 5 minutes — skipping signature upload"
|
||||
echo "skip_sign=true" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Sign with Sigstore
|
||||
if: env.skip_sign != 'true'
|
||||
uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
|
||||
with:
|
||||
inputs: >-
|
||||
./dist/*.tar.gz
|
||||
./dist/*.whl
|
||||
|
||||
- name: Attach signed artifacts to GitHub Release
|
||||
if: env.skip_sign != 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
# release.py already created the GitHub Release — just upload
|
||||
# the Sigstore signatures alongside the existing assets.
|
||||
run: >-
|
||||
gh release upload
|
||||
"$GITHUB_REF_NAME" dist/*.sigstore.json
|
||||
--repo "$GITHUB_REPOSITORY"
|
||||
--clobber
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"id": "hermes-agent",
|
||||
"name": "Hermes Agent",
|
||||
"version": "0.18.0",
|
||||
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
|
||||
"repository": "https://github.com/NousResearch/hermes-agent",
|
||||
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
|
||||
"authors": ["Nous Research"],
|
||||
"license": "MIT",
|
||||
"distribution": {
|
||||
"uvx": {
|
||||
"package": "hermes-agent[acp]==0.18.0",
|
||||
"args": ["hermes-acp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none">
|
||||
<path d="M8 1.5v13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M8 3.25c-2.35-1.4-4.7-.95-6.25.35 1.85-.2 3.8.2 5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 3.25c2.35-1.4 4.7-.95 6.25.35-1.85-.2-3.8.2-5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 13.25c-2.3-1-3.05-2.65-1.35-4.15-2 .8-2.35 2.95-.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 13.25c2.3-1 3.05-2.65 1.35-4.15 2 .8 2.35 2.95.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="8" cy="1.8" r="1.1" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 882 B |
@@ -3603,8 +3603,6 @@ def run_conversation(
|
||||
if agent._has_pending_fallback():
|
||||
if classified.reason == FailoverReason.content_policy_blocked:
|
||||
agent._buffer_status("⚠️ Provider safety filter blocked this request — trying fallback...")
|
||||
elif classified.reason == FailoverReason.ssl_cert_verification:
|
||||
agent._buffer_status("⚠️ TLS certificate verification failed — trying fallback...")
|
||||
else:
|
||||
agent._buffer_status(f"⚠️ Non-retryable error (HTTP {status_code}) — trying fallback...")
|
||||
if agent._try_activate_fallback():
|
||||
@@ -3633,11 +3631,6 @@ def run_conversation(
|
||||
f"❌ Provider safety filter blocked this request: "
|
||||
f"{_nonretryable_summary}"
|
||||
)
|
||||
elif classified.reason == FailoverReason.ssl_cert_verification:
|
||||
agent._emit_status(
|
||||
f"❌ TLS certificate verification failed: "
|
||||
f"{_nonretryable_summary}"
|
||||
)
|
||||
else:
|
||||
agent._emit_status(
|
||||
f"❌ Non-retryable error (HTTP {status_code}): "
|
||||
@@ -3711,43 +3704,6 @@ def run_conversation(
|
||||
f"{agent.log_prefix} hermes fallback add (interactive picker — same as `hermes model`)",
|
||||
force=True,
|
||||
)
|
||||
# TLS certificate failures are environment problems, not
|
||||
# provider/prompt problems — tell the user exactly which
|
||||
# knobs fix each common cause. Inspired by Claude Code
|
||||
# v2.1.199's immediate SSL fix hints.
|
||||
if classified.reason == FailoverReason.ssl_cert_verification:
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} 💡 The TLS certificate chain could not be verified. This fails the same",
|
||||
force=True,
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} way on every retry — fix the environment, then try again:",
|
||||
force=True,
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} • Corporate TLS-inspecting proxy? Point Python at its CA bundle:",
|
||||
force=True,
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} export SSL_CERT_FILE=/path/to/corp-ca.pem (also REQUESTS_CA_BUNDLE)",
|
||||
force=True,
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} • Missing/stale system CA store? Install/refresh it:",
|
||||
force=True,
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} pip install --upgrade certifi (macOS: run 'Install Certificates.command')",
|
||||
force=True,
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} • Self-signed local endpoint (llama.cpp, LM Studio, vLLM)? Use http://",
|
||||
force=True,
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix} for localhost, or add the server's cert to your trust store.",
|
||||
force=True,
|
||||
)
|
||||
logger.error(f"{agent.log_prefix}Non-retryable client error: {api_error}")
|
||||
# Skip session persistence when the error is likely
|
||||
# context-overflow related (status 400 + large session).
|
||||
|
||||
@@ -41,11 +41,6 @@ class FailoverReason(enum.Enum):
|
||||
|
||||
# Transport
|
||||
timeout = "timeout" # Connection/read timeout — rebuild client + retry
|
||||
# TLS certificate verification failure — deterministic for the host
|
||||
# (TLS-inspecting proxy, missing/expired CA bundle, self-signed cert).
|
||||
# Retrying reproduces the identical handshake failure, so fail fast
|
||||
# with actionable guidance instead of burning retries.
|
||||
ssl_cert_verification = "ssl_cert_verification"
|
||||
|
||||
# Context / payload
|
||||
context_overflow = "context_overflow" # Context too large — compress, not failover
|
||||
@@ -443,29 +438,6 @@ _SERVER_DISCONNECT_PATTERNS = [
|
||||
"incomplete chunked read",
|
||||
]
|
||||
|
||||
# SSL certificate verification failures — deterministic, NOT transient.
|
||||
#
|
||||
# A failed certificate chain (TLS-inspecting corporate proxy, missing
|
||||
# custom CA in the trust store, expired certificate, self-signed cert)
|
||||
# fails identically on every retry. Burning the retry budget before
|
||||
# surfacing the error hides the actionable fix from the user for minutes.
|
||||
# Inspired by Claude Code v2.1.199 (July 2026), which made SSL certificate
|
||||
# errors fail immediately with a fix hint instead of retrying.
|
||||
#
|
||||
# Must be checked BEFORE _SSL_TRANSIENT_PATTERNS — "certificate verify
|
||||
# failed" messages usually also contain "[SSL:" which would otherwise
|
||||
# match the transient list and retry forever.
|
||||
_SSL_CERT_VERIFY_PATTERNS = [
|
||||
"certificate verify failed", # Python ssl module canonical text
|
||||
"certificate_verify_failed", # OpenSSL error token
|
||||
"unable to get local issuer certificate",
|
||||
"self-signed certificate",
|
||||
"self signed certificate",
|
||||
"certificate has expired",
|
||||
"hostname mismatch, certificate is not valid",
|
||||
"unable to verify the first certificate", # Node/undici phrasing (MCP bridges)
|
||||
]
|
||||
|
||||
# SSL/TLS transient failure patterns — intentionally distinct from
|
||||
# _SERVER_DISCONNECT_PATTERNS above.
|
||||
#
|
||||
@@ -763,22 +735,7 @@ def classify_api_error(
|
||||
if classified is not None:
|
||||
return classified
|
||||
|
||||
# ── 5. SSL certificate verification failures → fail fast ────────
|
||||
# A broken certificate chain (TLS-inspecting proxy, missing custom CA,
|
||||
# expired/self-signed cert) is deterministic for the host — every retry
|
||||
# reproduces the identical handshake failure. Fail immediately with
|
||||
# actionable guidance instead of burning the retry budget first.
|
||||
# Checked BEFORE the transient-SSL patterns: cert-verify messages also
|
||||
# contain "[ssl:" which would otherwise match the transient list.
|
||||
# Inspired by Claude Code v2.1.199 (July 2026).
|
||||
if any(p in error_msg for p in _SSL_CERT_VERIFY_PATTERNS):
|
||||
return _result(
|
||||
FailoverReason.ssl_cert_verification,
|
||||
retryable=False,
|
||||
should_fallback=False,
|
||||
)
|
||||
|
||||
# ── 5b. SSL/TLS transient errors → retry as timeout (not compression) ──
|
||||
# ── 5. SSL/TLS transient errors → retry as timeout (not compression) ──
|
||||
# SSL alerts mid-stream are transport hiccups, not server-side context
|
||||
# overflow signals. Classify before the disconnect check so a large
|
||||
# session doesn't incorrectly trigger context compression when the real
|
||||
|
||||
@@ -47,5 +47,5 @@ function sourceDeclaresServe(dashboardPySource) {
|
||||
module.exports = {
|
||||
serveBackendArgs,
|
||||
dashboardFallbackArgs,
|
||||
sourceDeclaresServe
|
||||
sourceDeclaresServe,
|
||||
}
|
||||
|
||||
@@ -3,14 +3,32 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { serveBackendArgs, dashboardFallbackArgs, sourceDeclaresServe } = require('./backend-command.cjs')
|
||||
const {
|
||||
serveBackendArgs,
|
||||
dashboardFallbackArgs,
|
||||
sourceDeclaresServe,
|
||||
} = require('./backend-command.cjs')
|
||||
|
||||
test('serveBackendArgs builds a headless serve invocation', () => {
|
||||
assert.deepEqual(serveBackendArgs(), ['serve', '--host', '127.0.0.1', '--port', '0'])
|
||||
assert.deepEqual(serveBackendArgs(), [
|
||||
'serve',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'0',
|
||||
])
|
||||
})
|
||||
|
||||
test('serveBackendArgs pins a profile when provided', () => {
|
||||
assert.deepEqual(serveBackendArgs('worker'), ['--profile', 'worker', 'serve', '--host', '127.0.0.1', '--port', '0'])
|
||||
assert.deepEqual(serveBackendArgs('worker'), [
|
||||
'--profile',
|
||||
'worker',
|
||||
'serve',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'0',
|
||||
])
|
||||
})
|
||||
|
||||
test('dashboardFallbackArgs rewrites serve -> dashboard --no-open, keeping the -m prefix', () => {
|
||||
@@ -23,7 +41,7 @@ test('dashboardFallbackArgs rewrites serve -> dashboard --no-open, keeping the -
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'0'
|
||||
'0',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -39,7 +57,7 @@ test('dashboardFallbackArgs preserves a --profile flag ahead of serve', () => {
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'0'
|
||||
'0',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -63,27 +63,13 @@ test('createLinkTitleWindow still returns the window if muting throws', () => {
|
||||
test('guardLinkTitleSession cancels downloads triggered by the title-fetch window', () => {
|
||||
let cancelled = false
|
||||
const handlers = {}
|
||||
guardLinkTitleSession({
|
||||
on: (e, h) => {
|
||||
handlers[e] = h
|
||||
}
|
||||
})
|
||||
handlers['will-download'](null, {
|
||||
cancel: () => {
|
||||
cancelled = true
|
||||
}
|
||||
})
|
||||
guardLinkTitleSession({ on: (e, h) => { handlers[e] = h } })
|
||||
handlers['will-download'](null, { cancel: () => { cancelled = true } })
|
||||
assert.ok(cancelled)
|
||||
})
|
||||
|
||||
test('guardLinkTitleSession is a no-op when session.on throws', () => {
|
||||
assert.doesNotThrow(() =>
|
||||
guardLinkTitleSession({
|
||||
on() {
|
||||
throw new Error()
|
||||
}
|
||||
})
|
||||
)
|
||||
assert.doesNotThrow(() => guardLinkTitleSession({ on() { throw new Error() } }))
|
||||
})
|
||||
|
||||
test('readLinkTitleWindowTitle returns empty for missing or destroyed windows', () => {
|
||||
|
||||
@@ -1375,7 +1375,10 @@ function backendSupportsServe(backend) {
|
||||
let supported = null
|
||||
if (backend.root) {
|
||||
try {
|
||||
const src = fs.readFileSync(path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'), 'utf8')
|
||||
const src = fs.readFileSync(
|
||||
path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'),
|
||||
'utf8'
|
||||
)
|
||||
supported = sourceDeclaresServe(src)
|
||||
} catch {
|
||||
supported = null // source unreadable — fall through to the probe
|
||||
@@ -2332,7 +2335,9 @@ async function handOffWindowsBootstrapRecovery(reason) {
|
||||
// --repair (full venv recreate) and drove reinstall loops. The venv interpreter
|
||||
// and the bootstrap-complete marker are present earlier and are better signals.
|
||||
const haveRealInstall =
|
||||
fileExists(venvPython) || fileExists(venvHermes) || fileExists(path.join(updateRoot, '.hermes-bootstrap-complete'))
|
||||
fileExists(venvPython) ||
|
||||
fileExists(venvHermes) ||
|
||||
fileExists(path.join(updateRoot, '.hermes-bootstrap-complete'))
|
||||
const updaterArgs = haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
|
||||
|
||||
await releaseBackendLockForUpdate(updateRoot)
|
||||
|
||||
@@ -32,7 +32,11 @@ test('prepareProfileDeleteRequest returns the torn-down profile name', () => {
|
||||
)
|
||||
|
||||
// The early-exit guard must return null (not void/undefined).
|
||||
assert.match(fnBody, /return null/, 'early-exit guard should return null, not undefined')
|
||||
assert.match(
|
||||
fnBody,
|
||||
/return null/,
|
||||
'early-exit guard should return null, not undefined'
|
||||
)
|
||||
})
|
||||
|
||||
test('hermes:api handler routes profile-delete requests to the primary backend', () => {
|
||||
|
||||
@@ -12,11 +12,11 @@ const OVERLAY_FALLBACK_WIDTH = 144
|
||||
* macOS uses traffic lights positioned via trafficLightPosition, not a WCO
|
||||
* overlay, so it reserves nothing here. Every other desktop platform now paints
|
||||
* the Electron overlay (Windows, WSLg, and plain Linux KDE/GNOME), so they all
|
||||
* reserve the fallback width — the split is simply mac vs. not.
|
||||
* reserve the fallback width.
|
||||
*
|
||||
* @param {{ isMac?: boolean }} opts
|
||||
* @param {{ isWindows?: boolean, isWsl?: boolean, isMac?: boolean }} opts
|
||||
*/
|
||||
function nativeOverlayWidth({ isMac = false } = {}) {
|
||||
function nativeOverlayWidth({ isWindows = false, isWsl = false, isMac = false } = {}) {
|
||||
if (isMac) return 0
|
||||
return OVERLAY_FALLBACK_WIDTH
|
||||
}
|
||||
|
||||
@@ -43,13 +43,21 @@ test('findOnPath tries PATHEXT extensions before the bare (empty) name on Window
|
||||
test('Windows bootstrap recovery chooses --update when any real-install signal is present', () => {
|
||||
const source = readMain()
|
||||
assert.match(source, /const haveRealInstall =/, 'recovery must compute haveRealInstall')
|
||||
assert.match(source, /fileExists\(venvPython\)/, 'recovery must accept the venv interpreter as a real-install signal')
|
||||
assert.match(
|
||||
source,
|
||||
/fileExists\(venvPython\)/,
|
||||
'recovery must accept the venv interpreter as a real-install signal'
|
||||
)
|
||||
assert.match(
|
||||
source,
|
||||
/\.hermes-bootstrap-complete/,
|
||||
'recovery must accept the bootstrap-complete marker as a real-install signal'
|
||||
)
|
||||
assert.match(source, /updaterArgs = haveRealInstall \? \['--update'/, 'updaterArgs must gate on haveRealInstall')
|
||||
assert.match(
|
||||
source,
|
||||
/updaterArgs = haveRealInstall \? \['--update'/,
|
||||
'updaterArgs must gate on haveRealInstall'
|
||||
)
|
||||
// The old too-narrow check (only venv\Scripts\hermes.exe) must not return.
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Codicon } from '@/components/ui/codicon'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { compactNumber } from '@/lib/format'
|
||||
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -115,11 +114,14 @@ const fmtDuration = (seconds: number | undefined, a: Translations['agents']) =>
|
||||
return a.durationMinutes(m, s)
|
||||
}
|
||||
|
||||
const fmtTokens = (value: number | undefined, a: Translations['agents']) =>
|
||||
value ? a.tokens(compactNumber(value)) : ''
|
||||
const fmtTokens = (value: number | undefined, a: Translations['agents']) => {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return value >= 1000 ? a.tokensK((value / 1000).toFixed(1)) : a.tokens(value)
|
||||
}
|
||||
|
||||
// Distinct contract from coarseElapsed: rounds to the second (this ticks live),
|
||||
// and hours are unbounded ("25h", never "1d"). Kept local on purpose.
|
||||
const fmtAge = (updatedAt: number, nowMs: number, a: Translations['agents']) => {
|
||||
const s = Math.max(0, Math.round((nowMs - updatedAt) / 1000))
|
||||
|
||||
@@ -133,7 +135,11 @@ const fmtAge = (updatedAt: number, nowMs: number, a: Translations['agents']) =>
|
||||
|
||||
const m = Math.floor(s / 60)
|
||||
|
||||
return m < 60 ? a.ageMinutes(m) : a.ageHours(Math.floor(m / 60))
|
||||
if (m < 60) {
|
||||
return a.ageMinutes(m)
|
||||
}
|
||||
|
||||
return a.ageHours(Math.floor(m / 60))
|
||||
}
|
||||
|
||||
const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
Pagination,
|
||||
@@ -16,19 +17,18 @@ import {
|
||||
PaginationPrevious
|
||||
} from '@/components/ui/pagination'
|
||||
import { RowButton } from '@/components/ui/row-button'
|
||||
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getSessionMessages, listAllProfileSessions } from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
|
||||
import { FileImage, FileText, FolderOpen, Link2, Loader2, RefreshCw } from '@/lib/icons'
|
||||
import { downloadGatewayMediaFile, isRemoteGateway } from '@/lib/media'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { fmtDayTime } from '@/lib/time'
|
||||
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { PAGE_INSET_NEG_X, PAGE_INSET_X } from '../layout-constants'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import { sessionRoute } from '../routes'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
@@ -41,8 +41,15 @@ import {
|
||||
collectArtifactsForSession
|
||||
} from './artifact-utils'
|
||||
|
||||
const ARTIFACT_TIME_FMT = new Intl.DateTimeFormat(undefined, {
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
month: 'short'
|
||||
})
|
||||
|
||||
function formatArtifactTime(timestamp: number): string {
|
||||
return fmtDayTime.format(new Date(timestamp))
|
||||
return ARTIFACT_TIME_FMT.format(new Date(timestamp))
|
||||
}
|
||||
|
||||
function pageRangeLabel(total: number, page: number, pageSize: number, a: Translations['artifacts']): string {
|
||||
@@ -108,6 +115,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
const navigate = useNavigate()
|
||||
const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const [kindFilter, setKindFilter] = useRouteEnumParam('tab', ARTIFACT_FILTERS, 'all')
|
||||
|
||||
@@ -115,8 +123,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
const [imagePage, setImagePage] = useState(1)
|
||||
const [filePage, setFilePage] = useState(1)
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const refreshArtifacts = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
@@ -159,7 +165,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
return []
|
||||
}
|
||||
|
||||
const q = normalize(query)
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
return artifacts.filter(artifact => {
|
||||
if (kindFilter !== 'all' && artifact.kind !== kindFilter) {
|
||||
@@ -203,24 +209,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
[currentFilePage, visibleFileArtifacts]
|
||||
)
|
||||
|
||||
// Rotating placeholder nudges from real data — search matches file paths and
|
||||
// session titles, not just labels; show it.
|
||||
const searchHints = useMemo(() => {
|
||||
if (!artifacts?.length) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const extensions = [
|
||||
...new Set(artifacts.map(artifact => /\.(\w{2,4})$/.exec(artifact.value)?.[1]?.toLowerCase()).filter(Boolean))
|
||||
].slice(0, 3) as string[]
|
||||
|
||||
const titles = [...new Set(artifacts.map(artifact => artifact.sessionTitle).filter(Boolean))].slice(0, 2)
|
||||
|
||||
const hints = [...extensions.map(ext => t.common.tryHint(`.${ext}`)), ...titles.map(title => t.common.tryHint(title))]
|
||||
|
||||
return hints.length > 0 ? hints : undefined
|
||||
}, [artifacts, t])
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const all = artifacts || []
|
||||
|
||||
@@ -235,16 +223,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
const openArtifact = useCallback(
|
||||
async (href: string) => {
|
||||
try {
|
||||
// A gateway-local file resolves to file:// in remote mode (the file
|
||||
// lives on the gateway, not this disk). Opening that locally fails —
|
||||
// and an OAuth remote connection has no query token to build a download
|
||||
// URL. Fetch the bytes over the authenticated fs bridge instead.
|
||||
if (isRemoteGateway() && /^file:/i.test(href)) {
|
||||
await downloadGatewayMediaFile(href)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (window.hermesDesktop?.openExternal) {
|
||||
await window.hermesDesktop.openExternal(href)
|
||||
} else {
|
||||
@@ -275,33 +253,40 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
return (
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
activeTab={kindFilter}
|
||||
onSearchChange={setQuery}
|
||||
onTabChange={id => setKindFilter(id as typeof kindFilter)}
|
||||
searchHidden={counts.all === 0}
|
||||
searchHints={searchHints}
|
||||
searchPlaceholder={a.search}
|
||||
searchTrailingAction={
|
||||
<Tip label={refreshing ? a.refreshing : a.refresh}>
|
||||
<Button
|
||||
aria-label={refreshing ? a.refreshing : a.refresh}
|
||||
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refreshArtifacts()}
|
||||
size="icon-titlebar"
|
||||
variant="ghost"
|
||||
>
|
||||
{refreshing ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||
</Button>
|
||||
</Tip>
|
||||
<Button
|
||||
aria-label={refreshing ? a.refreshing : a.refresh}
|
||||
className="text-(--ui-text-tertiary) hover:bg-transparent hover:text-foreground"
|
||||
disabled={refreshing}
|
||||
onClick={() => void refreshArtifacts()}
|
||||
size="icon-xs"
|
||||
title={refreshing ? a.refreshing : a.refresh}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
|
||||
</Button>
|
||||
}
|
||||
searchValue={query}
|
||||
tabs={[
|
||||
{ id: 'all', label: a.tabAll, meta: artifacts ? counts.all : null },
|
||||
{ id: 'image', label: a.tabImages, meta: artifacts ? counts.image : null },
|
||||
{ id: 'file', label: a.tabFiles, meta: artifacts ? counts.file : null },
|
||||
{ id: 'link', label: a.tabLinks, meta: artifacts ? counts.link : null }
|
||||
]}
|
||||
tabs={
|
||||
<>
|
||||
<TextTab active={kindFilter === 'all'} onClick={() => setKindFilter('all')}>
|
||||
{a.tabAll} <TextTabMeta>({counts.all})</TextTabMeta>
|
||||
</TextTab>
|
||||
<TextTab active={kindFilter === 'image'} onClick={() => setKindFilter('image')}>
|
||||
{a.tabImages} <TextTabMeta>({counts.image})</TextTabMeta>
|
||||
</TextTab>
|
||||
<TextTab active={kindFilter === 'file'} onClick={() => setKindFilter('file')}>
|
||||
{a.tabFiles} <TextTabMeta>({counts.file})</TextTabMeta>
|
||||
</TextTab>
|
||||
<TextTab active={kindFilter === 'link'} onClick={() => setKindFilter('link')}>
|
||||
{a.tabLinks} <TextTabMeta>({counts.link})</TextTabMeta>
|
||||
</TextTab>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{!artifacts ? (
|
||||
<PageLoader label={a.indexing} />
|
||||
@@ -313,11 +298,17 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto [scrollbar-gutter:stable]">
|
||||
<div className="flex flex-col gap-3 px-3 pb-2">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className={cn('flex flex-col gap-3 pb-2', PAGE_INSET_X)}>
|
||||
{visibleImageArtifacts.length > 0 && (
|
||||
<section className="flex flex-col">
|
||||
<div className="sticky top-0 z-10 -mx-3 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-0 z-10 flex h-7 items-center gap-3 overflow-x-auto bg-background',
|
||||
PAGE_INSET_NEG_X,
|
||||
PAGE_INSET_X
|
||||
)}
|
||||
>
|
||||
<ArtifactsPagination
|
||||
className="ml-auto justify-end px-0"
|
||||
itemLabel={a.itemsImage}
|
||||
@@ -343,7 +334,13 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
|
||||
{visibleFileArtifacts.length > 0 && (
|
||||
<section className="flex flex-col">
|
||||
<div className="sticky top-0 z-10 -mx-3 flex h-7 items-center gap-3 overflow-x-auto bg-background px-3">
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-0 z-10 flex h-7 items-center gap-3 overflow-x-auto bg-background',
|
||||
PAGE_INSET_NEG_X,
|
||||
PAGE_INSET_X
|
||||
)}
|
||||
>
|
||||
<ArtifactsPagination
|
||||
className="ml-auto justify-end px-0"
|
||||
itemLabel={itemsLabel(kindFilter, a)}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-u
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { normalize } from '@/lib/text'
|
||||
|
||||
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
|
||||
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
|
||||
@@ -20,7 +19,7 @@ const STARTER_META: Record<string, string> = {
|
||||
}
|
||||
|
||||
function starterEntries(query: string): CompletionEntry[] {
|
||||
const q = normalize(query)
|
||||
const q = query.trim().toLowerCase()
|
||||
const kinds = Array.from(REF_STARTERS)
|
||||
const filtered = q ? kinds.filter(kind => kind.startsWith(q)) : kinds
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
isDesktopSlashExtensionCommand,
|
||||
isDesktopSlashSuggestion
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { $sessions } from '@/store/session'
|
||||
|
||||
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
|
||||
@@ -95,7 +94,7 @@ export function useSlashCompletions(options: {
|
||||
const sessionArg = /^\/(?:resume|sessions|switch)\s+(.*)$/is.exec(text)
|
||||
|
||||
if (sessionArg) {
|
||||
const needle = normalize(sessionArg[1])
|
||||
const needle = (sessionArg[1] ?? '').trim().toLowerCase()
|
||||
|
||||
const matches = (
|
||||
needle
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { ComposerPrimitive } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useEffect, useRef } from 'react'
|
||||
import {
|
||||
type ClipboardEvent,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
useEffect,
|
||||
useRef
|
||||
} from 'react'
|
||||
|
||||
import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -21,7 +27,11 @@ import { $autoSpeakReplies } from '@/store/voice-prefs'
|
||||
import { useTheme } from '@/themes'
|
||||
|
||||
import { AttachmentList } from './attachments'
|
||||
import { COMPOSER_FADE_BACKGROUND, type QueueEditState, slashArgStage } from './composer-utils'
|
||||
import {
|
||||
COMPOSER_FADE_BACKGROUND,
|
||||
type QueueEditState,
|
||||
slashArgStage
|
||||
} from './composer-utils'
|
||||
import { ContextMenu } from './context-menu'
|
||||
import { ComposerControls } from './controls'
|
||||
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance'
|
||||
|
||||
@@ -7,12 +7,16 @@ import { Codicon } from '@/components/ui/codicon'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { capitalize } from '@/lib/text'
|
||||
import type { TodoStatus } from '@/lib/todos'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ComposerStatusItem } from '@/store/composer-status'
|
||||
|
||||
const toolLabel = (name: string) => name.split('_').filter(Boolean).map(capitalize).join(' ') || name
|
||||
const toolLabel = (name: string) =>
|
||||
name
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map(part => part[0]!.toUpperCase() + part.slice(1))
|
||||
.join(' ') || name
|
||||
|
||||
// Todo rows speak checkbox, not spinner-and-dot: a dashed ring while the item
|
||||
// is still open (pending), codicons once it resolves, a live spinner only on
|
||||
|
||||
@@ -122,9 +122,9 @@ describe('extractDroppedFiles', () => {
|
||||
}
|
||||
|
||||
it('emits a dropped directory as a path-only entry with isDirectory (no File to upload)', () => {
|
||||
const transfer = stubTransfer([{ path: '/Users/jeff/projects/hermes', isDirectory: true }]) as DataTransfer & {
|
||||
_pathByFile: Map<File, string>
|
||||
}
|
||||
const transfer = stubTransfer([
|
||||
{ path: '/Users/jeff/projects/hermes', isDirectory: true }
|
||||
]) as DataTransfer & { _pathByFile: Map<File, string> }
|
||||
|
||||
stubBridge(transfer)
|
||||
|
||||
@@ -174,9 +174,9 @@ describe('extractDroppedFiles', () => {
|
||||
it('does not duplicate a folder that appears in both items and files', () => {
|
||||
// Chromium lists a dropped folder in transfer.files too (as a size-0 File);
|
||||
// the items pass claims its path first so the files fallback skips it.
|
||||
const transfer = stubTransfer([{ path: '/abs/project', isDirectory: true }]) as DataTransfer & {
|
||||
_pathByFile: Map<File, string>
|
||||
}
|
||||
const transfer = stubTransfer([
|
||||
{ path: '/abs/project', isDirectory: true }
|
||||
]) as DataTransfer & { _pathByFile: Map<File, string> }
|
||||
|
||||
stubBridge(transfer)
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
|
||||
import { readDesktopFileDataUrl, selectDesktopPaths } from '@/lib/desktop-fs'
|
||||
import { normalize } from '@/lib/text'
|
||||
import {
|
||||
addComposerAttachment,
|
||||
type ComposerAttachment,
|
||||
@@ -31,9 +30,9 @@ const BLOB_MIME_EXTENSION: Record<string, string> = {
|
||||
}
|
||||
|
||||
function blobExtension(blob: Blob): string {
|
||||
const mime = normalize(blob.type.split(';')[0])
|
||||
const mime = blob.type.split(';')[0]?.trim().toLowerCase()
|
||||
|
||||
return BLOB_MIME_EXTENSION[mime] || '.png'
|
||||
return (mime && BLOB_MIME_EXTENSION[mime]) || '.png'
|
||||
}
|
||||
|
||||
export function isImagePath(filePath: string): boolean {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getCronJobRuns, type SessionInfo } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { fmtDayTime, relativeTime } from '@/lib/time'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $selectedStoredSessionId } from '@/store/session'
|
||||
import type { CronJob } from '@/types/hermes'
|
||||
@@ -33,6 +32,30 @@ const PEEK_POLL_INTERVAL_MS = 8000
|
||||
const INITIAL_VISIBLE_JOBS = 3
|
||||
const LOAD_MORE_STEP = 10
|
||||
|
||||
const relativeFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' })
|
||||
|
||||
// Localized "in 5 min" / "2 hr ago" without hand-rolled strings — picks the
|
||||
// coarsest sensible unit so a daily job reads "in 14 hr", not "in 840 min".
|
||||
function relativeTime(targetMs: number, nowMs: number): string {
|
||||
const diff = targetMs - nowMs
|
||||
const abs = Math.abs(diff)
|
||||
const sign = diff < 0 ? -1 : 1
|
||||
|
||||
if (abs < 60_000) {
|
||||
return relativeFmt.format(sign * Math.round(abs / 1000), 'second')
|
||||
}
|
||||
|
||||
if (abs < 3_600_000) {
|
||||
return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute')
|
||||
}
|
||||
|
||||
if (abs < 86_400_000) {
|
||||
return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour')
|
||||
}
|
||||
|
||||
return relativeFmt.format(sign * Math.round(abs / 86_400_000), 'day')
|
||||
}
|
||||
|
||||
function nextRunMs(job: CronJob): null | number {
|
||||
if (!job.next_run_at) {
|
||||
return null
|
||||
@@ -53,7 +76,9 @@ function formatRunTime(seconds?: null | number): string {
|
||||
|
||||
const date = new Date(seconds * 1000)
|
||||
|
||||
return Number.isNaN(date.valueOf()) ? '—' : fmtDayTime.format(date)
|
||||
return Number.isNaN(date.valueOf())
|
||||
? '—'
|
||||
: date.toLocaleString(undefined, { day: 'numeric', hour: 'numeric', minute: '2-digit', month: 'short' })
|
||||
}
|
||||
|
||||
interface SidebarCronJobsSectionProps {
|
||||
|
||||
@@ -1132,7 +1132,7 @@ export function ChatSidebar({
|
||||
searchPending ? (
|
||||
<SidebarSessionSkeletons />
|
||||
) : (
|
||||
<div className="wrap-anywhere grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
|
||||
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
|
||||
{s.noMatch(trimmedQuery)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -22,21 +22,17 @@ import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { CodeEditor } from '@/components/chat/code-editor'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ColorSwatches } from '@/components/ui/color-swatches'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { getProfileSoul, updateProfileSoul } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
$profileColors,
|
||||
@@ -110,7 +106,6 @@ export function ProfileRail() {
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [pendingRename, setPendingRename] = useState<null | ProfileInfo>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
|
||||
const [pendingSoul, setPendingSoul] = useState<null | string>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Too many profiles for the square strip → collapse to the select. Declared
|
||||
@@ -282,7 +277,6 @@ export function ProfileRail() {
|
||||
key={profile.name}
|
||||
label={profile.name}
|
||||
onDelete={() => setPendingDelete(profile)}
|
||||
onEditSoul={() => setPendingSoul(profile.name)}
|
||||
onRecolor={color => setProfileColor(profile.name, color)}
|
||||
onRename={() => setPendingRename(profile)}
|
||||
onSelect={() => selectProfile(profile.name)}
|
||||
@@ -328,89 +322,10 @@ export function ProfileRail() {
|
||||
open={pendingDelete !== null}
|
||||
profile={pendingDelete}
|
||||
/>
|
||||
|
||||
<EditSoulDialog onClose={() => setPendingSoul(null)} profileName={pendingSoul} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Right-click → Edit SOUL.md for a sidebar profile — the same in-app markdown
|
||||
// editor as the memory-graph node edit, so a profile's persona is editable
|
||||
// without opening the Manage overlay.
|
||||
function EditSoulDialog({ onClose, profileName }: { onClose: () => void; profileName: null | string }) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const [content, setContent] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileName) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setContent('')
|
||||
|
||||
getProfileSoul(profileName)
|
||||
.then(soul => !cancelled && setContent(soul.content))
|
||||
.catch(err => !cancelled && notifyError(err, p.failedLoadSoul))
|
||||
.finally(() => !cancelled && setLoading(false))
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [p, profileName])
|
||||
|
||||
const save = async () => {
|
||||
if (!profileName) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
await updateProfileSoul(profileName, content)
|
||||
notify({ kind: 'success', title: p.soulSaved, message: profileName })
|
||||
onClose()
|
||||
} catch (err) {
|
||||
notifyError(err, p.failedSaveSoul)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={open => !open && !saving && onClose()} open={profileName !== null}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{profileName} · SOUL.md</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="h-80">
|
||||
{!loading && profileName && (
|
||||
<CodeEditor
|
||||
filePath="SOUL.md"
|
||||
framed
|
||||
initialValue={content}
|
||||
key={profileName}
|
||||
onCancel={() => !saving && onClose()}
|
||||
onChange={setContent}
|
||||
onSave={() => void save()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button disabled={saving} onClick={onClose} type="button" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={saving || loading} onClick={() => void save()}>
|
||||
{saving ? p.saving : p.saveSoul}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// The "+" create button, shared by both rail render paths.
|
||||
function AddProfileButton({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
@@ -512,7 +427,6 @@ interface ProfileSquareProps {
|
||||
onSelect: () => void
|
||||
onRecolor: (color: null | string) => void
|
||||
onRename: () => void
|
||||
onEditSoul: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
@@ -527,16 +441,7 @@ const LONG_PRESS_MS = 450
|
||||
// right-click to rename/delete. The button carries both the tooltip and
|
||||
// context-menu triggers via nested asChild Slots, so a single element keeps the
|
||||
// dnd listeners, hover tip, and right-click menu.
|
||||
function ProfileSquare({
|
||||
active,
|
||||
color,
|
||||
label,
|
||||
onDelete,
|
||||
onEditSoul,
|
||||
onRecolor,
|
||||
onRename,
|
||||
onSelect
|
||||
}: ProfileSquareProps) {
|
||||
function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, onSelect }: ProfileSquareProps) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const hue = color ?? 'var(--ui-text-quaternary)'
|
||||
@@ -660,12 +565,8 @@ function ProfileSquare({
|
||||
<span>{p.color}</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={onRename}>
|
||||
<Codicon name="text-size" size="0.875rem" />
|
||||
<span>{p.renameMenu}</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={onEditSoul}>
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
<span>{p.editSoul}</span>
|
||||
<span>{p.rename}</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
|
||||
@@ -149,7 +149,10 @@ export function ProjectDialog() {
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md" onInteractOutside={event => event.preventDefault()}>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
onInteractOutside={event => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{mode === 'create' && <DialogDescription>{p.createDesc}</DialogDescription>}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { HermesGitWorktree } from '@/global'
|
||||
import type { ProjectInfo, SessionInfo } from '@/hermes'
|
||||
import { normalize } from '@/lib/text'
|
||||
|
||||
// Session grouping is now computed authoritatively on the backend
|
||||
// (`tui_gateway/project_tree.py`, exposed via `projects.tree` /
|
||||
@@ -192,7 +191,7 @@ export function mergeRepoWorktreeGroups(
|
||||
return branchForPath !== group.label ? { ...group, label: branchForPath } : group
|
||||
}
|
||||
|
||||
const livePath = livePathByBranch.get(normalize(group.label))
|
||||
const livePath = livePathByBranch.get(group.label.trim().toLowerCase())
|
||||
|
||||
if (livePath && normalizePath(livePath) !== normalizePath(group.path)) {
|
||||
return { ...group, id: livePath, path: livePath }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { useSensors } from '@dnd-kit/core'
|
||||
import type { useSensors } from '@dnd-kit/core';
|
||||
import { closestCenter, DndContext, type DragEndEvent } from '@dnd-kit/core'
|
||||
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import type * as React from 'react'
|
||||
|
||||
@@ -11,7 +11,6 @@ import { type Translations, useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
|
||||
import { coarseElapsed } from '@/lib/time'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $attentionSessionIds } from '@/store/session'
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||
@@ -36,13 +35,22 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLElement>
|
||||
}
|
||||
|
||||
const AGE_KEY = { day: 'ageDay', hour: 'ageHour', minute: 'ageMin' } as const
|
||||
const AGE_TICKS: ReadonlyArray<[number, 'ageDay' | 'ageHour' | 'ageMin']> = [
|
||||
[86_400_000, 'ageDay'],
|
||||
[3_600_000, 'ageHour'],
|
||||
[60_000, 'ageMin']
|
||||
]
|
||||
|
||||
function formatAge(seconds: number, r: Translations['sidebar']['row']): string {
|
||||
const { unit, value } = coarseElapsed(Date.now() - seconds * 1000)
|
||||
const delta = Math.max(0, Date.now() - seconds * 1000)
|
||||
|
||||
// Under a minute reads as "now" — the sidebar never shows a seconds tick.
|
||||
return unit === 'second' ? r.ageNow : `${value}${r[AGE_KEY[unit]]}`
|
||||
for (const [ms, key] of AGE_TICKS) {
|
||||
if (delta >= ms) {
|
||||
return `${Math.floor(delta / ms)}${r[key]}`
|
||||
}
|
||||
}
|
||||
|
||||
return r.ageNow
|
||||
}
|
||||
|
||||
export function SidebarSessionRow({
|
||||
@@ -121,7 +129,7 @@ export function SidebarSessionRow({
|
||||
</div>
|
||||
}
|
||||
className={cn(
|
||||
'group row-hover relative',
|
||||
'group relative cursor-pointer transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none',
|
||||
isSelected && 'bg-(--ui-row-active-background)',
|
||||
isWorking && 'text-foreground',
|
||||
// Opaque surface while lifted so the dragged row erases what's under
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type MouseEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { LogTail } from '@/components/chat/log-tail'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { ResponsiveTabs } from '@/components/ui/tab-dropdown'
|
||||
import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway, updateHermes } from '@/hermes'
|
||||
import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { compactNumber } from '@/lib/format'
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
@@ -24,7 +21,6 @@ import {
|
||||
Wrench
|
||||
} from '@/lib/icons'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { fmtDateTime } from '@/lib/time'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { upsertDesktopActionTask } from '@/store/activity'
|
||||
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
|
||||
@@ -32,7 +28,7 @@ import { $sessions, sessionPinId } from '@/store/session'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { OverlayMain, OverlayNav, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
|
||||
import { MaintenancePanel } from './maintenance'
|
||||
@@ -67,7 +63,7 @@ function formatTimestamp(value?: number | null): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
return fmtDateTime.format(date)
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
||||
}
|
||||
|
||||
function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||
@@ -295,27 +291,29 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
||||
return (
|
||||
<OverlayView closeLabel={cc.close} onClose={onClose}>
|
||||
<OverlaySplitLayout>
|
||||
<OverlayNav
|
||||
groups={SECTIONS.map(value => ({
|
||||
active: section === value,
|
||||
icon:
|
||||
value === 'sessions'
|
||||
? MessageCircle
|
||||
: value === 'system'
|
||||
? Activity
|
||||
: value === 'maintenance'
|
||||
? Wrench
|
||||
: BarChart3,
|
||||
id: value,
|
||||
label: cc.sections[value],
|
||||
onSelect: () => setSection(value)
|
||||
}))}
|
||||
/>
|
||||
<OverlaySidebar>
|
||||
{SECTIONS.map(value => (
|
||||
<OverlayNavItem
|
||||
active={section === value}
|
||||
icon={
|
||||
value === 'sessions'
|
||||
? MessageCircle
|
||||
: value === 'system'
|
||||
? Activity
|
||||
: value === 'maintenance'
|
||||
? Wrench
|
||||
: BarChart3
|
||||
}
|
||||
key={value}
|
||||
label={cc.sections[value]}
|
||||
onClick={() => setSection(value)}
|
||||
/>
|
||||
))}
|
||||
</OverlaySidebar>
|
||||
|
||||
<OverlayMain>
|
||||
<header className="mb-4 flex items-center justify-between gap-3 max-[47.5rem]:mb-2">
|
||||
{/* Redundant on narrow — the nav dropdown already names the section. */}
|
||||
<div className="min-w-0 max-[47.5rem]:hidden">
|
||||
<header className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-[length:var(--conversation-text-font-size)] font-semibold text-foreground">
|
||||
{cc.sections[section]}
|
||||
</h2>
|
||||
@@ -408,12 +406,12 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
||||
<div>
|
||||
{status ? (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-start justify-between gap-3 max-[47.5rem]:flex-col max-[47.5rem]:gap-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'size-2 shrink-0 rounded-full',
|
||||
'size-2 rounded-full',
|
||||
status.gateway_running ? 'bg-emerald-500' : 'bg-amber-500'
|
||||
)}
|
||||
/>
|
||||
@@ -425,7 +423,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
||||
{cc.hermesActiveSessions(status.version, status.active_sessions)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 whitespace-nowrap max-[47.5rem]:whitespace-normal">
|
||||
<div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap">
|
||||
<Button onClick={() => void runSystemAction('restart')} size="xs" variant="text">
|
||||
{cc.restartGateway}
|
||||
</Button>
|
||||
@@ -451,21 +449,19 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col pt-2">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-[0.625rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
|
||||
{cc.recentLogs}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<ResponsiveTabs
|
||||
align="end"
|
||||
onChange={id => setLogFile(id as (typeof LOG_FILES)[number])}
|
||||
tabs={LOG_FILES.map(value => ({ id: value, label: value }))}
|
||||
<div className="flex items-center gap-2">
|
||||
<SegmentedControl
|
||||
onChange={id => setLogFile(id)}
|
||||
options={LOG_FILES.map(value => ({ id: value, label: value }))}
|
||||
value={logFile}
|
||||
/>
|
||||
<ResponsiveTabs
|
||||
align="end"
|
||||
onChange={id => setLogLevel(id as (typeof LOG_LEVELS)[number])}
|
||||
tabs={LOG_LEVELS.map(value => ({
|
||||
<SegmentedControl
|
||||
onChange={id => setLogLevel(id)}
|
||||
options={LOG_LEVELS.map(value => ({
|
||||
id: value,
|
||||
label: value === 'ALL' ? 'all' : value.toLowerCase()
|
||||
}))}
|
||||
@@ -485,11 +481,12 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<LogTail
|
||||
className="flex-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary)"
|
||||
emptyLabel={cc.noLogs}
|
||||
lines={systemLoading && logs.length === 0 ? null : visibleLogs}
|
||||
/>
|
||||
<pre
|
||||
className="min-h-0 flex-1 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.65rem] leading-relaxed text-(--ui-text-tertiary)"
|
||||
data-selectable-text="true"
|
||||
>
|
||||
{visibleLogs.length ? visibleLogs.join('\n') : cc.noLogs}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -499,6 +496,24 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
||||
)
|
||||
}
|
||||
|
||||
function formatTokens(value: null | number | undefined): string {
|
||||
const num = Number(value || 0)
|
||||
|
||||
if (num >= 1_000_000) {
|
||||
return `${(num / 1_000_000).toFixed(1)}M`
|
||||
}
|
||||
|
||||
if (num >= 1_000) {
|
||||
return `${(num / 1_000).toFixed(1)}K`
|
||||
}
|
||||
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
function formatInteger(value: null | number | undefined): string {
|
||||
return Number(value ?? 0).toLocaleString()
|
||||
}
|
||||
|
||||
interface UsagePanelProps {
|
||||
error: string
|
||||
loading: boolean
|
||||
@@ -552,11 +567,11 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4 py-2 sm:grid-cols-3">
|
||||
<UsageStat label={cc.statSessions} value={compactNumber(totals.total_sessions)} />
|
||||
<UsageStat label={cc.statApiCalls} value={compactNumber(totals.total_api_calls)} />
|
||||
<UsageStat label={cc.statSessions} value={formatInteger(totals.total_sessions)} />
|
||||
<UsageStat label={cc.statApiCalls} value={formatInteger(totals.total_api_calls)} />
|
||||
<UsageStat
|
||||
label={cc.statTokens}
|
||||
value={`${compactNumber(totals.total_input)} / ${compactNumber(totals.total_output)}`}
|
||||
value={`${formatTokens(totals.total_input)} / ${formatTokens(totals.total_output)}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -589,7 +604,7 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp
|
||||
<div
|
||||
className="group relative flex h-24 min-w-0 flex-1 flex-col justify-end"
|
||||
key={entry.day}
|
||||
title={`${entry.day} · in ${compactNumber(entry.input_tokens)} · out ${compactNumber(entry.output_tokens)}`}
|
||||
title={`${entry.day} · in ${formatTokens(entry.input_tokens)} · out ${formatTokens(entry.output_tokens)}`}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-t-[1px] bg-[color:var(--dt-primary)]/50"
|
||||
@@ -617,7 +632,7 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp
|
||||
rows={byModel.slice(0, 6).map(entry => ({
|
||||
key: entry.model,
|
||||
label: entry.model,
|
||||
value: `${compactNumber((entry.input_tokens || 0) + (entry.output_tokens || 0))}`
|
||||
value: `${formatTokens((entry.input_tokens || 0) + (entry.output_tokens || 0))}`
|
||||
}))}
|
||||
title={cc.topModels}
|
||||
/>
|
||||
@@ -626,7 +641,7 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp
|
||||
rows={topSkills.slice(0, 6).map(entry => ({
|
||||
key: entry.skill,
|
||||
label: entry.skill,
|
||||
value: cc.actions(compactNumber(entry.total_count))
|
||||
value: cc.actions(entry.total_count.toLocaleString())
|
||||
}))}
|
||||
title={cc.topSkills}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
|
||||
import { setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { KbdCombo } from '@/components/ui/kbd'
|
||||
import { getHermesConfigRecord, listAllProfileSessions } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
type IconComponent,
|
||||
Info,
|
||||
KeyRound,
|
||||
Layers3,
|
||||
MessageCircle,
|
||||
Monitor,
|
||||
Moon,
|
||||
@@ -37,7 +36,6 @@ import {
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Starmap,
|
||||
Sun,
|
||||
Terminal,
|
||||
@@ -45,7 +43,6 @@ import {
|
||||
Wrench,
|
||||
Zap
|
||||
} from '@/lib/icons'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $repoWorktrees } from '@/store/coding-status'
|
||||
import {
|
||||
@@ -58,7 +55,6 @@ import { $bindings } from '@/store/keybinds'
|
||||
import { openPetGenerate } from '@/store/pet-generate'
|
||||
import { requestStartWorkSession } from '@/store/projects'
|
||||
import { runGatewayRestart } from '@/store/system-actions'
|
||||
import { applyBackendUpdate } from '@/store/updates'
|
||||
import { luminance } from '@/themes/color'
|
||||
import { type ThemeMode, useTheme } from '@/themes/context'
|
||||
import { isUserTheme, resolveTheme } from '@/themes/user-themes'
|
||||
@@ -122,88 +118,22 @@ interface SessionEntry {
|
||||
title: string
|
||||
}
|
||||
|
||||
// Ranking happens in React, not cmdk. We score, sort, and prune the groups
|
||||
// ourselves and hand cmdk an already-ordered list with `shouldFilter={false}`,
|
||||
// leaving it as pure keyboard/selection machinery. (cmdk's own group
|
||||
// re-sorting silently no-ops: its sort() queries groups by an internal id that
|
||||
// never matches the heading text it writes into `data-value`, so groups always
|
||||
// keep source order — which put a generic keyword match like "Capabilities" on
|
||||
// top and the auto-highlight on it while an exact "Tools" row sat below.)
|
||||
//
|
||||
// cmdk still auto-selects the first DOM item whenever the search changes, so
|
||||
// rendering best-match-first is what puts the highlight on the best match.
|
||||
//
|
||||
// AND semantics: every typed word must appear in the label or keywords. The
|
||||
// grade rewards matches on the visible label — exact > prefix > whole word >
|
||||
// word prefix > substring > scattered terms > keyword-only — so typing "tools"
|
||||
// selects the row that says Tools, not a row that hides it in keywords.
|
||||
const scoreItem = (item: PaletteItem, needle: string): number => {
|
||||
const label = item.label.toLowerCase()
|
||||
const keys = (item.keywords ?? []).join(' ').toLowerCase()
|
||||
const terms = needle.split(/\s+/).filter(Boolean)
|
||||
// cmdk defaults to fuzzy subsequence scoring, so "color" matches anything with
|
||||
// c…o…l…o…r scattered across it. Use case-insensitive multi-term substring
|
||||
// matching instead: every typed word must literally appear in the item's
|
||||
// value/keywords, which keeps results tight and predictable.
|
||||
const paletteFilter = (value: string, search: string, keywords?: string[]): number => {
|
||||
const needle = search.trim().toLowerCase()
|
||||
|
||||
if (terms.some(term => !label.includes(term) && !keys.includes(term))) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (label === needle) {
|
||||
if (!needle) {
|
||||
return 1
|
||||
}
|
||||
|
||||
if (label.startsWith(needle)) {
|
||||
return 0.9
|
||||
}
|
||||
const haystack = `${value} ${keywords?.join(' ') ?? ''}`.toLowerCase()
|
||||
|
||||
const words = label.split(/[^\p{L}\p{N}]+/u).filter(Boolean)
|
||||
|
||||
if (words.includes(needle)) {
|
||||
return 0.85
|
||||
}
|
||||
|
||||
if (words.some(word => word.startsWith(needle))) {
|
||||
return 0.8
|
||||
}
|
||||
|
||||
if (label.includes(needle)) {
|
||||
return 0.7
|
||||
}
|
||||
|
||||
if (terms.every(term => label.includes(term))) {
|
||||
return 0.6
|
||||
}
|
||||
|
||||
// Matched only via keywords — the weakest, generic-row signal.
|
||||
return 0.4
|
||||
return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0
|
||||
}
|
||||
|
||||
// Order items within each group by score, order groups by their best item, and
|
||||
// drop everything that doesn't match. Ties keep their original order (stable
|
||||
// sort), so curated group/item ordering still breaks even scores.
|
||||
const rankGroups = (groups: PaletteGroup[], search: string): PaletteGroup[] => {
|
||||
const needle = normalize(search)
|
||||
|
||||
if (!needle) {
|
||||
return groups
|
||||
}
|
||||
|
||||
return groups
|
||||
.map(group => {
|
||||
const scored = group.items
|
||||
.map(item => ({ item, score: scoreItem(item, needle) }))
|
||||
.filter(entry => entry.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
|
||||
return { group: { ...group, items: scored.map(entry => entry.item) }, max: scored[0]?.score ?? 0 }
|
||||
})
|
||||
.filter(entry => entry.max > 0)
|
||||
.sort((a, b) => b.max - a.max)
|
||||
.map(entry => entry.group)
|
||||
}
|
||||
|
||||
// cmdk selection values must be unique; labels alone can repeat (the same
|
||||
// theme lists under both Light and Dark). The id suffix disambiguates.
|
||||
const paletteValue = (item: PaletteItem): string => `${item.label}\u0001${item.id}`
|
||||
|
||||
// Hermes session ids: <YYYYMMDD>_<HHMMSS>_<6 hex>. Used to offer a direct
|
||||
// "Go to session ‹id›" jump for ids that aren't in the recent-200 list.
|
||||
const SESSION_ID_RE = /^\d{8}_\d{6}_[a-f0-9]{6}$/
|
||||
@@ -257,6 +187,7 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{
|
||||
labelKey: 'keysSettings',
|
||||
tab: 'keys&kview=settings'
|
||||
},
|
||||
{ icon: Wrench, keywords: ['servers', 'tools'], labelKey: 'mcp', tab: 'mcp' },
|
||||
{ icon: Archive, keywords: ['history', 'archived'], labelKey: 'archivedChats', tab: 'sessions' },
|
||||
{ icon: Info, keywords: ['version', 'about'], labelKey: 'about', tab: 'about' }
|
||||
]
|
||||
@@ -427,7 +358,7 @@ export function CommandPalette() {
|
||||
action: 'nav.skills',
|
||||
icon: Wrench,
|
||||
id: 'nav-skills',
|
||||
keywords: ['skills', 'tools', 'toolsets', 'mcp', 'capabilities'],
|
||||
keywords: ['tools', 'toolsets'],
|
||||
label: cc.nav.skills.title,
|
||||
run: go(SKILLS_ROUTE)
|
||||
},
|
||||
@@ -495,13 +426,6 @@ export function CommandPalette() {
|
||||
keywords: ['gateway', 'restart', 'messaging', 'reconnect', 'system'],
|
||||
label: cc.restartGateway,
|
||||
run: () => void runGatewayRestart()
|
||||
},
|
||||
{
|
||||
icon: Download,
|
||||
id: 'cc-update-hermes',
|
||||
keywords: ['update', 'upgrade', 'hermes', 'version', 'system', 'restart'],
|
||||
label: cc.updateHermes,
|
||||
run: () => void applyBackendUpdate()
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -591,73 +515,6 @@ export function CommandPalette() {
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
result.push({
|
||||
heading: capLabel,
|
||||
items: [
|
||||
{
|
||||
icon: Wrench,
|
||||
id: 'cap-skills',
|
||||
keywords: ['skills', 'capabilities'],
|
||||
label: `${capLabel}: ${t.skills.tabSkills}`,
|
||||
run: go(`${SKILLS_ROUTE}?tab=skills`)
|
||||
},
|
||||
{
|
||||
icon: SlidersHorizontal,
|
||||
id: 'cap-toolsets',
|
||||
keywords: ['tools', 'toolsets', 'capabilities'],
|
||||
label: `${capLabel}: ${t.skills.tabToolsets}`,
|
||||
run: go(`${SKILLS_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`)
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Apply a theme directly from the root search (e.g. "nous" → Nous). Live
|
||||
// preview via keepOpen, mirroring the nested theme picker. If the theme
|
||||
// can't render the current light/dark mode, flip to the one it supports.
|
||||
result.push({
|
||||
heading: t.settings.appearance.themeTitle,
|
||||
items: availableThemes.map(theme => ({
|
||||
icon: Palette,
|
||||
id: `search-theme-${theme.name}`,
|
||||
keepOpen: true,
|
||||
keywords: ['theme', 'appearance', 'color', 'skin', theme.name, theme.description],
|
||||
label: theme.label,
|
||||
run: () => {
|
||||
setTheme(theme.name)
|
||||
|
||||
if (!themeSupportsMode(theme.name, resolvedMode)) {
|
||||
setMode(resolvedMode === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
// Switch light/dark/system directly (typing "dark" shouldn't require the
|
||||
// nested color-mode page).
|
||||
result.push({
|
||||
heading: t.settings.appearance.colorMode,
|
||||
items: THEME_MODES.map(entry => ({
|
||||
icon: entry.icon,
|
||||
id: `search-mode-${entry.mode}`,
|
||||
keepOpen: true,
|
||||
keywords: ['appearance', 'color mode', 'brightness', entry.mode, t.settings.modeOptions[entry.mode].label],
|
||||
label: t.settings.modeOptions[entry.mode].label,
|
||||
run: () => setMode(entry.mode)
|
||||
}))
|
||||
})
|
||||
|
||||
if (sessions.length > 0) {
|
||||
result.push({
|
||||
heading: t.commandCenter.sections.sessions,
|
||||
@@ -691,7 +548,7 @@ export function CommandPalette() {
|
||||
id: `mcp-${name}`,
|
||||
keywords: ['mcp', 'server', 'tool'],
|
||||
label: name,
|
||||
run: go(`${SKILLS_ROUTE}?tab=mcp&server=${encodeURIComponent(name)}`)
|
||||
run: go(`${SETTINGS_ROUTE}?tab=mcp&server=${encodeURIComponent(name)}`)
|
||||
}))
|
||||
})
|
||||
}
|
||||
@@ -710,20 +567,7 @@ export function CommandPalette() {
|
||||
}
|
||||
|
||||
return result
|
||||
}, [
|
||||
archivedSessions,
|
||||
availableThemes,
|
||||
configFieldLabel,
|
||||
go,
|
||||
mcpServers,
|
||||
resolvedMode,
|
||||
search,
|
||||
sessions,
|
||||
setMode,
|
||||
setTheme,
|
||||
settingsSectionLabel,
|
||||
t
|
||||
])
|
||||
}, [archivedSessions, configFieldLabel, go, mcpServers, search, sessions, settingsSectionLabel, t])
|
||||
|
||||
const groups = useMemo(() => [...baseGroups, ...searchGroups], [baseGroups, searchGroups])
|
||||
|
||||
@@ -795,7 +639,7 @@ export function CommandPalette() {
|
||||
// Server-driven page: items come from the Marketplace, rendered by
|
||||
// <MarketplaceThemePage> (loader + live search + per-row install).
|
||||
'install-theme': {
|
||||
title: t.commandCenter.installTheme.pageTitle,
|
||||
title: t.commandCenter.installTheme.title,
|
||||
placeholder: t.commandCenter.installTheme.placeholder,
|
||||
groups: []
|
||||
}
|
||||
@@ -804,8 +648,7 @@ export function CommandPalette() {
|
||||
)
|
||||
|
||||
const activePage = page ? subPages[page] : null
|
||||
const unrankedGroups = activePage ? activePage.groups : groups
|
||||
const visibleGroups = useMemo(() => rankGroups(unrankedGroups, search), [unrankedGroups, search])
|
||||
const visibleGroups = activePage ? activePage.groups : groups
|
||||
const placeholder = activePage ? activePage.placeholder : t.commandCenter.searchPlaceholder
|
||||
|
||||
const handleSelect = (item: PaletteItem) => {
|
||||
@@ -837,7 +680,7 @@ export function CommandPalette() {
|
||||
)}
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
|
||||
<Command className="bg-transparent" loop shouldFilter={false}>
|
||||
<Command className="bg-transparent" filter={paletteFilter} loop>
|
||||
{activePage && (
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
@@ -886,11 +729,7 @@ export function CommandPalette() {
|
||||
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
|
||||
) : (
|
||||
<>
|
||||
{/* Filtering happens in rankGroups, so cmdk's own CommandEmpty
|
||||
(keyed to its internal filter count) would never fire. */}
|
||||
{visibleGroups.length === 0 && (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">{t.commandCenter.noResults}</div>
|
||||
)}
|
||||
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
|
||||
{visibleGroups.map((group, index) => (
|
||||
<CommandGroup
|
||||
className={HUD_HEADING}
|
||||
@@ -907,7 +746,7 @@ export function CommandPalette() {
|
||||
key={item.id}
|
||||
keywords={item.keywords}
|
||||
onSelect={() => handleSelect(item)}
|
||||
value={paletteValue(item)}
|
||||
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
} from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertTriangle } from '@/lib/icons'
|
||||
import { asText } from '@/lib/text'
|
||||
import { $cronFocusJobId, $cronJobs, setCronFocusJobId, setCronJobs, updateCronJobs } from '@/store/cron'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
@@ -80,6 +79,8 @@ const STATE_TONE: Record<string, PanelPillTone> = {
|
||||
completed: 'muted'
|
||||
}
|
||||
|
||||
const asText = (value: unknown): string => (typeof value === 'string' ? value : '')
|
||||
|
||||
const truncate = (value: string, max = 80): string => (value.length > max ? `${value.slice(0, max)}…` : value)
|
||||
|
||||
function jobName(job: CronJob): string {
|
||||
@@ -431,11 +432,6 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
||||
<PanelBody>
|
||||
<PanelList
|
||||
onSearchChange={setQuery}
|
||||
searchHints={jobs
|
||||
.map(jobTitle)
|
||||
.filter(Boolean)
|
||||
.slice(0, 5)
|
||||
.map(title => t.common.tryHint(title))}
|
||||
searchLabel={c.search}
|
||||
searchPlaceholder={c.search}
|
||||
searchValue={query}
|
||||
@@ -681,7 +677,7 @@ function CronJobRuns({
|
||||
<div className="flex flex-col gap-px">
|
||||
{runs.map(run => (
|
||||
<button
|
||||
className="row-hover flex items-center justify-between gap-3 rounded-md px-2 py-1 text-left text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
className="flex items-center justify-between gap-3 rounded-md px-2 py-1 text-left text-xs transition-colors duration-100 hover:bg-(--ui-row-hover-background) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
key={run.id}
|
||||
onClick={() => onOpenSession?.(run.id)}
|
||||
type="button"
|
||||
|
||||
@@ -46,7 +46,12 @@ import {
|
||||
setPetOverlaySubmitHandler
|
||||
} from '../store/pet-overlay'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
|
||||
import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '../store/profile'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
$freshSessionRequest,
|
||||
$profileScope,
|
||||
refreshActiveProfile
|
||||
} from '../store/profile'
|
||||
import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '../store/projects'
|
||||
import { $reviewOpen, REVIEW_PANE_ID } from '../store/review'
|
||||
import {
|
||||
@@ -171,7 +176,7 @@ function sessionMessagesSignature(messages: SessionMessage[]): string {
|
||||
for (const m of messages) {
|
||||
hash = hashString(hash, m.role)
|
||||
hash = hashString(hash, String(m.timestamp ?? ''))
|
||||
hash = hashString(hash, typeof m.content === 'string' ? m.content : (JSON.stringify(m.content) ?? ''))
|
||||
hash = hashString(hash, typeof m.content === 'string' ? m.content : JSON.stringify(m.content) ?? '')
|
||||
}
|
||||
|
||||
return `${messages.length}:${hash}`
|
||||
|
||||
@@ -2,14 +2,7 @@
|
||||
// switcher). They pin just under the title bar, centered, and lean on a crisp
|
||||
// border + shadow to separate from the app — no dimming/blurring backdrop.
|
||||
// Each caller layers on its own z-index, width, and overflow.
|
||||
//
|
||||
// Narrow screens: the centered HUD widens toward full-width and its top-left
|
||||
// corner slides under the macOS traffic lights. Below ~44rem (where the overlap
|
||||
// begins) drop the whole surface beneath the titlebar band so the search row
|
||||
// always clears the window controls. These HUDs portal to <body>, outside the
|
||||
// app-shell subtree that defines --titlebar-height, so the var needs a fallback.
|
||||
export const HUD_POSITION =
|
||||
'fixed left-1/2 top-3 -translate-x-1/2 max-[44rem]:top-[calc(var(--titlebar-height,34px)+0.375rem)]'
|
||||
export const HUD_POSITION = 'fixed left-1/2 top-3 -translate-x-1/2'
|
||||
|
||||
// Matches the app's borderless-overlay surface (dialog, keybind panel, …):
|
||||
// hairline `--stroke-nous` paired with the soft `--shadow-nous` float.
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { getHermesConfigRecord } from '@/hermes'
|
||||
import { queryClient, writeCache } from '@/lib/query-client'
|
||||
import type { HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
// One shared cache for the whole profile config record (`GET /api/config`).
|
||||
// Every settings surface (MCP, model, config) reads and writes through this key
|
||||
// so a save in one shows in the others, and revisiting a tab paints the cache
|
||||
// instead of blanking on a fresh fetch.
|
||||
//
|
||||
// Distinct from session/hooks/use-hermes-config.ts, which is side-effecting —
|
||||
// it pushes personality/cwd/voice/… into the session stores for live chat.
|
||||
export const HERMES_CONFIG_KEY = ['hermes-config-record'] as const
|
||||
|
||||
// staleTime 0 → serve cache instantly, background-revalidate on every mount.
|
||||
export const useHermesConfigRecord = () =>
|
||||
useQuery({ queryKey: HERMES_CONFIG_KEY, queryFn: getHermesConfigRecord, staleTime: 0 })
|
||||
|
||||
export const setHermesConfigCache = writeCache<HermesConfigRecord>(HERMES_CONFIG_KEY)
|
||||
|
||||
export const invalidateHermesConfig = () => queryClient.invalidateQueries({ queryKey: HERMES_CONFIG_KEY })
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/** Debounce a fast-changing value (search input, slider, …) so effects/queries
|
||||
* keyed on it only fire once the value settles. */
|
||||
export function useDebounced<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setDebounced(value), delayMs)
|
||||
|
||||
return () => clearTimeout(handle)
|
||||
}, [value, delayMs])
|
||||
|
||||
return debounced
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
|
||||
/** Run `onSwitch` when the active gateway profile changes — never on first
|
||||
* mount. For dropping per-profile view state (probes, cached usage, drafts)
|
||||
* when the backend the app talks to swaps underneath a still-mounted view. */
|
||||
export function useOnProfileSwitch(onSwitch: () => void): void {
|
||||
const profile = useStore($activeGatewayProfile)
|
||||
const first = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (first.current) {
|
||||
first.current = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onSwitch()
|
||||
// Fire on profile change only; onSwitch identity is intentionally ignored.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [profile])
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { deleteLearningNode } from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { notify } from '@/store/notifications'
|
||||
|
||||
export const ARCHIVE_SKILL_DESCRIPTION = 'The skill is archived and can be restored with `hermes curator restore`.'
|
||||
|
||||
export function notifySkillArchived(t: Translations): void {
|
||||
notify({ kind: 'success', message: t.skills.skillArchivedMessage, title: t.skills.skillArchivedTitle })
|
||||
}
|
||||
|
||||
export async function archiveLearningSkill(id: string): Promise<void> {
|
||||
const res = await deleteLearningNode(id)
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.message || 'Archive failed')
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire-and-forget a mutation whose UI already applied optimistically; a failure just rolls it back + reports. */
|
||||
export function fireOptimistic(action: Promise<void>, rollback: () => void, onFailure: (err: unknown) => void): void {
|
||||
void action.catch(err => {
|
||||
rollback()
|
||||
onFailure(err)
|
||||
})
|
||||
}
|
||||
|
||||
interface ArchiveSkillConfirmDialogProps {
|
||||
/** Apply optimistic UI updates; return rollback if the background archive fails. */
|
||||
onApply: () => () => void
|
||||
onClose: () => void
|
||||
onFailure?: (err: unknown, skillName: string) => void
|
||||
onSuccess?: () => void
|
||||
open: boolean
|
||||
skillId: string
|
||||
skillName: string
|
||||
}
|
||||
|
||||
/** Shared archive confirm for learned skills (capabilities page + memory graph). */
|
||||
export function ArchiveSkillConfirmDialog({
|
||||
onApply,
|
||||
onClose,
|
||||
onFailure,
|
||||
onSuccess,
|
||||
open,
|
||||
skillId,
|
||||
skillName
|
||||
}: ArchiveSkillConfirmDialogProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
confirmLabel="Archive"
|
||||
description={ARCHIVE_SKILL_DESCRIPTION}
|
||||
destructive
|
||||
dismissOnConfirm
|
||||
onClose={onClose}
|
||||
onConfirm={() => {
|
||||
const rollback = onApply()
|
||||
|
||||
fireOptimistic(
|
||||
archiveLearningSkill(skillId).then(() => {
|
||||
notifySkillArchived(t)
|
||||
onSuccess?.()
|
||||
}),
|
||||
rollback,
|
||||
err => onFailure?.(err, skillName)
|
||||
)
|
||||
}}
|
||||
open={open}
|
||||
title={`Archive ${skillName}?`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, type PointerEvent as ReactPointerEvent, useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { RowButton } from '@/components/ui/row-button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $paneHeightOverride, $paneState, setPaneHeightOverride } from '@/store/panes'
|
||||
|
||||
// Monospace capability chip (tool name, transport, …). Shared by the Skills
|
||||
// and MCP tabs so the pill reads identically everywhere.
|
||||
export function ToolChip({ children, title }: { children: ReactNode; title?: string }) {
|
||||
return (
|
||||
<span
|
||||
className="rounded-md bg-(--ui-bg-quinary) px-1.5 py-0.5 font-mono text-[0.65rem] text-(--ui-text-tertiary)"
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Master–detail page scaffolding (14rem rail, p-2, centered max-w-2xl detail):
|
||||
// dense uniform rows on the left, roomy inspector on the right. Shared by the
|
||||
// Capabilities and Messaging pages — pages bring their own row/detail content
|
||||
// (CapRow here is the toggle-row flavor; Messaging has its own avatar rows).
|
||||
|
||||
// `pane` docks a full-bleed work surface (editor, log viewer, terminal) below
|
||||
// the whole master–detail grid — the app's bottom-pane pattern, page-local.
|
||||
// The wide-rail track shared by every Capabilities tab (skills/tools/mcp) so
|
||||
// the three read as one page. Exported for pages that build their own grid
|
||||
// (the MCP tab's cursor-driven layout) but must stay in step.
|
||||
export const MASTER_DETAIL_WIDE_COLS = 'sm:grid-cols-[minmax(0,0.75fr)_minmax(0,1fr)]'
|
||||
|
||||
// `split="wide"` gives list-heavy pages a rail that shares the page with a
|
||||
// sparse detail (skills/tools/mcp); the default 14rem rail suits pages whose
|
||||
// detail carries the weight (messaging).
|
||||
export function MasterDetail({
|
||||
children,
|
||||
pane,
|
||||
split = 'rail'
|
||||
}: {
|
||||
children: ReactNode
|
||||
pane?: ReactNode
|
||||
split?: 'rail' | 'wide'
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-h-0 flex-1 grid-cols-1',
|
||||
split === 'wide' ? MASTER_DETAIL_WIDE_COLS : 'sm:grid-cols-[14rem_minmax(0,1fr)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{pane}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ListColumn({ children, header }: { children: ReactNode; header?: ReactNode }) {
|
||||
return (
|
||||
<aside className="flex min-h-0 flex-col p-2">
|
||||
{header}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-gutter:stable]">{children}</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
// `footer` pins one quiet caption below the scroll (e.g. "changes apply to
|
||||
// new sessions") so per-item detail components never repeat it themselves.
|
||||
// `actionBar` pins a real control row (save/toggle) below the scroll instead.
|
||||
export function DetailColumn({
|
||||
actionBar,
|
||||
children,
|
||||
footer
|
||||
}: {
|
||||
actionBar?: ReactNode
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<main className="flex min-h-0 flex-col overflow-hidden">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-gutter:stable]">
|
||||
<div className="mx-auto max-w-2xl space-y-5 px-5 py-4">{children}</div>
|
||||
</div>
|
||||
{footer && (
|
||||
<div className="mx-auto w-full max-w-2xl shrink-0 px-5 pb-3 pt-1.5 text-right text-[0.65rem] text-muted-foreground/50">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
{actionBar && (
|
||||
<footer className="shrink-0 bg-(--ui-chat-surface-background) px-5 py-2.5">
|
||||
<div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2">{actionBar}</div>
|
||||
</footer>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
// Full-bleed docked bottom pane: title strip + actions + close, drag-resizable
|
||||
// on its top edge like every other pane (height persisted through the same
|
||||
// pane-state store the terminal uses). No min height — drag (or the chevron)
|
||||
// collapses it down to just the header. Content swaps freely: JSON editor
|
||||
// today, stdio/log viewers tomorrow.
|
||||
const DETAIL_PANE_DEFAULT_BODY_PX = 288
|
||||
const DETAIL_PANE_MAX_VH = 0.7
|
||||
const DETAIL_PANE_COLLAPSED_PX = 4
|
||||
|
||||
// Ghost icon-button on the kebab-trigger scale (pane headers, list-strip menu,
|
||||
// per-server MCP actions, JSON editor format button). MUST stay a class string
|
||||
// (not a CSS @utility): the leading `size-5` is what tailwind-merge uses to
|
||||
// strip <Button size="icon">'s larger built-in size — a custom utility class
|
||||
// isn't size-merge-aware, so Button's icon size would leak and blow it up.
|
||||
// Compose extra state (data-[state=open], hover:text-destructive) with cn().
|
||||
export const ICON_BUTTON =
|
||||
'size-5 cursor-pointer rounded-[4px] text-muted-foreground/70 hover:bg-(--ui-control-active-background) hover:text-foreground'
|
||||
|
||||
export function DetailPane({
|
||||
actions,
|
||||
children,
|
||||
defaultCollapsed = false,
|
||||
defaultHeight = DETAIL_PANE_DEFAULT_BODY_PX,
|
||||
id,
|
||||
onClose,
|
||||
title
|
||||
}: {
|
||||
actions?: ReactNode
|
||||
children: ReactNode
|
||||
/** Start collapsed to the header the first time this pane is ever shown.
|
||||
* Only seeds when the id has no saved state — a later expand/collapse
|
||||
* persists and wins, so it's "collapsed by default", not "always collapsed". */
|
||||
defaultCollapsed?: boolean
|
||||
/** Default body height in px (before any user resize). */
|
||||
defaultHeight?: number
|
||||
/** Pane-store key — height overrides persist under it. */
|
||||
id: string
|
||||
/** Omit for permanent panes (collapsible to the header, never removed). */
|
||||
onClose?: () => void
|
||||
title: ReactNode
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const override = useStore($paneHeightOverride(id))
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultCollapsed && $paneState(id).get() === undefined) {
|
||||
setPaneHeightOverride(id, 0)
|
||||
}
|
||||
}, [defaultCollapsed, id])
|
||||
|
||||
const height = override ?? defaultHeight
|
||||
const collapsed = height <= DETAIL_PANE_COLLAPSED_PX
|
||||
// Sash drag mirrors the shell's y-axis pane resize: pointer capture on the
|
||||
// top edge, clamped to [0, 70vh]; double-click resets to the default.
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
const startY = event.clientY
|
||||
const startHeight = height
|
||||
const max = Math.round(window.innerHeight * DETAIL_PANE_MAX_VH)
|
||||
setDragging(true)
|
||||
|
||||
const onMove = (move: globalThis.PointerEvent) => {
|
||||
setPaneHeightOverride(id, Math.min(max, Math.max(0, Math.round(startHeight + (startY - move.clientY)))))
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
setDragging(false)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp, { once: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative flex shrink-0 flex-col border-t border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background)">
|
||||
<div
|
||||
className="group/sash absolute inset-x-0 top-0 z-10 h-1 -translate-y-1/2 cursor-row-resize"
|
||||
onDoubleClick={() => setPaneHeightOverride(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/sash:bg-(--ui-stroke-secondary)'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<header className="flex h-9 shrink-0 items-center gap-2 px-3">
|
||||
<span className="min-w-0 truncate text-xs font-medium text-foreground">{title}</span>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
{actions}
|
||||
<Button
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={collapsed ? t.common.expand : t.common.collapse}
|
||||
className={ICON_BUTTON}
|
||||
onClick={() => setPaneHeightOverride(id, collapsed ? undefined : 0)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={collapsed ? 'chevron-up' : 'chevron-down'} size="0.8125rem" />
|
||||
</Button>
|
||||
{onClose && (
|
||||
<Button aria-label={t.common.close} className={ICON_BUTTON} onClick={onClose} size="icon" variant="ghost">
|
||||
<Codicon name="close" size="0.8125rem" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-0 overflow-hidden" style={{ height: collapsed ? 0 : height }}>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// One-line control strip pinned above the list: sort/primary action on the
|
||||
// left, overflow kebab on the right.
|
||||
export function ListStrip({ left, right }: { left?: ReactNode; right?: ReactNode }) {
|
||||
return (
|
||||
<div className="mb-1 flex h-6 shrink-0 items-center justify-between gap-2 pl-2 pr-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">{left}</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">{right}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface ListStripMenuItem {
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
export interface ListStripMenuToggle {
|
||||
checked: boolean
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onToggle: (checked: boolean) => void
|
||||
}
|
||||
|
||||
// Overflow kebab for list-wide actions. `toggle` renders as the first row —
|
||||
// one label + switch line covering enable-all/disable-all (checked = every
|
||||
// visible item on; mixed reads as off so one flip always means "all on").
|
||||
export function ListStripMenu({
|
||||
items = [],
|
||||
label,
|
||||
toggle
|
||||
}: {
|
||||
items?: ListStripMenuItem[]
|
||||
label: string
|
||||
toggle?: ListStripMenuToggle
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
ICON_BUTTON,
|
||||
'data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground'
|
||||
)}
|
||||
size="icon"
|
||||
title={label}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="kebab-vertical" size="0.8125rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44" sideOffset={6}>
|
||||
{toggle && (
|
||||
<DropdownMenuItem
|
||||
disabled={toggle.disabled}
|
||||
onSelect={event => {
|
||||
// Keep the menu open so the switch is seen flipping.
|
||||
event.preventDefault()
|
||||
toggle.onToggle(!toggle.checked)
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{toggle.label}</span>
|
||||
<Switch
|
||||
checked={toggle.checked}
|
||||
className={cn('pointer-events-none shrink-0', !toggle.checked && 'opacity-60')}
|
||||
size="xs"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{items.map(item => (
|
||||
<DropdownMenuItem disabled={item.disabled} key={item.label} onSelect={item.onSelect}>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function ListStripButton({
|
||||
active,
|
||||
children,
|
||||
disabled,
|
||||
onClick
|
||||
}: {
|
||||
active?: boolean
|
||||
children: ReactNode
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'cursor-pointer text-[0.68rem] font-medium transition-colors disabled:opacity-40',
|
||||
active ? 'text-foreground' : 'text-muted-foreground/70 hover:text-foreground'
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface CapRowProps {
|
||||
active: boolean
|
||||
busy?: boolean
|
||||
enabled: boolean
|
||||
meta?: ReactNode
|
||||
onSelect: () => void
|
||||
onToggle: (checked: boolean) => void
|
||||
rowId?: string
|
||||
/** Second line under the name (category, description, status). Rows grow to h-11. */
|
||||
subtitle?: ReactNode
|
||||
title: string
|
||||
toggleLabel: string
|
||||
}
|
||||
|
||||
// The one row used by all three lists. Fixed height, always-visible switch —
|
||||
// state reads from the switch + dimmed title, toggling never requires
|
||||
// selecting first. Off rows dim; the switch itself dims when off.
|
||||
export function CapRow({
|
||||
active,
|
||||
busy,
|
||||
enabled,
|
||||
meta,
|
||||
onSelect,
|
||||
onToggle,
|
||||
rowId,
|
||||
subtitle,
|
||||
title,
|
||||
toggleLabel
|
||||
}: CapRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/row row-hover flex w-full shrink-0 items-center rounded-md hover:text-foreground',
|
||||
subtitle ? 'h-11' : 'h-8',
|
||||
active ? 'bg-(--ui-row-active-background) text-foreground' : 'text-(--ui-text-secondary)'
|
||||
)}
|
||||
id={rowId}
|
||||
>
|
||||
<RowButton
|
||||
className="flex h-full min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md pl-2 pr-1.5 text-left"
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
'block truncate text-[0.78rem]',
|
||||
enabled ? 'font-medium text-foreground/85' : 'font-normal text-muted-foreground/60'
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{subtitle != null && (
|
||||
<span className="flex min-w-0 items-center gap-1 text-[0.62rem] text-muted-foreground/50">
|
||||
{typeof subtitle === 'string' ? <span className="truncate">{subtitle}</span> : subtitle}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{meta != null && (
|
||||
<span className="shrink-0 rounded bg-(--ui-bg-quinary) px-1 py-px text-[0.6rem] tabular-nums leading-3.5 text-(--ui-text-tertiary)">
|
||||
{meta}
|
||||
</span>
|
||||
)}
|
||||
</RowButton>
|
||||
<Switch
|
||||
aria-label={toggleLabel}
|
||||
checked={enabled}
|
||||
className={cn('mr-1.5 shrink-0 cursor-pointer', !enabled && 'opacity-60')}
|
||||
disabled={busy}
|
||||
onCheckedChange={onToggle}
|
||||
size="xs"
|
||||
title={toggleLabel}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { PageLoader } from '@/components/page-loader'
|
||||
import { StatusDot, type StatusTone } from '@/components/status-dot'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { ErrorBanner } from '@/components/ui/error-state'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
@@ -16,15 +15,13 @@ import {
|
||||
} from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { openExternalLink } from '@/lib/external-link'
|
||||
import { ExternalLink, Save, Trash2 } from '@/lib/icons'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { runGatewayRestart } from '@/store/system-actions'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { DetailColumn, ListColumn, MasterDetail } from '../master-detail'
|
||||
import { PageSearchShell } from '../page-search-shell'
|
||||
import { CREDENTIAL_CONTROL_CLASS } from '../settings/credential-key-ui'
|
||||
import { ListRow } from '../settings/primitives'
|
||||
@@ -174,7 +171,7 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
return []
|
||||
}
|
||||
|
||||
const q = normalize(query)
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
if (!q) {
|
||||
return platforms
|
||||
@@ -269,15 +266,14 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
{...props}
|
||||
onSearchChange={setQuery}
|
||||
searchHidden={(platforms?.length ?? 0) === 0}
|
||||
searchHints={platforms?.slice(0, 5).map(platform => t.common.tryHint(platform.name.toLowerCase()))}
|
||||
searchPlaceholder={m.search}
|
||||
searchValue={query}
|
||||
>
|
||||
{!platforms ? (
|
||||
<PageLoader label={m.loading} />
|
||||
) : (
|
||||
<MasterDetail>
|
||||
<ListColumn>
|
||||
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[14rem_minmax(0,1fr)]">
|
||||
<aside className="min-h-0 overflow-y-auto p-2">
|
||||
<ul className="space-y-1">
|
||||
{visiblePlatforms.map(platform => (
|
||||
<li key={platform.id}>
|
||||
@@ -289,21 +285,9 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ListColumn>
|
||||
</aside>
|
||||
|
||||
<DetailColumn
|
||||
actionBar={
|
||||
selected && (
|
||||
<PlatformActionBar
|
||||
hasEdits={Object.keys(trimEdits(edits[selected.id] || {})).length > 0}
|
||||
onSave={() => void handleSave(selected)}
|
||||
onToggle={enabled => void handleToggle(selected, enabled)}
|
||||
platform={selected}
|
||||
saving={saving}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<main className="min-h-0 overflow-hidden">
|
||||
{selected && (
|
||||
<PlatformDetail
|
||||
edits={edits[selected.id] || {}}
|
||||
@@ -317,12 +301,14 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
}
|
||||
}))
|
||||
}
|
||||
onSave={() => void handleSave(selected)}
|
||||
onToggle={enabled => void handleToggle(selected, enabled)}
|
||||
platform={selected}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
</DetailColumn>
|
||||
</MasterDetail>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</PageSearchShell>
|
||||
)
|
||||
@@ -340,8 +326,10 @@ function PlatformRow({
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'row-hover flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:text-foreground',
|
||||
active ? 'bg-(--ui-row-active-background) text-foreground' : 'text-(--ui-text-secondary)'
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors',
|
||||
active
|
||||
? 'bg-(--ui-row-active-background) text-foreground'
|
||||
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
@@ -359,155 +347,14 @@ function PlatformDetail({
|
||||
edits,
|
||||
onClear,
|
||||
onEdit,
|
||||
onSave,
|
||||
onToggle,
|
||||
platform,
|
||||
saving
|
||||
}: {
|
||||
edits: Record<string, string>
|
||||
onClear: (key: string) => void
|
||||
onEdit: (key: string, value: string) => void
|
||||
platform: MessagingPlatformInfo
|
||||
saving: string | null
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const m = t.messaging
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
const requiredFields = platform.env_vars.filter(field => field.required)
|
||||
const optionalFields = platform.env_vars.filter(field => !field.required && !fieldCopy(field, m).advanced)
|
||||
const advancedFields = platform.env_vars.filter(field => !field.required && fieldCopy(field, m).advanced)
|
||||
const hiddenCount = advancedFields.length
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="flex items-start gap-3">
|
||||
<PlatformAvatar platformId={platform.id} platformName={platform.name} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="min-w-0 truncate text-[0.9375rem] font-semibold tracking-tight">{platform.name}</h3>
|
||||
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state, m)}</StatePill>
|
||||
{/* Resting states earn no pill — only actionable ones. */}
|
||||
{!platform.configured && <SetupPill active={false}>{m.needsSetup}</SetupPill>}
|
||||
{!platform.gateway_running && <SetupPill active={false}>{m.gatewayStopped}</SetupPill>}
|
||||
</div>
|
||||
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{platform.description}
|
||||
</p>
|
||||
<PlatformHint platform={platform} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{platform.error_message && <ErrorBanner>{platform.error_message}</ErrorBanner>}
|
||||
|
||||
<section>
|
||||
<SectionTitle>{m.getCredentials}</SectionTitle>
|
||||
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{introCopy(platform, m)}
|
||||
</p>
|
||||
{platform.docs_url && (
|
||||
<div className="mt-3">
|
||||
<Button asChild size="sm" variant="textStrong">
|
||||
<a
|
||||
href={platform.docs_url}
|
||||
onClick={event => {
|
||||
// Route through the validated external opener instead of
|
||||
// letting Electron resolve the anchor. A packaged build's
|
||||
// empty/relative href resolves to the app's own
|
||||
// index.html file path, which shell.openPath then fails to
|
||||
// open ("file not found"). Plugin platforms (Teams, etc.)
|
||||
// ship no docs_url, so this guard + handler keeps the
|
||||
// button from ever pointing at a local bundle path.
|
||||
event.preventDefault()
|
||||
openExternalLink(platform.docs_url)
|
||||
}}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{m.openSetupGuide}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionTitle>{m.required}</SectionTitle>
|
||||
<div className="mt-3 grid gap-1">
|
||||
{requiredFields.length > 0 ? (
|
||||
requiredFields.map(field => (
|
||||
<MessagingField
|
||||
edits={edits}
|
||||
field={field}
|
||||
key={field.key}
|
||||
onClear={onClear}
|
||||
onEdit={onEdit}
|
||||
saving={saving}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{m.noTokenNeeded}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{optionalFields.length > 0 && (
|
||||
<section>
|
||||
<SectionTitle>{m.recommended}</SectionTitle>
|
||||
<div className="mt-3 grid gap-1">
|
||||
{optionalFields.map(field => (
|
||||
<MessagingField
|
||||
edits={edits}
|
||||
field={field}
|
||||
key={field.key}
|
||||
onClear={onClear}
|
||||
onEdit={onEdit}
|
||||
saving={saving}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hiddenCount > 0 && (
|
||||
<section>
|
||||
<button
|
||||
className="flex w-full items-center justify-between gap-2 py-0.5 text-left text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => setShowAdvanced(value => !value)}
|
||||
type="button"
|
||||
>
|
||||
<span>{m.advanced(hiddenCount)}</span>
|
||||
<DisclosureCaret open={showAdvanced} size="0.875rem" />
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-3 grid gap-1">
|
||||
{advancedFields.map(field => (
|
||||
<MessagingField
|
||||
edits={edits}
|
||||
field={field}
|
||||
key={field.key}
|
||||
onClear={onClear}
|
||||
onEdit={onEdit}
|
||||
saving={saving}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PlatformActionBar({
|
||||
hasEdits,
|
||||
onSave,
|
||||
onToggle,
|
||||
platform,
|
||||
saving
|
||||
}: {
|
||||
hasEdits: boolean
|
||||
onSave: () => void
|
||||
onToggle: (enabled: boolean) => void
|
||||
platform: MessagingPlatformInfo
|
||||
@@ -515,26 +362,165 @@ function PlatformActionBar({
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const m = t.messaging
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
const hasEdits = Object.keys(trimEdits(edits)).length > 0
|
||||
const requiredFields = platform.env_vars.filter(field => field.required)
|
||||
const optionalFields = platform.env_vars.filter(field => !field.required && !fieldCopy(field, m).advanced)
|
||||
const advancedFields = platform.env_vars.filter(field => !field.required && fieldCopy(field, m).advanced)
|
||||
const hiddenCount = advancedFields.length
|
||||
const isSavingEnv = saving === `env:${platform.id}`
|
||||
|
||||
return (
|
||||
<>
|
||||
<Switch
|
||||
aria-label={platform.enabled ? m.disableAria(platform.name) : m.enableAria(platform.name)}
|
||||
checked={platform.enabled}
|
||||
disabled={saving === `enabled:${platform.id}`}
|
||||
onCheckedChange={onToggle}
|
||||
size="xs"
|
||||
/>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-2xl space-y-5 px-5 py-4">
|
||||
<header className="flex items-start gap-3">
|
||||
<PlatformAvatar platformId={platform.id} platformName={platform.name} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-[0.9375rem] font-semibold tracking-tight">{platform.name}</h3>
|
||||
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{platform.description}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<StatePill tone={stateTone(platform)}>{stateLabel(platform.state, m)}</StatePill>
|
||||
<SetupPill active={platform.configured}>
|
||||
{platform.configured ? m.credentialsSet : m.needsSetup}
|
||||
</SetupPill>
|
||||
{!platform.gateway_running && <SetupPill active={false}>{m.gatewayStopped}</SetupPill>}
|
||||
</div>
|
||||
<PlatformHint platform={platform} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{hasEdits && <span className="text-xs text-muted-foreground">{m.unsavedChanges}</span>}
|
||||
<Button disabled={!hasEdits || isSavingEnv} onClick={onSave} size="sm">
|
||||
<Save />
|
||||
{isSavingEnv ? m.saving : m.saveChanges}
|
||||
</Button>
|
||||
{platform.error_message && (
|
||||
<div className="flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-destructive">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{platform.error_message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<SectionTitle>{m.getCredentials}</SectionTitle>
|
||||
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{introCopy(platform, m)}
|
||||
</p>
|
||||
{platform.docs_url && (
|
||||
<div className="mt-3">
|
||||
<Button asChild size="sm" variant="textStrong">
|
||||
<a
|
||||
href={platform.docs_url}
|
||||
onClick={event => {
|
||||
// Route through the validated external opener instead of
|
||||
// letting Electron resolve the anchor. A packaged build's
|
||||
// empty/relative href resolves to the app's own
|
||||
// index.html file path, which shell.openPath then fails to
|
||||
// open ("file not found"). Plugin platforms (Teams, etc.)
|
||||
// ship no docs_url, so this guard + handler keeps the
|
||||
// button from ever pointing at a local bundle path.
|
||||
event.preventDefault()
|
||||
openExternalLink(platform.docs_url)
|
||||
}}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{m.openSetupGuide}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionTitle>{m.required}</SectionTitle>
|
||||
<div className="mt-3 grid gap-1">
|
||||
{requiredFields.length > 0 ? (
|
||||
requiredFields.map(field => (
|
||||
<MessagingField
|
||||
edits={edits}
|
||||
field={field}
|
||||
key={field.key}
|
||||
onClear={onClear}
|
||||
onEdit={onEdit}
|
||||
saving={saving}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{m.noTokenNeeded}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{optionalFields.length > 0 && (
|
||||
<section>
|
||||
<SectionTitle>{m.recommended}</SectionTitle>
|
||||
<div className="mt-3 grid gap-1">
|
||||
{optionalFields.map(field => (
|
||||
<MessagingField
|
||||
edits={edits}
|
||||
field={field}
|
||||
key={field.key}
|
||||
onClear={onClear}
|
||||
onEdit={onEdit}
|
||||
saving={saving}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hiddenCount > 0 && (
|
||||
<section>
|
||||
<button
|
||||
className="flex w-full items-center justify-between gap-2 py-0.5 text-left text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => setShowAdvanced(value => !value)}
|
||||
type="button"
|
||||
>
|
||||
<span>{m.advanced(hiddenCount)}</span>
|
||||
<DisclosureCaret open={showAdvanced} size="0.875rem" />
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-3 grid gap-1">
|
||||
{advancedFields.map(field => (
|
||||
<MessagingField
|
||||
edits={edits}
|
||||
field={field}
|
||||
key={field.key}
|
||||
onClear={onClear}
|
||||
onEdit={onEdit}
|
||||
saving={saving}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
<footer className="bg-(--ui-chat-surface-background) px-5 py-2.5">
|
||||
<div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2">
|
||||
<Switch
|
||||
aria-label={platform.enabled ? m.disableAria(platform.name) : m.enableAria(platform.name)}
|
||||
checked={platform.enabled}
|
||||
disabled={saving === `enabled:${platform.id}`}
|
||||
onCheckedChange={onToggle}
|
||||
size="xs"
|
||||
/>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{hasEdits && <span className="text-xs text-muted-foreground">{m.unsavedChanges}</span>}
|
||||
<Button disabled={!hasEdits || isSavingEnv} onClick={onSave} size="sm">
|
||||
<Save />
|
||||
{isSavingEnv ? m.saving : m.saveChanges}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,51 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface OverlayActionButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
tone?: 'default' | 'danger' | 'subtle'
|
||||
}
|
||||
|
||||
export function OverlayActionButton({
|
||||
children,
|
||||
className,
|
||||
tone = 'default',
|
||||
type = 'button',
|
||||
...props
|
||||
}: OverlayActionButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex h-8 items-center rounded-md border px-3 text-xs font-medium transition-colors disabled:cursor-default disabled:opacity-45',
|
||||
tone === 'default' &&
|
||||
'border-[color-mix(in_srgb,var(--dt-border)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_80%,transparent)] text-foreground hover:bg-[color-mix(in_srgb,var(--dt-muted)_46%,var(--dt-card))]',
|
||||
tone === 'subtle' &&
|
||||
'h-7 border-transparent px-2 text-muted-foreground hover:border-[color-mix(in_srgb,var(--dt-border)_54%,transparent)] hover:bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)] hover:text-foreground',
|
||||
tone === 'danger' &&
|
||||
'h-7 border-transparent px-2 text-destructive hover:border-[color-mix(in_srgb,var(--dt-destructive)_40%,transparent)] hover:bg-[color-mix(in_srgb,var(--dt-destructive)_10%,transparent)] hover:text-destructive',
|
||||
className
|
||||
)}
|
||||
type={type}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface OverlayIconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
// Overlay chrome icon action — same titlebar-sized ghost button as the overlay
|
||||
// close (X), so footer/header actions read identically across breakpoints.
|
||||
export function OverlayIconButton({ children, className, type = 'button', ...props }: OverlayIconButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
className={cn('text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground', className)}
|
||||
size="icon-titlebar"
|
||||
<OverlayActionButton
|
||||
className={cn('h-7 w-7 justify-center px-0 [&_svg]:size-4', className)}
|
||||
tone="subtle"
|
||||
type={type}
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</OverlayActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { Fragment, type ReactNode } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { TabDropdown } from '@/components/ui/tab-dropdown'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { PAGE_INSET_X, PAGE_MAX_W } from '../layout-constants'
|
||||
|
||||
// The wide rail and the narrow dropdown swap at exactly the width where
|
||||
// OverlaySplitLayout drops to a single column, so the rail never stacks.
|
||||
const RAIL_HIDDEN = 'max-[47.5rem]:hidden'
|
||||
const BAR_HIDDEN = 'hidden max-[47.5rem]:flex'
|
||||
|
||||
interface OverlaySplitLayoutProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
@@ -41,10 +35,7 @@ export function OverlaySplitLayout({ children, className }: OverlaySplitLayoutPr
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Narrow: one column, and pin rows to [nav-bar auto | main 1fr] — without
|
||||
// an explicit template the grid's default align-content:stretch splits the
|
||||
// height evenly across the two rows, shoving the content to mid-screen.
|
||||
'grid h-full min-h-0 flex-1 grid-cols-[13rem_minmax(0,1fr)] overflow-hidden bg-transparent max-[47.5rem]:grid-cols-1 max-[47.5rem]:grid-rows-[auto_minmax(0,1fr)]',
|
||||
'grid h-full min-h-0 flex-1 grid-cols-[13rem_minmax(0,1fr)] overflow-hidden bg-transparent max-[47.5rem]:grid-cols-1',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -73,9 +64,7 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
|
||||
return (
|
||||
<main
|
||||
className={cn(
|
||||
// Narrow: the OverlayNav dropdown bar already clears the titlebar, so
|
||||
// drop the tall top pad to a normal gap below it.
|
||||
'mx-auto flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-transparent pb-3 pt-[calc(var(--titlebar-height)/2+1rem)] max-[47.5rem]:pt-2',
|
||||
'mx-auto flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-transparent pb-3 pt-[calc(var(--titlebar-height)/2+1rem)]',
|
||||
PAGE_MAX_W,
|
||||
PAGE_INSET_X,
|
||||
className
|
||||
@@ -114,96 +103,3 @@ export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, tra
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export interface OverlayNavLink {
|
||||
active: boolean
|
||||
icon: IconComponent
|
||||
id: string
|
||||
label: string
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
export interface OverlayNavGroup extends OverlayNavLink {
|
||||
/** Sub-links: expanded under the active group on the rail, always listed
|
||||
* (flattened + indented) in the narrow dropdown. */
|
||||
children?: OverlayNavLink[]
|
||||
/** Visual break before this group — a spacer on the rail, a separator in
|
||||
* the dropdown. */
|
||||
gapBefore?: boolean
|
||||
}
|
||||
|
||||
// Data-driven pane nav: one model renders a persistent left rail on wide
|
||||
// viewports and a single dropdown bar on narrow ones (matching the tab
|
||||
// dropdown in PageSearchShell), so every OverlaySplitLayout pane degrades the
|
||||
// same way instead of stacking its whole sidebar. Drop it in as the first
|
||||
// child of an OverlaySplitLayout, before OverlayMain.
|
||||
export function OverlayNav({ footer, groups }: { footer?: ReactNode; groups: OverlayNavGroup[] }) {
|
||||
return (
|
||||
<>
|
||||
<OverlaySidebar className={RAIL_HIDDEN}>
|
||||
{groups.map(group => (
|
||||
<Fragment key={group.id}>
|
||||
{group.gapBefore && <div aria-hidden className="h-2" />}
|
||||
<OverlayNavItem active={group.active} icon={group.icon} label={group.label} onClick={group.onSelect} />
|
||||
{group.children && group.active && (
|
||||
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
|
||||
{group.children.map(child => (
|
||||
<OverlayNavItem
|
||||
active={child.active}
|
||||
icon={child.icon}
|
||||
key={child.id}
|
||||
label={child.label}
|
||||
nested
|
||||
onClick={child.onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{footer && <div className="mt-auto flex items-center gap-1 pt-2">{footer}</div>}
|
||||
</OverlaySidebar>
|
||||
|
||||
{/* Narrow: ride the OverlayView titlebar strip so the dropdown shares the
|
||||
close button's row instead of taking its own. The bar is
|
||||
pointer-events-none (children opt back in) so the floating X underneath
|
||||
stays clickable; pr clears it, no-drag beats the strip's drag region,
|
||||
and the height matches the strip so the trigger lines up with the X. */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none relative z-20 h-[calc(var(--titlebar-height)+0.1875rem)] items-center justify-between gap-2 pl-3 pr-12',
|
||||
BAR_HIDDEN
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-auto min-w-0 [-webkit-app-region:no-drag]">
|
||||
<TabDropdown
|
||||
align="start"
|
||||
items={groups.flatMap(group => [
|
||||
{
|
||||
active: group.active && !group.children?.some(child => child.active),
|
||||
icon: group.icon,
|
||||
id: group.id,
|
||||
label: group.label,
|
||||
onSelect: group.onSelect,
|
||||
separatorBefore: group.gapBefore
|
||||
},
|
||||
...(group.children ?? []).map(child => ({
|
||||
active: child.active,
|
||||
icon: child.icon,
|
||||
id: child.id,
|
||||
indent: true,
|
||||
label: child.label,
|
||||
onSelect: child.onSelect
|
||||
}))
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
{footer && (
|
||||
<div className="pointer-events-auto flex shrink-0 items-center gap-1 [-webkit-app-region:no-drag]">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,19 +81,7 @@ export function PanelHeader({ actions, subtitle, title }: PanelHeaderProps) {
|
||||
}
|
||||
|
||||
export function PanelBody({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Side-by-side master/detail on a wide card; once it narrows (same
|
||||
// threshold the other overlays collapse at) stack the list above the
|
||||
// detail so the detail keeps full width instead of being squished.
|
||||
'flex min-h-0 flex-1 flex-col gap-4 overflow-hidden min-[47.5rem]:flex-row min-[47.5rem]:gap-5',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
return <div className={cn('flex min-h-0 flex-1 gap-5 overflow-hidden', className)}>{children}</div>
|
||||
}
|
||||
|
||||
interface PanelListProps {
|
||||
@@ -104,8 +92,6 @@ interface PanelListProps {
|
||||
onSearchChange?: (value: string) => void
|
||||
searchLabel?: string
|
||||
searchPlaceholder?: string
|
||||
/** Data-derived rotating placeholder nudges (see SearchField.hints). */
|
||||
searchHints?: string[]
|
||||
searchValue?: string
|
||||
}
|
||||
|
||||
@@ -118,18 +104,14 @@ export function PanelList({
|
||||
onSearchChange,
|
||||
searchLabel,
|
||||
searchPlaceholder,
|
||||
searchHints,
|
||||
searchValue
|
||||
}: PanelListProps) {
|
||||
return (
|
||||
// Full-width and height-capped when stacked (narrow); a fixed 13rem rail
|
||||
// beside the detail when wide.
|
||||
<div className={cn('flex w-full shrink-0 flex-col max-[47.5rem]:max-h-[40%] min-[47.5rem]:w-52', className)}>
|
||||
<div className={cn('flex w-52 shrink-0 flex-col', className)}>
|
||||
{onSearchChange ? (
|
||||
<SearchField
|
||||
aria-label={searchLabel ?? searchPlaceholder ?? ''}
|
||||
containerClassName="mb-1 w-full shrink-0"
|
||||
hints={searchHints}
|
||||
onChange={onSearchChange}
|
||||
placeholder={searchPlaceholder ?? ''}
|
||||
value={searchValue ?? ''}
|
||||
@@ -174,8 +156,10 @@ export function PanelListRow({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/row row-hover relative flex h-7 w-full items-center rounded-md text-[0.78rem] hover:text-foreground',
|
||||
active ? 'bg-(--ui-row-active-background) text-foreground' : 'text-(--ui-text-secondary)'
|
||||
'group/row relative flex h-7 w-full items-center rounded-md text-[0.78rem] transition-colors duration-100 ease-out',
|
||||
active
|
||||
? 'bg-(--ui-row-active-background) text-foreground'
|
||||
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground'
|
||||
)}
|
||||
data-panel-row={rowKey}
|
||||
>
|
||||
|
||||
@@ -1,74 +1,34 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { ResponsiveTabs } from '@/components/ui/tab-dropdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Tabs are data, not nodes: the shell owns their presentation so every page
|
||||
// gets the same behavior — a centered TextTab row on wide viewports that
|
||||
// collapses into a dropdown when the header can't fit both search and tabs.
|
||||
export interface PageShellTab {
|
||||
id: string
|
||||
label: string
|
||||
/** Count badge. `null` = still loading (renders a skeleton); `undefined` = no badge. */
|
||||
meta?: string | number | null
|
||||
}
|
||||
|
||||
interface PageSearchShellProps extends React.ComponentProps<'section'> {
|
||||
children: ReactNode
|
||||
tabs?: PageShellTab[]
|
||||
activeTab?: string
|
||||
onTabChange?: (id: string) => void
|
||||
/** Primary tabs shown on the top row, beside the search. */
|
||||
tabs?: ReactNode
|
||||
/** Secondary filters shown full-width on their own row below (expands). */
|
||||
filters?: ReactNode
|
||||
onSearchChange: (value: string) => void
|
||||
searchPlaceholder: string
|
||||
/** Data-derived rotating placeholder nudges (see SearchField.hints). */
|
||||
searchHints?: string[]
|
||||
searchTrailingAction?: ReactNode
|
||||
searchValue: string
|
||||
/** Hide the search field when there's nothing to search (empty dataset). */
|
||||
searchHidden?: boolean
|
||||
/** Right-aligned control in the header's trailing cell (e.g. a refresh button)
|
||||
* so mouse users get a visible affordance for the refresh hotkey. */
|
||||
searchTrailingAction?: ReactNode
|
||||
}
|
||||
|
||||
function ShellTabs({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange
|
||||
}: {
|
||||
tabs: PageShellTab[]
|
||||
activeTab?: string
|
||||
onTabChange?: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveTabs
|
||||
onChange={id => onTabChange?.(id)}
|
||||
tabs={tabs}
|
||||
value={activeTab ?? tabs[0]?.id ?? ''}
|
||||
wideClassName="justify-center"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageSearchShell({
|
||||
children,
|
||||
className,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
filters,
|
||||
onSearchChange,
|
||||
searchPlaceholder,
|
||||
searchHints,
|
||||
searchTrailingAction,
|
||||
searchValue,
|
||||
searchHidden = false,
|
||||
searchTrailingAction,
|
||||
...props
|
||||
}: PageSearchShellProps) {
|
||||
const hasTabs = (tabs?.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<section
|
||||
{...props}
|
||||
@@ -77,8 +37,9 @@ export function PageSearchShell({
|
||||
{/*
|
||||
Header lives in the page body, below the window chrome (the shell floats
|
||||
traffic lights over the top titlebar-height strip, which the `pt` clears
|
||||
and leaves draggable). Search left, tabs centered on the page via the
|
||||
1fr/auto/1fr grid; the trailing 1fr keeps the center honest.
|
||||
and leaves draggable). Top row: primary tabs + search. Second row:
|
||||
secondary filters, full-width so they expand. Interactive bits opt out
|
||||
of the drag region.
|
||||
*/}
|
||||
{/*
|
||||
IMPORTANT: do NOT put `-webkit-app-region: drag` on this header. It spans
|
||||
@@ -90,21 +51,20 @@ export function PageSearchShell({
|
||||
(see app-shell.tsx), so window dragging still works here.
|
||||
*/}
|
||||
<div className="shrink-0">
|
||||
{(hasTabs || !searchHidden) && (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 px-3 pb-2 pt-[calc(var(--titlebar-height)+0.5rem)]">
|
||||
<div className="flex min-w-0 items-center justify-start">
|
||||
{!searchHidden && (
|
||||
{(tabs || !searchHidden) && (
|
||||
<div className="flex items-center gap-3 px-3 pb-2 pt-[calc(var(--titlebar-height)+0.5rem)]">
|
||||
{tabs ? <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">{tabs}</div> : null}
|
||||
{!searchHidden && (
|
||||
<div className={cn('flex shrink-0 items-center', !tabs && 'flex-1')}>
|
||||
<SearchField
|
||||
containerClassName="max-w-[45vw]"
|
||||
hints={searchHints}
|
||||
onChange={onSearchChange}
|
||||
placeholder={searchPlaceholder}
|
||||
trailingAction={searchTrailingAction}
|
||||
value={searchValue}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{hasTabs ? <ShellTabs activeTab={activeTab} onTabChange={onTabChange} tabs={tabs!} /> : <span />}
|
||||
<div className="flex min-w-0 items-center justify-end">{searchTrailingAction}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{filters ? <div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 pb-2">{filters}</div> : null}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { CodeEditor } from '@/components/chat/code-editor'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { SanitizedInput } from '@/components/ui/sanitized-input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
createProfile,
|
||||
deleteProfile,
|
||||
@@ -28,7 +28,6 @@ import { useI18n } from '@/i18n'
|
||||
import { AlertTriangle, Save } from '@/lib/icons'
|
||||
import { profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
|
||||
import { slug } from '@/lib/sanitize'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $profileColors, refreshProfiles } from '@/store/profile'
|
||||
@@ -101,7 +100,7 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
}, [profiles, selectedName])
|
||||
|
||||
const visibleProfiles = useMemo(() => {
|
||||
const q = normalize(query)
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
if (!profiles || !q) {
|
||||
return profiles ?? []
|
||||
@@ -203,7 +202,7 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
profile.is_default
|
||||
? []
|
||||
: [
|
||||
{ icon: 'edit', label: p.renameMenu, onSelect: () => setPendingRename(profile) },
|
||||
{ icon: 'edit', label: p.rename, onSelect: () => setPendingRename(profile) },
|
||||
{
|
||||
icon: 'trash',
|
||||
label: t.common.delete,
|
||||
@@ -416,6 +415,7 @@ function SoulEditor({ profileName }: { profileName: string }) {
|
||||
}, [p, profileName])
|
||||
|
||||
const dirty = content !== original
|
||||
const isEmpty = !content.trim()
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
@@ -445,16 +445,12 @@ function SoulEditor({ profileName }: { profileName: string }) {
|
||||
{loading ? (
|
||||
<PageLoader className="min-h-44" label={p.loadingSoul} />
|
||||
) : (
|
||||
<div className="min-h-48">
|
||||
<CodeEditor
|
||||
filePath="SOUL.md"
|
||||
framed
|
||||
initialValue={content}
|
||||
key={profileName}
|
||||
onChange={setContent}
|
||||
onSave={() => void handleSave()}
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
className="min-h-48 font-mono text-xs leading-5"
|
||||
onChange={event => setContent(event.target.value)}
|
||||
placeholder={isEmpty ? p.emptySoul : undefined}
|
||||
value={content}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -185,7 +185,7 @@ export function RemoteFolderPicker() {
|
||||
function FolderRow({ disabled = false, name, onClick }: { disabled?: boolean; name: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className="row-hover flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-(--ui-text-secondary) hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
|
||||
@@ -275,7 +275,7 @@ function ProjectTreeRow({
|
||||
aria-expanded={isFolder ? node.isOpen : undefined}
|
||||
aria-selected={node.isSelected}
|
||||
className={cn(
|
||||
'group/row row-hover flex h-full select-none items-center gap-1 border border-transparent px-3 text-xs font-normal leading-(--file-tree-row-height) text-(--ui-text-secondary) hover:text-foreground',
|
||||
'group/row flex h-full cursor-pointer select-none items-center gap-1 border border-transparent px-3 text-xs font-normal leading-(--file-tree-row-height) text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:text-foreground hover:transition-none',
|
||||
node.isSelected && 'bg-(--ui-row-active-background) text-foreground',
|
||||
isPlaceholder && 'pointer-events-none italic text-muted-foreground/70'
|
||||
)}
|
||||
|
||||
@@ -206,7 +206,7 @@ function ReviewDirRow({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="group/review-row row-hover flex h-6 select-none items-center gap-1.5 rounded-md pr-1.5 text-xs text-(--ui-text-secondary) hover:text-foreground"
|
||||
className="group/review-row flex h-6 cursor-pointer select-none items-center gap-1.5 rounded-md pr-1.5 text-xs text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:text-foreground hover:transition-none"
|
||||
onClick={toggle}
|
||||
style={rowStyle(depth)}
|
||||
>
|
||||
@@ -302,7 +302,7 @@ function ReviewFileRow({ node, depth }: { node: ReviewTreeNode; depth: number })
|
||||
<div
|
||||
aria-selected={selected}
|
||||
className={cn(
|
||||
'group/review-row row-hover flex h-6 select-none items-center gap-1.5 rounded-md pr-1.5 text-xs text-(--ui-text-secondary) hover:text-foreground',
|
||||
'group/review-row flex h-6 cursor-pointer select-none items-center gap-1.5 rounded-md pr-1.5 text-xs text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:text-foreground hover:transition-none',
|
||||
selected && 'bg-(--ui-row-active-background) text-foreground'
|
||||
)}
|
||||
draggable
|
||||
|
||||
@@ -44,12 +44,7 @@ export function TerminalInstance({ id, active, cwd, onAddSelectionToChat, revive
|
||||
>
|
||||
{status === 'starting' && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
|
||||
<Loader
|
||||
className="size-8 text-(--ui-text-tertiary)"
|
||||
pathSteps={180}
|
||||
strokeScale={0.68}
|
||||
type="spiral-search"
|
||||
/>
|
||||
<Loader className="size-8 text-(--ui-text-tertiary)" pathSteps={180} strokeScale={0.68} type="spiral-search" />
|
||||
</div>
|
||||
)}
|
||||
{selection.trim() && (
|
||||
|
||||
@@ -62,10 +62,12 @@ export function SessionSwitcher() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'row-hover flex items-center rounded leading-tight',
|
||||
'flex cursor-pointer items-center rounded leading-tight',
|
||||
HUD_ITEM,
|
||||
HUD_TEXT,
|
||||
selected ? 'bg-accent text-accent-foreground' : 'text-(--ui-text-secondary)'
|
||||
selected
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background)'
|
||||
)}
|
||||
key={session.id}
|
||||
onMouseDown={e => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { type MutableRefObject, useCallback, useState } from 'react'
|
||||
|
||||
import { getHermesConfig, getHermesConfigDefaults } from '@/hermes'
|
||||
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '@/lib/chat-runtime'
|
||||
import { normalize } from '@/lib/text'
|
||||
import {
|
||||
$currentCwd,
|
||||
setAvailablePersonalities,
|
||||
@@ -34,7 +33,7 @@ function normalizeConfigEffort(value: unknown): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
const effort = normalize(value)
|
||||
const effort = value.trim().toLowerCase()
|
||||
|
||||
return effort === 'false' || effort === 'disabled' ? 'none' : effort
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
|
||||
import { clearActiveSessionTodos } from '@/store/todos'
|
||||
import { recordToolDiff } from '@/store/tool-diffs'
|
||||
import { reportInstallMethodWarning } from '@/store/updates'
|
||||
import { notifyWorkspaceChanged, toolMayMutateFiles } from '@/store/workspace-events'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
@@ -216,6 +217,10 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||
requestDesktopOnboarding(payload.credential_warning)
|
||||
}
|
||||
|
||||
if (apply) {
|
||||
reportInstallMethodWarning(payload?.install_warning)
|
||||
}
|
||||
|
||||
void refreshHermesConfig()
|
||||
|
||||
if (modelChanged || providerChanged) {
|
||||
|
||||
@@ -329,9 +329,7 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
// `/ text` parses to an empty command name on every surface (CLI parity).
|
||||
// The composer draft was already cleared on submit and slash input never
|
||||
|
||||
@@ -9,7 +9,6 @@ import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from
|
||||
import { pathLabel, SLASH_COMMAND_RE } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { clearClarifyRequest } from '@/store/clarify'
|
||||
import {
|
||||
$composerAttachments,
|
||||
@@ -375,7 +374,7 @@ export function usePromptActions({
|
||||
return { error: copy.sessionUnavailable, ok: false }
|
||||
}
|
||||
|
||||
const target = normalize(platform)
|
||||
const target = platform.trim().toLowerCase()
|
||||
|
||||
if (!target) {
|
||||
return { error: copy.handoff.failed(''), ok: false }
|
||||
|
||||
@@ -6,7 +6,11 @@ import { type CommandsCatalogLike, filterDesktopCommandsCatalog } from '@/lib/de
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
|
||||
export type GatewayRequest = <T>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
|
||||
export type GatewayRequest = <T>(
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
timeoutMs?: number
|
||||
) => Promise<T>
|
||||
|
||||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
@@ -9,7 +9,12 @@ import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import { clearQueuedPrompts } from '@/store/composer-queue'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
$newChatProfile,
|
||||
ensureGatewayProfile,
|
||||
normalizeProfileKey
|
||||
} from '@/store/profile'
|
||||
import { resolveNewSessionCwd, tombstoneSessions, untombstoneSessions } from '@/store/projects'
|
||||
import {
|
||||
$currentCwd,
|
||||
@@ -43,7 +48,11 @@ import {
|
||||
} from '@/store/session'
|
||||
import { broadcastSessionsChanged } from '@/store/session-sync'
|
||||
import { isWatchWindow } from '@/store/windows'
|
||||
import type { SessionCreateResponse, SessionResumeResponse, UsageStats } from '@/types/hermes'
|
||||
import type {
|
||||
SessionCreateResponse,
|
||||
SessionResumeResponse,
|
||||
UsageStats
|
||||
} from '@/types/hermes'
|
||||
|
||||
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../../routes'
|
||||
import type { ClientSessionState, SidebarNavItem } from '../../../types'
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
setSessions,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { reportBackendContract } from '@/store/updates'
|
||||
import { reportBackendContract, reportInstallMethodWarning } from '@/store/updates'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionRuntimeInfo } from '@/types/hermes'
|
||||
|
||||
import type { ClientSessionState } from '../../../types'
|
||||
@@ -270,6 +270,8 @@ export function applyRuntimeInfo(info: SessionRuntimeInfo | undefined): SessionR
|
||||
requestDesktopOnboarding(info.credential_warning)
|
||||
}
|
||||
|
||||
reportInstallMethodWarning(info.install_warning)
|
||||
|
||||
if (typeof info.model === 'string') {
|
||||
setCurrentModel(info.model)
|
||||
sessionState.model = info.model
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
|
||||
import { selectableCardClass } from '@/lib/selectable-card'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent'
|
||||
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
||||
@@ -244,7 +243,7 @@ export function AppearanceSettings() {
|
||||
// One box does double duty: filter installed themes live (below), and run a
|
||||
// name search against the VS Code Marketplace (the Cmd-K "Install theme…"
|
||||
// backend) for anything not already installed.
|
||||
const needle = normalize(query)
|
||||
const needle = query.trim().toLowerCase()
|
||||
|
||||
const filteredThemes = availableThemes
|
||||
.filter(
|
||||
|
||||
@@ -134,7 +134,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
|
||||
<div className="mt-3 flex items-center gap-2 px-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Checking Computer Use status…
|
||||
</div>
|
||||
@@ -147,7 +147,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
|
||||
|
||||
if (!status.platform_supported) {
|
||||
return (
|
||||
<p className="px-1 text-xs text-muted-foreground">
|
||||
<p className="mt-3 px-1 text-xs text-muted-foreground">
|
||||
Computer Use isn't supported on this platform ({status.platform}).
|
||||
</p>
|
||||
)
|
||||
@@ -155,7 +155,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
|
||||
|
||||
if (!status.installed) {
|
||||
return (
|
||||
<p className="px-1 text-xs text-muted-foreground">
|
||||
<p className="mt-3 px-1 text-xs text-muted-foreground">
|
||||
Install the cua-driver backend below to drive this machine.
|
||||
{status.can_grant && ' Then grant Accessibility and Screen Recording here.'}
|
||||
</p>
|
||||
@@ -165,7 +165,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps)
|
||||
const failingChecks = status.checks.filter(c => c.status !== 'ok')
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<div className="mt-3 grid gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-1">
|
||||
<div className="min-w-0">
|
||||
{status.can_grant ? (
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { ChangeEvent, ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes'
|
||||
import {
|
||||
getElevenLabsVoices,
|
||||
getHermesConfigDefaults,
|
||||
getHermesConfigRecord,
|
||||
getHermesConfigSchema,
|
||||
saveHermesConfig
|
||||
} from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config-record'
|
||||
import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
|
||||
import { PanelEmpty } from '../overlays/panel'
|
||||
|
||||
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
|
||||
import { fieldCopyForSchemaKey } from './field-copy'
|
||||
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
|
||||
import { MemoryConnect } from './memory/connect'
|
||||
import { ModelSettings, ModelSettingsSkeleton } from './model-settings'
|
||||
import { ModelSettings } from './model-settings'
|
||||
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
|
||||
import { ProviderConfigPanel } from './provider-config-panel'
|
||||
|
||||
@@ -225,49 +225,31 @@ export function ConfigSettings({
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.settings.config
|
||||
// The editable draft is local (debounced autosave watches it), but it's seeded
|
||||
// from — and saved back through — the shared config cache, so edits are visible
|
||||
// in the MCP/model surfaces and reopening the page doesn't reload-flash.
|
||||
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
||||
const { data: loadedConfig, isError: configLoadFailed, refetch: refetchConfig } = useHermesConfigRecord()
|
||||
|
||||
const {
|
||||
data: schemaResponse,
|
||||
isError: schemaFailed,
|
||||
refetch: refetchSchema
|
||||
} = useQuery({
|
||||
queryKey: ['hermes-config-schema'],
|
||||
queryFn: getHermesConfigSchema,
|
||||
staleTime: 5 * 60 * 1000
|
||||
})
|
||||
|
||||
const schema = schemaResponse?.fields ?? null
|
||||
const [_defaults, setDefaults] = useState<HermesConfigRecord | null>(null)
|
||||
const [schema, setSchema] = useState<Record<string, ConfigFieldSchema> | null>(null)
|
||||
const [elevenLabsVoiceOptions, setElevenLabsVoiceOptions] = useState<string[] | null>(null)
|
||||
const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState<Record<string, string>>({})
|
||||
const saveVersionRef = useRef(0)
|
||||
const [saveVersion, setSaveVersion] = useState(0)
|
||||
|
||||
// Seed the local draft once, the first time the shared record lands.
|
||||
// Background refetches thereafter must not clobber in-progress edits.
|
||||
const configSeeded = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (loadedConfig && !configSeeded.current) {
|
||||
configSeeded.current = true
|
||||
setConfig(loadedConfig)
|
||||
}
|
||||
}, [loadedConfig])
|
||||
let cancelled = false
|
||||
Promise.all([getHermesConfigRecord(), getHermesConfigDefaults(), getHermesConfigSchema()])
|
||||
.then(([c, d, s]) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
// A profile switch invalidates (but doesn't clear) the shared config query, so
|
||||
// the local draft would otherwise keep profile A's data and autosave it into
|
||||
// B. Drop the seed + draft (re-seeds from B's refetch) and zero saveVersion so
|
||||
// the pending debounced autosave is cancelled by its effect cleanup.
|
||||
useOnProfileSwitch(() => {
|
||||
configSeeded.current = false
|
||||
setConfig(null)
|
||||
saveVersionRef.current = 0
|
||||
setSaveVersion(0)
|
||||
})
|
||||
setConfig(c)
|
||||
setDefaults(d)
|
||||
setSchema(s.fields)
|
||||
})
|
||||
.catch(err => notifyError(err, c.failedLoad))
|
||||
|
||||
return () => void (cancelled = true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount; copy is stable
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -302,9 +284,6 @@ export function ConfigSettings({
|
||||
void (async () => {
|
||||
try {
|
||||
await saveHermesConfig(config)
|
||||
// Mirror the saved record into the shared cache so MCP/model surfaces
|
||||
// reflect the edit without their own refetch.
|
||||
setHermesConfigCache(config)
|
||||
|
||||
if (saveVersionRef.current === v) {
|
||||
onConfigSaved?.()
|
||||
@@ -396,41 +375,6 @@ export function ConfigSettings({
|
||||
}
|
||||
|
||||
if (!config || !schema) {
|
||||
// A failed config/schema fetch must surface a retry, not spin forever.
|
||||
if ((configLoadFailed && !config) || (schemaFailed && !schema)) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-1">
|
||||
<PanelEmpty
|
||||
action={
|
||||
<Button
|
||||
onClick={() => {
|
||||
void refetchConfig()
|
||||
void refetchSchema()
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
{t.skills.refresh}
|
||||
</Button>
|
||||
}
|
||||
icon="error"
|
||||
title={c.failedLoad}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Model keeps its shape via a skeleton (its catalog fetch is the slow part);
|
||||
// other sections are quick config/schema reads, so a light loader is fine.
|
||||
if (activeSectionId === 'model') {
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="mb-6">
|
||||
<ModelSettingsSkeleton />
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
return <LoadingState label={c.loading} />
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import {
|
||||
Box,
|
||||
Brain,
|
||||
type IconComponent,
|
||||
Lock,
|
||||
MessageCircle,
|
||||
Mic,
|
||||
Monitor,
|
||||
Moon,
|
||||
Palette,
|
||||
Sun,
|
||||
Wrench
|
||||
} from '@/lib/icons'
|
||||
import { codiconIcon } from '@/components/ui/codicon'
|
||||
import { Brain, type IconComponent, Lock, MessageCircle, Mic, Monitor, Moon, Palette, Sun, Wrench } from '@/lib/icons'
|
||||
import type { ThemeMode } from '@/themes/context'
|
||||
|
||||
import { defineFieldCopy } from './field-copy'
|
||||
@@ -501,7 +490,7 @@ export const SECTIONS: DesktopConfigSection[] = [
|
||||
{
|
||||
id: 'model',
|
||||
label: 'Model',
|
||||
icon: Box,
|
||||
icon: codiconIcon('symbol-namespace'),
|
||||
keys: ['model_context_length', 'fallback_providers']
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type ChangeEvent, type KeyboardEvent } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { translateNow, useI18n } from '@/i18n'
|
||||
import { ChevronDown, ExternalLink, Loader2, Save, Trash2 } from '@/lib/icons'
|
||||
import { ChevronDown, ExternalLink, Loader2, Save } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
@@ -17,13 +17,6 @@ export type KeyRowProps = Omit<EnvRowProps, 'info' | 'varKey'>
|
||||
/** Matches Advanced / config field controls (ListRow + Input). */
|
||||
export const CREDENTIAL_CONTROL_CLASS = cn('h-8', CONTROL_TEXT)
|
||||
|
||||
// Resting credential field: chrome stripped so it reads as plain subtext.
|
||||
// Stacked (<@2xl) it collapses to zero box (flush under its label); at @2xl it
|
||||
// keeps the full control metrics (h-8 + px-2.5/py-1.5) so it centres on the
|
||||
// label and nothing shifts when focus/expand adds the border. `!` beats the
|
||||
// unlayered chrome CSS and the shared control sizing.
|
||||
const CRED_BARE = 'border-0! bg-transparent! shadow-none! h-auto! p-0! @2xl:h-8! @2xl:px-2.5! @2xl:py-1.5!'
|
||||
|
||||
export const isKeyVar = (key: string, info: EnvVarInfo) => info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key)
|
||||
|
||||
export const friendlyFieldLabel = (key: string, info: EnvVarInfo) =>
|
||||
@@ -44,13 +37,11 @@ export const credentialPlaceholder = (key: string, info: EnvVarInfo, label: stri
|
||||
// (redacted value) that edits in place on click. Save appears once typed; a set
|
||||
// key also offers Remove, and Esc cancels without closing the overlay.
|
||||
export function KeyField({
|
||||
expanded = false,
|
||||
info,
|
||||
placeholder,
|
||||
rowProps,
|
||||
varKey
|
||||
}: {
|
||||
expanded?: boolean
|
||||
info: EnvVarInfo
|
||||
placeholder?: string
|
||||
rowProps: KeyRowProps
|
||||
@@ -59,9 +50,6 @@ export function KeyField({
|
||||
const { t } = useI18n()
|
||||
const { edits, onClear, onSave, saving, setEdits } = rowProps
|
||||
const editing = edits[varKey] !== undefined
|
||||
// Bare (plain subtext) only while the group is collapsed and idle. Expanding
|
||||
// the card counts as "focused in", so it gets full input chrome too.
|
||||
const bare = !editing && !expanded
|
||||
const draft = edits[varKey] ?? ''
|
||||
const dirty = draft.trim().length > 0
|
||||
const busy = saving === varKey
|
||||
@@ -85,7 +73,7 @@ export function KeyField({
|
||||
if (info.is_set && !editing) {
|
||||
return (
|
||||
<Input
|
||||
className={cn(CREDENTIAL_CONTROL_CLASS, bare && CRED_BARE, 'cursor-pointer text-muted-foreground')}
|
||||
className={cn(CREDENTIAL_CONTROL_CLASS, 'cursor-pointer text-muted-foreground')}
|
||||
onFocus={startEdit}
|
||||
readOnly
|
||||
value={masked}
|
||||
@@ -94,46 +82,42 @@ export function KeyField({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
|
||||
<Input
|
||||
autoFocus={editing}
|
||||
className={cn(CREDENTIAL_CONTROL_CLASS, bare && CRED_BARE)}
|
||||
onChange={update}
|
||||
onFocus={() => {
|
||||
if (!editing) {
|
||||
startEdit()
|
||||
}
|
||||
}}
|
||||
onKeyDown={keydown}
|
||||
placeholder={placeholder ?? t.settings.credentials.pasteKey}
|
||||
type={editType}
|
||||
value={draft}
|
||||
/>
|
||||
{/* Inline trailing controls — mirrors SearchField's inline clear button.
|
||||
No floating hint row that reflows the grid or overlaps the card body;
|
||||
Esc still cancels via keydown. */}
|
||||
{editing && (info.is_set || dirty) && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="grid gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
autoFocus={editing}
|
||||
className={cn(CREDENTIAL_CONTROL_CLASS, 'min-w-0 flex-1')}
|
||||
onChange={update}
|
||||
onKeyDown={keydown}
|
||||
placeholder={placeholder ?? t.settings.credentials.pasteKey}
|
||||
type={editType}
|
||||
value={draft}
|
||||
/>
|
||||
{dirty && (
|
||||
<Button className="h-8 shrink-0" disabled={busy} onClick={() => void onSave(varKey)} size="sm">
|
||||
{busy ? <Loader2 className="animate-spin" /> : <Save />}
|
||||
{busy ? t.settings.credentials.saving : t.common.save}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{editing && (
|
||||
<div className="flex items-center gap-1 text-[0.6875rem]">
|
||||
{info.is_set && (
|
||||
<Button
|
||||
aria-label={t.settings.credentials.remove}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void onClear(varKey)}
|
||||
size="icon-xs"
|
||||
title={t.settings.credentials.remove}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
)}
|
||||
{dirty && (
|
||||
<Button className="h-8" disabled={busy} onClick={() => void onSave(varKey)} size="sm">
|
||||
{busy ? <Loader2 className="animate-spin" /> : <Save />}
|
||||
{busy ? t.settings.credentials.saving : t.common.save}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
className="text-[0.6875rem] text-destructive hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void onClear(varKey)}
|
||||
size="inline"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{t.settings.credentials.remove}
|
||||
</Button>
|
||||
<span className="text-muted-foreground">{t.settings.credentials.or}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground">{t.settings.credentials.escToCancel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -175,22 +159,15 @@ export function CredentialKeyCard({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'@container group/card rounded-[6px] p-3 transition-colors',
|
||||
'group/card rounded-[6px] px-2 py-1 transition-colors',
|
||||
expandable && 'cursor-pointer',
|
||||
expandable && !expanded && 'row-hover',
|
||||
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
|
||||
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
|
||||
)}
|
||||
onClick={expandable ? onToggle : undefined}
|
||||
onKeyDown={
|
||||
expandable
|
||||
? e => {
|
||||
// Only the card's own focus toggles it — ignore Enter/Space
|
||||
// bubbling up from the inputs/buttons inside (Enter saves a key,
|
||||
// Space types a space) so keyboard editing never collapses the card.
|
||||
if (e.target !== e.currentTarget) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
@@ -201,11 +178,8 @@ export function CredentialKeyCard({
|
||||
role={expandable ? 'button' : undefined}
|
||||
tabIndex={expandable ? 0 : undefined}
|
||||
>
|
||||
{/* One CSS grid: 1 col stacked, 2 cols at @2xl. p-3 card padding = gap-3
|
||||
row/col gaps, everything top-left aligned (items-start), no indents.
|
||||
The label row is h-8 to line up with the input row beside it. */}
|
||||
<div className="grid grid-cols-1 items-start gap-x-3 gap-y-1.5 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:gap-y-3">
|
||||
<div className="flex h-8 min-w-0 items-center gap-2">
|
||||
<div className="grid gap-3 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={cn('size-2 shrink-0 rounded-full', info.is_set ? 'bg-primary' : 'bg-(--ui-stroke-secondary)')}
|
||||
/>
|
||||
@@ -225,7 +199,7 @@ export function CredentialKeyCard({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="min-w-0"
|
||||
className="min-w-0 sm:justify-self-end"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onFocus={() => {
|
||||
if (expandable && !expanded) {
|
||||
@@ -233,21 +207,21 @@ export function CredentialKeyCard({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<KeyField expanded={expanded} info={info} placeholder={placeholder} rowProps={rowProps} varKey={varKey} />
|
||||
<KeyField info={info} placeholder={placeholder} rowProps={rowProps} varKey={varKey} />
|
||||
</div>
|
||||
|
||||
{expandable && expanded && (
|
||||
<div className="grid gap-3 @2xl:col-span-2" onClick={e => e.stopPropagation()}>
|
||||
{description && (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{docsUrl && <CredentialDocsLink href={docsUrl} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expandable && expanded && (
|
||||
<div className="grid gap-2.5 pb-2 pl-4" onClick={e => e.stopPropagation()}>
|
||||
{description && (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{docsUrl && <CredentialDocsLink href={docsUrl} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -262,22 +236,15 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'@container group/card rounded-[6px] p-3 transition-colors',
|
||||
'group/card rounded-[6px] px-2 py-1 transition-colors',
|
||||
expandable && 'cursor-pointer',
|
||||
expandable && !expanded && 'row-hover',
|
||||
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
|
||||
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
|
||||
)}
|
||||
onClick={expandable ? onToggle : undefined}
|
||||
onKeyDown={
|
||||
expandable
|
||||
? e => {
|
||||
// Only the card's own focus toggles it — ignore Enter/Space
|
||||
// bubbling up from the inputs/buttons inside (Enter saves a key,
|
||||
// Space types a space) so keyboard editing never collapses the card.
|
||||
if (e.target !== e.currentTarget) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
@@ -288,10 +255,8 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps
|
||||
role={expandable ? 'button' : undefined}
|
||||
tabIndex={expandable ? 0 : undefined}
|
||||
>
|
||||
{/* Same grid as CredentialKeyCard: 1 col stacked, 2 cols at @2xl, p-3 =
|
||||
gap-3, items-start, label row h-8 to line up with the input row. */}
|
||||
<div className="grid grid-cols-1 items-start gap-x-3 gap-y-1.5 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:gap-y-3">
|
||||
<div className="flex h-8 min-w-0 items-center gap-2">
|
||||
<div className="grid gap-3 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'size-2 shrink-0 rounded-full',
|
||||
@@ -314,7 +279,7 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="min-w-0"
|
||||
className="min-w-0 sm:justify-self-end"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onFocus={() => {
|
||||
if (expandable && !expanded) {
|
||||
@@ -323,48 +288,46 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps
|
||||
}}
|
||||
>
|
||||
<KeyField
|
||||
expanded={expanded}
|
||||
info={group.primary[1]}
|
||||
placeholder={t.settings.credentials.pasteLabelKey(group.name)}
|
||||
rowProps={rowProps}
|
||||
varKey={group.primary[0]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{expandable && expanded && (
|
||||
<div className="grid gap-3 @2xl:col-span-2" onClick={e => e.stopPropagation()}>
|
||||
{description && (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{group.advanced.map(([key, info]) => {
|
||||
const fieldLabel = isKeyVar(key, info)
|
||||
? prettyName(key.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, ''))
|
||||
: friendlyFieldLabel(key, info)
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<KeyField
|
||||
expanded={expanded}
|
||||
info={info}
|
||||
placeholder={credentialPlaceholder(key, info, fieldLabel)}
|
||||
rowProps={rowProps}
|
||||
varKey={key}
|
||||
/>
|
||||
}
|
||||
key={key}
|
||||
title={fieldLabel}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{docsUrl && <CredentialDocsLink href={docsUrl} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expandable && expanded && (
|
||||
<div className="grid gap-2.5 pb-2 pl-4" onClick={e => e.stopPropagation()}>
|
||||
{description && (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{group.advanced.map(([key, info]) => {
|
||||
const fieldLabel = isKeyVar(key, info)
|
||||
? prettyName(key.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, ''))
|
||||
: friendlyFieldLabel(key, info)
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<KeyField
|
||||
info={info}
|
||||
placeholder={credentialPlaceholder(key, info, fieldLabel)}
|
||||
rowProps={rowProps}
|
||||
varKey={key}
|
||||
/>
|
||||
}
|
||||
key={key}
|
||||
title={fieldLabel}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{docsUrl && <CredentialDocsLink href={docsUrl} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { asText } from '@/lib/text'
|
||||
import type { HermesConfigRecord, ToolsetInfo } from '@/types/hermes'
|
||||
|
||||
import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS } from './constants'
|
||||
|
||||
// Canonical implementations live in @/lib/text; re-exported here so the many
|
||||
// settings/capabilities call sites keep their import path.
|
||||
export { asText, includesQuery, prettyName } from '@/lib/text'
|
||||
export const asText = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : String(v))
|
||||
|
||||
export const includesQuery = (v: unknown, q: string) => asText(v).toLowerCase().includes(q)
|
||||
|
||||
export const prettyName = (v: string) => v.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
|
||||
/** Strip leading emoji from toolset titles (CLI registry prefixes labels with icons). */
|
||||
export const stripToolsetLabel = (label: string): string =>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { codiconIcon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
@@ -11,9 +10,8 @@ import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
import { OverlayIconButton } from '../overlays/overlay-chrome'
|
||||
import { OverlayMain, OverlayNav, type OverlayNavGroup, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
import { SKILLS_ROUTE } from '../routes'
|
||||
|
||||
import { AboutSettings } from './about-settings'
|
||||
import { AppearanceSettings } from './appearance-settings'
|
||||
@@ -21,6 +19,7 @@ import { ConfigSettings } from './config-settings'
|
||||
import { SECTIONS } from './constants'
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
|
||||
import { McpSettings } from './mcp-settings'
|
||||
import { NotificationsSettings } from './notifications-settings'
|
||||
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
|
||||
import { SessionsSettings } from './sessions-settings'
|
||||
@@ -31,55 +30,29 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
'providers',
|
||||
'gateway',
|
||||
'keys',
|
||||
'mcp',
|
||||
'notifications',
|
||||
'sessions',
|
||||
'about'
|
||||
]
|
||||
|
||||
export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const { hash, pathname, search } = useLocation()
|
||||
|
||||
// MCP moved out of Settings into Capabilities (/skills?tab=mcp). Keep old
|
||||
// `/settings?tab=mcp` deep links working — `useRouteEnumParam` would silently
|
||||
// coerce the unknown tab to the default view otherwise. Preserve `server=` so
|
||||
// an old bookmark still lands on (and highlights) the selected server.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(search)
|
||||
|
||||
if (params.get('tab') === 'mcp') {
|
||||
const server = params.get('server')
|
||||
const suffix = server ? `&server=${encodeURIComponent(server)}` : ''
|
||||
navigate(`${SKILLS_ROUTE}?tab=mcp${suffix}`, { replace: true })
|
||||
}
|
||||
}, [navigate, search])
|
||||
|
||||
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
|
||||
// Providers subnav (Accounts vs API keys) lives in its own param so each
|
||||
// sub-view is deep-linkable and survives a refresh.
|
||||
const [providerView, setProviderView] = useRouteEnumParam<ProviderView>('pview', PROVIDER_VIEWS, 'accounts')
|
||||
const [keysView] = useRouteEnumParam<KeysView>('kview', KEYS_VIEWS, 'tools')
|
||||
const [keysView, setKeysView] = useRouteEnumParam<KeysView>('kview', KEYS_VIEWS, 'tools')
|
||||
|
||||
// Jump to a section + its sub-view in one navigate. Two sequential setters
|
||||
// would each read the same stale `search` and the second would clobber the
|
||||
// first's `tab` — so the sub-view never opened on narrow screens.
|
||||
const openSubView = (tab: SettingsViewId, param: string, value: string, fallback: string) => {
|
||||
const params = new URLSearchParams(search)
|
||||
params.set('tab', tab)
|
||||
|
||||
if (value === fallback) {
|
||||
params.delete(param)
|
||||
} else {
|
||||
params.set(param, value)
|
||||
}
|
||||
|
||||
const qs = params.toString()
|
||||
navigate({ hash, pathname, search: qs ? `?${qs}` : '' }, { replace: true })
|
||||
const openProviderView = (view: ProviderView) => {
|
||||
setActiveView('providers')
|
||||
setProviderView(view)
|
||||
}
|
||||
|
||||
const openProviderView = (view: ProviderView) => openSubView('providers', 'pview', view, 'accounts')
|
||||
const openKeysView = (view: KeysView) => openSubView('keys', 'kview', view, 'tools')
|
||||
const openKeysView = (view: KeysView) => {
|
||||
setActiveView('keys')
|
||||
setKeysView(view)
|
||||
}
|
||||
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
@@ -113,133 +86,134 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
||||
}
|
||||
}
|
||||
|
||||
const navGroups: OverlayNavGroup[] = [
|
||||
...SECTIONS.map(s => {
|
||||
const view = `config:${s.id}` as SettingsViewId
|
||||
|
||||
return {
|
||||
active: activeView === view,
|
||||
icon: s.icon,
|
||||
id: view,
|
||||
label: t.settings.sections[s.id] ?? s.label,
|
||||
onSelect: () => setActiveView(view)
|
||||
}
|
||||
}),
|
||||
{
|
||||
active: activeView === 'notifications',
|
||||
icon: Bell,
|
||||
id: 'notifications',
|
||||
label: t.settings.nav.notifications,
|
||||
onSelect: () => setActiveView('notifications')
|
||||
},
|
||||
{
|
||||
active: activeView === 'providers',
|
||||
children: [
|
||||
{
|
||||
active: activeView === 'providers' && providerView === 'accounts',
|
||||
icon: codiconIcon('account'),
|
||||
id: 'pview:accounts',
|
||||
label: t.settings.nav.providerAccounts,
|
||||
onSelect: () => openProviderView('accounts')
|
||||
},
|
||||
{
|
||||
active: activeView === 'providers' && providerView === 'keys',
|
||||
icon: KeyRound,
|
||||
id: 'pview:keys',
|
||||
label: t.settings.nav.providerApiKeys,
|
||||
onSelect: () => openProviderView('keys')
|
||||
}
|
||||
],
|
||||
gapBefore: true,
|
||||
icon: Zap,
|
||||
id: 'providers',
|
||||
label: t.settings.nav.providers,
|
||||
onSelect: () => setActiveView('providers')
|
||||
},
|
||||
{
|
||||
active: activeView === 'gateway',
|
||||
icon: Globe,
|
||||
id: 'gateway',
|
||||
label: t.settings.nav.gateway,
|
||||
onSelect: () => setActiveView('gateway')
|
||||
},
|
||||
{
|
||||
active: activeView === 'keys',
|
||||
children: [
|
||||
{
|
||||
active: activeView === 'keys' && keysView === 'tools',
|
||||
icon: Wrench,
|
||||
id: 'kview:tools',
|
||||
label: t.settings.nav.keysTools,
|
||||
onSelect: () => openKeysView('tools')
|
||||
},
|
||||
{
|
||||
active: activeView === 'keys' && keysView === 'settings',
|
||||
icon: Settings2,
|
||||
id: 'kview:settings',
|
||||
label: t.settings.nav.keysSettings,
|
||||
onSelect: () => openKeysView('settings')
|
||||
}
|
||||
],
|
||||
icon: KeyRound,
|
||||
id: 'keys',
|
||||
label: t.settings.nav.apiKeys,
|
||||
onSelect: () => setActiveView('keys')
|
||||
},
|
||||
{
|
||||
active: activeView === 'sessions',
|
||||
icon: Archive,
|
||||
id: 'sessions',
|
||||
label: t.settings.nav.archivedChats,
|
||||
onSelect: () => setActiveView('sessions')
|
||||
},
|
||||
{
|
||||
active: activeView === 'about',
|
||||
gapBefore: true,
|
||||
icon: Info,
|
||||
id: 'about',
|
||||
label: t.settings.nav.about,
|
||||
onSelect: () => setActiveView('about')
|
||||
}
|
||||
]
|
||||
|
||||
const navFooter = (
|
||||
<>
|
||||
<Tip label={t.settings.exportConfig}>
|
||||
<OverlayIconButton onClick={() => void exportConfig()}>
|
||||
<Download />
|
||||
</OverlayIconButton>
|
||||
</Tip>
|
||||
<Tip label={t.settings.importConfig}>
|
||||
<OverlayIconButton
|
||||
onClick={() => {
|
||||
triggerHaptic('open')
|
||||
importInputRef.current?.click()
|
||||
}}
|
||||
>
|
||||
<Upload />
|
||||
</OverlayIconButton>
|
||||
</Tip>
|
||||
<Tip label={t.settings.resetToDefaults}>
|
||||
<OverlayIconButton
|
||||
className="hover:text-destructive"
|
||||
onClick={() => {
|
||||
triggerHaptic('warning')
|
||||
void resetConfig()
|
||||
}}
|
||||
>
|
||||
<RefreshCw />
|
||||
</OverlayIconButton>
|
||||
</Tip>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<OverlayView closeLabel={t.settings.closeSettings} onClose={onClose}>
|
||||
<OverlaySplitLayout>
|
||||
<OverlayNav footer={navFooter} groups={navGroups} />
|
||||
<OverlaySidebar>
|
||||
{SECTIONS.map(s => {
|
||||
const view = `config:${s.id}` as SettingsViewId
|
||||
|
||||
<OverlayMain className="px-0 pb-0">
|
||||
return (
|
||||
<OverlayNavItem
|
||||
active={activeView === view}
|
||||
icon={s.icon}
|
||||
key={s.id}
|
||||
label={t.settings.sections[s.id] ?? s.label}
|
||||
onClick={() => setActiveView(view)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<OverlayNavItem
|
||||
active={activeView === 'notifications'}
|
||||
icon={Bell}
|
||||
label={t.settings.nav.notifications}
|
||||
onClick={() => setActiveView('notifications')}
|
||||
/>
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<OverlayNavItem
|
||||
active={activeView === 'providers'}
|
||||
icon={Zap}
|
||||
label={t.settings.nav.providers}
|
||||
onClick={() => setActiveView('providers')}
|
||||
/>
|
||||
{activeView === 'providers' && (
|
||||
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
|
||||
<OverlayNavItem
|
||||
active={providerView === 'accounts'}
|
||||
icon={codiconIcon('account')}
|
||||
label={t.settings.nav.providerAccounts}
|
||||
nested
|
||||
onClick={() => openProviderView('accounts')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={providerView === 'keys'}
|
||||
icon={KeyRound}
|
||||
label={t.settings.nav.providerApiKeys}
|
||||
nested
|
||||
onClick={() => openProviderView('keys')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<OverlayNavItem
|
||||
active={activeView === 'gateway'}
|
||||
icon={Globe}
|
||||
label={t.settings.nav.gateway}
|
||||
onClick={() => setActiveView('gateway')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={activeView === 'keys'}
|
||||
icon={KeyRound}
|
||||
label={t.settings.nav.apiKeys}
|
||||
onClick={() => setActiveView('keys')}
|
||||
/>
|
||||
{activeView === 'keys' && (
|
||||
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
|
||||
<OverlayNavItem
|
||||
active={keysView === 'tools'}
|
||||
icon={Wrench}
|
||||
label={t.settings.nav.keysTools}
|
||||
nested
|
||||
onClick={() => openKeysView('tools')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={keysView === 'settings'}
|
||||
icon={Settings2}
|
||||
label={t.settings.nav.keysSettings}
|
||||
nested
|
||||
onClick={() => openKeysView('settings')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<OverlayNavItem
|
||||
active={activeView === 'mcp'}
|
||||
icon={Wrench}
|
||||
label={t.settings.nav.mcp}
|
||||
onClick={() => setActiveView('mcp')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={activeView === 'sessions'}
|
||||
icon={Archive}
|
||||
label={t.settings.nav.archivedChats}
|
||||
onClick={() => setActiveView('sessions')}
|
||||
/>
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<OverlayNavItem
|
||||
active={activeView === 'about'}
|
||||
icon={Info}
|
||||
label={t.settings.nav.about}
|
||||
onClick={() => setActiveView('about')}
|
||||
/>
|
||||
<div className="mt-auto flex items-center gap-1 pt-2">
|
||||
<Tip label={t.settings.exportConfig}>
|
||||
<OverlayIconButton onClick={() => void exportConfig()}>
|
||||
<Download className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
</Tip>
|
||||
<Tip label={t.settings.importConfig}>
|
||||
<OverlayIconButton
|
||||
onClick={() => {
|
||||
triggerHaptic('open')
|
||||
importInputRef.current?.click()
|
||||
}}
|
||||
>
|
||||
<Upload className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
</Tip>
|
||||
<Tip label={t.settings.resetToDefaults}>
|
||||
<OverlayIconButton
|
||||
className="hover:text-destructive"
|
||||
onClick={() => {
|
||||
triggerHaptic('warning')
|
||||
void resetConfig()
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
</Tip>
|
||||
</div>
|
||||
</OverlaySidebar>
|
||||
|
||||
<OverlayMain className="px-0 pb-0 pt-[calc(var(--titlebar-height)/2+1rem)]">
|
||||
{activeView === 'config:appearance' ? (
|
||||
<AppearanceSettings />
|
||||
) : activeView === 'about' ? (
|
||||
@@ -257,6 +231,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
||||
<ProvidersSettings onClose={onClose} onViewChange={setProviderView} view={providerView} />
|
||||
) : activeView === 'keys' ? (
|
||||
<KeysSettings view={keysView} />
|
||||
) : activeView === 'mcp' ? (
|
||||
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
|
||||
) : activeView === 'notifications' ? (
|
||||
<NotificationsSettings />
|
||||
) : (
|
||||
|
||||
534
apps/desktop/src/app/settings/mcp-settings.tsx
Normal file
534
apps/desktop/src/app/settings/mcp-settings.tsx
Normal file
@@ -0,0 +1,534 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
getHermesConfigRecord,
|
||||
getMcpCatalog,
|
||||
type HermesGateway,
|
||||
installMcpCatalogEntry,
|
||||
type McpCatalogEntry,
|
||||
saveHermesConfig,
|
||||
setMcpServerEnabled,
|
||||
testMcpServer
|
||||
} from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Wrench } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
import type { HermesConfigRecord, McpServerTestResponse } from '@/types/hermes'
|
||||
|
||||
import { EmptyState, LoadingState, Pill, SettingsContent } from './primitives'
|
||||
import { useDeepLinkHighlight } from './use-deep-link-highlight'
|
||||
|
||||
interface McpSettingsProps {
|
||||
gateway?: HermesGateway | null
|
||||
onConfigSaved?: () => void
|
||||
}
|
||||
|
||||
type McpServers = Record<string, Record<string, unknown>>
|
||||
type McpView = 'catalog' | 'servers'
|
||||
|
||||
const EMPTY_SERVER = {
|
||||
command: '',
|
||||
args: [],
|
||||
env: {}
|
||||
}
|
||||
|
||||
function getServers(config: HermesConfigRecord | null): McpServers {
|
||||
const raw = config?.mcp_servers
|
||||
|
||||
return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as McpServers) : {}
|
||||
}
|
||||
|
||||
const transportLabel = (server: Record<string, unknown>) =>
|
||||
typeof server.transport === 'string'
|
||||
? server.transport
|
||||
: typeof server.url === 'string'
|
||||
? 'http'
|
||||
: typeof server.command === 'string'
|
||||
? 'stdio'
|
||||
: 'custom'
|
||||
|
||||
export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
|
||||
const { t } = useI18n()
|
||||
const m = t.settings.mcp
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const [view, setView] = useState<McpView>('servers')
|
||||
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [reloading, setReloading] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<McpServerTestResponse | null>(null)
|
||||
const [togglingEnabled, setTogglingEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
getHermesConfigRecord()
|
||||
.then(next => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setConfig(next)
|
||||
const first = Object.keys(getServers(next)).sort()[0] ?? null
|
||||
setSelected(first)
|
||||
})
|
||||
.catch(err => notifyError(err, m.failedLoad))
|
||||
|
||||
return () => void (cancelled = true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount; copy is stable
|
||||
}, [])
|
||||
|
||||
const servers = useMemo(() => getServers(config), [config])
|
||||
const names = useMemo(() => Object.keys(servers).sort(), [servers])
|
||||
|
||||
useDeepLinkHighlight({
|
||||
block: 'nearest',
|
||||
elementId: serverName => `mcp-server-${serverName}`,
|
||||
onResolve: setSelected,
|
||||
param: 'server',
|
||||
ready: serverName => Boolean(config) && serverName in servers
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const server = selected ? servers[selected] : null
|
||||
|
||||
setName(selected ?? '')
|
||||
setBody(JSON.stringify(server ?? EMPTY_SERVER, null, 2))
|
||||
setTestResult(null)
|
||||
}, [selected, servers])
|
||||
|
||||
const refreshConfig = useCallback(async () => {
|
||||
try {
|
||||
const next = await getHermesConfigRecord()
|
||||
setConfig(next)
|
||||
} catch (err) {
|
||||
notifyError(err, m.failedLoad)
|
||||
}
|
||||
}, [m.failedLoad])
|
||||
|
||||
if (!config) {
|
||||
return <LoadingState label={m.loading} />
|
||||
}
|
||||
|
||||
const saveServer = async () => {
|
||||
const nextName = name.trim()
|
||||
|
||||
if (!nextName) {
|
||||
notify({ kind: 'error', title: m.nameRequiredTitle, message: m.nameRequiredMessage })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let parsed: Record<string, unknown>
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(body)
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
throw new Error(m.objectRequired)
|
||||
}
|
||||
|
||||
parsed = raw as Record<string, unknown>
|
||||
} catch (err) {
|
||||
notifyError(err, m.invalidJson)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
const nextServers = { ...servers }
|
||||
|
||||
if (selected && selected !== nextName) {
|
||||
delete nextServers[selected]
|
||||
}
|
||||
|
||||
nextServers[nextName] = parsed
|
||||
|
||||
const nextConfig = { ...config, mcp_servers: nextServers }
|
||||
await saveHermesConfig(nextConfig)
|
||||
setConfig(nextConfig)
|
||||
setSelected(nextName)
|
||||
onConfigSaved?.()
|
||||
notify({ kind: 'success', title: m.savedTitle, message: m.savedMessage(nextName) })
|
||||
} catch (err) {
|
||||
notifyError(err, m.saveFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const removeServer = async (serverName: string) => {
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
const nextServers = { ...servers }
|
||||
delete nextServers[serverName]
|
||||
|
||||
const nextConfig = { ...config, mcp_servers: nextServers }
|
||||
await saveHermesConfig(nextConfig)
|
||||
setConfig(nextConfig)
|
||||
setSelected(Object.keys(nextServers).sort()[0] ?? null)
|
||||
onConfigSaved?.()
|
||||
} catch (err) {
|
||||
notifyError(err, m.removeFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const reloadMcp = async () => {
|
||||
if (!gateway) {
|
||||
notify({ kind: 'warning', title: m.gatewayUnavailableTitle, message: m.gatewayUnavailableMessage })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setReloading(true)
|
||||
|
||||
try {
|
||||
await gateway.request('reload.mcp', {
|
||||
confirm: true,
|
||||
session_id: activeSessionId ?? undefined
|
||||
})
|
||||
notify({ kind: 'success', title: m.reloadedTitle, message: m.reloadedMessage })
|
||||
} catch (err) {
|
||||
notifyError(err, m.reloadFailed)
|
||||
} finally {
|
||||
setReloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runTest = async (serverName: string) => {
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
|
||||
try {
|
||||
const result = await testMcpServer(serverName)
|
||||
setTestResult(result)
|
||||
} catch (err) {
|
||||
setTestResult({ ok: false, error: err instanceof Error ? err.message : String(err), tools: [] })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEnabled = async (serverName: string, enabled: boolean) => {
|
||||
setTogglingEnabled(true)
|
||||
|
||||
try {
|
||||
await setMcpServerEnabled(serverName, enabled)
|
||||
// Mirror the change locally so the editor and list stay in sync.
|
||||
const nextServers = { ...servers, [serverName]: { ...servers[serverName], enabled } }
|
||||
setConfig({ ...config, mcp_servers: nextServers })
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? m.serverEnabled(serverName) : m.serverDisabled(serverName),
|
||||
message: ''
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, m.toggleFailed(serverName))
|
||||
} finally {
|
||||
setTogglingEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedEnabled = selected ? servers[selected]?.enabled !== false : true
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<TabButton active={view === 'servers'} label={m.tabServers} onClick={() => setView('servers')} />
|
||||
<TabButton active={view === 'catalog'} label={m.tabCatalog} onClick={() => setView('catalog')} />
|
||||
</div>
|
||||
{view === 'servers' && (
|
||||
<div className="flex items-center gap-4">
|
||||
<Button onClick={() => setSelected(null)} size="xs" variant="text">
|
||||
{m.newServer}
|
||||
</Button>
|
||||
<Button disabled={reloading} onClick={() => void reloadMcp()} size="xs" variant="text">
|
||||
{reloading ? m.reloading : m.reload}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{view === 'catalog' ? (
|
||||
<McpCatalogBrowser onInstalled={() => void refreshConfig()} />
|
||||
) : (
|
||||
<div className="grid min-h-0 gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]">
|
||||
<div className="min-h-64">
|
||||
{names.length === 0 ? (
|
||||
<EmptyState description={m.emptyDesc} title={m.emptyTitle} />
|
||||
) : (
|
||||
<div className="grid gap-0.5">
|
||||
{names.map(serverName => {
|
||||
const server = servers[serverName]
|
||||
const active = selected === serverName
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'scroll-mt-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-(--chrome-action-hover)',
|
||||
active ? 'bg-(--ui-bg-tertiary) text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
id={`mcp-server-${serverName}`}
|
||||
key={serverName}
|
||||
onClick={() => setSelected(serverName)}
|
||||
type="button"
|
||||
>
|
||||
<div className="truncate text-sm font-medium">{serverName}</div>
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
<Pill>{transportLabel(server)}</Pill>
|
||||
{(server.enabled === false || server.disabled === true) && <Pill>{m.disabled}</Pill>}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid content-start gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Wrench className="size-4 text-muted-foreground" />
|
||||
{selected ? m.editServer : m.newServer}
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button disabled={testing} onClick={() => void runTest(selected)} size="xs" variant="text">
|
||||
{testing ? m.testing : m.test}
|
||||
</Button>
|
||||
<Switch
|
||||
aria-label={selectedEnabled ? m.disableServer(selected) : m.enableServer(selected)}
|
||||
checked={selectedEnabled}
|
||||
disabled={togglingEnabled}
|
||||
onCheckedChange={checked => void toggleEnabled(selected, checked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{testResult && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 text-xs',
|
||||
testResult.ok ? 'text-emerald-400' : 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{testResult.ok ? m.testOk(testResult.tools.length) : `${m.testFailed}: ${testResult.error ?? ''}`}
|
||||
{testResult.ok && testResult.tools.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{testResult.tools.map(tool => (
|
||||
<span
|
||||
className="rounded-md bg-(--ui-bg-quinary) px-1.5 py-0.5 font-mono text-[0.65rem] text-(--ui-text-tertiary)"
|
||||
key={tool.name}
|
||||
title={tool.description}
|
||||
>
|
||||
{tool.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{m.name}</span>
|
||||
<Input onChange={event => setName(event.currentTarget.value)} placeholder="filesystem" value={name} />
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{m.serverJson}</span>
|
||||
<Textarea
|
||||
className="min-h-80 font-mono text-xs"
|
||||
onChange={event => setBody(event.currentTarget.value)}
|
||||
spellCheck={false}
|
||||
value={body}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center justify-between">
|
||||
{selected ? (
|
||||
<Button
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={saving}
|
||||
onClick={() => void removeServer(selected)}
|
||||
size="xs"
|
||||
variant="text"
|
||||
>
|
||||
{m.remove}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button disabled={saving} onClick={() => void saveServer()} size="sm">
|
||||
{saving ? t.common.saving : m.saveServer}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
function TabButton({ active, label, onClick }: { active: boolean; label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'cursor-pointer text-sm font-medium transition-colors',
|
||||
active ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Nous-approved MCP catalog browser — the desktop counterpart of
|
||||
* `hermes mcp catalog` / `hermes mcp install` and the dashboard MCP page. */
|
||||
function McpCatalogBrowser({ onInstalled }: { onInstalled: () => void }) {
|
||||
const { t } = useI18n()
|
||||
const m = t.settings.mcp
|
||||
const [entries, setEntries] = useState<McpCatalogEntry[] | null>(null)
|
||||
const [installing, setInstalling] = useState<null | string>(null)
|
||||
// Per-entry env var drafts for catalog entries that need credentials.
|
||||
const [envDrafts, setEnvDrafts] = useState<Record<string, Record<string, string>>>({})
|
||||
const [envOpenFor, setEnvOpenFor] = useState<null | string>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
getMcpCatalog()
|
||||
.then(response => {
|
||||
if (!cancelled) {
|
||||
setEntries(response.entries)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
if (!cancelled) {
|
||||
notifyError(err, m.catalogLoadFailed)
|
||||
setEntries([])
|
||||
}
|
||||
})
|
||||
|
||||
return () => void (cancelled = true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount
|
||||
}, [])
|
||||
|
||||
const install = async (entry: McpCatalogEntry) => {
|
||||
const required = entry.required_env.filter(env => env.required)
|
||||
const draft = envDrafts[entry.name] ?? {}
|
||||
|
||||
if (required.some(env => !draft[env.name]?.trim())) {
|
||||
if (envOpenFor !== entry.name) {
|
||||
setEnvOpenFor(entry.name)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notify({ kind: 'error', title: m.catalogEnvPrompt(entry.name), message: m.catalogEnvRequired })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setInstalling(entry.name)
|
||||
|
||||
try {
|
||||
await installMcpCatalogEntry(entry.name, draft)
|
||||
notify({ kind: 'success', title: m.catalogInstallStarted(entry.name), message: '' })
|
||||
setEntries(
|
||||
current =>
|
||||
current?.map(row => (row.name === entry.name ? { ...row, installed: true, enabled: true } : row)) ?? current
|
||||
)
|
||||
setEnvOpenFor(null)
|
||||
onInstalled()
|
||||
} catch (err) {
|
||||
notifyError(err, m.catalogInstallFailed(entry.name))
|
||||
} finally {
|
||||
setInstalling(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (entries === null) {
|
||||
return <LoadingState label={m.catalogLoading} />
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <EmptyState description={m.catalogEmpty} title={m.tabCatalog} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{entries.map(entry => {
|
||||
const envOpen = envOpenFor === entry.name
|
||||
const draft = envDrafts[entry.name] ?? {}
|
||||
|
||||
return (
|
||||
<div className="px-0 py-2.5" key={entry.name}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{entry.name}</span>
|
||||
<Pill>{entry.transport}</Pill>
|
||||
{entry.installed && (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-400">
|
||||
{entry.enabled ? m.catalogEnabled : m.catalogInstalled}
|
||||
</Badge>
|
||||
)}
|
||||
{entry.needs_install && !entry.installed && <Pill>{m.catalogNeedsInstall}</Pill>}
|
||||
</div>
|
||||
<Button
|
||||
disabled={entry.installed || installing !== null}
|
||||
onClick={() => void install(entry)}
|
||||
size="xs"
|
||||
variant="textStrong"
|
||||
>
|
||||
{installing === entry.name
|
||||
? m.catalogInstalling
|
||||
: entry.installed
|
||||
? m.catalogInstalled
|
||||
: m.catalogInstall}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{entry.description}</p>
|
||||
{envOpen && entry.required_env.length > 0 && (
|
||||
<div className="mt-2 grid max-w-md gap-2">
|
||||
{entry.required_env.map(env => (
|
||||
<label className="grid gap-1" key={env.name}>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{env.prompt || env.name}
|
||||
{env.required ? ' *' : ''}
|
||||
</span>
|
||||
<Input
|
||||
onChange={event =>
|
||||
setEnvDrafts(prev => ({
|
||||
...prev,
|
||||
[entry.name]: { ...prev[entry.name], [env.name]: event.currentTarget.value }
|
||||
}))
|
||||
}
|
||||
type="password"
|
||||
value={draft[env.name] ?? ''}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
getAuxiliaryModels,
|
||||
getGlobalModelInfo,
|
||||
getGlobalModelOptions,
|
||||
getHermesConfigRecord,
|
||||
getMoaModels,
|
||||
getRecommendedDefaultModel,
|
||||
saveHermesConfig,
|
||||
@@ -28,57 +28,11 @@ import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
|
||||
|
||||
import { invalidateHermesConfig, setHermesConfigCache, useHermesConfigRecord } from '../hooks/use-config-record'
|
||||
import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
|
||||
import type { HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { getNested, setNested } from './helpers'
|
||||
import { ListRow, Pill, SectionHeading } from './primitives'
|
||||
|
||||
// Skeleton mirror of the Model settings DOM so the page keeps its shape while
|
||||
// the provider/model catalog loads, instead of collapsing to a centered
|
||||
// spinner. Same containers/rhythm as the real render below.
|
||||
export function ModelSettingsSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-6" data-slot="model-settings-skeleton">
|
||||
<section>
|
||||
<Skeleton className="mb-3 h-3 w-72 max-w-full" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-8 w-60 max-w-full" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-8 w-28" />
|
||||
<Skeleton className="h-6 w-20" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-2.5 flex items-center gap-2 pt-2">
|
||||
<Skeleton className="size-4" />
|
||||
<Skeleton className="h-4 w-36" />
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
{[0, 1, 2, 3].map(row => (
|
||||
<div
|
||||
className="grid gap-3 py-3 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center"
|
||||
key={row}
|
||||
>
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-32" />
|
||||
<Skeleton className="h-3 w-52 max-w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-full @2xl:justify-self-end @2xl:w-56" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
|
||||
|
||||
// Hermes' reasoning levels (VALID_REASONING_EFFORTS); `none` = thinking off.
|
||||
// Empty config = Hermes default (medium), shown as Medium.
|
||||
@@ -182,10 +136,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
const [moa, setMoa] = useState<MoaConfigResponse | null>(null)
|
||||
const [selectedMoaPreset, setSelectedMoaPreset] = useState('')
|
||||
const [newMoaPresetName, setNewMoaPresetName] = useState('')
|
||||
// agent.* defaults round-trip through the shared config cache (read → write
|
||||
// back the whole record), so a save here shows in the MCP/config surfaces.
|
||||
const { data: config } = useHermesConfigRecord()
|
||||
const setConfig = setHermesConfigCache
|
||||
// Full profile config, kept so the reasoning/speed defaults round-trip
|
||||
// (read agent.* → write back the whole record) like the generic config page.
|
||||
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
||||
const [applying, setApplying] = useState(false)
|
||||
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
|
||||
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
|
||||
@@ -197,28 +150,19 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
const [apiKeyDraft, setApiKeyDraft] = useState('')
|
||||
const [activating, setActivating] = useState(false)
|
||||
|
||||
// Every profile-scoped async here captures this and bails before writing back,
|
||||
// so a request in flight when the user switches profiles can't paint profile
|
||||
// A's models/providers into profile B (or fire onMainModelChanged for A).
|
||||
const profileEpoch = useRef(0)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const epoch = profileEpoch.current
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const [modelInfo, modelOptions, auxiliaryModels, moaModels] = await Promise.all([
|
||||
const [modelInfo, modelOptions, auxiliaryModels, moaModels, cfg] = await Promise.all([
|
||||
getGlobalModelInfo(),
|
||||
getGlobalModelOptions(),
|
||||
getAuxiliaryModels(),
|
||||
getMoaModels().catch(() => null)
|
||||
getMoaModels().catch(() => null),
|
||||
getHermesConfigRecord()
|
||||
])
|
||||
|
||||
if (profileEpoch.current !== epoch) {
|
||||
return
|
||||
}
|
||||
|
||||
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
|
||||
setProviders(modelOptions.providers || [])
|
||||
setSelectedProvider(prev => prev || modelInfo.provider)
|
||||
@@ -230,17 +174,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
setSelectedMoaPreset(prev => (prev && moaModels.presets[prev] ? prev : moaModels.default_preset))
|
||||
}
|
||||
|
||||
// The config record loads via its own shared query; a model switch can
|
||||
// change it server-side (aux slots), so nudge that cache to refetch.
|
||||
void invalidateHermesConfig()
|
||||
setConfig(cfg)
|
||||
} catch (err) {
|
||||
if (profileEpoch.current === epoch) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
if (profileEpoch.current === epoch) {
|
||||
setLoading(false)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -248,19 +186,14 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
// A profile switch swaps the backend under the mounted panel — reload for the
|
||||
// new profile (bumping the epoch first so any in-flight A request is discarded).
|
||||
useOnProfileSwitch(() => {
|
||||
profileEpoch.current += 1
|
||||
void refresh()
|
||||
})
|
||||
|
||||
const providerOptions = providers.length ? providers : NO_PROVIDERS
|
||||
|
||||
// MoA reference/aggregator slots must never be the moa virtual provider —
|
||||
// that would create a recursive MoA tree (the backend rejects it on save).
|
||||
// Hide it from the slot selectors so it isn't offered as a dead choice.
|
||||
const moaSlotProviderOptions = providerOptions.filter(provider => (provider.slug || '').toLowerCase() !== 'moa')
|
||||
const moaSlotProviderOptions = providerOptions.filter(
|
||||
provider => (provider.slug || '').toLowerCase() !== 'moa'
|
||||
)
|
||||
|
||||
const selectedProviderRow = useMemo(
|
||||
() => providers.find(provider => provider.slug === selectedProvider),
|
||||
@@ -328,17 +261,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
}, [])
|
||||
|
||||
const saveMoa = useCallback(async (next: MoaConfigResponse) => {
|
||||
const epoch = profileEpoch.current
|
||||
setApplying(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const saved = await saveMoaModels(next)
|
||||
|
||||
if (profileEpoch.current !== epoch) {
|
||||
return
|
||||
}
|
||||
|
||||
setMoa(saved)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
@@ -385,7 +312,6 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
const rawEffort = String(getNested(config ?? {}, 'agent.reasoning_effort') ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
const effortValue = rawEffort === 'false' || rawEffort === 'disabled' ? 'none' : rawEffort || 'medium'
|
||||
|
||||
const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier'))
|
||||
@@ -423,7 +349,6 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const epoch = profileEpoch.current
|
||||
setActivating(true)
|
||||
setError('')
|
||||
|
||||
@@ -444,11 +369,6 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
}
|
||||
|
||||
const options = await getGlobalModelOptions()
|
||||
|
||||
if (profileEpoch.current !== epoch) {
|
||||
return
|
||||
}
|
||||
|
||||
setProviders(options.providers || [])
|
||||
const refreshedRow = options.providers?.find(p => p.slug === slug)
|
||||
const fallbackModel = refreshedRow?.models?.[0] ?? ''
|
||||
@@ -486,17 +406,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const epoch = profileEpoch.current
|
||||
setApplying(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await setModelAssignment({ model: selectedModel, provider: selectedProvider, scope: 'main' })
|
||||
|
||||
if (profileEpoch.current !== epoch) {
|
||||
return
|
||||
}
|
||||
|
||||
const provider = result.provider || selectedProvider
|
||||
const model = result.model || selectedModel
|
||||
setMainModel({ provider, model })
|
||||
@@ -592,7 +506,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
}, [mainModel, refresh])
|
||||
|
||||
if (loading && !mainModel) {
|
||||
return <ModelSettingsSkeleton />
|
||||
return <LoadingState label={m.loading} />
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -871,7 +785,6 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
...moa,
|
||||
default_preset: selectedMoaPreset || moa.default_preset
|
||||
}
|
||||
|
||||
void saveMoa(next)
|
||||
}}
|
||||
size="sm"
|
||||
@@ -889,14 +802,12 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
const presets = { ...moa.presets }
|
||||
delete presets[selectedMoaPreset]
|
||||
const fallback = Object.keys(presets)[0]
|
||||
|
||||
const next: MoaConfigResponse = {
|
||||
...moa,
|
||||
presets,
|
||||
default_preset: moa.default_preset === selectedMoaPreset ? fallback : moa.default_preset,
|
||||
active_preset: moa.active_preset === selectedMoaPreset ? '' : moa.active_preset
|
||||
}
|
||||
|
||||
setSelectedMoaPreset(Object.keys(moa.presets).find(name => name !== selectedMoaPreset) || '')
|
||||
void saveMoa(next)
|
||||
}}
|
||||
@@ -915,7 +826,6 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
disabled={!newMoaPresetName.trim() || !!moa.presets[newMoaPresetName.trim()] || applying}
|
||||
onClick={() => {
|
||||
const name = newMoaPresetName.trim()
|
||||
|
||||
const next: MoaConfigResponse = {
|
||||
...moa,
|
||||
presets: {
|
||||
@@ -923,7 +833,6 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
[name]: { ...currentMoaPreset, reference_models: [...currentMoaPreset.reference_models] }
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedMoaPreset(name)
|
||||
setNewMoaPresetName('')
|
||||
void saveMoa(next)
|
||||
|
||||
@@ -78,6 +78,8 @@ export function NotificationsSettings() {
|
||||
onChange={setNativeNotifyEnabled}
|
||||
/>
|
||||
|
||||
<div className="my-1 h-px bg-border/30" />
|
||||
|
||||
{NATIVE_NOTIFICATION_KINDS.map(kind => (
|
||||
<ToggleRow
|
||||
checked={prefs.enabled && prefs.kinds[kind]}
|
||||
@@ -89,6 +91,8 @@ export function NotificationsSettings() {
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="my-1 h-px bg-border/30" />
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
|
||||
@@ -143,7 +143,7 @@ export function PetSettings() {
|
||||
{copy.unreachable}
|
||||
</p>
|
||||
) : shown.length === 0 ? (
|
||||
<p className="wrap-anywhere text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{copy.noMatch(query)}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -76,28 +76,23 @@ export function ListRow({
|
||||
wide?: boolean
|
||||
}) {
|
||||
return (
|
||||
// Container-queried, not viewport-queried: the label/control split keys on
|
||||
// the row's own pane width, so a narrow detail column (messaging, split
|
||||
// views) stacks instead of squishing the label against minmax(15rem,…).
|
||||
<div className="@container">
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-3 py-3',
|
||||
!wide && '@2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center'
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-3 py-3 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center',
|
||||
wide && 'sm:grid-cols-1 sm:items-start'
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">{title}</div>
|
||||
{description && (
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">{title}</div>
|
||||
{description && (
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{hint && <div className="mt-1 block font-mono text-[0.68rem] text-muted-foreground/45">{hint}</div>}
|
||||
{below}
|
||||
</div>
|
||||
{action && <div className={cn('min-w-0', !wide && '@2xl:justify-self-end')}>{action}</div>}
|
||||
{hint && <div className="mt-1 block font-mono text-[0.68rem] text-muted-foreground/45">{hint}</div>}
|
||||
{below}
|
||||
</div>
|
||||
{action && <div className={cn('min-w-0', !wide && 'sm:justify-self-end')}>{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -106,6 +101,13 @@ export function LoadingState({ label }: { label: string }) {
|
||||
return <PageLoader label={label} />
|
||||
}
|
||||
|
||||
// Canonical implementation lives in components/ui; re-exported so the many
|
||||
// settings call sites keep their import path.
|
||||
export { EmptyState } from '@/components/ui/empty-state'
|
||||
export function EmptyState({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="grid min-h-48 place-items-center text-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import { SearchField } from '@/components/ui/search-field'
|
||||
import { disconnectOAuthProvider, listOAuthProviders } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Check, ChevronDown, ChevronRight, KeyRound, Loader2, Terminal, Trash2 } from '@/lib/icons'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding'
|
||||
@@ -401,7 +400,7 @@ export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSett
|
||||
const keyGroups = buildProviderKeyGroups(vars)
|
||||
|
||||
if (showApiKeys) {
|
||||
const q = normalize(keyQuery)
|
||||
const q = keyQuery.trim().toLowerCase()
|
||||
|
||||
const visibleGroups = q
|
||||
? keyGroups.filter(group => {
|
||||
|
||||
@@ -66,12 +66,6 @@ function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Radix menus/selects call these on open; jsdom implements neither, so the
|
||||
// dropdown never opens without the stubs (mirrors model-settings.test.tsx).
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
Element.prototype.hasPointerCapture = vi.fn(() => false)
|
||||
Element.prototype.releasePointerCapture = vi.fn()
|
||||
|
||||
getToolsetConfig.mockResolvedValue(config())
|
||||
getToolsetModels.mockResolvedValue({
|
||||
name: 'tts',
|
||||
@@ -171,10 +165,8 @@ describe('ToolsetConfigPanel', () => {
|
||||
const elevenlabs = await screen.findByRole('button', { name: /ElevenLabs/ })
|
||||
fireEvent.click(elevenlabs)
|
||||
|
||||
// Open the credential actions menu (Radix opens on pointerdown), then "Set".
|
||||
const trigger = await screen.findByRole('button', { name: /Actions for ELEVENLABS_API_KEY/ })
|
||||
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' })
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Set' }))
|
||||
// Click "Set" to reveal the input for the unset key.
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Set' }))
|
||||
|
||||
const input = await screen.findByPlaceholderText('ElevenLabs API key')
|
||||
fireEvent.change(input, { target: { value: 'sk-test-123' } })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
@@ -511,31 +512,32 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
|
||||
onConfiguredChange?.()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
// Inline row, not a full block loader — a big centered spinner is what
|
||||
// caused the Skills/Tools tab-switch layout jump; this reads as "more
|
||||
// config incoming" without reserving a tall empty area.
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{copy.loadingConfig}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const emptyMessage = useMemo(() => {
|
||||
if (loading || !cfg) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!cfg.has_category) {
|
||||
return copy.noProviderOptions
|
||||
}
|
||||
|
||||
if (providers.length === 0) {
|
||||
return copy.noProviders
|
||||
}
|
||||
|
||||
// Nothing to configure → render nothing. An inspector explaining that there
|
||||
// is nothing to explain is noise (the old expander UX needed the message so
|
||||
// an expanded-empty panel didn't look broken; the always-open detail doesn't).
|
||||
if (!cfg || !cfg.has_category) {
|
||||
return null
|
||||
}, [cfg, copy, loading, providers.length])
|
||||
|
||||
if (loading) {
|
||||
return <PageLoader className="min-h-32" label={copy.loadingConfig} />
|
||||
}
|
||||
|
||||
if (providers.length === 0) {
|
||||
return <p className="px-1 py-3 text-xs text-muted-foreground">{copy.noProviders}</p>
|
||||
if (emptyMessage) {
|
||||
return <p className="px-1 py-3 text-xs text-muted-foreground">{emptyMessage}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<div className="mt-3 grid gap-2">
|
||||
{providers.map(provider => {
|
||||
const isActive = activeProvider === provider.name
|
||||
const configured = providerConfigured(provider, envState)
|
||||
|
||||
@@ -8,6 +8,7 @@ export type SettingsView =
|
||||
| 'about'
|
||||
| 'gateway'
|
||||
| 'keys'
|
||||
| 'mcp'
|
||||
| 'notifications'
|
||||
| 'providers'
|
||||
| 'sessions'
|
||||
|
||||
@@ -30,50 +30,30 @@ export function useDeepLinkHighlight({
|
||||
|
||||
onResolve?.(target)
|
||||
|
||||
let cancelled = false
|
||||
let timer = 0
|
||||
|
||||
// onResolve may flip view state that mounts the row a few frames later, so
|
||||
// poll briefly for it and only drop the param AFTER a successful scroll —
|
||||
// deleting up front would lose the deep link when the target mounts late.
|
||||
let attempts = 0
|
||||
|
||||
const attempt = () => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
// Defer a frame so async state (expansion, selection) mounts the row first.
|
||||
const scrollTimeout = window.setTimeout(() => {
|
||||
const element = document.getElementById(elementId(target))
|
||||
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block })
|
||||
element.classList.add('setting-field-highlight')
|
||||
window.setTimeout(() => element.classList.remove('setting-field-highlight'), 1600)
|
||||
|
||||
setSearchParams(
|
||||
previous => {
|
||||
const next = new URLSearchParams(previous)
|
||||
next.delete(param)
|
||||
|
||||
return next
|
||||
},
|
||||
{ replace: true }
|
||||
)
|
||||
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
|
||||
if (attempts++ < 20) {
|
||||
timer = window.setTimeout(attempt, 80)
|
||||
}
|
||||
}
|
||||
element.scrollIntoView({ behavior: 'smooth', block })
|
||||
element.classList.add('setting-field-highlight')
|
||||
window.setTimeout(() => element.classList.remove('setting-field-highlight'), 1600)
|
||||
}, 80)
|
||||
|
||||
timer = window.setTimeout(attempt, 80)
|
||||
setSearchParams(
|
||||
previous => {
|
||||
const next = new URLSearchParams(previous)
|
||||
next.delete(param)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
return next
|
||||
},
|
||||
{ replace: true }
|
||||
)
|
||||
|
||||
return () => window.clearTimeout(scrollTimeout)
|
||||
}, [block, elementId, onResolve, param, ready, setSearchParams, target])
|
||||
|
||||
return target
|
||||
|
||||
@@ -9,7 +9,10 @@ describe('withActive', () => {
|
||||
const curated = ['hermes-4', 'hermes-4-mini']
|
||||
|
||||
it('prepends a custom model missing from the curated list', () => {
|
||||
expect(withActive(curated, 'anthropic/claude-opus-4.7')).toEqual(['anthropic/claude-opus-4.7', ...curated])
|
||||
expect(withActive(curated, 'anthropic/claude-opus-4.7')).toEqual([
|
||||
'anthropic/claude-opus-4.7',
|
||||
...curated
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves the list untouched when the active model is already curated', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { compactNumber } from '@/lib/format'
|
||||
import { formatK } from '@/lib/statusbar'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ContextBreakdown, ContextUsageCategory, UsageStats } from '@/types/hermes'
|
||||
|
||||
@@ -21,7 +21,6 @@ export function ContextUsagePanel({ currentUsage, requestGateway, sessionId }: C
|
||||
if (!sessionId) {
|
||||
setBreakdown(null)
|
||||
setLoading(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -52,7 +51,6 @@ export function ContextUsagePanel({ currentUsage, requestGateway, sessionId }: C
|
||||
|
||||
const contextMax = breakdown?.context_max ?? currentUsage.context_max ?? 0
|
||||
const contextUsed = breakdown?.context_used ?? currentUsage.context_used ?? 0
|
||||
|
||||
const contextPercent = Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(breakdown?.context_percent ?? currentUsage.context_percent ?? 0))
|
||||
@@ -64,7 +62,7 @@ export function ContextUsagePanel({ currentUsage, requestGateway, sessionId }: C
|
||||
...category,
|
||||
label: copy.categories[category.id as keyof typeof copy.categories] ?? category.label
|
||||
})),
|
||||
[breakdown?.categories, copy]
|
||||
[breakdown?.categories, copy.categories]
|
||||
)
|
||||
|
||||
const segmentTotal = categories.reduce((sum, category) => sum + category.tokens, 0) || contextUsed || 1
|
||||
@@ -75,7 +73,7 @@ export function ContextUsagePanel({ currentUsage, requestGateway, sessionId }: C
|
||||
<p className="font-medium text-foreground">{copy.title}</p>
|
||||
|
||||
<span className="text-[0.6875rem] text-muted-foreground">
|
||||
{copy.tokenSummary(`~${compactNumber(contextUsed)}`, compactNumber(contextMax))}
|
||||
{copy.tokenSummary(`~${formatK(contextUsed)}`, formatK(contextMax))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -87,12 +85,15 @@ export function ContextUsagePanel({ currentUsage, requestGateway, sessionId }: C
|
||||
{categories.map(category => (
|
||||
<li className="flex items-center justify-between gap-2" key={category.id}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="size-2 shrink-0 rounded-[2px]" style={{ background: category.color }} />
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-[2px]"
|
||||
style={{ background: category.color }}
|
||||
/>
|
||||
|
||||
<span className="truncate text-muted-foreground">{category.label}</span>
|
||||
</span>
|
||||
|
||||
<span className="shrink-0 tabular-nums text-foreground">{compactNumber(category.tokens)}</span>
|
||||
<span className="shrink-0 tabular-nums text-foreground">{formatCategoryTokens(category.tokens)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -132,3 +133,15 @@ function ContextUsageBar({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatCategoryTokens(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return '0'
|
||||
}
|
||||
|
||||
if (value >= 1_000) {
|
||||
return `${formatK(value)}`
|
||||
}
|
||||
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { StatusDot, type StatusTone } from '@/components/status-dot'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -8,7 +8,6 @@ import { getLogs } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { LayoutDashboard, RefreshCw } from '@/lib/icons'
|
||||
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { runGatewayRestart } from '@/store/system-actions'
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
@@ -177,13 +176,13 @@ export function GatewayMenuPanel({
|
||||
</div>
|
||||
|
||||
{inferenceStatus?.reason && (
|
||||
<Section className="text-xs text-muted-foreground">
|
||||
<div className="border-t border-border/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="line-clamp-3">{inferenceStatus.reason}</div>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recentLogs.length > 0 && (
|
||||
<Section>
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<SectionLabel>{copy.recentActivity}</SectionLabel>
|
||||
<Button
|
||||
@@ -199,11 +198,11 @@ export function GatewayMenuPanel({
|
||||
<LogView className="mt-1.5 max-h-40 border-0 px-0" ref={logScrollRef}>
|
||||
{recentLogs.map(trimLogLine).join('\n')}
|
||||
</LogView>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{platforms.length > 0 && (
|
||||
<Section>
|
||||
<div className="border-t border-border/50 px-3 py-2">
|
||||
<SectionLabel>{copy.messagingPlatforms}</SectionLabel>
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{platforms.map(([name, platform]) => (
|
||||
@@ -216,16 +215,12 @@ export function GatewayMenuPanel({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <div className={cn('border-t border-border/50 px-3 py-2', className)}>{children}</div>
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="text-[0.62rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground/80">{children}</div>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { setModelPreset } from '@/store/model-presets'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $activeSessionId, setCurrentFastMode, setCurrentReasoningEffort } from '@/store/session'
|
||||
@@ -234,11 +233,11 @@ export function ModelEditSubmenu({
|
||||
|
||||
function isThinkingEnabled(effort: string): boolean {
|
||||
// Empty = Hermes default (medium) = on; only an explicit "none" is off.
|
||||
return normalize(effort || 'medium') !== 'none'
|
||||
return (effort || 'medium').trim().toLowerCase() !== 'none'
|
||||
}
|
||||
|
||||
function normalizeEffort(effort: string): string {
|
||||
const value = normalize(effort || 'medium')
|
||||
const value = (effort || 'medium').trim().toLowerCase()
|
||||
|
||||
// Thinking off → no effort selected in the radio group.
|
||||
if (value === 'none') {
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
modelDisplayParts,
|
||||
reasoningEffortLabel
|
||||
} from '@/lib/model-status-label'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
|
||||
import {
|
||||
@@ -340,7 +339,9 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">MoA: {preset}</span>
|
||||
{isCurrentMoa ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
|
||||
{isCurrentMoa ? (
|
||||
<Codicon className="ml-auto text-foreground" name="check" size="0.75rem" />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
@@ -383,7 +384,7 @@ function groupModels(
|
||||
current: { model: string; provider: string },
|
||||
visible: Set<string> | null
|
||||
): ProviderGroup[] {
|
||||
const q = normalize(search)
|
||||
const q = search.trim().toLowerCase()
|
||||
const groups: ProviderGroup[] = []
|
||||
|
||||
for (const provider of providers) {
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useDebounced } from '@/app/hooks/use-debounced'
|
||||
import { DetailPane } from '@/app/master-detail'
|
||||
import { LogTail } from '@/components/chat/log-tail'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -18,33 +13,25 @@ import {
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
getActionStatus,
|
||||
getSkillHubSources,
|
||||
installSkillFromHub,
|
||||
previewSkillHub,
|
||||
scanSkillHub,
|
||||
searchSkillsHub,
|
||||
type SkillHubInstalledEntry,
|
||||
type SkillHubPreview,
|
||||
type SkillHubResult,
|
||||
type SkillHubScanResult
|
||||
type SkillHubScanResult,
|
||||
type SkillHubSource,
|
||||
updateSkillsFromHub
|
||||
} from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { stripAnsi } from '@/lib/ansi'
|
||||
import { Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$hubActions,
|
||||
$hubActiveLog,
|
||||
$hubInstalledOverride,
|
||||
closeHubLog,
|
||||
HUB_SOURCES_KEY,
|
||||
installHubSkill,
|
||||
uninstallHubSkill,
|
||||
UPDATE_ALL_KEY,
|
||||
updateHubSkills
|
||||
} from '@/store/hub-actions'
|
||||
import { upsertDesktopActionTask } from '@/store/activity'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
// Dedup rank when the same skill surfaces from multiple sources — higher trust
|
||||
// wins. Mirrors the backend's unified_search `_TRUST_RANK`.
|
||||
const TRUST_RANK: Record<string, number> = { builtin: 2, trusted: 1, community: 0 }
|
||||
const ACTION_POLL_MS = 1200
|
||||
|
||||
function trustTone(level: string): string {
|
||||
switch (level) {
|
||||
@@ -72,145 +59,208 @@ function verdictTone(policy: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// One hub result — a self-contained row that installs/uninstalls ITSELF and
|
||||
// reads its own action status from the store, so parallel installs never desync.
|
||||
// `rawInstalled` is the sources/search truth; the store's optimistic override
|
||||
// wins so the row flips the instant its own action resolves.
|
||||
function HubSkillRow({
|
||||
installedName,
|
||||
onPreview,
|
||||
rawInstalled,
|
||||
skill
|
||||
}: {
|
||||
installedName: null | string
|
||||
onPreview: (skill: SkillHubResult) => void
|
||||
rawInstalled: boolean
|
||||
skill: SkillHubResult
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const h = t.skills.hub
|
||||
const action = useStore($hubActions)[skill.identifier]
|
||||
const override = useStore($hubInstalledOverride)[skill.identifier]
|
||||
const installed = override ?? rawInstalled
|
||||
const running = action?.running ?? false
|
||||
|
||||
const doInstall = () => {
|
||||
notify({ kind: 'success', title: h.installStarted(skill.name), message: h.actionLog })
|
||||
void installHubSkill(skill.identifier).catch(err => notifyError(err, h.actionFailed))
|
||||
}
|
||||
|
||||
const doUninstall = () => {
|
||||
notify({ kind: 'success', title: h.uninstallStarted(skill.name), message: h.actionLog })
|
||||
void uninstallHubSkill(skill.identifier, installedName || skill.name).catch(err => notifyError(err, h.actionFailed))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row-hover flex items-start gap-3 rounded-md px-2 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="truncate text-[0.78rem] font-medium text-foreground/85">{skill.name}</span>
|
||||
<span className={cn('rounded px-1.5 py-0.5 text-[0.6rem]', trustTone(skill.trust_level))}>
|
||||
{h.trust[skill.trust_level] ?? skill.trust_level}
|
||||
</span>
|
||||
{installed && <span className="text-[0.6rem] text-emerald-400">{h.installed}</span>}
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-2 text-[0.68rem] text-muted-foreground/70">{skill.description}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button onClick={() => onPreview(skill)} size="xs" variant="text">
|
||||
{h.preview}
|
||||
</Button>
|
||||
{installed ? (
|
||||
<Button className="hover:text-destructive" disabled={running} onClick={doUninstall} size="xs" variant="text">
|
||||
{running && <Loader2 className="size-3 animate-spin" />}
|
||||
{running ? h.uninstalling : h.uninstall}
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled={running} onClick={doInstall} size="xs" variant="textStrong">
|
||||
{running && <Loader2 className="size-3 animate-spin" />}
|
||||
{running ? h.installing : h.install}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SkillsHubProps {
|
||||
/** Called after an install/uninstall/update finishes so the parent can refresh the installed-skills list. */
|
||||
onInstalledChange?: () => void
|
||||
query: string
|
||||
}
|
||||
|
||||
export function SkillsHub({ query }: SkillsHubProps) {
|
||||
export function SkillsHub({ onInstalledChange, query }: SkillsHubProps) {
|
||||
const { t } = useI18n()
|
||||
const h = t.skills.hub
|
||||
|
||||
// Sources + featured + the installed map — one cached fetch, revalidated on
|
||||
// mount and re-fetched (from the store) after an action lands.
|
||||
const sourcesQuery = useQuery({
|
||||
queryKey: HUB_SOURCES_KEY,
|
||||
queryFn: getSkillHubSources,
|
||||
staleTime: 5 * 60_000
|
||||
})
|
||||
const [sources, setSources] = useState<SkillHubSource[]>([])
|
||||
const [featured, setFeatured] = useState<SkillHubResult[]>([])
|
||||
const [installed, setInstalled] = useState<Record<string, SkillHubInstalledEntry>>({})
|
||||
const [sourcesLoading, setSourcesLoading] = useState(true)
|
||||
|
||||
// Debounced hub search, keyed on the settled query so RQ dedupes/caches per
|
||||
// term and abandons stale terms for us (no hand-rolled sequence guard).
|
||||
const term = useDebounced(query.trim(), 350)
|
||||
const [results, setResults] = useState<SkillHubResult[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [searched, setSearched] = useState(false)
|
||||
const [timedOut, setTimedOut] = useState<string[]>([])
|
||||
const [searchMs, setSearchMs] = useState<null | number>(null)
|
||||
|
||||
// Progressive per-source search: one query per source the backend says is
|
||||
// worth hitting individually (it marks index-covered API sources unsearchable
|
||||
// so we don't re-hammer ~70 GitHub calls). Each resolves independently, so the
|
||||
// list fills in as sources return instead of blocking on the slowest one, and
|
||||
// each source shows its own spinner. Stale terms key out and are abandoned.
|
||||
const searchableSources = useMemo(
|
||||
() => (sourcesQuery.data?.sources ?? []).filter(source => source.searchable !== false),
|
||||
[sourcesQuery.data]
|
||||
)
|
||||
// Live log tail for the most recent install/uninstall/update action.
|
||||
const [action, setAction] = useState<null | string>(null)
|
||||
const [actionLog, setActionLog] = useState<string[]>([])
|
||||
const [actionRunning, setActionRunning] = useState(false)
|
||||
|
||||
const sourceSearches = useQueries({
|
||||
queries: searchableSources.map(source => ({
|
||||
queryKey: ['skill-hub-search', term, source.id],
|
||||
queryFn: () => searchSkillsHub(term, source.id),
|
||||
enabled: term.length > 0,
|
||||
staleTime: 60_000
|
||||
}))
|
||||
})
|
||||
|
||||
// Per-item action lifecycle + log live in the store (store/hub-actions): each
|
||||
// row reads ITS own entry, so concurrent installs never desync each other,
|
||||
// and an optimistic installed-override flips a row the instant its own action
|
||||
// resolves rather than racing the sources refetch.
|
||||
const actions = useStore($hubActions)
|
||||
const overrides = useStore($hubInstalledOverride)
|
||||
const activeLogKey = useStore($hubActiveLog)
|
||||
const activeLog = activeLogKey ? actions[activeLogKey] : undefined
|
||||
|
||||
// Preview/scan dialog. Preview is cache-worthy (keyed by identifier); scan is
|
||||
// an explicit, on-demand security pass so it stays imperative.
|
||||
// Preview/scan dialog state.
|
||||
const [detail, setDetail] = useState<null | SkillHubResult>(null)
|
||||
const [preview, setPreview] = useState<null | SkillHubPreview>(null)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const [scan, setScan] = useState<null | SkillHubScanResult>(null)
|
||||
const [scanning, setScanning] = useState(false)
|
||||
|
||||
const previewQuery = useQuery({
|
||||
queryKey: ['skill-hub-preview', detail?.identifier],
|
||||
queryFn: () => previewSkillHub(detail!.identifier),
|
||||
enabled: detail !== null,
|
||||
staleTime: 5 * 60_000
|
||||
})
|
||||
const searchSeq = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
getSkillHubSources()
|
||||
.then(response => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setSources(response.sources)
|
||||
setFeatured(response.featured)
|
||||
setInstalled(response.installed)
|
||||
})
|
||||
.catch(err => notifyError(err, h.loadFailed))
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setSourcesLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => void (cancelled = true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount
|
||||
}, [])
|
||||
|
||||
// Debounced hub search driven by the shared page search field.
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
setResults([])
|
||||
setSearched(false)
|
||||
setSearching(false)
|
||||
setTimedOut([])
|
||||
setSearchMs(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const seq = searchSeq.current + 1
|
||||
searchSeq.current = seq
|
||||
setSearching(true)
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
const started = performance.now()
|
||||
|
||||
searchSkillsHub(trimmed)
|
||||
.then(response => {
|
||||
if (searchSeq.current !== seq) {
|
||||
return
|
||||
}
|
||||
|
||||
setResults(response.results)
|
||||
setTimedOut(response.timed_out || [])
|
||||
setInstalled(prev => ({ ...prev, ...(response.installed || {}) }))
|
||||
setSearchMs(Math.round(performance.now() - started))
|
||||
setSearched(true)
|
||||
})
|
||||
.catch(err => {
|
||||
if (searchSeq.current === seq) {
|
||||
notifyError(err, h.searchFailed)
|
||||
setResults([])
|
||||
setSearched(true)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (searchSeq.current === seq) {
|
||||
setSearching(false)
|
||||
}
|
||||
})
|
||||
}, 350)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [h, query])
|
||||
|
||||
// Poll a spawned hub action's log until it exits, then refresh installed state.
|
||||
useEffect(() => {
|
||||
if (!action) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let timer: null | number = null
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const status = await getActionStatus(action, 200)
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setActionLog(status.lines)
|
||||
setActionRunning(status.running)
|
||||
upsertDesktopActionTask(status)
|
||||
|
||||
if (status.running) {
|
||||
timer = window.setTimeout(() => void poll(), ACTION_POLL_MS)
|
||||
} else {
|
||||
getSkillHubSources()
|
||||
.then(response => {
|
||||
if (!cancelled) {
|
||||
setInstalled(response.installed)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
onInstalledChange?.()
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setActionRunning(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void poll()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [action, onInstalledChange])
|
||||
|
||||
const install = useCallback(
|
||||
(identifier: string, name: string) => {
|
||||
setDetail(null)
|
||||
notify({ kind: 'success', title: h.installStarted(name), message: h.actionLog })
|
||||
void installHubSkill(identifier).catch(err => notifyError(err, h.actionFailed))
|
||||
async (identifier: string, name: string) => {
|
||||
try {
|
||||
const started = await installSkillFromHub(identifier)
|
||||
notify({ kind: 'success', title: h.installStarted(name), message: h.actionLog })
|
||||
setActionLog([])
|
||||
setActionRunning(true)
|
||||
setAction(started.name)
|
||||
setDetail(null)
|
||||
} catch (err) {
|
||||
notifyError(err, h.actionFailed)
|
||||
}
|
||||
},
|
||||
[h]
|
||||
)
|
||||
|
||||
const updateAll = useCallback(() => {
|
||||
notify({ kind: 'success', title: h.updateStarted, message: h.actionLog })
|
||||
void updateHubSkills().catch(err => notifyError(err, h.actionFailed))
|
||||
const updateAll = useCallback(async () => {
|
||||
try {
|
||||
const started = await updateSkillsFromHub()
|
||||
notify({ kind: 'success', title: h.updateStarted, message: h.actionLog })
|
||||
setActionLog([])
|
||||
setActionRunning(true)
|
||||
setAction(started.name)
|
||||
} catch (err) {
|
||||
notifyError(err, h.actionFailed)
|
||||
}
|
||||
}, [h])
|
||||
|
||||
const openDetail = useCallback(
|
||||
(skill: SkillHubResult) => {
|
||||
setDetail(skill)
|
||||
setPreview(null)
|
||||
setScan(null)
|
||||
setPreviewLoading(true)
|
||||
previewSkillHub(skill.identifier)
|
||||
.then(setPreview)
|
||||
.catch(err => notifyError(err, h.previewFailed))
|
||||
.finally(() => setPreviewLoading(false))
|
||||
},
|
||||
[h]
|
||||
)
|
||||
|
||||
const runScan = useCallback(
|
||||
(identifier: string) => {
|
||||
setScanning(true)
|
||||
@@ -222,170 +272,115 @@ export function SkillsHub({ query }: SkillsHubProps) {
|
||||
[h]
|
||||
)
|
||||
|
||||
const openDetail = useCallback((skill: SkillHubResult) => {
|
||||
setDetail(skill)
|
||||
setScan(null)
|
||||
}, [])
|
||||
const isInstalled = useCallback((identifier: string) => Boolean(installed[identifier]), [installed])
|
||||
|
||||
// Per-source progress, keyed by source id (drives the connected-hub chips'
|
||||
// spinner/degraded tint while a search is streaming in).
|
||||
const searchStateById = new Map<string, { failed: boolean; fetching: boolean }>()
|
||||
searchableSources.forEach((source, i) => {
|
||||
const q = sourceSearches[i]
|
||||
searchStateById.set(source.id, { failed: q.isError, fetching: term.length > 0 && q.isFetching })
|
||||
})
|
||||
|
||||
// Merge every source's results, deduped by identifier preferring higher trust
|
||||
// (mirrors the backend's unified_search rank). Recomputes as each source lands.
|
||||
const results = useMemo(() => {
|
||||
const seen = new Map<string, SkillHubResult>()
|
||||
|
||||
for (const q of sourceSearches) {
|
||||
for (const r of q.data?.results ?? []) {
|
||||
const prev = seen.get(r.identifier)
|
||||
|
||||
if (!prev || (TRUST_RANK[r.trust_level] ?? 0) > (TRUST_RANK[prev.trust_level] ?? 0)) {
|
||||
seen.set(r.identifier, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...seen.values()].sort(
|
||||
(a, b) => (TRUST_RANK[b.trust_level] ?? 0) - (TRUST_RANK[a.trust_level] ?? 0) || a.name.localeCompare(b.name)
|
||||
)
|
||||
}, [sourceSearches])
|
||||
|
||||
// Installed map: sources seeds it, search results patch it (a term can surface
|
||||
// installs the sources list didn't feature); the optimistic override wins so a
|
||||
// just-(un)installed row reflects its own outcome without the refetch race.
|
||||
const installed = { ...(sourcesQuery.data?.installed ?? {}) }
|
||||
|
||||
for (const q of sourceSearches) {
|
||||
Object.assign(installed, q.data?.installed ?? {})
|
||||
}
|
||||
|
||||
const isInstalled = (identifier: string) => overrides[identifier] ?? Boolean(installed[identifier])
|
||||
|
||||
const sources = sourcesQuery.data?.sources ?? []
|
||||
const featured = sourcesQuery.data?.featured ?? []
|
||||
|
||||
// Still fetching from at least one source; "done" only once every source has
|
||||
// settled (so "No results" doesn't flash while slower sources are still in).
|
||||
const anyFetching = term.length > 0 && sourceSearches.some(q => q.isFetching)
|
||||
const searched = term.length > 0 && sourceSearches.length > 0 && sourceSearches.every(q => !q.isFetching)
|
||||
const showLanding = term.length === 0
|
||||
const listed = showLanding ? featured : results
|
||||
// Only block the whole pane on the first sources landing; after that results
|
||||
// stream in progressively while a subtle footer shows more are coming.
|
||||
const searching = anyFetching && results.length === 0
|
||||
const hasInstalled = Object.keys(installed).length > 0
|
||||
const showLanding = !searched && !searching
|
||||
const listed = showLanding ? featured : results
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* Connected hubs — label on its own line, chips below, roomy padding. */}
|
||||
<div className="shrink-0 px-4 pt-5 pb-8 text-[0.68rem] text-(--ui-text-tertiary)">
|
||||
<span className="mb-1.5 block">{h.connectedHubs}</span>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{sourcesQuery.isLoading
|
||||
? null
|
||||
: sources.map(source => {
|
||||
const state = searchStateById.get(source.id)
|
||||
const degraded = source.available === false || source.rate_limited === true || state?.failed
|
||||
const fetching = state?.fetching ?? false
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{sourcesLoading ? (
|
||||
<span>{h.connectingHubs}</span>
|
||||
) : (
|
||||
<>
|
||||
<span>{h.connectedHubs}</span>
|
||||
{sources.map(source => {
|
||||
const degraded = source.available === false || source.rate_limited === true
|
||||
|
||||
return (
|
||||
<span
|
||||
<Badge
|
||||
className={cn(
|
||||
'relative rounded px-1.5 py-0.5 text-[0.6rem] transition-opacity',
|
||||
degraded ? 'bg-amber-500/15 text-amber-400' : 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)',
|
||||
// While searching, un-hit sources dim so the active ones read clearly.
|
||||
term.length > 0 && !fetching && !state?.failed && 'opacity-55'
|
||||
degraded ? 'bg-amber-500/15 text-amber-400' : 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)'
|
||||
)}
|
||||
key={source.id}
|
||||
>
|
||||
{/* Spinner overlays the (dimmed) label rather than pushing it,
|
||||
so a chip never resizes as its search starts/finishes. */}
|
||||
<span className={cn(fetching && 'opacity-30')}>{source.label}</span>
|
||||
{fetching && (
|
||||
<span className="absolute inset-0 grid place-items-center">
|
||||
<Loader2 className="size-2.5 animate-spin" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{source.label}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result summary (left) + Update installed (right) — only when a results
|
||||
table is actually on screen, and update only if something's installed. */}
|
||||
{listed.length > 0 && (
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 px-4 pb-1.5 text-[0.68rem] text-(--ui-text-tertiary)">
|
||||
<span className="min-w-0 truncate">
|
||||
{term.length > 0 ? h.resultCount(results.length, null) : h.featured}
|
||||
{anyFetching && results.length > 0 && <span className="ml-2 text-(--ui-text-quaternary)">{h.searching}</span>}
|
||||
</span>
|
||||
|
||||
{hasInstalled && (
|
||||
<Button
|
||||
className="shrink-0"
|
||||
disabled={actions[UPDATE_ALL_KEY]?.running}
|
||||
onClick={updateAll}
|
||||
size="xs"
|
||||
variant="text"
|
||||
>
|
||||
{actions[UPDATE_ALL_KEY]?.running && <Loader2 className="size-3 animate-spin" />}
|
||||
{actions[UPDATE_ALL_KEY]?.running ? h.updating : h.updateAll}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrollable results. */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 [scrollbar-gutter:stable]">
|
||||
{searching ? (
|
||||
<div className="grid min-h-40 place-items-center">
|
||||
<PageLoader label={h.searching} />
|
||||
</div>
|
||||
) : listed.length === 0 ? (
|
||||
<div className="grid min-h-40 place-items-center px-6 text-center">
|
||||
<p className="max-w-md text-[0.72rem] text-(--ui-text-tertiary)">
|
||||
{searched ? h.noResults : h.landingHint}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{listed.map(skill => (
|
||||
<HubSkillRow
|
||||
installedName={installed[skill.identifier]?.name ?? null}
|
||||
key={skill.identifier}
|
||||
onPreview={openDetail}
|
||||
rawInstalled={Boolean(installed[skill.identifier])}
|
||||
skill={skill}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasInstalled && (
|
||||
<Button disabled={actionRunning} onClick={() => void updateAll()} size="xs" variant="text">
|
||||
{actionRunning ? h.updating : h.updateAll}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action log — same resizable, flush-width bottom pane + LogTail surface
|
||||
as the MCP logs. ANSI stripped so spawn output reads clean. Tails the
|
||||
latest-started action ($hubActiveLog). */}
|
||||
{activeLogKey && (
|
||||
<DetailPane
|
||||
defaultCollapsed
|
||||
defaultHeight={176}
|
||||
id="hub-action-log"
|
||||
onClose={closeHubLog}
|
||||
title={
|
||||
<span className="flex items-center gap-1.5 text-[0.68rem] font-normal text-muted-foreground/60">
|
||||
{h.actionLog}
|
||||
{activeLog?.running && <Codicon name="loading" size="0.75rem" spinning />}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<LogTail emptyLabel={h.searching} lines={activeLog?.lines.length ? activeLog.lines.map(stripAnsi) : null} />
|
||||
</DetailPane>
|
||||
{searched && !searching && (
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{h.resultCount(results.length, searchMs)}</span>
|
||||
{timedOut.length > 0 && <span className="text-amber-400">{h.timedOut(timedOut.join(', '))}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searching ? (
|
||||
<PageLoader className="min-h-40" label={h.searching} />
|
||||
) : listed.length === 0 ? (
|
||||
<div className="grid min-h-40 place-items-center text-center">
|
||||
<div className="max-w-md text-xs text-muted-foreground">{searched ? h.noResults : h.landingHint}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{showLanding && (
|
||||
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{h.featured}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{listed.map(skill => (
|
||||
<div
|
||||
className="grid gap-3 px-0 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
|
||||
key={skill.identifier}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{skill.name}</span>
|
||||
<Badge className={trustTone(skill.trust_level)}>
|
||||
{h.trust[skill.trust_level] ?? skill.trust_level}
|
||||
</Badge>
|
||||
{isInstalled(skill.identifier) && (
|
||||
<Badge className="bg-emerald-500/15 text-emerald-400">{h.installed}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{skill.description}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button onClick={() => openDetail(skill)} size="xs" variant="text">
|
||||
{h.preview}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={actionRunning || isInstalled(skill.identifier)}
|
||||
onClick={() => void install(skill.identifier, skill.name)}
|
||||
size="xs"
|
||||
variant="textStrong"
|
||||
>
|
||||
{isInstalled(skill.identifier) ? h.installed : h.install}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action && actionLog.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center gap-2 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{h.actionLog}
|
||||
{actionRunning && <Codicon name="loading" size="0.75rem" spinning />}
|
||||
</div>
|
||||
<pre
|
||||
className="max-h-48 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.65rem] leading-relaxed text-(--ui-text-tertiary)"
|
||||
data-selectable-text="true"
|
||||
>
|
||||
{actionLog.join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog onOpenChange={open => !open && setDetail(null)} open={detail !== null}>
|
||||
@@ -426,19 +421,19 @@ export function SkillsHub({ query }: SkillsHubProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewQuery.isLoading ? (
|
||||
{previewLoading ? (
|
||||
<PageLoader className="min-h-32" label={h.searching} />
|
||||
) : previewQuery.data ? (
|
||||
) : preview ? (
|
||||
<>
|
||||
<pre
|
||||
className="max-h-72 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"
|
||||
>
|
||||
{previewQuery.data.skill_md || h.noReadme}
|
||||
{preview.skill_md || h.noReadme}
|
||||
</pre>
|
||||
{previewQuery.data.files.length > 0 && (
|
||||
{preview.files.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">{h.files}:</span> {previewQuery.data.files.join(', ')}
|
||||
<span className="font-medium">{h.files}:</span> {preview.files.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -450,8 +445,8 @@ export function SkillsHub({ query }: SkillsHubProps) {
|
||||
{scanning ? h.scanning : h.scan}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={actions[detail.identifier]?.running || isInstalled(detail.identifier)}
|
||||
onClick={() => install(detail.identifier, detail.name)}
|
||||
disabled={actionRunning || isInstalled(detail.identifier)}
|
||||
onClick={() => void install(detail.identifier, detail.name)}
|
||||
size="sm"
|
||||
>
|
||||
{isInstalled(detail.identifier) ? h.installed : h.install}
|
||||
|
||||
@@ -1,32 +1,24 @@
|
||||
// @vitest-environment jsdom
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as HermesApi from '@/hermes'
|
||||
import { queryClient } from '@/lib/query-client'
|
||||
|
||||
const getSkills = vi.fn()
|
||||
const getToolsets = vi.fn()
|
||||
const toggleSkill = vi.fn()
|
||||
const toggleToolset = vi.fn()
|
||||
const getToolsetConfig = vi.fn()
|
||||
const selectToolsetProvider = vi.fn()
|
||||
const getUsageAnalytics = vi.fn()
|
||||
|
||||
// Partial mock: keep the real module (SkillsView pulls in @/store/profile,
|
||||
// whose import-time subscription calls setApiRequestProfile) and stub only the
|
||||
// calls we assert on.
|
||||
vi.mock('@/hermes', async importOriginal => ({
|
||||
...(await importOriginal<typeof HermesApi>()),
|
||||
vi.mock('@/hermes', () => ({
|
||||
getSkills: () => getSkills(),
|
||||
getToolsets: () => getToolsets(),
|
||||
toggleSkill: (name: string, enabled: boolean) => toggleSkill(name, enabled),
|
||||
toggleToolset: (name: string, enabled: boolean) => toggleToolset(name, enabled),
|
||||
getToolsetConfig: (name: string) => getToolsetConfig(name),
|
||||
selectToolsetProvider: (toolset: string, provider: string) => selectToolsetProvider(toolset, provider),
|
||||
getUsageAnalytics: (days: number) => getUsageAnalytics(days)
|
||||
deleteEnvVar: vi.fn(),
|
||||
revealEnvVar: vi.fn(),
|
||||
setEnvVar: vi.fn()
|
||||
}))
|
||||
|
||||
// Notifications hit nanostores/timers we don't care about here.
|
||||
@@ -51,12 +43,9 @@ function toolset(overrides: Record<string, unknown> = {}) {
|
||||
function renderSkills() {
|
||||
return import('./index').then(({ SkillsView }) =>
|
||||
render(
|
||||
// SkillsView reads skills/toolsets via useQuery, so it needs a provider.
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/skills?tab=toolsets']}>
|
||||
<SkillsView />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
<MemoryRouter initialEntries={['/skills?tab=toolsets']}>
|
||||
<SkillsView />
|
||||
</MemoryRouter>
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -65,15 +54,12 @@ beforeEach(() => {
|
||||
getSkills.mockResolvedValue([])
|
||||
getToolsets.mockResolvedValue([toolset()])
|
||||
toggleToolset.mockResolvedValue({ ok: true, name: 'web', enabled: false })
|
||||
getToolsetConfig.mockResolvedValue({ has_category: true, active_provider: null, providers: [] })
|
||||
getUsageAnalytics.mockResolvedValue({ tools: [] })
|
||||
getToolsetConfig.mockResolvedValue({ has_category: false, active_provider: null, providers: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
// Shared singleton client — drop cached skills/toolsets so each test refetches.
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
describe('SkillsView toolset management', () => {
|
||||
@@ -93,20 +79,23 @@ describe('SkillsView toolset management', () => {
|
||||
|
||||
await renderSkills()
|
||||
|
||||
// The label renders in both the row and the auto-selected detail header, so
|
||||
// assert via the switch's (emoji-stripped) accessible name and the absence
|
||||
// of the emoji rather than a single-match text lookup.
|
||||
await screen.findByRole('switch', { name: 'Toggle Cron Jobs toolset' })
|
||||
expect(await screen.findByText('Cron Jobs')).toBeTruthy()
|
||||
expect(screen.queryByText(/⏰/)).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the provider config panel inline for the selected toolset', async () => {
|
||||
// The master-detail UI dropped the resting "Configured" pill and the
|
||||
// "Configure" expander: the detail column auto-selects the first toolset
|
||||
// and renders its config panel directly, which fetches on mount.
|
||||
it('keeps the configured pill alongside the switch', async () => {
|
||||
await renderSkills()
|
||||
|
||||
await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
|
||||
expect(screen.getByText('Configured')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('expands the provider config panel when the configured pill is clicked', async () => {
|
||||
await renderSkills()
|
||||
|
||||
const configureBtn = await screen.findByRole('button', { name: 'Configure Web Search' })
|
||||
fireEvent.click(configureBtn)
|
||||
|
||||
await waitFor(() => expect(getToolsetConfig).toHaveBeenCalledWith('web'))
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +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)
|
||||
@@ -76,17 +76,7 @@ function hslToRgb(h: number, s: number, l: number): Rgb {
|
||||
const m = l - c / 2
|
||||
|
||||
const [r, g, b] =
|
||||
hue < 60
|
||||
? [c, x, 0]
|
||||
: hue < 120
|
||||
? [x, c, 0]
|
||||
: hue < 180
|
||||
? [0, c, x]
|
||||
: hue < 240
|
||||
? [0, x, c]
|
||||
: hue < 300
|
||||
? [x, 0, c]
|
||||
: [c, 0, x]
|
||||
hue < 60 ? [c, x, 0] : hue < 120 ? [x, c, 0] : hue < 180 ? [0, c, x] : hue < 240 ? [0, x, c] : hue < 300 ? [x, 0, c] : [c, 0, x]
|
||||
|
||||
return { b: Math.round((b + m) * 255), g: Math.round((g + m) * 255), r: Math.round((r + m) * 255) }
|
||||
}
|
||||
@@ -116,9 +106,7 @@ export function computePalette(canvas: HTMLCanvasElement): Palette {
|
||||
const primary = resolveRgb(style.getPropertyValue('--theme-primary').trim() || style.color)
|
||||
|
||||
const bg = resolveRgb(
|
||||
style.getPropertyValue('--background').trim() ||
|
||||
style.getPropertyValue('--dt-background').trim() ||
|
||||
(darkTheme ? '#000' : '#fff')
|
||||
style.getPropertyValue('--background').trim() || style.getPropertyValue('--dt-background').trim() || (darkTheme ? '#000' : '#fff')
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -46,12 +46,7 @@ export function StarmapView({ onClose }: { onClose: () => void }) {
|
||||
) : shown && shown.nodes.length === 0 && !imported ? (
|
||||
<PanelEmpty description={t.starmap.emptyDesc} icon="lightbulb" title={t.starmap.emptyTitle} />
|
||||
) : shown ? (
|
||||
<StarMap
|
||||
graph={shown}
|
||||
imported={imported !== null}
|
||||
onImport={setImported}
|
||||
onResetMap={() => setImported(null)}
|
||||
/>
|
||||
<StarMap graph={shown} imported={imported !== null} onImport={setImported} onResetMap={() => setImported(null)} />
|
||||
) : null}
|
||||
</Panel>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { ArchiveSkillConfirmDialog, fireOptimistic } from '@/app/learning/archive-skill-confirm-dialog'
|
||||
import { CodeEditor } from '@/components/chat/code-editor'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { deleteLearningNode, editLearningNode, getLearningNode } from '@/hermes'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { evictStarmapNode, loadStarmapGraph } from '@/store/starmap'
|
||||
|
||||
import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
|
||||
|
||||
export interface NodeMenuTarget {
|
||||
id: string
|
||||
@@ -20,8 +15,8 @@ export interface NodeMenuTarget {
|
||||
}
|
||||
|
||||
interface NodeContextMenuProps {
|
||||
onChanged: () => void
|
||||
onClose: () => void
|
||||
onNodeRemoved: () => void
|
||||
target: NodeMenuTarget | null
|
||||
}
|
||||
|
||||
@@ -32,27 +27,13 @@ interface EditState {
|
||||
}
|
||||
|
||||
/** Right-click actions for a star-map node: edit (modal) or delete (confirm). */
|
||||
export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextMenuProps) {
|
||||
export function NodeContextMenu({ onChanged, onClose, target }: NodeContextMenuProps) {
|
||||
const [editing, setEditing] = useState<EditState | null>(null)
|
||||
const [deleting, setDeleting] = useState<Omit<NodeMenuTarget, 'x' | 'y'> | null>(null)
|
||||
const [deleting, setDeleting] = useState<{ id: string; label: string } | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
// Bumped on profile switch so an in-flight openEdit fetch from profile A can't
|
||||
// reopen the editor with A's node content after switching to B.
|
||||
const editEpoch = useRef(0)
|
||||
|
||||
// A profile switch swaps the backend under an open edit/delete dialog — its
|
||||
// node id belongs to the previous profile, so a Save/Delete after the switch
|
||||
// would hit the newly active profile. Close everything on switch.
|
||||
useOnProfileSwitch(() => {
|
||||
editEpoch.current += 1
|
||||
setEditing(null)
|
||||
setDeleting(null)
|
||||
setError(null)
|
||||
})
|
||||
|
||||
const noun = target?.kind === 'memory' ? 'memory' : 'skill'
|
||||
|
||||
const openEdit = async () => {
|
||||
@@ -60,17 +41,10 @@ export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextM
|
||||
return
|
||||
}
|
||||
|
||||
const epoch = editEpoch.current
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const detail = await getLearningNode(target.id)
|
||||
|
||||
if (editEpoch.current !== epoch) {
|
||||
return
|
||||
}
|
||||
|
||||
setEditing({ content: detail.content, id: target.id, label: target.label })
|
||||
onClose()
|
||||
} catch (e) {
|
||||
@@ -87,16 +61,13 @@ export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextM
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await editLearningNode(editing.id, editing.content)
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.message)
|
||||
}
|
||||
|
||||
setEditing(null)
|
||||
void loadStarmapGraph(true)
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
@@ -111,16 +82,13 @@ export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextM
|
||||
{menuOpen ? (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50" onClick={onClose} onContextMenu={e => e.preventDefault()} />
|
||||
{/* Styled to DropdownMenuContent/Item scale (rounded-lg card, p-1,
|
||||
text-xs rows) — the hand-rolled fixed positioning stays because
|
||||
the target is a canvas point, not a DOM anchor. */}
|
||||
<div
|
||||
className="fixed z-50 min-w-36 rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 shadow-md backdrop-blur-md"
|
||||
className="fixed z-50 min-w-36 overflow-hidden rounded-md border border-border bg-popover py-1 text-sm shadow-md"
|
||||
style={{ left: target.x, top: target.y }}
|
||||
>
|
||||
<div className="truncate px-2 py-1 text-[0.68rem] text-muted-foreground">{target.label}</div>
|
||||
<div className="truncate px-3 py-1 text-xs text-muted-foreground">{target.label}</div>
|
||||
<button
|
||||
className="block w-full cursor-pointer rounded-md px-2 py-1 text-left text-xs hover:bg-(--ui-control-active-background) hover:text-foreground disabled:opacity-50"
|
||||
className="block w-full px-3 py-1 text-left hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
||||
disabled={loading}
|
||||
onClick={() => void openEdit()}
|
||||
type="button"
|
||||
@@ -128,14 +96,14 @@ export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextM
|
||||
Edit {noun}…
|
||||
</button>
|
||||
<button
|
||||
className="block w-full cursor-pointer rounded-md px-2 py-1 text-left text-xs text-destructive hover:bg-destructive/10"
|
||||
className="block w-full px-3 py-1 text-left text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
setDeleting({ id: target.id, kind: target.kind, label: target.label })
|
||||
setDeleting({ id: target.id, label: target.label })
|
||||
onClose()
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{target.kind === 'skill' ? 'Archive skill' : 'Delete memory'}
|
||||
Delete {noun}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
@@ -146,19 +114,11 @@ export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextM
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit {editing?.label}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="h-80">
|
||||
{editing && (
|
||||
<CodeEditor
|
||||
filePath={noun === 'skill' ? 'SKILL.md' : 'memory.md'}
|
||||
framed
|
||||
initialValue={editing.content}
|
||||
key={editing.id}
|
||||
onCancel={() => !saving && setEditing(null)}
|
||||
onChange={content => setEditing(prev => (prev ? { ...prev, content } : prev))}
|
||||
onSave={() => void save()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
className="h-80 font-mono text-xs"
|
||||
onChange={e => setEditing(prev => (prev ? { ...prev, content: e.target.value } : prev))}
|
||||
value={editing?.content ?? ''}
|
||||
/>
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
<DialogFooter>
|
||||
<Button disabled={saving} onClick={() => setEditing(null)} type="button" variant="ghost">
|
||||
@@ -171,49 +131,29 @@ export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextM
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{deleting?.kind === 'skill' ? (
|
||||
<ArchiveSkillConfirmDialog
|
||||
onApply={() => {
|
||||
onNodeRemoved()
|
||||
<ConfirmDialog
|
||||
confirmLabel="Delete"
|
||||
description={
|
||||
noun === 'skill'
|
||||
? 'The skill is archived and can be restored with `hermes curator restore`.'
|
||||
: 'This memory is removed permanently.'
|
||||
}
|
||||
destructive
|
||||
onClose={() => setDeleting(null)}
|
||||
onConfirm={async () => {
|
||||
if (!deleting) {
|
||||
return
|
||||
}
|
||||
|
||||
return evictStarmapNode(deleting.id)
|
||||
}}
|
||||
onClose={() => setDeleting(null)}
|
||||
onFailure={(err, name) => notifyError(err, name)}
|
||||
open
|
||||
skillId={deleting.id}
|
||||
skillName={deleting.label}
|
||||
/>
|
||||
) : (
|
||||
<ConfirmDialog
|
||||
confirmLabel="Delete"
|
||||
description="This memory is removed permanently."
|
||||
destructive
|
||||
dismissOnConfirm
|
||||
onClose={() => setDeleting(null)}
|
||||
onConfirm={() => {
|
||||
if (!deleting) {
|
||||
return
|
||||
}
|
||||
|
||||
const { id, label } = deleting
|
||||
const rollback = evictStarmapNode(id)
|
||||
onNodeRemoved()
|
||||
|
||||
fireOptimistic(
|
||||
deleteLearningNode(id).then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(res.message)
|
||||
}
|
||||
}),
|
||||
rollback,
|
||||
err => notifyError(err, label)
|
||||
)
|
||||
}}
|
||||
open={Boolean(deleting)}
|
||||
title={`Delete ${deleting?.label ?? ''}?`}
|
||||
/>
|
||||
)}
|
||||
const res = await deleteLearningNode(deleting.id)
|
||||
if (!res.ok) {
|
||||
throw new Error(res.message)
|
||||
}
|
||||
onChanged()
|
||||
}}
|
||||
open={Boolean(deleting)}
|
||||
title={`Delete ${deleting?.label ?? ''}?`}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,40 +16,9 @@ function sampleGraph(): StarmapGraph {
|
||||
{ body: 'Uses a worktree.', source: 'memory', timestamp: null, title: 'Env' }
|
||||
],
|
||||
nodes: [
|
||||
{
|
||||
category: 'devops',
|
||||
createdBy: 'agent',
|
||||
id: 'skill-a',
|
||||
kind: 'skill',
|
||||
label: 'skill-a',
|
||||
pinned: true,
|
||||
state: 'active',
|
||||
timestamp: 1_699_900_000,
|
||||
useCount: 7
|
||||
},
|
||||
{
|
||||
category: 'devops',
|
||||
createdBy: null,
|
||||
id: 'skill-b',
|
||||
kind: 'skill',
|
||||
label: 'skill-b',
|
||||
pinned: false,
|
||||
state: 'draft',
|
||||
timestamp: 1_699_950_000,
|
||||
useCount: 0
|
||||
},
|
||||
{
|
||||
category: 'memory',
|
||||
createdBy: null,
|
||||
id: 'memory:profile:0',
|
||||
kind: 'memory',
|
||||
label: 'A fact',
|
||||
memorySource: 'profile',
|
||||
pinned: false,
|
||||
state: 'active',
|
||||
timestamp: 1_700_000_000,
|
||||
useCount: 0
|
||||
}
|
||||
{ category: 'devops', createdBy: 'agent', id: 'skill-a', kind: 'skill', label: 'skill-a', pinned: true, state: 'active', timestamp: 1_699_900_000, useCount: 7 },
|
||||
{ category: 'devops', createdBy: null, id: 'skill-b', kind: 'skill', label: 'skill-b', pinned: false, state: 'draft', timestamp: 1_699_950_000, useCount: 0 },
|
||||
{ category: 'memory', createdBy: null, id: 'memory:profile:0', kind: 'memory', label: 'A fact', memorySource: 'profile', pinned: false, state: 'active', timestamp: 1_700_000_000, useCount: 0 }
|
||||
],
|
||||
stats: {}
|
||||
}
|
||||
|
||||
@@ -157,9 +157,7 @@ function readGraph(r: BitReader): StarmapGraph {
|
||||
counts.set(n.category, (counts.get(n.category) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const clusters = [...counts.entries()]
|
||||
.map(([category, count]) => ({ category, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
const clusters = [...counts.entries()].map(([category, count]) => ({ category, count })).sort((a, b) => b.count - a.count)
|
||||
|
||||
// Memory cards are dropped (viz-only); a marker lets the UI tell a decoded map
|
||||
// apart from a freshly-scanned one.
|
||||
|
||||
@@ -80,8 +80,7 @@ function bucketStart(ts: number, { kind, step }: Unit): number {
|
||||
return Math.floor(d.getTime() / 1000)
|
||||
}
|
||||
|
||||
const populatedStarts = (stamps: number[], u: Unit): number[] =>
|
||||
[...new Set(stamps.map(t => bucketStart(t, u)))].sort((a, b) => a - b)
|
||||
const populatedStarts = (stamps: number[], u: Unit): number[] => [...new Set(stamps.map(t => bucketStart(t, u)))].sort((a, b) => a - b)
|
||||
|
||||
// "Nice ticks" for time (à la D3/Heckbert): aim for a target ring count that
|
||||
// grows ~log2 with the span, then snap to the calendar interval whose POPULATED
|
||||
@@ -119,9 +118,7 @@ function bucketLabel(ts: number, { kind, step }: Unit): string {
|
||||
try {
|
||||
const d = new Date(ts * 1000)
|
||||
|
||||
return step >= 12
|
||||
? String(d.getUTCFullYear())
|
||||
: d.toLocaleDateString(undefined, { month: 'short', timeZone: 'UTC', year: 'numeric' })
|
||||
return step >= 12 ? String(d.getUTCFullYear()) : d.toLocaleDateString(undefined, { month: 'short', timeZone: 'UTC', year: 'numeric' })
|
||||
} catch {
|
||||
return formatDate(ts)
|
||||
}
|
||||
@@ -141,10 +138,7 @@ interface Layout {
|
||||
// or one instant): keep the legacy continuous mapping so nothing regresses.
|
||||
function evenLayout(recById: Map<string, number>, minTs: null | number, maxTs: null | number, timed: boolean): Layout {
|
||||
const rings: Ring[] = Array.from({ length: RING_STEPS + 1 }, (_, i) => ({
|
||||
label:
|
||||
timed && minTs !== null && maxTs !== null
|
||||
? formatDate(Math.round(minTs + (maxTs - minTs) * (i / RING_STEPS)))
|
||||
: null,
|
||||
label: timed && minTs !== null && maxTs !== null ? formatDate(Math.round(minTs + (maxTs - minTs) * (i / RING_STEPS))) : null,
|
||||
r: ringRadius(i),
|
||||
ratio: recForRatio(i / RING_STEPS)
|
||||
}))
|
||||
@@ -169,13 +163,7 @@ function evenLayout(recById: Map<string, number>, minTs: null | number, maxTs: n
|
||||
|
||||
// One equal-width ring per POPULATED calendar bucket; a bucket's nodes fill the
|
||||
// band INSIDE their ring (fanned by angle) and ignite staggered across it.
|
||||
function buildLayout(
|
||||
graph: StarmapGraph,
|
||||
recById: Map<string, number>,
|
||||
minTs: null | number,
|
||||
maxTs: null | number,
|
||||
timed: boolean
|
||||
): Layout {
|
||||
function buildLayout(graph: StarmapGraph, recById: Map<string, number>, minTs: null | number, maxTs: null | number, timed: boolean): Layout {
|
||||
const stamps = graph.nodes.map(n => Number(n.timestamp)).filter(Number.isFinite)
|
||||
|
||||
if (!(timed && minTs !== null && maxTs !== null && maxTs > minTs && stamps.length)) {
|
||||
@@ -196,12 +184,7 @@ function buildLayout(
|
||||
// decouples a ring's ignite moment from its position — a bursty gap makes a
|
||||
// ring appear bands ahead of the nodes that belong to it. Labels stay real dates.
|
||||
const last = Math.max(1, starts.length - 1)
|
||||
|
||||
const rings: Ring[] = starts.map((s, i) => ({
|
||||
label: bucketLabel(s, unit),
|
||||
r: ringRadius(i),
|
||||
ratio: recForRatio(i / last)
|
||||
}))
|
||||
const rings: Ring[] = starts.map((s, i) => ({ label: bucketLabel(s, unit), r: ringRadius(i), ratio: recForRatio(i / last) }))
|
||||
|
||||
// A node's bucket is its ring; undated nodes (rare, in an otherwise-timed
|
||||
// graph) fall to the newest ring so they still appear.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { useThemeEpoch } from '@/hooks/use-theme-epoch'
|
||||
import { createDoubleTapDetector, isSmartZoomWheel } from '@/lib/trackpad-gestures'
|
||||
import { loadStarmapGraph } from '@/store/starmap'
|
||||
import type { StarmapGraph } from '@/types/hermes'
|
||||
|
||||
import { computePalette, memoryInkFor, resolveRgb, rgba } from './color'
|
||||
@@ -928,11 +929,12 @@ export function StarMap({
|
||||
/>
|
||||
|
||||
<NodeContextMenu
|
||||
onClose={() => setMenuTarget(null)}
|
||||
onNodeRemoved={() => {
|
||||
onChanged={() => {
|
||||
setMenuTarget(null)
|
||||
setSelectedId(null)
|
||||
void loadStarmapGraph(true)
|
||||
}}
|
||||
onClose={() => setMenuTarget(null)}
|
||||
target={menuTarget}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { fmtDate } from '@/lib/time'
|
||||
import type { StarmapNode } from '@/types/hermes'
|
||||
|
||||
export function formatDate(ts?: null | number): string {
|
||||
@@ -7,7 +6,7 @@ export function formatDate(ts?: null | number): string {
|
||||
}
|
||||
|
||||
try {
|
||||
return fmtDate.format(new Date(ts * 1000))
|
||||
return new Date(ts * 1000).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
@@ -280,11 +280,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ClarifyShell
|
||||
aria-label={copy.loadingQuestion}
|
||||
className="grid min-h-12 place-items-center px-2.5 py-3"
|
||||
role="status"
|
||||
>
|
||||
<ClarifyShell aria-label={copy.loadingQuestion} className="grid min-h-12 place-items-center px-2.5 py-3" role="status">
|
||||
<Loader2 aria-hidden className="size-4 animate-spin text-(--ui-text-tertiary)" />
|
||||
</ClarifyShell>
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ const VIEWPORT = '[data-slot="aui_thread-viewport"]'
|
||||
const HOVER_CLOSE_MS = 140
|
||||
|
||||
const ROW_CLASS =
|
||||
'row-hover relative flex w-full min-w-0 max-w-full select-none overflow-hidden rounded-md px-2 py-1 text-left outline-hidden'
|
||||
'relative flex w-full min-w-0 max-w-full cursor-pointer select-none overflow-hidden rounded-md px-2 py-1 text-left outline-hidden transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none'
|
||||
|
||||
// Surface (border-color/bg/shadow/blur) comes from the shared
|
||||
// `[data-slot='thread-timeline-popover']` rule in styles.css, so it's 1:1 with
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { fmtClock, fmtDayTime } from '@/lib/time'
|
||||
const TIME_FMT = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
|
||||
|
||||
const SHORT_FMT = new Intl.DateTimeFormat(undefined, {
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
month: 'short'
|
||||
})
|
||||
|
||||
function startOfDay(d: Date): number {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
|
||||
@@ -21,12 +28,12 @@ export function formatMessageTimestamp(
|
||||
const dayDelta = Math.round((startOfDay(new Date()) - startOfDay(date)) / 86_400_000)
|
||||
|
||||
if (dayDelta === 0) {
|
||||
return labels.today(fmtClock.format(date))
|
||||
return labels.today(TIME_FMT.format(date))
|
||||
}
|
||||
|
||||
if (dayDelta === 1) {
|
||||
return labels.yesterday(fmtClock.format(date))
|
||||
return labels.yesterday(TIME_FMT.format(date))
|
||||
}
|
||||
|
||||
return fmtDayTime.format(date)
|
||||
return SHORT_FMT.format(date)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type ToolTitleKey, translateNow } from '@/i18n'
|
||||
import { normalizeExternalUrl } from '@/lib/external-link'
|
||||
import { summarizeShellCommand } from '@/lib/summarize-command'
|
||||
import { capitalize, normalize } from '@/lib/text'
|
||||
import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary'
|
||||
|
||||
import {
|
||||
@@ -14,7 +13,12 @@ import {
|
||||
prettyJson,
|
||||
unwrapToolPayload
|
||||
} from './format'
|
||||
import { findFirstUrl, hostnameOf, looksLikePath, looksLikeUrl } from './targets'
|
||||
import {
|
||||
findFirstUrl,
|
||||
hostnameOf,
|
||||
looksLikePath,
|
||||
looksLikeUrl
|
||||
} from './targets'
|
||||
import type {
|
||||
CountMetric,
|
||||
MessageRunningStateSlice,
|
||||
@@ -213,7 +217,13 @@ export const selectMessageRunning = (state: MessageRunningStateSlice) =>
|
||||
function titleForTool(name: string): string {
|
||||
const normalized = name.replace(/^browser_/, '').replace(/^web_/, '')
|
||||
|
||||
return normalized.split('_').filter(Boolean).map(capitalize).join(' ') || name
|
||||
return (
|
||||
normalized
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map(part => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`)
|
||||
.join(' ') || name
|
||||
)
|
||||
}
|
||||
|
||||
const PREFIX_META: { icon?: string; labelKey: string; prefix: string; tone: ToolTone }[] = [
|
||||
@@ -351,7 +361,7 @@ function countFromUnknown(value: unknown): null | number {
|
||||
}
|
||||
|
||||
function singularizeNoun(noun: string): string {
|
||||
const normalized = normalize(noun)
|
||||
const normalized = noun.trim().toLowerCase()
|
||||
|
||||
if (!normalized) {
|
||||
return ''
|
||||
@@ -865,7 +875,7 @@ function cronjobSubtitle(argsRecord: Record<string, unknown>, resultRecord: Reco
|
||||
|
||||
const action = firstStringField(argsRecord, ['action']) || 'manage'
|
||||
const name = firstStringField(resultRecord, ['name']) || firstStringField(argsRecord, ['name', 'job_id'])
|
||||
const label = capitalize(action)
|
||||
const label = `${action[0]?.toUpperCase() ?? ''}${action.slice(1)}`
|
||||
|
||||
return name ? `${label} ${name}` : `Cron ${action}`
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
import type { ToolPart } from './types'
|
||||
|
||||
export function looksLikeUrl(value: string): boolean {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
|
||||
export type ToolStatus = 'error' | 'running' | 'success' | 'warning'
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user