Compare commits

..

5 Commits

Author SHA1 Message Date
ethernet
5a346903d2 time to cook 2026-07-03 15:09:27 -04:00
ethernet
05c01af68c fix: correct detect install method when running from a subtree 2026-07-03 14:33:04 -04:00
ethernet
eb40402420 feat: print install method when running --version 2026-07-03 14:33:04 -04:00
ethernet
8fa9e6c013 fix(nix): make hermes in developement environment actually work
install modules as editable overlay with uv
2026-07-03 14:33:04 -04:00
ethernet
de45b9529d feat(install): warn pip/Homebrew installs are unsupported (CLI, TUI, desktop)
pip and Homebrew are now Unsupported install methods per
website/docs/getting-started/platform-support.md. Surface a
warn-don't-block deprecation notice everywhere the install method is
already shown, pointing at the platform-support docs and noting these
installs will not receive further updates. NixOS (Tier 2) is untouched.

- hermes_cli/config.py: shared is_unsupported_install_method() /
  format_unsupported_install_warning() helpers so the wording and docs
  link stay consistent across every surface.
- hermes_cli/banner.py: generalize the existing pip-only banner
  warning to also cover Homebrew.
- hermes_cli/main.py: hermes update and hermes update --check print
  the warning before proceeding (still update; warn, don't block).
- tui_gateway/server.py: session.info gains install_warning.
- ui-tui: SessionPanel renders install_warning alongside the existing
  'N commits behind' notice.
- apps/desktop: SessionRuntimeInfo/GatewayEventPayload gain
  install_warning; applyRuntimeInfo + the live session.info event fire
  a snoozable warning toast via a new reportInstallMethodWarning(),
  mirroring the existing backend-contract-skew toast pattern. i18n
  strings added for en/zh/zh-hant/ja.
- Tests: updated pip banner assertions for the new wording, added a
  Homebrew banner test, and two tui_gateway session_info tests
  (install_warning present for pip, absent for git).
2026-07-03 14:33:04 -04:00
212 changed files with 2891 additions and 8557 deletions

2
.envrc
View File

@@ -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

View File

@@ -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 }}

View File

@@ -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"

View File

@@ -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

View File

@@ -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"]
}
}
}

View File

@@ -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

View File

@@ -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).

View File

@@ -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

View File

@@ -47,5 +47,5 @@ function sourceDeclaresServe(dashboardPySource) {
module.exports = {
serveBackendArgs,
dashboardFallbackArgs,
sourceDeclaresServe
sourceDeclaresServe,
}

View File

@@ -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',
])
})

View File

@@ -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', () => {

View File

@@ -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)

View File

@@ -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', () => {

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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[] =>

View File

@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import {
Pagination,
@@ -16,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)}

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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>
)

View File

@@ -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"

View File

@@ -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>}

View File

@@ -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 }

View File

@@ -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'

View File

@@ -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

View File

@@ -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}
/>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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}`

View File

@@ -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.

View File

@@ -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 })

View File

@@ -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
}

View File

@@ -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])
}

View File

@@ -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}?`}
/>
)
}

View File

@@ -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>
)
}
// Masterdetail 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 masterdetail 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
</>
)
}

View File

@@ -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}
>

View File

@@ -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}

View File

@@ -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 && (

View File

@@ -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"

View File

@@ -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'
)}

View File

@@ -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

View File

@@ -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() && (

View File

@@ -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 => {

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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 }

View File

@@ -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))

View File

@@ -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'

View File

@@ -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

View File

@@ -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(

View File

@@ -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&apos;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 ? (

View File

@@ -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} />
}

View File

@@ -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']
},
{

View File

@@ -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>
)
}

View File

@@ -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 =>

View File

@@ -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 />
) : (

View 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>
)
}

View File

@@ -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)

View File

@@ -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">

View File

@@ -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>
) : (

View File

@@ -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>
)
}

View File

@@ -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 => {

View File

@@ -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' } })

View File

@@ -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)

View File

@@ -8,6 +8,7 @@ export type SettingsView =
| 'about'
| 'gateway'
| 'keys'
| 'mcp'
| 'notifications'
| 'providers'
| 'sessions'

View File

@@ -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

View File

@@ -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', () => {

View File

@@ -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()
}

View File

@@ -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>

View File

@@ -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') {

View File

@@ -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) {

View File

@@ -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}

View File

@@ -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

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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>
)

View File

@@ -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 ?? ''}?`}
/>
</>
)
}

View File

@@ -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: {}
}

View File

@@ -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.

View File

@@ -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.

View File

@@ -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}
/>

View File

@@ -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'
}

View File

@@ -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>
)

View File

@@ -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

View File

@@ -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)
}

View File

@@ -1,3 +1,4 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}

View File

@@ -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}`
}

View File

@@ -1,3 +1,4 @@
import type { ToolPart } from './types'
export function looksLikeUrl(value: string): boolean {

View File

@@ -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