Compare commits

..

1 Commits

Author SHA1 Message Date
teknium1 d948b0c00d feat(trust): rule-based permission engine with allow/deny/ask rules
Adds 'hermes trust' — a declarative permission layer that sits BEFORE
the --yolo bypass and BEFORE the dangerous-pattern detector.  Rules live
in ~/.hermes/trust.json and are matched by (tool, pattern, scope) with
priority / decision precedence.

Inspired by Vellum Assistant's Trust Rules v3 schema.

## Design

- A **deny** rule is a user-expressed invariant — it beats --yolo.
  (Hardline floor still wins over deny: irrecoverable commands are
  never allowed.)
- An **allow** rule short-circuits the dangerous-pattern check.
- An **ask** rule forces a prompt even under yolo.
- **No match** falls through to the existing flow unchanged.
- Opt-in: if trust.json is absent, behavior is identical to pre-engine.

Risk classifier reuses the existing dangerous-command detector (single
source of truth).  Threshold gate (approvals.auto_approve_up_to) controls
what auto-approves on no_match: none | low | medium | high.

## Changes
- tools/trust.py: engine (load/save/evaluate/explain/classify_risk)
- tools/approval.py: trust hook BEFORE yolo in check_dangerous_command
- hermes_cli/trust.py: CLI (list, add, remove, show, why, init)
- hermes_cli/main.py: argparse wiring + cmd_trust entrypoint
- hermes_cli/config.py: approvals.auto_approve_up_to default
- tests/tools/test_trust.py: 36 tests (matching, risk, threshold,
  persistence, approval integration incl. deny-beats-yolo +
  hardline-beats-allow)
- website/docs/user-guide/features/trust-engine.md: full docs + sidebar

## Validation

- tests/tools/test_trust.py                  → 36 passed
- tests/tools/ -k approval                    → 175 passed
- hermes trust init / list / why              → CLI works end-to-end

## Scope notes for reviewers

Currently hooks into the terminal approval path only.  File-tool
integration is a natural follow-up — the engine is already callable
from anywhere via evaluate_trust(tool=..., candidate=...).  Rule
'scope' requires the caller to pass a path; terminal doesn't, so
scope is only meaningful once file-tool integration lands.  Docs
call this out.
2026-05-07 13:20:29 -07:00
387 changed files with 1712 additions and 77239 deletions
+3 -5
View File
@@ -393,9 +393,9 @@ IMAGE_TOOLS_DEBUG=false
# Default STT provider is "local" (faster-whisper) — runs on your machine, no API key needed.
# Install with: pip install faster-whisper
# Model downloads automatically on first use (~150 MB for "base").
# To use cloud providers instead, set GROQ_API_KEY, VOICE_TOOLS_OPENAI_KEY, or ELEVENLABS_API_KEY above.
# Provider priority: local > groq > openai > mistral > xai > elevenlabs
# Configure in config.yaml: stt.provider: local | groq | openai | mistral | xai | elevenlabs
# To use cloud providers instead, set GROQ_API_KEY or VOICE_TOOLS_OPENAI_KEY above.
# Provider priority: local > groq > openai
# Configure in config.yaml: stt.provider: local | groq | openai
# =============================================================================
# STT ADVANCED OVERRIDES (optional)
@@ -403,12 +403,10 @@ IMAGE_TOOLS_DEBUG=false
# Override default STT models per provider (normally set via stt.model in config.yaml)
# STT_GROQ_MODEL=whisper-large-v3-turbo
# STT_OPENAI_MODEL=whisper-1
# STT_ELEVENLABS_MODEL=scribe_v2
# Override STT provider endpoints (for proxies or self-hosted instances)
# GROQ_BASE_URL=https://api.groq.com/openai/v1
# STT_OPENAI_BASE_URL=https://api.openai.com/v1
# ELEVENLABS_STT_BASE_URL=https://api.elevenlabs.io/v1
# =============================================================================
# MICROSOFT TEAMS INTEGRATION
-343
View File
@@ -1,343 +0,0 @@
name: Desktop Release
on:
push:
branches: [main]
release:
types: [published]
workflow_dispatch:
inputs:
channel:
description: Release channel to build
required: true
default: nightly
type: choice
options:
- nightly
- stable
release_tag:
description: "Required when channel=stable (example: v2026.5.5)"
required: false
type: string
permissions:
contents: write
concurrency:
group: desktop-release-${{ github.ref }}
cancel-in-progress: false
jobs:
prepare:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
outputs:
channel: ${{ steps.meta.outputs.channel }}
release_name: ${{ steps.meta.outputs.release_name }}
release_tag: ${{ steps.meta.outputs.release_tag }}
version: ${{ steps.meta.outputs.version }}
is_stable: ${{ steps.meta.outputs.is_stable }}
steps:
- id: meta
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_CHANNEL: ${{ github.event.inputs.channel }}
INPUT_RELEASE_TAG: ${{ github.event.inputs.release_tag }}
RELEASE_TAG_FROM_EVENT: ${{ github.event.release.tag_name }}
GITHUB_SHA: ${{ github.sha }}
run: |
set -euo pipefail
channel="nightly"
release_tag="desktop-nightly"
is_stable="false"
if [[ "$EVENT_NAME" == "release" ]]; then
channel="stable"
release_tag="$RELEASE_TAG_FROM_EVENT"
is_stable="true"
elif [[ "$EVENT_NAME" == "workflow_dispatch" && "$INPUT_CHANNEL" == "stable" ]]; then
channel="stable"
release_tag="$INPUT_RELEASE_TAG"
is_stable="true"
fi
if [[ "$channel" == "stable" ]]; then
if [[ -z "$release_tag" ]]; then
echo "Stable desktop releases require a release tag." >&2
exit 1
fi
version="${release_tag#v}"
release_name="Hermes Desktop ${release_tag}"
else
stamp="$(date -u +%Y%m%d)"
short_sha="${GITHUB_SHA::7}"
version="0.0.0-nightly.${stamp}.${short_sha}"
release_name="Hermes Desktop Nightly ${stamp}-${short_sha}"
fi
{
echo "channel=$channel"
echo "release_name=$release_name"
echo "release_tag=$release_tag"
echo "version=$version"
echo "is_stable=$is_stable"
} >> "$GITHUB_OUTPUT"
build:
if: github.repository == 'NousResearch/hermes-agent'
needs: prepare
strategy:
fail-fast: false
matrix:
include:
- platform: mac
runner: macos-latest
build_args: --mac dmg zip
- platform: win
runner: windows-latest
build_args: --win nsis msi
runs-on: ${{ matrix.runner }}
env:
DESKTOP_CHANNEL: ${{ needs.prepare.outputs.channel }}
DESKTOP_VERSION: ${{ needs.prepare.outputs.version }}
MAC_CSC_LINK: ${{ secrets.CSC_LINK }}
MAC_CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: package-lock.json
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.11"
- name: Enforce signing gates for stable releases
if: needs.prepare.outputs.is_stable == 'true'
shell: bash
run: |
set -euo pipefail
missing=()
if [[ "${{ matrix.platform }}" == "mac" ]]; then
[[ -z "${MAC_CSC_LINK:-}" ]] && missing+=("CSC_LINK")
[[ -z "${MAC_CSC_KEY_PASSWORD:-}" ]] && missing+=("CSC_KEY_PASSWORD")
[[ -z "${APPLE_API_KEY:-}" ]] && missing+=("APPLE_API_KEY")
[[ -z "${APPLE_API_KEY_ID:-}" ]] && missing+=("APPLE_API_KEY_ID")
[[ -z "${APPLE_API_ISSUER:-}" ]] && missing+=("APPLE_API_ISSUER")
else
[[ -z "${WIN_CSC_LINK:-}" ]] && missing+=("WIN_CSC_LINK")
[[ -z "${WIN_CSC_KEY_PASSWORD:-}" ]] && missing+=("WIN_CSC_KEY_PASSWORD")
fi
if (( ${#missing[@]} > 0 )); then
echo "::error::Stable desktop release missing required secrets: ${missing[*]}"
exit 1
fi
- name: Install workspace dependencies
run: npm ci
- name: Build bundled TUI payload
run: npm --prefix ui-tui run build
- name: Build desktop renderer
run: npm --prefix apps/desktop run build
- name: Stage Hermes payload
run: npm --prefix apps/desktop run stage:hermes
- name: Map macOS signing credentials
if: matrix.platform == 'mac'
shell: bash
run: |
set -euo pipefail
has_link=0
has_pass=0
[[ -n "${MAC_CSC_LINK:-}" ]] && has_link=1
[[ -n "${MAC_CSC_KEY_PASSWORD:-}" ]] && has_pass=1
if [[ $has_link -eq 1 && $has_pass -eq 1 ]]; then
echo "CSC_LINK=${MAC_CSC_LINK}" >> "$GITHUB_ENV"
echo "CSC_KEY_PASSWORD=${MAC_CSC_KEY_PASSWORD}" >> "$GITHUB_ENV"
elif [[ $has_link -eq 1 || $has_pass -eq 1 ]]; then
echo "::error::macOS signing secrets are partially configured. Set both CSC_LINK and CSC_KEY_PASSWORD."
exit 1
fi
- name: Map Windows signing credentials
if: matrix.platform == 'win'
shell: bash
run: |
set -euo pipefail
has_link=0
has_pass=0
[[ -n "${WIN_CSC_LINK:-}" ]] && has_link=1
[[ -n "${WIN_CSC_KEY_PASSWORD:-}" ]] && has_pass=1
if [[ $has_link -eq 1 && $has_pass -eq 1 ]]; then
echo "CSC_LINK=${WIN_CSC_LINK}" >> "$GITHUB_ENV"
echo "CSC_KEY_PASSWORD=${WIN_CSC_KEY_PASSWORD}" >> "$GITHUB_ENV"
echo "CSC_FOR_PULL_REQUEST=true" >> "$GITHUB_ENV"
elif [[ $has_link -eq 1 || $has_pass -eq 1 ]]; then
echo "::error::Windows signing secrets are partially configured. Set both WIN_CSC_LINK and WIN_CSC_KEY_PASSWORD."
exit 1
fi
- name: Build desktop installers
shell: bash
env:
NODE_OPTIONS: --max-old-space-size=16384
run: |
set -euo pipefail
npm --prefix apps/desktop exec electron-builder -- \
${{ matrix.build_args }} \
--publish never \
--config.extraMetadata.version="${DESKTOP_VERSION}" \
--config.extraMetadata.desktopChannel="${DESKTOP_CHANNEL}" \
'--config.artifactName=Hermes-${version}-${env.DESKTOP_CHANNEL}-${os}-${arch}.${ext}'
- name: Notarize and staple macOS DMG
if: matrix.platform == 'mac' && needs.prepare.outputs.is_stable == 'true'
shell: bash
run: |
set -euo pipefail
dmg_path="$(ls apps/desktop/release/*.dmg | head -n 1)"
node apps/desktop/scripts/notarize-artifact.cjs "$dmg_path"
- name: Validate macOS notarization and Gatekeeper trust
if: matrix.platform == 'mac' && needs.prepare.outputs.is_stable == 'true'
shell: bash
run: |
set -euo pipefail
app_path="$(ls -d apps/desktop/release/mac*/Hermes.app | head -n 1)"
dmg_path="$(ls apps/desktop/release/*.dmg | head -n 1)"
xcrun stapler validate "$app_path"
xcrun stapler validate "$dmg_path"
spctl --assess --type execute --verbose=4 "$app_path"
- name: Generate desktop checksums
shell: bash
run: |
set -euo pipefail
node <<'EOF'
const crypto = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const releaseDir = path.resolve('apps/desktop/release')
const platform = process.env.PLATFORM
const extensions = platform === 'mac' ? ['.dmg', '.zip'] : ['.exe', '.msi']
const files = fs
.readdirSync(releaseDir)
.filter(name => extensions.some(ext => name.endsWith(ext)))
.sort()
if (!files.length) {
throw new Error(`No release artifacts were produced for ${platform}`)
}
const lines = files.map(name => {
const full = path.join(releaseDir, name)
const hash = crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex')
return `${hash} ${name}`
})
fs.writeFileSync(path.join(releaseDir, `SHA256SUMS-${platform}.txt`), `${lines.join('\n')}\n`)
EOF
env:
PLATFORM: ${{ matrix.platform }}
- name: Upload packaged desktop artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-${{ matrix.platform }}
path: |
apps/desktop/release/*.dmg
apps/desktop/release/*.zip
apps/desktop/release/*.exe
apps/desktop/release/*.msi
apps/desktop/release/SHA256SUMS-${{ matrix.platform }}.txt
if-no-files-found: error
publish:
if: github.repository == 'NousResearch/hermes-agent'
needs: [prepare, build]
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
CHANNEL: ${{ needs.prepare.outputs.channel }}
RELEASE_NAME: ${{ needs.prepare.outputs.release_name }}
RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: desktop-*
merge-multiple: true
path: dist/desktop
- name: Publish desktop assets to GitHub release
shell: bash
run: |
set -euo pipefail
shopt -s globstar nullglob
files=(
dist/desktop/**/*.dmg
dist/desktop/**/*.zip
dist/desktop/**/*.exe
dist/desktop/**/*.msi
dist/desktop/**/SHA256SUMS-*.txt
)
if (( ${#files[@]} == 0 )); then
echo "No desktop artifacts were downloaded for publishing." >&2
exit 1
fi
if [[ "$CHANNEL" == "nightly" ]]; then
git tag -f "$RELEASE_TAG" "$GITHUB_SHA"
git push origin "refs/tags/$RELEASE_TAG" --force
notes="Automated nightly desktop build from main. This prerelease is replaced on each new run."
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
while IFS= read -r asset_name; do
gh release delete-asset "$RELEASE_TAG" "$asset_name" --yes
done < <(gh release view "$RELEASE_TAG" --json assets -q '.assets[].name')
gh release edit "$RELEASE_TAG" \
--title "$RELEASE_NAME" \
--prerelease \
--notes "$notes"
else
gh release create "$RELEASE_TAG" \
--target "$GITHUB_SHA" \
--title "$RELEASE_NAME" \
--notes "$notes" \
--prerelease
fi
else
if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
notes="Automated desktop artifacts attached by desktop-release workflow."
gh release create "$RELEASE_TAG" \
--target "$GITHUB_SHA" \
--title "$RELEASE_NAME" \
--notes "$notes"
fi
fi
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber
+4 -4
View File
@@ -6,8 +6,8 @@ on:
paths:
- 'ui-tui/package-lock.json'
- 'ui-tui/package.json'
- 'apps/dashboard/package-lock.json'
- 'apps/dashboard/package.json'
- 'web/package-lock.json'
- 'web/package.json'
workflow_dispatch:
inputs:
pr_number:
@@ -28,7 +28,7 @@ concurrency:
jobs:
# ── Auto-fix on main ───────────────────────────────────────────────
# Fires when a push to main touches package.json or package-lock.json
# in ui-tui/ or apps/dashboard/. Runs fix-lockfiles and pushes the hash
# in ui-tui/ or web/. Runs fix-lockfiles and pushes the hash
# update commit directly to main so Nix builds never stay broken.
#
# Safety invariants:
@@ -110,7 +110,7 @@ jobs:
# run recompute from the correct package-lock state.
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
'ui-tui/package-lock.json' 'ui-tui/package.json' \
'apps/dashboard/package-lock.json' 'apps/dashboard/package.json' || true)"
'web/package-lock.json' 'web/package.json' || true)"
if [ -n "$pkg_changed" ]; then
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
exit 0
-13
View File
@@ -54,10 +54,6 @@ environments/benchmarks/evals/
# Web UI build output
hermes_cli/web_dist/
apps/desktop/build/
apps/desktop/dist/
apps/desktop/release/
apps/desktop/*.tsbuildinfo
# Web UI assets — synced from @nous-research/ui at build time via
# `npm run sync-assets` (see web/package.json).
@@ -74,12 +70,3 @@ mini-swe-agent/
result
website/static/api/skills-index.json
models-dev-upstream/
# Local editor / agent tooling (machine-specific; keep in global config, not the repo)
.codex/
.cursor/
.gemini/
.zed/
.mcp.json
opencode.json
config/mcporter.json
+1 -26
View File
@@ -2,8 +2,6 @@
Instructions for AI coding assistants and developers working on the hermes-agent codebase.
**Never give up on the right solution.**
## Development Environment
```bash
@@ -69,29 +67,6 @@ hermes-agent/
`gateway.log` when running the gateway. Profile-aware via `get_hermes_home()`.
Browse with `hermes logs [--follow] [--level ...] [--session ...]`.
## TypeScript Style
Applies to TypeScript across Hermes: desktop, TUI, website, and future TS packages.
- Prefer small nanostores over component state when state is shared, reused, or read by distant UI.
- Let each feature own its atoms. Chat state belongs near chat, shell state near shell, shared state in `src/store`.
- Components that render from an atom should use `useStore`. Non-rendering actions should read with `$atom.get()`.
- Do not pass state through three components when the leaf can subscribe to the atom.
- Keep persistence beside the atom that owns it.
- Keep route roots thin. They compose routes and shell; they should not become controllers.
- No monolithic hooks. A hook should own one narrow job.
- Prefer colocated action modules over hidden god hooks.
- If a callback is pure side effect, use the terse void form:
`onState={st => void setGatewayState(st)}`.
- Async UI handlers should make intent explicit:
`onClick={() => void save()}`.
- Prefer interfaces for public props and shared object shapes. Avoid `type X = { ... }` for object props.
- Extend React primitives for props: `React.ComponentProps<'button'>`, `React.ComponentProps<typeof Dialog>`, `Omit<...>`, `Pick<...>`.
- Table-driven beats condition ladders when mapping ids, routes, or views.
- `src/app` owns routes, pages, and page-specific components.
- `src/store` owns shared atoms.
- `src/lib` owns shared pure helpers.
## File Dependency Chain
```
@@ -275,7 +250,7 @@ npm test # vitest
The dashboard embeds the real `hermes --tui`**not** a rewrite. See `hermes_cli/pty_bridge.py` + the `@app.websocket("/api/pty")` endpoint in `hermes_cli/web_server.py`.
- Browser loads `apps/dashboard/src/pages/ChatPage.tsx`, which mounts xterm.js's `Terminal` with the WebGL renderer, `@xterm/addon-fit` for container-driven resize, and `@xterm/addon-unicode11` for modern wide-character widths.
- Browser loads `web/src/pages/ChatPage.tsx`, which mounts xterm.js's `Terminal` with the WebGL renderer, `@xterm/addon-fit` for container-driven resize, and `@xterm/addon-unicode11` for modern wide-character widths.
- `/api/pty?token=…` upgrades to a WebSocket; auth uses the same ephemeral `_SESSION_TOKEN` as REST, via query param (browsers can't set `Authorization` on WS upgrade).
- The server spawns whatever `hermes --tui` would spawn, through `ptyprocess` (POSIX PTY — WSL works, native Windows does not).
- Frames: raw PTY bytes each direction; resize via `\x1b[RESIZE:<cols>;<rows>]` intercepted on the server and applied with `TIOCSWINSZ`.
+14 -159
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal
@@ -83,121 +82,6 @@ _UTC_NOW = lambda: datetime.now(timezone.utc)
# Official docs snapshot entries. Models whose published pricing and cache
# semantics are stable enough to encode exactly.
_OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
# ── Anthropic Claude 4.7 ─────────────────────────────────────────────
# Opus 4.5/4.6/4.7 share $5/$25 pricing (new tokenizer, up to 35% more
# tokens for the same text).
# Source: https://platform.claude.com/docs/en/about-claude/pricing
(
"anthropic",
"claude-opus-4-7",
): PricingEntry(
input_cost_per_million=Decimal("5.00"),
output_cost_per_million=Decimal("25.00"),
cache_read_cost_per_million=Decimal("0.50"),
cache_write_cost_per_million=Decimal("6.25"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
(
"anthropic",
"claude-opus-4-7-20250507",
): PricingEntry(
input_cost_per_million=Decimal("5.00"),
output_cost_per_million=Decimal("25.00"),
cache_read_cost_per_million=Decimal("0.50"),
cache_write_cost_per_million=Decimal("6.25"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
# ── Anthropic Claude 4.6 ─────────────────────────────────────────────
(
"anthropic",
"claude-opus-4-6",
): PricingEntry(
input_cost_per_million=Decimal("5.00"),
output_cost_per_million=Decimal("25.00"),
cache_read_cost_per_million=Decimal("0.50"),
cache_write_cost_per_million=Decimal("6.25"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
(
"anthropic",
"claude-opus-4-6-20250414",
): PricingEntry(
input_cost_per_million=Decimal("5.00"),
output_cost_per_million=Decimal("25.00"),
cache_read_cost_per_million=Decimal("0.50"),
cache_write_cost_per_million=Decimal("6.25"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
(
"anthropic",
"claude-sonnet-4-6",
): PricingEntry(
input_cost_per_million=Decimal("3.00"),
output_cost_per_million=Decimal("15.00"),
cache_read_cost_per_million=Decimal("0.30"),
cache_write_cost_per_million=Decimal("3.75"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
(
"anthropic",
"claude-sonnet-4-6-20250414",
): PricingEntry(
input_cost_per_million=Decimal("3.00"),
output_cost_per_million=Decimal("15.00"),
cache_read_cost_per_million=Decimal("0.30"),
cache_write_cost_per_million=Decimal("3.75"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
# ── Anthropic Claude 4.5 ─────────────────────────────────────────────
(
"anthropic",
"claude-opus-4-5",
): PricingEntry(
input_cost_per_million=Decimal("5.00"),
output_cost_per_million=Decimal("25.00"),
cache_read_cost_per_million=Decimal("0.50"),
cache_write_cost_per_million=Decimal("6.25"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
(
"anthropic",
"claude-sonnet-4-5",
): PricingEntry(
input_cost_per_million=Decimal("3.00"),
output_cost_per_million=Decimal("15.00"),
cache_read_cost_per_million=Decimal("0.30"),
cache_write_cost_per_million=Decimal("3.75"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
(
"anthropic",
"claude-haiku-4-5",
): PricingEntry(
input_cost_per_million=Decimal("1.00"),
output_cost_per_million=Decimal("5.00"),
cache_read_cost_per_million=Decimal("0.10"),
cache_write_cost_per_million=Decimal("1.25"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
# ── Anthropic Claude 4 / 4.1 ─────────────────────────────────────────
(
"anthropic",
"claude-opus-4-20250514",
@@ -207,8 +91,8 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
cache_read_cost_per_million=Decimal("1.50"),
cache_write_cost_per_million=Decimal("18.75"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
source_url="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",
pricing_version="anthropic-prompt-caching-2026-03-16",
),
(
"anthropic",
@@ -219,8 +103,8 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
cache_read_cost_per_million=Decimal("0.30"),
cache_write_cost_per_million=Decimal("3.75"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
source_url="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",
pricing_version="anthropic-prompt-caching-2026-03-16",
),
# OpenAI
(
@@ -300,7 +184,7 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
source_url="https://openai.com/api/pricing/",
pricing_version="openai-pricing-2026-03-16",
),
# ── Anthropic older models (pre-4.5 generation) ────────────────────────
# Anthropic older models (pre-4.6 generation)
(
"anthropic",
"claude-3-5-sonnet-20241022",
@@ -310,8 +194,8 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
cache_read_cost_per_million=Decimal("0.30"),
cache_write_cost_per_million=Decimal("3.75"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
source_url="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",
pricing_version="anthropic-pricing-2026-03-16",
),
(
"anthropic",
@@ -322,8 +206,8 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
cache_read_cost_per_million=Decimal("0.08"),
cache_write_cost_per_million=Decimal("1.00"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
source_url="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",
pricing_version="anthropic-pricing-2026-03-16",
),
(
"anthropic",
@@ -334,8 +218,8 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
cache_read_cost_per_million=Decimal("1.50"),
cache_write_cost_per_million=Decimal("18.75"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
source_url="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",
pricing_version="anthropic-pricing-2026-03-16",
),
(
"anthropic",
@@ -346,8 +230,8 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
cache_read_cost_per_million=Decimal("0.03"),
cache_write_cost_per_million=Decimal("0.30"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
source_url="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching",
pricing_version="anthropic-pricing-2026-03-16",
),
# DeepSeek
(
@@ -542,37 +426,8 @@ def resolve_billing_route(
return BillingRoute(provider=provider_name or "unknown", model=model.split("/")[-1] if model else "", base_url=base_url or "", billing_mode="unknown")
def _normalize_anthropic_model_name(model: str) -> str:
"""Normalize Anthropic model name variants to canonical form.
Handles:
- Dot notation: claude-opus-4.7 → claude-opus-4-7
- Short aliases: claude-opus-4.7 → claude-opus-4-7
- Strips anthropic/ prefix if present
"""
name = model.lower().strip()
if name.startswith("anthropic/"):
name = name[len("anthropic/"):]
# Normalize dots to dashes in version numbers (e.g. 4.7 → 4-7, 4.6 → 4-6)
# But preserve the rest of the name structure
name = re.sub(r"(\d+)\.(\d+)", r"\1-\2", name)
return name
def _lookup_official_docs_pricing(route: BillingRoute) -> Optional[PricingEntry]:
model = route.model.lower()
# Direct lookup first
entry = _OFFICIAL_DOCS_PRICING.get((route.provider, model))
if entry:
return entry
# Try normalized name for Anthropic (handles dot-notation like opus-4.7)
if route.provider == "anthropic":
normalized = _normalize_anthropic_model_name(model)
if normalized != model:
entry = _OFFICIAL_DOCS_PRICING.get((route.provider, normalized))
if entry:
return entry
return None
return _OFFICIAL_DOCS_PRICING.get((route.provider, route.model.lower()))
def _openrouter_pricing_entry(route: BillingRoute) -> Optional[PricingEntry]:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env node
/**
* Copy font and asset folders from @nous-research/ui into public/ for Vite.
*
* Locates @nous-research/ui by walking up from this script looking for
* node_modules/@nous-research/ui — works whether the dep is co-located
* (non-workspace layout) or hoisted to the repo root (npm workspaces).
*/
const fs = require('node:fs')
const path = require('node:path')
const DASHBOARD_ROOT = path.resolve(__dirname, '..')
function locateUiPackage() {
let dir = DASHBOARD_ROOT
const { root } = path.parse(dir)
while (true) {
const candidate = path.join(dir, 'node_modules', '@nous-research', 'ui')
if (fs.existsSync(path.join(candidate, 'package.json'))) {
return candidate
}
if (dir === root) break
dir = path.dirname(dir)
}
throw new Error(
'@nous-research/ui not found. Run `npm install` from the repo root.'
)
}
const uiRoot = locateUiPackage()
const distRoot = path.join(uiRoot, 'dist')
const mappings = [
['fonts', path.join(DASHBOARD_ROOT, 'public', 'fonts')],
['assets', path.join(DASHBOARD_ROOT, 'public', 'ds-assets')],
]
for (const [srcName, destPath] of mappings) {
const srcPath = path.join(distRoot, srcName)
if (!fs.existsSync(srcPath)) {
throw new Error(`Missing ${srcPath} in @nous-research/ui — rebuild that package.`)
}
fs.rmSync(destPath, { recursive: true, force: true })
fs.cpSync(srcPath, destPath, { recursive: true })
console.log(`synced ${path.relative(DASHBOARD_ROOT, destPath)}`)
}
-36
View File
@@ -1,36 +0,0 @@
import {
JsonRpcGatewayClient,
type ConnectionState,
type GatewayEvent,
type GatewayEventName,
} from "@hermes/shared";
export type { ConnectionState, GatewayEvent, GatewayEventName };
/**
* Browser wrapper for the shared tui_gateway JSON-RPC client.
*
* Dashboard resolves its token and host from the served page. Desktop uses the
* same shared protocol client, but supplies an absolute wsUrl from Electron.
*/
export class GatewayClient extends JsonRpcGatewayClient {
async connect(token?: string): Promise<void> {
const resolved = token ?? window.__HERMES_SESSION_TOKEN__ ?? "";
if (!resolved) {
throw new Error(
"Session token not available — page must be served by the Hermes dashboard",
);
}
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
await super.connect(
`${scheme}//${location.host}/api/ws?token=${encodeURIComponent(resolved)}`,
);
}
}
declare global {
interface Window {
__HERMES_SESSION_TOKEN__?: string;
}
}
-11
View File
@@ -1,11 +0,0 @@
{
"arrowParens": "avoid",
"bracketSpacing": true,
"endOfLine": "auto",
"printWidth": 120,
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"useTabs": false
}
-207
View File
@@ -1,207 +0,0 @@
# Hermes Desktop
Native Electron shell for Hermes. It packages the desktop renderer, a bundled Hermes source payload, and installer targets for macOS and Windows.
## Setup
Install workspace dependencies from the repo root so `apps/desktop`, `apps/dashboard`, and `apps/shared` stay linked:
```bash
npm install
```
Use the normal Hermes Python environment for local runs:
```bash
source .venv/bin/activate # or: source venv/bin/activate
python -m pip install -e .
```
## Development
```bash
cd apps/desktop
npm run dev
```
`npm run dev` starts Vite on `127.0.0.1:5174`, launches Electron, and lets Electron boot the Hermes dashboard backend on an open port in `9120-9199`. This path is for UI iteration and may still show Electron/dev identities in OS prompts.
Useful overrides:
```bash
HERMES_DESKTOP_HERMES_ROOT=/path/to/hermes-agent npm run dev
HERMES_DESKTOP_PYTHON=/path/to/python npm run dev
HERMES_DESKTOP_CWD=/path/to/project npm run dev
HERMES_DESKTOP_IGNORE_EXISTING=1 npm run dev
HERMES_DESKTOP_BOOT_FAKE=1 npm run dev
HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=900 npm run dev
```
`HERMES_DESKTOP_IGNORE_EXISTING=1` skips any `hermes` CLI already on `PATH`, which is useful when testing the bundled/runtime bootstrap path.
`HERMES_DESKTOP_BOOT_FAKE=1` adds deterministic per-phase delays to desktop startup so you can validate the startup overlay and progress bar. For convenience, `npm run dev:fake-boot` enables fake mode with defaults.
On a fresh Hermes profile, Desktop shows a first-run setup overlay after boot. The overlay saves the minimum required provider credential (for example `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, or `OPENAI_API_KEY`) to the active Hermes `.env`, reloads the backend env, and then lets the user continue without opening Settings manually.
## Dashboard Dev
Run the Python dashboard backend with embedded chat enabled:
```bash
hermes dashboard --tui --no-open
```
For dashboard HMR, start Vite in another terminal:
```bash
cd apps/dashboard
npm run dev
```
Open the Vite URL. The dev server proxies `/api`, `/api/pty`, and plugin assets to `http://127.0.0.1:9119` and fetches the live dashboard HTML so the ephemeral session token matches the running backend.
## Build
```bash
npm run build
npm run pack # unpacked app at release/mac-<arch>/Hermes.app
npm run dist:mac # macOS DMG + zip
npm run dist:mac:dmg # DMG only
npm run dist:mac:zip # zip only
npm run dist:win # NSIS + MSI
```
Before packaging, `stage:hermes` copies the Python Hermes payload into `build/hermes-agent`. Electron Builder then ships it as `Contents/Resources/hermes-agent`.
## Automated Releases
Desktop installers are published by [`.github/workflows/desktop-release.yml`](../../.github/workflows/desktop-release.yml) with two channels:
- **Stable:** runs on published GitHub releases and uploads signed artifacts to that release tag.
- **Nightly:** runs on `main` pushes and updates the rolling `desktop-nightly` prerelease.
The workflow injects a channel-aware desktop version at build time:
- stable: derived from the release tag (for example `v2026.5.5` -> `2026.5.5`)
- nightly: `0.0.0-nightly.YYYYMMDD.<sha>`
Artifact names include channel, platform, and architecture:
```text
Hermes-<version>-<channel>-<platform>-<arch>.<ext>
```
Each run also publishes `SHA256SUMS-<platform>.txt` so installers can be verified.
### Stable release gates
Stable builds fail fast if signing credentials are missing:
- macOS signing + notarization: `CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_API_KEY`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`
- Windows signing: `WIN_CSC_LINK`, `WIN_CSC_KEY_PASSWORD`
Stable macOS builds also validate stapling and Gatekeeper assessment in CI before upload.
## Icons
Desktop icons live in `assets/`:
- `assets/icon.icns`
- `assets/icon.ico`
- `assets/icon.png`
The builder config points at `assets/icon`. Replace these files directly if the app icon changes.
## Testing Install Paths
Use the package-local test scripts from this directory:
```bash
npm run test:desktop:all
npm run test:desktop:existing
npm run test:desktop:fresh
npm run test:desktop:dmg
npm run test:desktop:platforms
```
`test:desktop:existing` builds the packaged app and opens it normally. It should use an existing `hermes` CLI if one is on `PATH`, preserving the users real `~/.hermes` config.
`test:desktop:fresh` builds the packaged app and launches it in a throwaway fresh-install sandbox. It sets `HERMES_DESKTOP_IGNORE_EXISTING=1`, points Electron `userData` at a temp dir, points `HERMES_HOME` at a temp dir, and launches through the bundled payload path without touching your real desktop runtime or `~/.hermes`.
`test:desktop:dmg` builds and opens the DMG.
`test:desktop:platforms` runs platform bootstrap-path assertions, including:
- existing vs bundled runtime path selection semantics
- WSL2 protection against Windows `.exe/.cmd/.bat/.ps1` overrides
- platform-specific bundled runtime import checks (`winpty` vs `ptyprocess`)
For fast reruns without rebuilding:
```bash
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:fresh
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:existing
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:dmg
```
## Installing Locally
```bash
npm run dist:mac:dmg
open release/Hermes-0.0.0-arm64.dmg
```
Drag `Hermes` to Applications. If testing repeated installs, replace the existing app.
## Runtime Bootstrap
Packaged desktop startup resolves Hermes in this order:
1. `HERMES_DESKTOP_HERMES_ROOT`
2. existing `hermes` CLI, unless `HERMES_DESKTOP_IGNORE_EXISTING=1`
3. bundled `Contents/Resources/hermes-agent`
4. dev repo source
5. installed `python -m hermes_cli.main`
When the bundled path is used, Electron creates or reuses:
```text
~/Library/Application Support/Hermes/hermes-runtime
```
The runtime is validated before use. If required dashboard imports are missing, it reinstalls the desktop runtime dependencies and retries.
## Debugging
Desktop boot logs are written to:
```text
~/Library/Application Support/Hermes/desktop.log
```
If the UI reports `Desktop boot failed`, check that log first. It includes the backend command output and recent Python traceback context.
To reset bundled runtime state:
```bash
rm -rf "$HOME/Library/Application Support/Hermes/hermes-runtime"
```
To reset stale macOS microphone permission prompts:
```bash
tccutil reset Microphone com.github.Electron
tccutil reset Microphone com.nousresearch.hermes
```
## Verification
Run before handing off installer changes:
```bash
npm run fix
npm run type-check
npm run lint
npm run test:desktop:all
```
Current lint may report existing warnings, but it should exit with no errors.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 KiB

-21
View File
@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
@@ -1,30 +0,0 @@
function isWslEnvironment(env = process.env, platform = process.platform) {
if (platform !== 'linux') return false
return Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP)
}
function isWindowsBinaryPathInWsl(filePath, options = {}) {
const isWsl = options.isWsl ?? isWslEnvironment(options.env, options.platform)
if (!isWsl) return false
const normalized = String(filePath || '')
.replace(/\\/g, '/')
.toLowerCase()
return (
normalized.endsWith('.exe') ||
normalized.endsWith('.cmd') ||
normalized.endsWith('.bat') ||
normalized.endsWith('.ps1')
)
}
function bundledRuntimeImportCheck(platform = process.platform) {
return platform === 'win32' ? 'import fastapi, uvicorn, winpty' : 'import fastapi, uvicorn, ptyprocess'
}
module.exports = {
bundledRuntimeImportCheck,
isWindowsBinaryPathInWsl,
isWslEnvironment
}
@@ -1,50 +0,0 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const {
bundledRuntimeImportCheck,
isWindowsBinaryPathInWsl,
isWslEnvironment
} = require('./bootstrap-platform.cjs')
test('isWslEnvironment detects WSL2 env vars on linux', () => {
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
assert.equal(isWslEnvironment({ WSL_INTEROP: '/run/WSL/123_interop' }, 'linux'), true)
assert.equal(isWslEnvironment({}, 'linux'), false)
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'darwin'), false)
})
test('isWindowsBinaryPathInWsl blocks Windows binary types on WSL', () => {
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.exe', { isWsl: true }), true)
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.cmd', { isWsl: true }), true)
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.bat', { isWsl: true }), true)
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/install.ps1', { isWsl: true }), true)
assert.equal(isWindowsBinaryPathInWsl('/usr/local/bin/hermes', { isWsl: true }), false)
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.exe', { isWsl: false }), false)
})
test('bundledRuntimeImportCheck selects platform-specific import checks', () => {
assert.equal(bundledRuntimeImportCheck('win32'), 'import fastapi, uvicorn, winpty')
assert.equal(bundledRuntimeImportCheck('darwin'), 'import fastapi, uvicorn, ptyprocess')
assert.equal(bundledRuntimeImportCheck('linux'), 'import fastapi, uvicorn, ptyprocess')
})
test('packaged electron entrypoints do not require unpackaged npm modules', () => {
const electronDir = __dirname
const entrypoints = ['main.cjs', 'preload.cjs', 'bootstrap-platform.cjs']
const allowedBareRequires = new Set(['electron'])
const requirePattern = /require\(['"]([^'"]+)['"]\)/g
for (const entrypoint of entrypoints) {
const source = fs.readFileSync(path.join(electronDir, entrypoint), 'utf8')
const bareRequires = Array.from(source.matchAll(requirePattern))
.map(match => match[1])
.filter(specifier => !specifier.startsWith('node:'))
.filter(specifier => !specifier.startsWith('.'))
.filter(specifier => !allowedBareRequires.has(specifier))
assert.deepEqual(bareRequires, [], `${entrypoint} has unpackaged runtime requires`)
}
})
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
File diff suppressed because it is too large Load Diff
-50
View File
@@ -1,50 +0,0 @@
const { contextBridge, ipcRenderer, webUtils } = require('electron')
contextBridge.exposeInMainWorld('hermesDesktop', {
getConnection: () => ipcRenderer.invoke('hermes:connection'),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
api: request => ipcRenderer.invoke('hermes:api', request),
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),
readFileDataUrl: filePath => ipcRenderer.invoke('hermes:readFileDataUrl', filePath),
readFileText: filePath => ipcRenderer.invoke('hermes:readFileText', filePath),
selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options),
writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text),
saveImageFromUrl: url => ipcRenderer.invoke('hermes:saveImageFromUrl', url),
saveImageBuffer: (data, ext) => ipcRenderer.invoke('hermes:saveImageBuffer', { data, ext }),
saveClipboardImage: () => ipcRenderer.invoke('hermes:saveClipboardImage'),
getPathForFile: file => {
try {
return webUtils.getPathForFile(file) || ''
} catch {
return ''
}
},
normalizePreviewTarget: (target, baseDir) => ipcRenderer.invoke('hermes:normalizePreviewTarget', target, baseDir),
watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url),
stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id),
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
onClosePreviewRequested: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:close-preview-requested', listener)
return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener)
},
onPreviewFileChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:preview-file-changed', listener)
return () => ipcRenderer.removeListener('hermes:preview-file-changed', listener)
},
onBackendExit: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:backend-exit', listener)
return () => ipcRenderer.removeListener('hermes:backend-exit', listener)
},
onBootProgress: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:boot-progress', listener)
return () => ipcRenderer.removeListener('hermes:boot-progress', listener)
}
})
-122
View File
@@ -1,122 +0,0 @@
import js from '@eslint/js'
import typescriptEslint from '@typescript-eslint/eslint-plugin'
import typescriptParser from '@typescript-eslint/parser'
import perfectionist from 'eslint-plugin-perfectionist'
import reactPlugin from 'eslint-plugin-react'
import reactCompiler from 'eslint-plugin-react-compiler'
import hooksPlugin from 'eslint-plugin-react-hooks'
import unusedImports from 'eslint-plugin-unused-imports'
import globals from 'globals'
const noopRule = {
meta: { schema: [], type: 'problem' },
create: () => ({})
}
const customRules = {
rules: {
'no-process-cwd': noopRule,
'no-process-env-top-level': noopRule,
'no-sync-fs': noopRule,
'no-top-level-dynamic-import': noopRule,
'no-top-level-side-effects': noopRule
}
}
export default [
{
ignores: ['**/node_modules/**', '**/dist/**', 'src/**/*.js']
},
js.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
globals: {
...globals.browser,
...globals.node
},
parser: typescriptParser,
parserOptions: {
ecmaFeatures: { jsx: true },
ecmaVersion: 'latest',
sourceType: 'module'
}
},
plugins: {
'@typescript-eslint': typescriptEslint,
'custom-rules': customRules,
perfectionist,
react: reactPlugin,
'react-compiler': reactCompiler,
'react-hooks': hooksPlugin,
'unused-imports': unusedImports
},
rules: {
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
'@typescript-eslint/no-unused-vars': 'off',
curly: ['error', 'all'],
'no-fallthrough': ['error', { allowEmptyCase: true }],
'no-undef': 'off',
'no-unused-vars': 'off',
'padding-line-between-statements': [
1,
{
blankLine: 'always',
next: [
'block-like',
'block',
'return',
'if',
'class',
'continue',
'debugger',
'break',
'multiline-const',
'multiline-let'
],
prev: '*'
},
{
blankLine: 'always',
next: '*',
prev: ['case', 'default', 'multiline-const', 'multiline-let', 'multiline-block-like']
},
{ blankLine: 'never', next: ['block', 'block-like'], prev: ['case', 'default'] },
{ blankLine: 'always', next: ['block', 'block-like'], prev: ['block', 'block-like'] },
{ blankLine: 'always', next: ['empty'], prev: 'export' },
{ blankLine: 'never', next: 'iife', prev: ['block', 'block-like', 'empty'] }
],
'perfectionist/sort-exports': ['error', { order: 'asc', type: 'natural' }],
'perfectionist/sort-imports': [
'error',
{
groups: ['side-effect', 'builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
order: 'asc',
type: 'natural'
}
],
'perfectionist/sort-jsx-props': ['error', { order: 'asc', type: 'natural' }],
'perfectionist/sort-named-exports': ['error', { order: 'asc', type: 'natural' }],
'perfectionist/sort-named-imports': ['error', { order: 'asc', type: 'natural' }],
'react-compiler/react-compiler': 'warn',
'react-hooks/exhaustive-deps': 'warn',
'react-hooks/rules-of-hooks': 'error',
'unused-imports/no-unused-imports': 'error'
},
settings: {
react: { version: 'detect' }
}
},
{
files: ['**/*.js', '**/*.cjs'],
ignores: ['**/node_modules/**', '**/dist/**'],
languageOptions: {
ecmaVersion: 'latest',
globals: { ...globals.node },
sourceType: 'commonjs'
}
},
{
ignores: ['*.config.*']
}
]
-14
View File
@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/apple-touch-icon.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>Hermes</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-17722
View File
File diff suppressed because it is too large Load Diff
-187
View File
@@ -1,187 +0,0 @@
{
"name": "hermes",
"productName": "Hermes",
"private": true,
"version": "0.0.0",
"description": "Native desktop shell for Hermes Agent.",
"author": "Nous Research",
"type": "module",
"main": "electron/main.cjs",
"scripts": {
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
"dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev",
"dev:renderer": "vite --host 127.0.0.1 --port 5174",
"dev:electron": "wait-on http://127.0.0.1:5174 && cross-env HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
"build": "tsc -b && vite build",
"stage:hermes": "node scripts/stage-hermes-payload.mjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
"pack": "npm run build && npm run stage:hermes && npm run builder -- --dir",
"dist": "npm run build && npm run stage:hermes && npm run builder",
"dist:mac": "npm run build && npm run stage:hermes && npm run builder -- --mac",
"dist:mac:dmg": "npm run build && npm run stage:hermes && npm run builder -- --mac dmg",
"dist:mac:zip": "npm run build && npm run stage:hermes && npm run builder -- --mac zip",
"dist:win": "npm run build && npm run stage:hermes && npm run builder -- --win",
"dist:win:msi": "npm run build && npm run stage:hermes && npm run builder -- --win msi",
"dist:win:nsis": "npm run build && npm run stage:hermes && npm run builder -- --win nsis",
"test:desktop": "node scripts/test-desktop.mjs",
"test:desktop:all": "node scripts/test-desktop.mjs all",
"test:desktop:dmg": "node scripts/test-desktop.mjs dmg",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' 'vite.config.ts'",
"fix": "npm run lint:fix && npm run fmt",
"test:ui": "vitest run --environment jsdom",
"preview": "vite preview --host 127.0.0.1 --port 4174"
},
"dependencies": {
"@assistant-ui/react": "^0.12.28",
"@assistant-ui/react-streamdown": "^0.1.11",
"@audiowave/react": "^0.6.2",
"@chenglou/pretext": "^0.0.6",
"@hermes/shared": "file:../shared",
"@nanostores/react": "^1.1.0",
"@radix-ui/react-slot": "^1.2.4",
"@streamdown/code": "^1.1.1",
"@tabler/icons-react": "^3.41.1",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.100.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"ignore": "^7.0.5",
"liquid-glass-react": "^1.1.1",
"lucide-react": "^0.577.0",
"nanostores": "^1.3.0",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react-arborist": "^3.5.0",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
"react-shiki": "^0.9.3",
"shiki": "^4.0.2",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
"tw-shimmer": "^0.4.11",
"unicode-animations": "^1.0.3",
"use-stick-to-bottom": "^1.1.4",
"web-haptics": "^0.0.6"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"@vitejs/plugin-react": "^6.0.1",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^40.9.3",
"electron-builder": "^26.8.1",
"eslint": "^9.39.4",
"eslint-plugin-perfectionist": "^5.9.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^16.5.0",
"jsdom": "^29.1.1",
"prettier": "^3.8.3",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vitest": "^4.1.5",
"wait-on": "^9.0.5"
},
"build": {
"appId": "com.nousresearch.hermes",
"productName": "Hermes",
"executableName": "Hermes",
"artifactName": "Hermes-${version}-${os}-${arch}.${ext}",
"icon": "assets/icon",
"directories": {
"output": "release"
},
"files": [
"dist/**",
"assets/**",
"electron/**",
"public/**",
"package.json"
],
"beforeBuild": "scripts/before-build.cjs",
"extraResources": [
{
"from": "build/hermes-agent",
"to": "hermes-agent"
}
],
"asar": true,
"afterSign": "scripts/notarize.cjs",
"asarUnpack": [
"**/*.node"
],
"mac": {
"category": "public.app-category.developer-tools",
"entitlements": "electron/entitlements.mac.plist",
"entitlementsInherit": "electron/entitlements.mac.inherit.plist",
"extendInfo": {
"CFBundleDisplayName": "Hermes",
"CFBundleExecutable": "Hermes",
"CFBundleName": "Hermes",
"NSAudioCaptureUsageDescription": "Hermes uses audio capture for voice conversations.",
"NSMicrophoneUsageDescription": "Hermes uses the microphone for voice input and voice conversations."
},
"gatekeeperAssess": false,
"hardenedRuntime": true,
"target": [
"dmg",
"zip"
]
},
"dmg": {
"title": "Install Hermes",
"backgroundColor": "#f5f5f7",
"iconSize": 96,
"window": {
"width": 560,
"height": 360
},
"contents": [
{
"x": 160,
"y": 170,
"type": "file"
},
{
"x": 400,
"y": 170,
"type": "link",
"path": "/Applications"
}
]
},
"win": {
"legalTrademarks": "Hermes",
"target": [
"nsis",
"msi"
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"perMachine": false,
"shortcutName": "Hermes",
"uninstallDisplayName": "Hermes"
}
}
}
-65
View File
@@ -1,65 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Preview Demo</title>
<style>
:root { color-scheme: dark; }
html, body { height: 100%; margin: 0; }
body {
font-family: ui-sans-serif, system-ui, -apple-system, "SF Pro Text", sans-serif;
background: radial-gradient(1200px 600px at 20% 10%, #4a1a33 0%, #2a1020 40%, #120810 100%);
color: #ffe4f1;
display: grid;
place-items: center;
padding: 2rem;
}
.card {
max-width: 520px;
padding: 2rem 2.25rem;
border: 1px solid rgba(255,182,214,0.18);
border-radius: 14px;
background: rgba(28,14,22,0.6);
backdrop-filter: blur(6px);
box-shadow: 0 10px 40px rgba(0,0,0,0.4);
}
h1 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
letter-spacing: 0.01em;
}
p { margin: 0.35rem 0; opacity: 0.85; line-height: 1.5; }
.dot {
display: inline-block; width: 10px; height: 10px; border-radius: 50%;
background: #ff6fb5; margin-right: 0.5rem;
box-shadow: 0 0 12px #ff6fb5;
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse {
0%,100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.4); opacity: 0.6; }
}
code {
background: rgba(255,182,214,0.10);
padding: 0.1rem 0.35rem;
border-radius: 4px;
font-size: 0.9em;
}
.time { font-variant-numeric: tabular-nums; opacity: 0.7; font-size: 0.85rem; margin-top: 1rem; }
</style>
</head>
<body>
<div class="card">
<h1><span class="dot"></span>preview-demo.html</h1>
<p>Tiny standalone HTML artifact — no server, no build step.</p>
<p>Open directly in a browser via <code>file://</code>.</p>
<p class="time" id="t"></p>
</div>
<script>
const el = document.getElementById('t');
const tick = () => { el.textContent = new Date().toLocaleString(); };
tick(); setInterval(tick, 1000);
</script>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 883 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

-9
View File
@@ -1,9 +0,0 @@
/**
* Desktop bundles ship precompiled renderer assets and a staged Hermes payload
* from extraResources. Returning false here tells electron-builder to skip the
* node_modules collector/install step, which avoids workspace dependency graph
* explosions and keeps packaging deterministic across environments.
*/
module.exports = async function beforeBuild() {
return false
}
@@ -1,74 +0,0 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { execFile } = require('node:child_process')
function run(command, args) {
return new Promise((resolve, reject) => {
execFile(command, args, (error, stdout, stderr) => {
if (error) {
reject(new Error(`${command} ${args.join(' ')} failed: ${stderr?.trim() || stdout?.trim() || error.message}`))
return
}
resolve()
})
})
}
function inlineKeyLooksValid(value) {
return value.includes('BEGIN PRIVATE KEY') && value.includes('END PRIVATE KEY')
}
function resolveApiKeyPath(rawValue) {
const value = String(rawValue || '').trim()
if (!value) return { keyPath: '', cleanup: () => {} }
if (fs.existsSync(value)) {
return { keyPath: value, cleanup: () => {} }
}
if (!inlineKeyLooksValid(value)) {
throw new Error('APPLE_API_KEY must be a file path or inline .p8 key content')
}
const tempPath = path.join(os.tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`)
fs.writeFileSync(tempPath, value, 'utf8')
return {
keyPath: tempPath,
cleanup: () => fs.rmSync(tempPath, { force: true })
}
}
async function main() {
const artifactPath = process.argv[2]
if (!artifactPath || !fs.existsSync(artifactPath)) {
throw new Error(`Missing artifact to notarize: ${artifactPath || '(none)'}`)
}
const profile = String(process.env.APPLE_NOTARY_PROFILE || '').trim()
if (profile) {
await run('xcrun', ['notarytool', 'submit', artifactPath, '--keychain-profile', profile, '--wait'])
await run('xcrun', ['stapler', 'staple', '-v', artifactPath])
return
}
const keyId = String(process.env.APPLE_API_KEY_ID || '').trim()
const issuer = String(process.env.APPLE_API_ISSUER || '').trim()
const rawApiKey = process.env.APPLE_API_KEY
if (!rawApiKey || !keyId || !issuer) {
throw new Error('APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER are required')
}
const { keyPath, cleanup } = resolveApiKeyPath(rawApiKey)
try {
await run('xcrun', ['notarytool', 'submit', artifactPath, '--key', keyPath, '--key-id', keyId, '--issuer', issuer, '--wait'])
await run('xcrun', ['stapler', 'staple', '-v', artifactPath])
} finally {
cleanup()
}
}
main().catch(error => {
console.error(error.message)
process.exit(1)
})
-100
View File
@@ -1,100 +0,0 @@
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { execFile } = require('node:child_process')
function run(command, args) {
return new Promise((resolve, reject) => {
execFile(command, args, (error, stdout, stderr) => {
if (error) {
reject(
new Error(
`${command} ${args.join(' ')} failed: ${stderr?.trim() || stdout?.trim() || error.message}`
)
)
return
}
resolve({ stdout, stderr })
})
})
}
function inlineKeyLooksValid(value) {
return value.includes('BEGIN PRIVATE KEY') && value.includes('END PRIVATE KEY')
}
function resolveApiKeyPath(rawValue) {
const value = String(rawValue || '').trim()
if (!value) return { keyPath: '', cleanup: () => {} }
if (fs.existsSync(value)) {
return { keyPath: value, cleanup: () => {} }
}
if (!inlineKeyLooksValid(value)) {
throw new Error('APPLE_API_KEY must be a file path or inline .p8 key content')
}
const tempPath = path.join(os.tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`)
fs.writeFileSync(tempPath, value, 'utf8')
return {
keyPath: tempPath,
cleanup: () => {
try {
fs.rmSync(tempPath, { force: true })
} catch {
// Best-effort cleanup.
}
}
}
}
exports.default = async function notarize(context) {
const { electronPlatformName, appOutDir, packager } = context
if (electronPlatformName !== 'darwin') return
const appName = packager.appInfo.productFilename
const appPath = path.join(appOutDir, `${appName}.app`)
if (!fs.existsSync(appPath)) {
throw new Error(`Cannot notarize missing app bundle: ${appPath}`)
}
const profile = String(process.env.APPLE_NOTARY_PROFILE || '').trim()
if (profile) {
const zipPath = path.join(appOutDir, `${appName}.zip`)
await run('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', appPath, zipPath])
await run('xcrun', ['notarytool', 'submit', zipPath, '--keychain-profile', profile, '--wait'])
await run('xcrun', ['stapler', 'staple', '-v', appPath])
try {
fs.rmSync(zipPath, { force: true })
} catch {
// Best-effort cleanup.
}
return
}
const keyId = String(process.env.APPLE_API_KEY_ID || '').trim()
const issuer = String(process.env.APPLE_API_ISSUER || '').trim()
const rawApiKey = process.env.APPLE_API_KEY
if (!rawApiKey || !keyId || !issuer) {
console.log(
'Skipping notarization: APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER are not fully configured.'
)
return
}
const { keyPath, cleanup } = resolveApiKeyPath(rawApiKey)
const zipPath = path.join(appOutDir, `${appName}.zip`)
try {
await run('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', appPath, zipPath])
await run('xcrun', ['notarytool', 'submit', zipPath, '--key', keyPath, '--key-id', keyId, '--issuer', issuer, '--wait'])
await run('xcrun', ['stapler', 'staple', '-v', appPath])
} finally {
try {
fs.rmSync(zipPath, { force: true })
} catch {
// Best-effort cleanup.
}
cleanup()
}
}
@@ -1,109 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '../..')
const OUT_ROOT = path.join(DESKTOP_ROOT, 'build', 'hermes-agent')
const ROOT_FILES = [
'README.md',
'LICENSE',
'pyproject.toml',
'run_agent.py',
'model_tools.py',
'toolsets.py',
'batch_runner.py',
'trajectory_compressor.py',
'toolset_distributions.py',
'cli.py',
'hermes_constants.py',
'hermes_logging.py',
'hermes_state.py',
'hermes_time.py',
'rl_cli.py',
'utils.py'
]
const ROOT_DIRS = [
'acp_adapter',
'agent',
'cron',
'gateway',
'hermes_cli',
'plugins',
'scripts',
'skills',
'tools',
'tui_gateway'
]
const TUI_FILES = ['package.json', 'package-lock.json']
const TUI_DIRS = ['dist', 'packages/hermes-ink/dist']
const EXCLUDED_NAMES = new Set([
'.DS_Store',
'.git',
'.mypy_cache',
'.pytest_cache',
'.ruff_cache',
'.venv',
'__pycache__',
'node_modules',
'release',
'venv'
])
function keep(entry) {
return !EXCLUDED_NAMES.has(entry.name) && !entry.name.endsWith('.pyc') && !entry.name.endsWith('.pyo')
}
async function exists(target) {
try {
await fs.access(target)
return true
} catch {
return false
}
}
async function copyFileIfPresent(relativePath) {
const from = path.join(REPO_ROOT, relativePath)
if (!(await exists(from))) return
const to = path.join(OUT_ROOT, relativePath)
await fs.mkdir(path.dirname(to), { recursive: true })
await fs.copyFile(from, to)
}
async function copyDirIfPresent(relativePath) {
const from = path.join(REPO_ROOT, relativePath)
if (!(await exists(from))) return
const to = path.join(OUT_ROOT, relativePath)
await fs.cp(from, to, {
recursive: true,
filter: source => keep({ name: path.basename(source) })
})
}
async function main() {
await fs.rm(OUT_ROOT, { force: true, recursive: true })
await fs.mkdir(OUT_ROOT, { recursive: true })
await Promise.all(ROOT_FILES.map(copyFileIfPresent))
for (const dir of ROOT_DIRS) {
await copyDirIfPresent(dir)
}
for (const file of TUI_FILES) {
await copyFileIfPresent(path.join('ui-tui', file))
}
for (const dir of TUI_DIRS) {
await copyDirIfPresent(path.join('ui-tui', dir))
}
}
await main()
-268
View File
@@ -1,268 +0,0 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { spawn, spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { listPackage } from '@electron/asar'
const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(DESKTOP_ROOT, 'package.json'), 'utf8'))
const MODE = process.argv[2] || 'help'
const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64'
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
const APP_PATH = path.join(RELEASE_ROOT, `mac-${ARCH}`, 'Hermes.app')
const APP_BIN = path.join(APP_PATH, 'Contents', 'MacOS', 'Hermes')
const USER_DATA = path.join(os.homedir(), 'Library', 'Application Support', 'Hermes')
const RUNTIME_ROOT = path.join(USER_DATA, 'hermes-runtime')
const FRESH_SANDBOX_ROOT = path.join(os.tmpdir(), 'hermes-desktop-fresh-install')
function die(message) {
console.error(`\n${message}`)
process.exit(1)
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd || DESKTOP_ROOT,
env: options.env || process.env,
shell: Boolean(options.shell),
stdio: 'inherit'
})
if (result.status !== 0) {
die(`${command} ${args.join(' ')} failed`)
}
}
function output(command, args) {
const result = spawnSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
})
return result.status === 0 ? result.stdout.trim() : ''
}
function exists(target) {
return fs.existsSync(target)
}
function resolveDmgPath() {
if (!exists(RELEASE_ROOT)) {
return path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
}
const prefix = `Hermes-${PACKAGE_JSON.version}`
const candidates = fs
.readdirSync(RELEASE_ROOT)
.filter(name => name.endsWith('.dmg'))
.filter(name => name.startsWith(prefix))
.filter(name => name.includes(ARCH))
.sort((a, b) => {
const aMtime = fs.statSync(path.join(RELEASE_ROOT, a)).mtimeMs
const bMtime = fs.statSync(path.join(RELEASE_ROOT, b)).mtimeMs
return bMtime - aMtime
})
if (candidates.length > 0) {
return path.join(RELEASE_ROOT, candidates[0])
}
return path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
}
function ensureMac() {
if (process.platform !== 'darwin') {
die('Desktop launch tests are macOS-only from this script.')
}
}
function ensurePackagedApp() {
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(APP_BIN)) {
return
}
run('npm', ['run', 'pack'])
}
function ensureDmg() {
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(resolveDmgPath())) {
return
}
run('npm', ['run', 'dist:mac:dmg'])
}
function openApp() {
if (!exists(APP_PATH)) {
die(`Missing packaged app: ${APP_PATH}`)
}
run('open', ['-n', APP_PATH])
}
function openDmg() {
const dmgPath = resolveDmgPath()
if (!exists(dmgPath)) {
die(`Missing DMG: ${dmgPath}`)
}
run('open', [dmgPath])
}
const CREDENTIAL_ENV_SUFFIXES = [
'_API_KEY',
'_TOKEN',
'_SECRET',
'_PASSWORD',
'_CREDENTIALS',
'_ACCESS_KEY',
'_PRIVATE_KEY',
'_OAUTH_TOKEN'
]
const CREDENTIAL_ENV_NAMES = new Set([
'ANTHROPIC_BASE_URL',
'ANTHROPIC_TOKEN',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_SESSION_TOKEN',
'CUSTOM_API_KEY',
'GEMINI_BASE_URL',
'OPENAI_BASE_URL',
'OPENROUTER_BASE_URL',
'OLLAMA_BASE_URL',
'GROQ_BASE_URL',
'XAI_BASE_URL'
])
function isCredentialEnvVar(name) {
if (CREDENTIAL_ENV_NAMES.has(name)) return true
return CREDENTIAL_ENV_SUFFIXES.some(suffix => name.endsWith(suffix))
}
function launchFresh() {
if (!exists(APP_BIN)) {
die(`Missing app executable: ${APP_BIN}`)
}
const python = output('which', ['python3'])
if (!python) {
die('python3 is required for fresh bundled-runtime bootstrap.')
}
const sandbox = fs.mkdtempSync(`${FRESH_SANDBOX_ROOT}-`)
const userDataDir = path.join(sandbox, 'electron-user-data')
const hermesHome = path.join(sandbox, 'hermes-home')
const cwd = path.join(sandbox, 'workspace')
fs.mkdirSync(userDataDir, { recursive: true })
fs.mkdirSync(hermesHome, { recursive: true })
fs.mkdirSync(cwd, { recursive: true })
// Strip every credential-shaped env var so the sandbox is actually fresh.
// Without this, shell-set OPENAI_API_KEY/OPENAI_BASE_URL/etc. leak into the
// packaged backend, making setup.status report "configured" while the
// agent's own credential resolution still fails.
const env = {}
for (const [key, value] of Object.entries(process.env)) {
if (isCredentialEnvVar(key)) continue
env[key] = value
}
env.HERMES_DESKTOP_CWD = cwd
env.HERMES_DESKTOP_IGNORE_EXISTING = '1'
env.HERMES_DESKTOP_TEST_MODE = 'fresh-install'
env.HERMES_DESKTOP_USER_DATA_DIR = userDataDir
env.HERMES_HOME = hermesHome
delete env.HERMES_DESKTOP_HERMES
delete env.HERMES_DESKTOP_HERMES_ROOT
const child = spawn(APP_BIN, [], {
cwd: os.homedir(),
detached: true,
env,
stdio: 'ignore'
})
child.unref()
console.log('\nFresh install sandbox:')
console.log(` root: ${sandbox}`)
console.log(` electron userData: ${userDataDir}`)
console.log(` HERMES_HOME: ${hermesHome}`)
console.log(` cwd: ${cwd}`)
return { runtimeRoot: path.join(userDataDir, 'hermes-runtime') }
}
function validateBundle() {
const appAsar = path.join(APP_PATH, 'Contents', 'Resources', 'app.asar')
const unpackedIndex = path.join(APP_PATH, 'Contents', 'Resources', 'app.asar.unpacked', 'dist', 'index.html')
const required = [
APP_BIN,
path.join(APP_PATH, 'Contents', 'Resources', 'hermes-agent', 'hermes_cli', 'main.py')
]
for (const target of required) {
if (!exists(target)) {
die(`Missing packaged payload file: ${target}`)
}
}
if (exists(unpackedIndex)) {
return
}
if (!exists(appAsar)) {
die(`Missing renderer payload: neither ${unpackedIndex} nor ${appAsar} exists`)
}
const files = listPackage(appAsar)
if (!files.includes('/dist/index.html') && !files.includes('dist/index.html')) {
die(`Missing renderer payload file in app.asar: ${appAsar} (expected dist/index.html)`)
}
}
function printArtifacts(options = {}) {
const runtimeRoot = options.runtimeRoot || RUNTIME_ROOT
console.log('\nDesktop artifacts:')
console.log(` app: ${APP_PATH}`)
console.log(` dmg: ${resolveDmgPath()}`)
console.log(` runtime: ${runtimeRoot}`)
}
function help() {
console.log(`Usage:
npm run test:desktop:existing # build packaged app, launch with normal PATH/existing Hermes
npm run test:desktop:fresh # build packaged app, launch with temp userData + HERMES_HOME
npm run test:desktop:dmg # build DMG and open it
npm run test:desktop:all # build DMG, validate app payload, print paths
Fast rerun:
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:fresh
`)
}
ensureMac()
if (MODE === 'existing') {
ensurePackagedApp()
validateBundle()
openApp()
printArtifacts()
} else if (MODE === 'fresh') {
ensurePackagedApp()
validateBundle()
printArtifacts(launchFresh())
} else if (MODE === 'dmg') {
ensureDmg()
openDmg()
printArtifacts()
} else if (MODE === 'all') {
ensureDmg()
validateBundle()
printArtifacts()
} else {
help()
}
-140
View File
@@ -1,140 +0,0 @@
import { useStore } from '@nanostores/react'
import { useMemo, useState } from 'react'
import { Activity, AlertCircle, Layers3, Loader2, type LucideIcon, RefreshCw, Sparkles } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $desktopActionTasks, buildRailTasks, type RailTask, type RailTaskStatus } from '@/store/activity'
import { $previewServerRestart } from '@/store/preview'
import { $sessions, $workingSessionIds } from '@/store/session'
import { OverlayCard } from '../overlays/overlay-chrome'
import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
type AgentsSection = 'tree' | 'activity' | 'history'
interface SectionDef {
description: string
icon: LucideIcon
id: AgentsSection
label: string
}
const SECTIONS: readonly SectionDef[] = [
{ description: 'Live subagent spawn tree for the current turn', icon: Layers3, id: 'tree', label: 'Spawn tree' },
{ description: 'Background work across sessions and the desktop', icon: Activity, id: 'activity', label: 'Activity' },
{ description: 'Past spawn snapshots, replay, and diff', icon: RefreshCw, id: 'history', label: 'History' }
]
const STATUS_TONE: Record<RailTaskStatus, string> = {
error: 'text-destructive',
running: 'text-foreground',
success: 'text-emerald-500'
}
const STATUS_ICON: Record<RailTaskStatus, LucideIcon> = {
error: AlertCircle,
running: Loader2,
success: Sparkles
}
interface AgentsViewProps {
initialSection?: AgentsSection
onClose: () => void
}
export function AgentsView({ initialSection = 'tree', onClose }: AgentsViewProps) {
const [section, setSection] = useState<AgentsSection>(initialSection)
const sessions = useStore($sessions)
const workingSessionIds = useStore($workingSessionIds)
const previewRestart = useStore($previewServerRestart)
const desktopActionTasks = useStore($desktopActionTasks)
const activityTasks = useMemo(
() => buildRailTasks(workingSessionIds, sessions, previewRestart, desktopActionTasks),
[desktopActionTasks, previewRestart, sessions, workingSessionIds]
)
const active = SECTIONS.find(s => s.id === section) ?? SECTIONS[0]!
return (
<OverlayView closeLabel="Close agents" onClose={onClose}>
<OverlaySplitLayout>
<OverlaySidebar>
{SECTIONS.map(s => (
<OverlayNavItem
active={s.id === section}
icon={s.icon}
key={s.id}
label={s.label}
onClick={() => setSection(s.id)}
/>
))}
</OverlaySidebar>
<OverlayMain>
<header className="mb-4">
<h2 className="text-sm font-semibold text-foreground">{active.label}</h2>
<p className="text-xs text-muted-foreground">{active.description}</p>
</header>
{section === 'activity' ? <ActivityList tasks={activityTasks} /> : <SectionStub label={active.label} />}
</OverlayMain>
</OverlaySplitLayout>
</OverlayView>
)
}
function ActivityList({ tasks }: { tasks: readonly RailTask[] }) {
if (tasks.length === 0) {
return (
<OverlayCard className="px-3 py-4 text-sm text-muted-foreground">
No background activity. Long-running tools, preview restarts, and parallel sessions surface here.
</OverlayCard>
)
}
return (
<div className="grid min-h-0 gap-1.5 overflow-y-auto pr-1">
{tasks.map(task => {
const Icon = STATUS_ICON[task.status]
return (
<OverlayCard className="flex items-start gap-2.5 px-3 py-2" key={task.id}>
<Icon
className={cn(
'mt-0.5 size-3.5 shrink-0',
STATUS_TONE[task.status],
task.status === 'running' && 'animate-spin'
)}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{task.label}</div>
{task.detail && <div className="truncate text-xs text-muted-foreground">{task.detail}</div>}
</div>
</OverlayCard>
)
})}
</div>
)
}
function SectionStub({ label }: { label: string }) {
return (
<OverlayCard className="grid place-items-center gap-3 px-6 py-12 text-center">
<Sparkles className="size-6 text-muted-foreground/70" />
<div className="grid gap-1">
<p className="text-sm font-medium text-foreground">{label} coming soon</p>
<p className="max-w-md text-xs leading-relaxed text-muted-foreground">
Subagent stores aren&apos;t wired into the desktop yet. Once gateway events for{' '}
<code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-[0.65rem]">
subagent.spawn / progress / complete
</code>{' '}
land here, this view shows the live spawn tree, replay history, and pause/kill controls modelled on the
TUI&apos;s <code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-[0.65rem]">/agents</code> overlay.
</p>
</div>
</OverlayCard>
)
}
-859
View File
@@ -1,859 +0,0 @@
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { CopyButton } from '@/components/ui/copy-button'
import { Input } from '@/components/ui/input'
import {
Pagination,
PaginationButton,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationNext,
PaginationPrevious
} from '@/components/ui/pagination'
import { getSessionMessages, listSessions } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { ExternalLink, FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import type { SessionInfo, SessionMessage } from '@/types/hermes'
import { sessionRoute } from '../routes'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { titlebarHeaderBaseClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
type ArtifactKind = 'image' | 'file' | 'link'
interface ArtifactRecord {
id: string
kind: ArtifactKind
value: string
href: string
label: string
sessionId: string
sessionTitle: string
timestamp: number
}
const MARKDOWN_IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g
const MARKDOWN_LINK_RE = /\[([^\]]+)\]\(([^)\s]+)\)/g
const URL_RE = /https?:\/\/[^\s<>"')]+/g
const PATH_RE = /(^|[\s("'`])((?:\/|~\/|\.\.?\/)[^\s"'`<>]+(?:\.[a-z0-9]{1,8})?)/gi
const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp|svg|bmp)(?:\?.*)?$/i
const FILE_EXT_RE = /\.(?:png|jpe?g|gif|webp|svg|bmp|pdf|txt|json|md|csv|zip|tar|gz|mp3|wav|mp4|mov)(?:\?.*)?$/i
const KEY_HINT_RE = /(path|file|url|image|artifact|output|download|result|target)/i
const ARTIFACT_TIME_FMT = new Intl.DateTimeFormat(undefined, {
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
month: 'short'
})
function normalizeValue(value: string): string {
return value.trim().replace(/[),.;]+$/, '')
}
function parseMaybeJson(value: string): unknown {
if (!value.trim()) {
return null
}
try {
return JSON.parse(value)
} catch {
return null
}
}
function looksLikePathOrUrl(value: string): boolean {
return (
value.startsWith('http://') ||
value.startsWith('https://') ||
value.startsWith('file://') ||
value.startsWith('data:image/') ||
value.startsWith('/') ||
value.startsWith('./') ||
value.startsWith('../') ||
value.startsWith('~/')
)
}
function looksLikeArtifact(value: string): boolean {
if (value.startsWith('data:image/')) {
return true
}
if (looksLikePathOrUrl(value) && (IMAGE_EXT_RE.test(value) || FILE_EXT_RE.test(value))) {
return true
}
return value.startsWith('/') && value.includes('.')
}
function artifactKind(value: string): ArtifactKind {
if (value.startsWith('data:image/') || IMAGE_EXT_RE.test(value)) {
return 'image'
}
if (
value.startsWith('/') ||
value.startsWith('./') ||
value.startsWith('../') ||
value.startsWith('~/') ||
value.startsWith('file://')
) {
return 'file'
}
return 'link'
}
function artifactHref(value: string): string {
if (
value.startsWith('http://') ||
value.startsWith('https://') ||
value.startsWith('file://') ||
value.startsWith('data:')
) {
return value
}
if (value.startsWith('/')) {
return `file://${encodeURI(value)}`
}
return value
}
function artifactLabel(value: string): string {
try {
const url = new URL(value)
const item = url.pathname.split('/').filter(Boolean).pop()
return item || value
} catch {
const parts = value.split(/[\\/]/).filter(Boolean)
return parts.pop() || value
}
}
function messageText(message: SessionMessage): string {
if (typeof message.content === 'string' && message.content.trim()) {
return message.content
}
if (typeof message.text === 'string' && message.text.trim()) {
return message.text
}
if (typeof message.context === 'string' && message.context.trim()) {
return message.context
}
return ''
}
function collectStringValues(
value: unknown,
keyPath: string,
collector: (value: string, keyPath: string) => void
): void {
if (typeof value === 'string') {
collector(value, keyPath)
return
}
if (Array.isArray(value)) {
value.forEach((entry, index) => collectStringValues(entry, `${keyPath}.${index}`, collector))
return
}
if (!value || typeof value !== 'object') {
return
}
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
collectStringValues(child, keyPath ? `${keyPath}.${key}` : key, collector)
}
}
function collectArtifactsFromText(text: string, pushValue: (value: string) => void): void {
for (const match of text.matchAll(MARKDOWN_IMAGE_RE)) {
pushValue(match[2] || '')
}
for (const match of text.matchAll(MARKDOWN_LINK_RE)) {
const start = match.index ?? 0
if (start > 0 && text[start - 1] === '!') {
continue
}
const value = match[2] || ''
if (looksLikeArtifact(value)) {
pushValue(value)
}
}
for (const match of text.matchAll(URL_RE)) {
const value = match[0] || ''
if (looksLikeArtifact(value)) {
pushValue(value)
}
}
for (const match of text.matchAll(PATH_RE)) {
pushValue(match[2] || '')
}
}
function collectArtifactsFromMessage(message: SessionMessage, pushValue: (value: string) => void): void {
const text = messageText(message)
if (text) {
collectArtifactsFromText(text, pushValue)
}
if (message.role !== 'tool' && !Array.isArray(message.tool_calls)) {
return
}
if (Array.isArray(message.tool_calls)) {
for (const call of message.tool_calls) {
collectStringValues(call, 'tool_call', (value, keyPath) => {
const normalized = normalizeValue(value)
if (!normalized) {
return
}
if (KEY_HINT_RE.test(keyPath) && (looksLikePathOrUrl(normalized) || FILE_EXT_RE.test(normalized))) {
pushValue(normalized)
}
})
}
}
const parsed = parseMaybeJson(text)
if (parsed !== null) {
collectStringValues(parsed, 'tool_result', (value, keyPath) => {
const normalized = normalizeValue(value)
if (!normalized) {
return
}
if ((KEY_HINT_RE.test(keyPath) || looksLikePathOrUrl(normalized)) && looksLikeArtifact(normalized)) {
pushValue(normalized)
}
})
}
}
function collectArtifactsForSession(session: SessionInfo, messages: SessionMessage[]): ArtifactRecord[] {
const found = new Map<string, ArtifactRecord>()
const title = sessionTitle(session)
for (const message of messages) {
if (message.role !== 'assistant' && message.role !== 'tool') {
continue
}
collectArtifactsFromMessage(message, candidate => {
const value = normalizeValue(candidate)
if (!value || !looksLikeArtifact(value)) {
return
}
const key = `${session.id}:${value}`
if (found.has(key)) {
return
}
found.set(key, {
id: key,
kind: artifactKind(value),
value,
href: artifactHref(value),
label: artifactLabel(value),
sessionId: session.id,
sessionTitle: title,
timestamp: message.timestamp || session.last_active || session.started_at || Date.now()
})
})
}
return Array.from(found.values())
}
function formatArtifactTime(timestamp: number): string {
return ARTIFACT_TIME_FMT.format(new Date(timestamp))
}
function pageRangeLabel(total: number, page: number, pageSize: number): string {
if (total === 0) {
return '0'
}
const start = (page - 1) * pageSize + 1
const end = Math.min(total, page * pageSize)
return `${start}-${end} of ${total}`
}
function paginationItems(page: number, pageCount: number): Array<number | 'ellipsis'> {
if (pageCount <= 7) {
return Array.from({ length: pageCount }, (_, index) => index + 1)
}
const pages: Array<number | 'ellipsis'> = [1]
const start = Math.max(2, page - 1)
const end = Math.min(pageCount - 1, page + 1)
if (start > 2) {
pages.push('ellipsis')
}
for (let nextPage = start; nextPage <= end; nextPage += 1) {
pages.push(nextPage)
}
if (end < pageCount - 1) {
pages.push('ellipsis')
}
pages.push(pageCount)
return pages
}
interface ArtifactsViewProps extends React.ComponentProps<'section'> {
setStatusbarItemGroup?: SetStatusbarItemGroup
setTitlebarToolGroup?: SetTitlebarToolGroup
}
export function ArtifactsView({
setStatusbarItemGroup: _setStatusbarItemGroup,
setTitlebarToolGroup,
...props
}: ArtifactsViewProps) {
const navigate = useNavigate()
const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null)
const [query, setQuery] = useState('')
const [kindFilter, setKindFilter] = useState<'all' | ArtifactKind>('all')
const [refreshing, setRefreshing] = useState(false)
const [failedImageIds, setFailedImageIds] = useState<Set<string>>(() => new Set())
const [imagePage, setImagePage] = useState(1)
const [filePage, setFilePage] = useState(1)
const refreshArtifacts = useCallback(async () => {
setRefreshing(true)
try {
const sessions = (await listSessions(30, 1)).sessions
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
const nextArtifacts: ArtifactRecord[] = []
results.forEach((result, index) => {
if (result.status !== 'fulfilled') {
return
}
const session = sessions[index]
nextArtifacts.push(...collectArtifactsForSession(session, result.value.messages))
})
setArtifacts(nextArtifacts.sort((a, b) => b.timestamp - a.timestamp))
} catch (err) {
notifyError(err, 'Artifacts failed to load')
setArtifacts([])
} finally {
setRefreshing(false)
}
}, [])
useEffect(() => {
void refreshArtifacts()
}, [refreshArtifacts])
useEffect(() => {
if (!setTitlebarToolGroup) {
return
}
setTitlebarToolGroup('artifacts', [
{
disabled: refreshing,
icon: <RefreshCw className={cn(refreshing && 'animate-spin')} />,
id: 'refresh-artifacts',
label: refreshing ? 'Refreshing artifacts' : 'Refresh artifacts',
onSelect: () => void refreshArtifacts()
}
])
return () => setTitlebarToolGroup('artifacts', [])
}, [refreshArtifacts, refreshing, setTitlebarToolGroup])
useEffect(() => {
setImagePage(1)
setFilePage(1)
}, [artifacts, kindFilter, query])
const visibleArtifacts = useMemo(() => {
if (!artifacts) {
return []
}
const q = query.trim().toLowerCase()
return artifacts.filter(artifact => {
if (kindFilter !== 'all' && artifact.kind !== kindFilter) {
return false
}
if (!q) {
return true
}
return (
artifact.label.toLowerCase().includes(q) ||
artifact.value.toLowerCase().includes(q) ||
artifact.sessionTitle.toLowerCase().includes(q)
)
})
}, [artifacts, kindFilter, query])
const visibleImageArtifacts = useMemo(
() => visibleArtifacts.filter(artifact => artifact.kind === 'image'),
[visibleArtifacts]
)
const visibleFileArtifacts = useMemo(
() => visibleArtifacts.filter(artifact => artifact.kind !== 'image'),
[visibleArtifacts]
)
const imagePageCount = Math.max(1, Math.ceil(visibleImageArtifacts.length / 24))
const filePageCount = Math.max(1, Math.ceil(visibleFileArtifacts.length / 100))
const currentImagePage = Math.min(imagePage, imagePageCount)
const currentFilePage = Math.min(filePage, filePageCount)
const pagedImageArtifacts = useMemo(
() => visibleImageArtifacts.slice((currentImagePage - 1) * 24, currentImagePage * 24),
[currentImagePage, visibleImageArtifacts]
)
const pagedFileArtifacts = useMemo(
() => visibleFileArtifacts.slice((currentFilePage - 1) * 100, currentFilePage * 100),
[currentFilePage, visibleFileArtifacts]
)
const counts = useMemo(() => {
const all = artifacts || []
return {
all: all.length,
image: all.filter(artifact => artifact.kind === 'image').length,
file: all.filter(artifact => artifact.kind === 'file').length,
link: all.filter(artifact => artifact.kind === 'link').length
}
}, [artifacts])
const openArtifact = useCallback(async (href: string) => {
try {
if (window.hermesDesktop?.openExternal) {
await window.hermesDesktop.openExternal(href)
} else {
window.open(href, '_blank', 'noopener,noreferrer')
}
} catch (err) {
notifyError(err, 'Open failed')
}
}, [])
const markImageFailed = useCallback((id: string) => {
setFailedImageIds(current => {
if (current.has(id)) {
return current
}
return new Set(current).add(id)
})
}, [])
return (
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background">
<header className={titlebarHeaderBaseClass}>
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Artifacts</h2>
<span className="pointer-events-auto text-xs text-muted-foreground">{counts.all} found</span>
</header>
<div className="min-h-0 flex-1 overflow-hidden rounded-[1.0625rem] border border-border/50 bg-background/85">
<div className="border-b border-border/50 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<FilterButton
active={kindFilter === 'all'}
icon={Layers3}
label={`All (${counts.all})`}
onClick={() => setKindFilter('all')}
/>
<FilterButton
active={kindFilter === 'image'}
icon={FileImage}
label={`Images (${counts.image})`}
onClick={() => setKindFilter('image')}
/>
<FilterButton
active={kindFilter === 'file'}
icon={FileText}
label={`Files (${counts.file})`}
onClick={() => setKindFilter('file')}
/>
<FilterButton
active={kindFilter === 'link'}
icon={Link2}
label={`Links (${counts.link})`}
onClick={() => setKindFilter('link')}
/>
<div className="ml-auto w-full max-w-sm min-w-64">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 rounded-lg pl-8 pr-8 text-sm"
onChange={event => setQuery(event.target.value)}
placeholder="Search artifacts..."
value={query}
/>
{query && (
<Button
aria-label="Clear search"
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setQuery('')}
size="icon"
type="button"
variant="ghost"
>
<X className="size-3.5" />
</Button>
)}
</div>
</div>
</div>
</div>
{!artifacts ? (
<PageLoader label="Indexing recent session artifacts" />
) : visibleArtifacts.length === 0 ? (
<div className="grid h-full place-items-center px-6 text-center">
<div>
<div className="text-sm font-medium">No artifacts found</div>
<div className="mt-1 text-xs text-muted-foreground">
Generated images and file outputs will appear here as sessions produce them.
</div>
</div>
</div>
) : (
<div className="h-full overflow-y-auto">
<div className="flex flex-col gap-4 px-2 pb-2">
{visibleImageArtifacts.length > 0 && (
<section aria-labelledby="artifacts-images-heading" className="flex flex-col">
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center justify-between gap-3 overflow-x-auto bg-background px-3">
<h3 className="shrink-0 text-xs font-semibold" id="artifacts-images-heading">
Images
</h3>
<ArtifactsPagination
className="justify-end px-0"
itemLabel="images"
onPageChange={setImagePage}
page={currentImagePage}
pageSize={24}
total={visibleImageArtifacts.length}
/>
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(12rem,1fr))] items-start gap-2 pt-1.5">
{pagedImageArtifacts.map(artifact => (
<ArtifactImageCard
artifact={artifact}
failedImage={failedImageIds.has(artifact.id)}
key={artifact.id}
onImageError={markImageFailed}
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
/>
))}
</div>
</section>
)}
{visibleFileArtifacts.length > 0 && (
<section aria-labelledby="artifacts-files-heading" className="flex flex-col">
<div className="sticky top-0 z-10 -mx-2 flex h-7 items-center justify-between gap-3 overflow-x-auto bg-background px-3">
<h3 className="shrink-0 text-xs font-semibold" id="artifacts-files-heading">
{kindFilter === 'link' ? 'Links' : kindFilter === 'file' ? 'Files' : 'Files and links'}
</h3>
<ArtifactsPagination
className="justify-end px-0"
itemLabel="files"
onPageChange={setFilePage}
page={currentFilePage}
pageSize={100}
total={visibleFileArtifacts.length}
/>
</div>
<div className="overflow-x-auto rounded-lg border border-border/50 bg-background/70 shadow-[0_0.125rem_0.5rem_color-mix(in_srgb,black_3%,transparent)]">
<table className="w-full min-w-176 table-fixed text-left text-xs">
<thead className="border-b border-border/50 bg-muted/35 text-[0.62rem] uppercase tracking-[0.08em] text-muted-foreground">
<tr>
<th className="w-[31%] px-2.5 py-1.5 font-medium">Name</th>
<th className="w-[35%] px-2.5 py-1.5 font-medium">Location</th>
<th className="w-[22%] px-2.5 py-1.5 font-medium">Session</th>
<th className="w-[12%] px-2.5 py-1.5 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border/45">
{pagedFileArtifacts.map(artifact => (
<ArtifactListRow
artifact={artifact}
key={artifact.id}
onOpen={openArtifact}
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
/>
))}
</tbody>
</table>
</div>
</section>
)}
</div>
</div>
)}
</div>
</section>
)
}
interface ArtifactsPaginationProps {
className?: string
itemLabel: string
onPageChange: (page: number) => void
page: number
pageSize: number
total: number
}
function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSize, total }: ArtifactsPaginationProps) {
const pageCount = Math.max(1, Math.ceil(total / pageSize))
return (
<div className={cn('flex h-6 items-center justify-between gap-2 px-1', className)}>
<div className="shrink-0 text-[0.62rem] text-muted-foreground">
{pageRangeLabel(total, page, pageSize)} {itemLabel}
</div>
{pageCount > 1 && (
<Pagination className="mx-0 w-auto min-w-0 justify-end">
<PaginationContent className="gap-0.5">
<PaginationItem>
<PaginationPrevious disabled={page <= 1} onClick={() => onPageChange(Math.max(1, page - 1))} />
</PaginationItem>
{paginationItems(page, pageCount).map((item, index) => (
<PaginationItem key={`${item}-${index}`}>
{item === 'ellipsis' ? (
<PaginationEllipsis />
) : (
<PaginationButton
aria-label={`Go to ${itemLabel} page ${item}`}
isActive={page === item}
onClick={() => onPageChange(item)}
>
{item}
</PaginationButton>
)}
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
disabled={page >= pageCount}
onClick={() => onPageChange(Math.min(pageCount, page + 1))}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
)
}
function FilterButton({
active,
icon: Icon,
label,
onClick
}: {
active: boolean
icon: typeof Layers3
label: string
onClick: () => void
}) {
return (
<Button
className={cn(
'h-8 gap-1.5 rounded-md px-2.5 text-xs',
active ? 'bg-accent text-foreground' : 'text-muted-foreground hover:text-foreground'
)}
onClick={onClick}
size="sm"
type="button"
variant="ghost"
>
<Icon className="size-3.5" />
{label}
</Button>
)
}
interface ArtifactImageCardProps {
artifact: ArtifactRecord
failedImage: boolean
onImageError: (id: string) => void
onOpenChat: (sessionId: string) => void
}
function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: ArtifactImageCardProps) {
return (
<article
className={cn(
'group/artifact overflow-hidden rounded-lg border border-border/50 bg-background/70 shadow-[0_0.125rem_0.5rem_color-mix(in_srgb,black_3%,transparent)]',
'bg-muted/20'
)}
>
<div
className={cn(
'relative flex h-44 w-full items-center justify-center overflow-hidden border-b border-border/50 bg-[color-mix(in_srgb,var(--dt-muted)_58%,var(--dt-background))] p-1.5',
failedImage && 'cursor-default'
)}
>
{!failedImage && (
<ZoomableImage
alt={artifact.label}
className="max-h-40 max-w-full rounded-md object-contain shadow-sm"
containerClassName="max-h-full"
decoding="async"
loading="lazy"
onError={() => onImageError(artifact.id)}
slot="artifact-media"
src={artifact.href}
/>
)}
</div>
<div className="space-y-1.5 p-2">
<div className="min-w-0">
<div className="mb-0.5 flex items-center gap-1 text-[0.62rem] uppercase tracking-[0.08em] text-muted-foreground">
<FileImage className="size-3" />
{artifact.kind}
</div>
<div className="truncate text-xs font-medium">{artifact.label}</div>
<div className="mt-0.5 truncate text-[0.62rem] text-muted-foreground">{artifact.value}</div>
</div>
<div className="truncate text-[0.62rem] text-muted-foreground">
{artifact.sessionTitle} · {formatArtifactTime(artifact.timestamp)}
</div>
<div className="flex flex-wrap gap-1.5">
<Button onClick={() => onOpenChat(artifact.sessionId)} size="xs" type="button" variant="outline">
<FolderOpen className="size-3" />
Chat
</Button>
</div>
</div>
</article>
)
}
interface ArtifactListRowProps {
artifact: ArtifactRecord
onOpen: (href: string) => void | Promise<void>
onOpenChat: (sessionId: string) => void
}
function ArtifactListRow({ artifact, onOpen, onOpenChat }: ArtifactListRowProps) {
const Icon = artifact.kind === 'file' ? FileText : Link2
return (
<tr className="group/artifact transition-colors hover:bg-muted/30">
<td className="px-2.5 py-1.5 align-middle">
<div className="flex min-w-0 items-center gap-2">
<div className="grid size-7 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
<Icon className="size-3.5" />
</div>
<div className="min-w-0">
<div className="truncate font-medium" title={artifact.label}>
{artifact.label}
</div>
<div className="text-[0.6rem] uppercase tracking-[0.08em] text-muted-foreground">{artifact.kind}</div>
</div>
</div>
</td>
<td className="px-2.5 py-1.5 align-middle">
<div className="truncate font-mono text-[0.68rem] text-muted-foreground/85" title={artifact.value}>
{artifact.value}
</div>
</td>
<td className="px-2.5 py-1.5 align-middle">
<div className="min-w-0">
<div className="truncate text-[0.68rem] text-muted-foreground" title={artifact.sessionTitle}>
{artifact.sessionTitle}
</div>
<div className="text-[0.6rem] text-muted-foreground/75">{formatArtifactTime(artifact.timestamp)}</div>
</div>
</td>
<td className="px-2.5 py-1.5 align-middle">
<div className="flex justify-end gap-0.5 opacity-70 transition-opacity group-hover/artifact:opacity-100">
<Button
className="text-muted-foreground hover:text-foreground"
onClick={() => void onOpen(artifact.href)}
size="icon-xs"
title="Open"
type="button"
variant="ghost"
>
<ExternalLink className="size-3.5" />
</Button>
<CopyButton
appearance="button"
buttonSize="icon-xs"
className="text-muted-foreground hover:text-foreground"
iconClassName="size-3.5"
label="Copy"
text={artifact.value}
/>
<Button
className="text-muted-foreground hover:text-foreground"
onClick={() => onOpenChat(artifact.sessionId)}
size="icon-xs"
title="Open chat"
type="button"
variant="ghost"
>
<FolderOpen className="size-3.5" />
</Button>
</div>
</td>
</tr>
)
}
@@ -1,109 +0,0 @@
import { useStore } from '@nanostores/react'
import { FileText, FolderOpen, ImageIcon, Link, X } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
export function AttachmentList({
attachments,
onRemove
}: {
attachments: ComposerAttachment[]
onRemove?: (id: string) => void
}) {
return (
<div className="flex max-w-full flex-wrap gap-1.5 px-1 pt-1" data-slot="composer-attachments">
{attachments.map(a => (
<AttachmentPill attachment={a} key={a.id} onRemove={onRemove} />
))}
</div>
)
}
function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) {
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText }[attachment.kind]
const cwd = useStore($currentCwd)
const canPreview = attachment.kind !== 'folder'
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
async function openPreview() {
if (!canPreview) {
return
}
const rawTarget =
attachment.path ||
attachment.detail ||
attachment.refText?.replace(/^@(file|image|url):/, '') ||
attachment.label ||
''
const target = rawTarget.replace(/^`|`$/g, '')
if (!target) {
return
}
try {
const preview = await normalizeOrLocalPreviewTarget(target, cwd || undefined)
if (!preview) {
throw new Error(`Could not preview ${attachment.label}`)
}
setCurrentSessionPreviewTarget(preview, 'manual', target)
} catch (error) {
notifyError(error, 'Preview unavailable')
}
}
return (
<div
className="group/attachment relative min-w-0 shrink-0"
title={attachment.path || attachment.detail || attachment.label}
>
<button
aria-label={canPreview ? `Preview ${attachment.label}` : attachment.label}
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
disabled={!canPreview}
onClick={() => void openPreview()}
title={canPreview ? `Preview ${attachment.label}` : attachment.label}
type="button"
>
{attachment.previewUrl && attachment.kind === 'image' ? (
<img
alt={attachment.label}
className="size-8 shrink-0 border border-border/70 object-cover"
draggable={false}
src={attachment.previewUrl}
/>
) : (
<span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground">
<Icon className="size-3.5" />
</span>
)}
<span className="min-w-0">
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
{attachment.label}
</span>
{detail && (
<span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">{detail}</span>
)}
</span>
</button>
{onRemove && (
<button
aria-label={`Remove ${attachment.label}`}
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
onClick={() => onRemove(attachment.id)}
type="button"
>
<X className="size-2.5" />
</button>
)}
</div>
)
}
@@ -1,56 +0,0 @@
import type { Unstable_TriggerAdapter } from '@assistant-ui/core'
import { ComposerPrimitive } from '@assistant-ui/react'
import type { ReactNode } from 'react'
export const COMPLETION_DRAWER_CLASS = [
'absolute inset-x-0 bottom-[calc(100%-0.5rem)] z-50',
'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
'rounded-t-(--composer-active-radius) border border-b-0',
'border-[color-mix(in_srgb,var(--dt-ring)_45%,transparent)]',
'bg-[color-mix(in_srgb,var(--dt-popover)_96%,transparent)]',
'px-1.5 pb-3 pt-1.5 text-popover-foreground',
'backdrop-blur-[0.75rem] backdrop-saturate-[1.1]',
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.1)]',
'data-[state=open]:-mb-2',
'data-[state=open]:shadow-[0_-0.0625rem_0_0.0625rem_color-mix(in_srgb,var(--dt-ring)_35%,transparent),0_-1rem_2.25rem_-1.75rem_color-mix(in_srgb,var(--dt-foreground)_34%,transparent),0_-0.3125rem_0.875rem_-0.6875rem_color-mix(in_srgb,var(--dt-foreground)_22%,transparent)]'
].join(' ')
export const COMPLETION_DRAWER_ROW_CLASS = [
'flex w-full min-w-0 items-baseline gap-2 rounded-md px-2.5 py-1',
'text-left text-xs transition-colors',
'hover:bg-[color-mix(in_srgb,var(--dt-accent)_70%,transparent)]',
'data-[highlighted]:bg-[color-mix(in_srgb,var(--dt-accent)_70%,transparent)]'
].join(' ')
export function ComposerCompletionDrawer({
adapter,
ariaLabel,
char,
children
}: {
adapter: Unstable_TriggerAdapter
ariaLabel: string
char: string
children: ReactNode
}) {
return (
<ComposerPrimitive.Unstable_TriggerPopover
adapter={adapter}
aria-label={ariaLabel}
char={char}
className={COMPLETION_DRAWER_CLASS}
data-slot="composer-completion-drawer"
>
{children}
</ComposerPrimitive.Unstable_TriggerPopover>
)
}
export function CompletionDrawerEmpty({ children, title }: { children?: ReactNode; title: string }) {
return (
<div className="px-3 py-3 text-sm text-muted-foreground">
<p>{title}</p>
{children && <p className="mt-1 text-xs text-muted-foreground/80">{children}</p>}
</div>
)
}
@@ -1,119 +0,0 @@
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Clipboard, FileText, FolderOpen, ImageIcon, Link, type LucideIcon, MessageSquareText, Plus } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { GHOST_ICON_BTN } from './controls'
import type { ChatBarState } from './types'
export function ContextMenu({
state,
onInsertText,
onOpenUrlDialog,
onPasteClipboardImage,
onPickFiles,
onPickFolders,
onPickImages
}: {
state: ChatBarState
onInsertText: (text: string) => void
onOpenUrlDialog: () => void
onPasteClipboardImage?: () => void
onPickFiles?: () => void
onPickFolders?: () => void
onPickImages?: () => void
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={state.tools.label}
className={cn(GHOST_ICON_BTN, 'data-[state=open]:bg-accent data-[state=open]:text-foreground')}
disabled={!state.tools.enabled}
size="icon"
title={state.tools.label}
type="button"
variant="ghost"
>
<Plus size={18} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
<DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85">
Attach
</DropdownMenuLabel>
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
Files
</ContextMenuItem>
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
Folder
</ContextMenuItem>
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
Images
</ContextMenuItem>
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
Paste image
</ContextMenuItem>
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
URL
</ContextMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<MessageSquareText />
<span>Prompt snippets</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-72">
{[
{ label: 'Code review', text: 'Please review this for bugs, regressions, and missing tests.' },
{ label: 'Implementation plan', text: 'Please make a concise implementation plan before changing code.' },
{ label: 'Explain this', text: 'Please explain how this works and point me to the key files.' }
].map(snippet => (
<ContextMenuItem icon={MessageSquareText} key={snippet.label} onSelect={() => onInsertText(snippet.text)}>
{snippet.label}
</ContextMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference files
inline.
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function ContextMenuItem({
children,
disabled,
icon: Icon,
onSelect
}: {
children: string
disabled?: boolean
icon: LucideIcon
onSelect?: () => void
}) {
return (
<DropdownMenuItem disabled={disabled} onSelect={onSelect}>
<Icon />
<span>{children}</span>
</DropdownMenuItem>
)
}
@@ -1,242 +0,0 @@
import { Button } from '@/components/ui/button'
import { triggerHaptic } from '@/lib/haptics'
import { ArrowUp, AudioLines, Loader2, Mic, MicOff, Square } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { ConversationStatus } from './hooks/use-voice-conversation'
import type { ChatBarState, VoiceStatus } from './types'
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-full'
export const GHOST_ICON_BTN = cn(ICON_BTN, 'text-muted-foreground hover:bg-accent hover:text-foreground')
// Send/voice-conversation primary: solid foreground-on-background circle
// (reads as black-on-white in light mode, white-on-black in dark mode) to
// match the reference composer's high-contrast CTA. Keeps the pill itself
// neutral and lets the action visually dominate the row.
export const PRIMARY_ICON_BTN = cn(
'size-(--composer-control-primary-size,var(--composer-control-size)) shrink-0 rounded-full p-0',
'bg-foreground text-background hover:bg-foreground/90',
'disabled:bg-foreground/30 disabled:text-background disabled:opacity-100'
)
interface ConversationProps {
active: boolean
level: number
muted: boolean
status: ConversationStatus
onEnd: () => void
onStart: () => void
onStopTurn: () => void
onToggleMute: () => void
}
export function ComposerControls({
busy,
canSubmit,
conversation,
disabled,
hasComposerPayload,
state,
voiceStatus,
onDictate
}: {
busy: boolean
canSubmit: boolean
conversation: ConversationProps
disabled: boolean
hasComposerPayload: boolean
state: ChatBarState
voiceStatus: VoiceStatus
onDictate: () => void
}) {
if (conversation.active) {
return <ConversationPill {...conversation} disabled={disabled} />
}
const showVoicePrimary = !busy && !hasComposerPayload
return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
{showVoicePrimary ? (
<Button
aria-label="Start voice conversation"
className={PRIMARY_ICON_BTN}
disabled={disabled}
onClick={() => {
triggerHaptic('open')
conversation.onStart()
}}
size="icon"
title="Start voice conversation"
type="button"
>
<AudioLines size={17} />
</Button>
) : (
<Button
aria-label={busy ? 'Stop' : 'Send'}
className={PRIMARY_ICON_BTN}
disabled={disabled || !canSubmit}
type="submit"
>
{busy ? <span className="block size-3 rounded-[0.1875rem] bg-current" /> : <ArrowUp size={18} />}
</Button>
)}
</div>
)
}
function ConversationPill({
disabled,
level,
muted,
onEnd,
onStopTurn,
onToggleMute,
status
}: ConversationProps & { disabled: boolean }) {
const speaking = status === 'speaking'
const listening = status === 'listening' && !muted
const label =
status === 'speaking'
? 'Speaking'
: status === 'transcribing'
? 'Transcribing'
: status === 'thinking'
? 'Thinking'
: muted
? 'Muted'
: 'Listening'
return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<Button
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'}
aria-pressed={muted}
className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')}
disabled={disabled}
onClick={() => {
triggerHaptic('selection')
onToggleMute()
}}
size="icon"
title={muted ? 'Unmute microphone' : 'Mute microphone'}
type="button"
variant="ghost"
>
{muted ? <MicOff size={16} /> : <Mic size={16} />}
</Button>
{listening && (
<Button
aria-label="Stop listening and send"
className="h-(--composer-control-size) shrink-0 gap-1.5 rounded-full px-2.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
disabled={disabled}
onClick={() => {
triggerHaptic('submit')
onStopTurn()
}}
title="Stop listening and send"
type="button"
variant="ghost"
>
<Square className="fill-current" size={11} />
<span>Stop</span>
</Button>
)}
<Button
aria-label="End voice conversation"
className="h-(--composer-control-size) gap-1.5 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
disabled={disabled}
onClick={() => {
triggerHaptic('close')
onEnd()
}}
title="End voice conversation"
type="button"
>
<ConversationIndicator level={level} listening={listening} speaking={speaking} />
<span>End</span>
</Button>
<span className="sr-only" role="status">
{label}
</span>
</div>
)
}
function ConversationIndicator({
level,
listening,
speaking
}: {
level: number
listening: boolean
speaking: boolean
}) {
if (speaking) {
return <Loader2 className="animate-spin" size={12} />
}
const bars = [0.55, 0.85, 1, 0.85, 0.55]
const normalized = Math.max(0, Math.min(level, 1))
return (
<span aria-hidden="true" className="flex h-3 items-center gap-0.5">
{bars.map((weight, index) => {
const height = listening ? 0.3 + Math.min(0.7, normalized * weight) : 0.3
return <span className="w-0.5 rounded-full bg-current" key={index} style={{ height: `${height * 100}%` }} />
})}
</span>
)
}
function DictationButton({
disabled,
state,
status,
onToggle
}: {
disabled: boolean
state: ChatBarState['voice']
status: VoiceStatus
onToggle: () => void
}) {
const active = state.active || status !== 'idle'
const aria =
status === 'recording' ? 'Stop dictation' : status === 'transcribing' ? 'Transcribing dictation' : 'Voice dictation'
return (
<Button
aria-label={aria}
aria-pressed={active}
className={cn(
GHOST_ICON_BTN,
'p-0',
'data-[active=true]:bg-accent data-[active=true]:text-foreground',
status === 'recording' && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary',
status === 'transcribing' && 'bg-primary/10 text-primary'
)}
data-active={active}
disabled={disabled || !state.enabled || status === 'transcribing'}
onClick={() => {
triggerHaptic(active ? 'close' : 'open')
onToggle()
}}
size="icon"
title={aria}
type="button"
variant="ghost"
>
{status === 'recording' ? (
<Square className="fill-current" size={12} />
) : status === 'transcribing' ? (
<Loader2 className="animate-spin" size={16} />
) : (
<Mic size={16} />
)}
</Button>
)
}
@@ -1,71 +0,0 @@
import type { ReactNode } from 'react'
import { COMPLETION_DRAWER_CLASS } from './completion-drawer'
const COMMON_COMMANDS: [string, string][] = [
['/help', 'full list of commands + hotkeys'],
['/clear', 'start a new session'],
['/resume', 'resume a prior session'],
['/details', 'control transcript detail level'],
['/copy', 'copy selection or last assistant message'],
['/quit', 'exit hermes']
]
const HOTKEYS: [string, string][] = [
['@', 'reference files, folders, urls, git'],
['/', 'slash command palette'],
['?', 'this quick help (delete to dismiss)'],
['Enter', 'send · Shift+Enter for newline'],
['Cmd/Ctrl+K', 'send next queued turn'],
['Cmd/Ctrl+L', 'redraw'],
['Esc', 'close popover · cancel run'],
['↑ / ↓', 'cycle popover / history']
]
export function HelpHint() {
return (
<div className={COMPLETION_DRAWER_CLASS} data-slot="composer-completion-drawer" data-state="open" role="dialog">
<Section title="Common commands">
{COMMON_COMMANDS.map(([key, desc]) => (
<Row description={desc} key={key} keyLabel={key} mono />
))}
</Section>
<Section title="Hotkeys">
{HOTKEYS.map(([key, desc]) => (
<Row description={desc} key={key} keyLabel={key} />
))}
</Section>
<p className="px-2.5 py-1 text-xs text-muted-foreground/80">
<span className="font-mono text-foreground/80">/help</span> opens the full panel · backspace dismisses
</p>
</div>
)
}
function Section({ children, title }: { children: ReactNode; title: string }) {
return (
<div className="grid gap-0.5 pt-0.5">
<p className="px-2.5 pb-0.5 pt-1 text-[0.65rem] font-medium uppercase tracking-wide text-muted-foreground/75">
{title}
</p>
{children}
</div>
)
}
function Row({ description, keyLabel, mono = false }: { description: string; keyLabel: string; mono?: boolean }) {
return (
<div className="flex min-w-0 items-baseline gap-2 rounded-md px-2.5 py-1 text-xs">
<span
className={
mono ? 'shrink-0 truncate font-mono font-medium text-foreground/85' : 'shrink-0 truncate text-foreground/85'
}
>
{keyLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground/80">{description}</span>
</div>
)
}
@@ -1,141 +0,0 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { useCallback } from 'react'
import type { HermesGateway } from '@/hermes'
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
const KIND_RE = /^@(file|folder|url|image|tool|git):(.*)$/
const REF_STARTERS = new Set(['file', 'folder', 'url', 'image', 'tool', 'git'])
const STARTER_META: Record<string, string> = {
file: 'Attach a file reference',
folder: 'Attach a folder reference',
url: 'Attach a URL reference',
image: 'Attach an image reference',
tool: 'Attach a tool reference',
git: 'Attach git context'
}
function starterEntries(query: string): CompletionEntry[] {
const q = query.trim().toLowerCase()
const kinds = Array.from(REF_STARTERS)
const filtered = q ? kinds.filter(kind => kind.startsWith(q)) : kinds
return filtered.map(kind => ({
text: `@${kind}:`,
display: `@${kind}:`,
meta: STARTER_META[kind] || ''
}))
}
interface AtItemMetadata extends Record<string, string> {
icon: string
display: string
meta: string
/** Raw `text` field from the gateway, e.g. `@file:src/main.tsx` or `@diff`. */
rawText: string
/** Just the value portion (after `@kind:`), or empty for simple refs. */
insertId: string
}
function textValue(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback
}
/** Parse the gateway's `text` field (`@file:src/foo.ts`, `@diff`, `@folder:`) into popover-ready data. */
function classify(entry: CompletionEntry): {
type: string
insertId: string
display: string
meta: string
} {
const match = KIND_RE.exec(entry.text)
if (match) {
const [, kind, rest] = match
return {
type: kind,
insertId: rest,
display: textValue(entry.display, rest || `@${kind}:`),
meta: textValue(entry.meta)
}
}
return {
type: 'simple',
insertId: entry.text,
display: textValue(entry.display, entry.text),
meta: textValue(entry.meta)
}
}
/** Live `@` completions backed by the gateway's `complete.path` RPC. */
export function useAtCompletions(options: {
gateway: HermesGateway | null
sessionId: string | null
cwd: string | null
}): { adapter: Unstable_TriggerAdapter; loading: boolean } {
const { gateway, sessionId, cwd } = options
const enabled = Boolean(gateway)
const fetcher = useCallback(
async (query: string): Promise<CompletionPayload> => {
const starters = starterEntries(query)
if (!gateway) {
return { items: starters, query }
}
const word = REF_STARTERS.has(query) ? `@${query}:` : `@${query}`
const params: Record<string, unknown> = { word }
if (sessionId) {
params.session_id = sessionId
}
if (cwd) {
params.cwd = cwd
}
try {
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.path', params)
const items = result.items ?? []
return { items: items.length > 0 ? items : starters, query }
} catch {
return { items: starters, query }
}
},
[gateway, sessionId, cwd]
)
const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => {
const classified = classify(entry)
const metadata: AtItemMetadata = {
icon: classified.type,
display: classified.display,
meta: classified.meta,
rawText: entry.text,
insertId: classified.insertId
}
return {
// Unique id keyed on the gateway's full `text` so two entries that share
// a basename (e.g. multiple `index.ts`) don't collide in keyboard nav.
id: `${entry.text}|${index}`,
type: classified.type,
label: classified.display,
...(classified.meta ? { description: classified.meta } : {}),
metadata
}
}, [])
return useLiveCompletionAdapter({ enabled, fetcher, toItem })
}
/** Re-export `classify` for use by the formatter (insertion side). */
export { classify }
@@ -1,35 +0,0 @@
export type ComposerLiquidGlassMode = 'polar' | 'prominent' | 'shader' | 'standard'
export interface ComposerGlassTweakOutputs {
fadeBackground: string
liquid: {
aberrationIntensity: number
blurAmount: number
cornerRadius: number
displacementScale: number
elasticity: number
mode: ComposerLiquidGlassMode
saturation: number
}
liquidKey: string
showLibraryRims: boolean
}
const COMPOSER_GLASS_TWEAKS: ComposerGlassTweakOutputs = {
fadeBackground: 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))',
liquid: {
aberrationIntensity: 0.95,
blurAmount: 0.072,
cornerRadius: 24,
displacementScale: 46,
elasticity: 0,
mode: 'standard',
saturation: 128
},
liquidKey: ['standard', '0.950', '0.072', '24', '46', '0.00', '128'].join(':'),
showLibraryRims: false
}
export function useComposerGlassTweaks(): ComposerGlassTweakOutputs {
return COMPOSER_GLASS_TWEAKS
}
@@ -1,119 +0,0 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
export interface CompletionEntry {
text: string
display?: unknown
meta?: unknown
}
export interface CompletionPayload {
items: CompletionEntry[]
query: string
}
const EMPTY_QUERY = '\u0000'
export function useLiveCompletionAdapter(options: {
enabled: boolean
debounceMs?: number
fetcher: (query: string) => Promise<CompletionPayload>
toItem: (entry: CompletionEntry, index: number) => Unstable_TriggerItem
}): { adapter: Unstable_TriggerAdapter; loading: boolean } {
const { enabled, debounceMs = 60, fetcher, toItem } = options
const [state, setState] = useState<{ query: string; items: Unstable_TriggerItem[] }>({
query: EMPTY_QUERY,
items: []
})
const [loading, setLoading] = useState(false)
const tokenRef = useRef(0)
const timerRef = useRef<number | null>(null)
const pendingQueryRef = useRef<string | null>(null)
const cancelTimer = useCallback(() => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
useEffect(() => () => cancelTimer(), [cancelTimer])
useEffect(() => {
if (enabled) {
return
}
cancelTimer()
pendingQueryRef.current = null
tokenRef.current += 1
setLoading(false)
setState({ query: EMPTY_QUERY, items: [] })
}, [cancelTimer, enabled])
const scheduleFetch = useCallback(
(query: string) => {
if (!enabled) {
return
}
if (pendingQueryRef.current === query) {
return
}
pendingQueryRef.current = query
cancelTimer()
const token = ++tokenRef.current
setLoading(true)
timerRef.current = window.setTimeout(() => {
timerRef.current = null
fetcher(query)
.then(payload => {
if (token !== tokenRef.current) {
return
}
setState({
query: payload.query,
items: payload.items.map((entry, index) => toItem(entry, index))
})
})
.catch(() => {
if (token !== tokenRef.current) {
return
}
setState({ query, items: [] })
})
.finally(() => {
if (token === tokenRef.current) {
setLoading(false)
}
})
}, debounceMs)
},
[cancelTimer, debounceMs, enabled, fetcher, toItem]
)
const adapter = useMemo<Unstable_TriggerAdapter>(
() => ({
categories: () => [],
categoryItems: () => [],
search: (query: string) => {
if (query !== state.query) {
scheduleFetch(query)
}
return state.items
}
}),
[scheduleFetch, state]
)
return { adapter, loading }
}
@@ -1,281 +0,0 @@
import { useEffect, useRef, useState } from 'react'
type BrowserAudioContext = typeof AudioContext
export interface MicRecorderOptions {
onLevel?: (level: number) => void
onError?: (error: Error) => void
onSilence?: () => void
silenceLevel?: number
silenceMs?: number
idleSilenceMs?: number
}
export interface MicRecording {
audio: Blob
durationMs: number
heardSpeech: boolean
}
interface MicRecorderHandle {
start: (options?: MicRecorderOptions) => Promise<void>
stop: () => Promise<MicRecording | null>
cancel: () => void
}
function micError(error: unknown): Error {
const name = error instanceof DOMException ? error.name : ''
if (name === 'NotAllowedError' || name === 'SecurityError') {
return new Error('Microphone permission was denied.')
}
if (name === 'NotFoundError' || name === 'DevicesNotFoundError') {
return new Error('No microphone was found.')
}
if (name === 'NotReadableError' || name === 'TrackStartError') {
return new Error('Microphone is already in use by another app.')
}
if (name === 'OverconstrainedError') {
return new Error('Microphone constraints are not supported by this device.')
}
if (error instanceof Error) {
return error
}
return new Error('Could not start microphone recording.')
}
export function useMicRecorder(): { handle: MicRecorderHandle; level: number; recording: boolean } {
const [level, setLevel] = useState(0)
const [recording, setRecording] = useState(false)
const recorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const chunksRef = useRef<Blob[]>([])
const audioContextRef = useRef<AudioContext | null>(null)
const animationRef = useRef<number | null>(null)
const startedAtRef = useRef(0)
const heardSpeechRef = useRef(false)
const silenceTriggeredRef = useRef(false)
const silenceStartedAtRef = useRef<number | null>(null)
const stopResolverRef = useRef<((recording: MicRecording | null) => void) | null>(null)
const cleanup = () => {
if (animationRef.current) {
window.cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
void audioContextRef.current?.close()
audioContextRef.current = null
streamRef.current?.getTracks().forEach(track => track.stop())
streamRef.current = null
recorderRef.current = null
setLevel(0)
setRecording(false)
silenceTriggeredRef.current = false
}
useEffect(() => () => cleanup(), [])
const startMeter = (stream: MediaStream, options: MicRecorderOptions) => {
const audioWindow = window as Window & { webkitAudioContext?: BrowserAudioContext }
const AudioContextCtor = window.AudioContext || audioWindow.webkitAudioContext
if (!AudioContextCtor) {
return
}
try {
const audioContext = new AudioContextCtor()
const analyser = audioContext.createAnalyser()
const source = audioContext.createMediaStreamSource(stream)
analyser.fftSize = 256
const data = new Uint8Array(analyser.fftSize)
source.connect(analyser)
audioContextRef.current = audioContext
const tick = () => {
analyser.getByteTimeDomainData(data)
let sum = 0
for (const value of data) {
const centered = value - 128
sum += centered * centered
}
const rms = Math.sqrt(sum / data.length)
const normalized = Math.min(1, rms / 42)
const now = Date.now()
setLevel(normalized)
options.onLevel?.(normalized)
const speechThreshold = options.silenceLevel ?? 0
const silenceMs = options.silenceMs ?? 0
const idleSilenceMs = options.idleSilenceMs ?? 0
if (speechThreshold > 0 && options.onSilence && !silenceTriggeredRef.current) {
if (normalized >= speechThreshold) {
heardSpeechRef.current = true
silenceStartedAtRef.current = null
} else if (heardSpeechRef.current && silenceMs > 0) {
silenceStartedAtRef.current ??= now
if (now - silenceStartedAtRef.current >= silenceMs) {
silenceTriggeredRef.current = true
options.onSilence()
return
}
} else if (!heardSpeechRef.current && idleSilenceMs > 0 && now - startedAtRef.current >= idleSilenceMs) {
silenceTriggeredRef.current = true
options.onSilence()
return
}
}
animationRef.current = window.requestAnimationFrame(tick)
}
tick()
} catch {
setLevel(0)
}
}
const start: MicRecorderHandle['start'] = async (options = {}) => {
if (recorderRef.current) {
return
}
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
throw new Error('This runtime does not support microphone recording.')
}
const permitted = await window.hermesDesktop?.requestMicrophoneAccess?.()
if (permitted === false) {
throw new Error('Microphone access denied.')
}
let stream: MediaStream
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true }
})
} catch (error) {
throw micError(error)
}
const mimeType =
['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus', 'audio/ogg', 'audio/wav'].find(
type => MediaRecorder.isTypeSupported(type)
) ?? ''
let recorder: MediaRecorder
try {
recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
} catch (error) {
stream.getTracks().forEach(track => track.stop())
throw micError(error)
}
chunksRef.current = []
streamRef.current = stream
recorderRef.current = recorder
heardSpeechRef.current = false
silenceTriggeredRef.current = false
silenceStartedAtRef.current = null
startedAtRef.current = Date.now()
recorder.ondataavailable = event => {
if (event.data.size > 0) {
chunksRef.current.push(event.data)
}
}
recorder.onstop = () => {
const chunks = chunksRef.current
const recordingType = recorder.mimeType || mimeType || 'audio/webm'
const durationMs = Date.now() - startedAtRef.current
const heardSpeech = heardSpeechRef.current
chunksRef.current = []
cleanup()
const resolver = stopResolverRef.current
stopResolverRef.current = null
if (!chunks.length) {
resolver?.(null)
return
}
resolver?.({
audio: new Blob(chunks, { type: recordingType }),
durationMs,
heardSpeech
})
}
recorder.onerror = event => {
const error = micError((event as Event & { error?: unknown }).error)
const resolver = stopResolverRef.current
stopResolverRef.current = null
cleanup()
options.onError?.(error)
resolver?.(null)
}
recorder.start()
setRecording(true)
startMeter(stream, options)
}
const stop: MicRecorderHandle['stop'] = () =>
new Promise<MicRecording | null>(resolve => {
const recorder = recorderRef.current
if (!recorder || recorder.state === 'inactive') {
cleanup()
resolve(null)
return
}
stopResolverRef.current = resolve
recorder.stop()
})
const cancel: MicRecorderHandle['cancel'] = () => {
const recorder = recorderRef.current
const resolver = stopResolverRef.current
stopResolverRef.current = null
if (recorder && recorder.state !== 'inactive') {
recorder.ondataavailable = null
recorder.onerror = null
recorder.onstop = null
recorder.stop()
}
cleanup()
resolver?.(null)
}
const handle: MicRecorderHandle = { start, stop, cancel }
return { handle, level, recording }
}
@@ -1,107 +0,0 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { useCallback } from 'react'
import type { HermesGateway } from '@/hermes'
import {
type CommandsCatalogLike,
desktopSlashDescription,
filterDesktopCommandsCatalog,
isDesktopSlashSuggestion
} from '@/lib/desktop-slash-commands'
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
interface SlashItemMetadata extends Record<string, string> {
command: string
display: string
meta: string
}
function textValue(value: unknown, fallback = ''): string {
if (typeof value === 'string') {
return value
}
if (Array.isArray(value)) {
return value
.map(part => (Array.isArray(part) ? String(part[1] ?? '') : typeof part === 'string' ? part : ''))
.join('')
.trim()
}
return fallback
}
function commandText(value: string): string {
return value.startsWith('/') ? value : `/${value}`
}
/** Live `/` completions backed by the gateway's `complete.slash` RPC. */
export function useSlashCompletions(options: { gateway: HermesGateway | null }): {
adapter: Unstable_TriggerAdapter
loading: boolean
} {
const { gateway } = options
const enabled = Boolean(gateway)
const fetcher = useCallback(
async (query: string): Promise<CompletionPayload> => {
if (!gateway) {
return { items: [], query }
}
const text = `/${query}`
try {
if (!query) {
const catalog = filterDesktopCommandsCatalog(await gateway.request<CommandsCatalogLike>('commands.catalog'))
const items = (catalog.pairs ?? []).map(([command, meta]) => ({
text: command,
display: command,
meta
}))
return { items, query }
}
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text })
const items = (result.items ?? [])
.filter(item => isDesktopSlashSuggestion(item.text))
.map(item => ({
...item,
meta: desktopSlashDescription(item.text, textValue(item.meta))
}))
return { items, query }
} catch {
return { items: [], query }
}
},
[gateway]
)
const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => {
const command = commandText(entry.text)
const display = textValue(entry.display, commandText(entry.text))
const meta = textValue(entry.meta)
const metadata: SlashItemMetadata = {
command,
display,
meta
}
return {
id: `${entry.text}|${index}`,
type: 'slash',
label: display.startsWith('/') ? display.slice(1) : display,
...(meta ? { description: meta } : {}),
metadata
}
}, [])
return useLiveCompletionAdapter({ enabled, fetcher, toItem })
}
@@ -1,387 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import { notify, notifyError } from '@/store/notifications'
import { useMicRecorder } from './use-mic-recorder'
export type ConversationStatus = 'idle' | 'listening' | 'transcribing' | 'thinking' | 'speaking'
interface PendingVoiceResponse {
id: string
pending: boolean
text: string
}
interface VoiceConversationOptions {
busy: boolean
enabled: boolean
onFatalError?: () => void
onSubmit: (text: string) => Promise<void> | void
onTranscribeAudio?: (audio: Blob) => Promise<string>
pendingResponse: () => PendingVoiceResponse | null
consumePendingResponse: () => void
}
export function useVoiceConversation({
busy,
enabled,
onFatalError,
onSubmit,
onTranscribeAudio,
pendingResponse,
consumePendingResponse
}: VoiceConversationOptions) {
const { handle, level } = useMicRecorder()
const [status, setStatus] = useState<ConversationStatus>('idle')
const [muted, setMuted] = useState(false)
const turnTimeoutRef = useRef<number | null>(null)
const pendingStartRef = useRef(false)
const turnClosingRef = useRef(false)
const awaitingSpokenResponseRef = useRef(false)
const responseIdRef = useRef<string | null>(null)
const spokenSourceLengthRef = useRef(0)
const speechBufferRef = useRef('')
const enabledRef = useRef(enabled)
const mutedRef = useRef(muted)
const busyRef = useRef(busy)
const statusRef = useRef<ConversationStatus>('idle')
const wasEnabledRef = useRef(enabled)
useEffect(() => {
enabledRef.current = enabled
}, [enabled])
useEffect(() => {
mutedRef.current = muted
}, [muted])
useEffect(() => {
busyRef.current = busy
}, [busy])
useEffect(() => {
statusRef.current = status
}, [status])
const clearTurnTimeout = () => {
if (turnTimeoutRef.current) {
window.clearTimeout(turnTimeoutRef.current)
turnTimeoutRef.current = null
}
}
const resetSpeechBuffer = () => {
responseIdRef.current = null
spokenSourceLengthRef.current = 0
speechBufferRef.current = ''
}
const appendSpeechText = (text: string) => {
if (!text) {
return
}
speechBufferRef.current = `${speechBufferRef.current}${text}`
}
const takeSpeechChunk = (force = false): string | null => {
const buffer = speechBufferRef.current.replace(/\s+/g, ' ').trim()
if (!buffer) {
speechBufferRef.current = ''
return null
}
const sentence = buffer.match(/^(.+?[.!?。!?])(?:\s+|$)/)
if (sentence?.[1] && (sentence[1].length >= 8 || force)) {
const chunk = sentence[1].trim()
speechBufferRef.current = buffer.slice(sentence[1].length).trim()
return chunk
}
if (!force && buffer.length > 220) {
const softBoundary = Math.max(
buffer.lastIndexOf(', ', 180),
buffer.lastIndexOf('; ', 180),
buffer.lastIndexOf(': ', 180)
)
if (softBoundary > 80) {
const chunk = buffer.slice(0, softBoundary + 1).trim()
speechBufferRef.current = buffer.slice(softBoundary + 1).trim()
return chunk
}
}
if (!force) {
return null
}
speechBufferRef.current = ''
return buffer
}
const handleTurn = useCallback(
async (forceTranscribe = false) => {
if (turnClosingRef.current) {
return
}
turnClosingRef.current = true
clearTurnTimeout()
setStatus('transcribing')
try {
const result = await handle.stop()
if (!result || (!result.heardSpeech && !forceTranscribe) || !onTranscribeAudio) {
if (enabledRef.current && !mutedRef.current && !busyRef.current && statusRef.current !== 'speaking') {
pendingStartRef.current = true
}
setStatus('idle')
return
}
try {
const transcript = (await onTranscribeAudio(result.audio)).trim()
if (!transcript) {
if (enabledRef.current) {
pendingStartRef.current = true
}
setStatus('idle')
return
}
awaitingSpokenResponseRef.current = true
resetSpeechBuffer()
await onSubmit(transcript)
setStatus('thinking')
} catch (error) {
notifyError(error, 'Voice transcription failed')
if (enabledRef.current && !mutedRef.current && !busyRef.current) {
pendingStartRef.current = true
}
setStatus('idle')
}
} finally {
turnClosingRef.current = false
}
},
[handle, onSubmit, onTranscribeAudio]
)
const startListening = useCallback(async () => {
pendingStartRef.current = false
if (!enabledRef.current || mutedRef.current || busyRef.current) {
return
}
if (statusRef.current !== 'idle') {
return
}
try {
// VAD tuning mirrors `tools.voice_mode` defaults so the browser loop matches the CLI.
await handle.start({
silenceLevel: 0.075,
silenceMs: 1_250,
idleSilenceMs: 12_000,
onError: error => {
notifyError(error, 'Microphone failed')
pendingStartRef.current = false
onFatalError?.()
},
onSilence: () => void handleTurn()
})
setStatus('listening')
turnTimeoutRef.current = window.setTimeout(() => void handleTurn(), 60_000)
} catch (error) {
notifyError(error, 'Could not start voice session')
pendingStartRef.current = false
setStatus('idle')
onFatalError?.()
}
}, [handle, handleTurn, onFatalError])
const speak = useCallback(async (text: string) => {
setStatus('speaking')
try {
await playSpeechText(text, { source: 'voice-conversation' })
} catch (error) {
notifyError(error, 'Voice playback failed')
} finally {
if (enabledRef.current) {
pendingStartRef.current = true
setStatus('idle')
} else {
setStatus('idle')
}
}
}, [])
const start = useCallback(async () => {
if (!onTranscribeAudio) {
notify({
kind: 'warning',
title: 'Voice unavailable',
message: 'Configure speech-to-text to use voice mode.'
})
onFatalError?.()
return
}
setMuted(false)
awaitingSpokenResponseRef.current = false
resetSpeechBuffer()
consumePendingResponse()
pendingStartRef.current = true
await startListening()
}, [consumePendingResponse, onFatalError, onTranscribeAudio, startListening])
const end = useCallback(async () => {
pendingStartRef.current = false
clearTurnTimeout()
stopVoicePlayback()
handle.cancel()
turnClosingRef.current = false
awaitingSpokenResponseRef.current = false
resetSpeechBuffer()
consumePendingResponse()
setMuted(false)
setStatus('idle')
}, [consumePendingResponse, handle])
const stopTurn = useCallback(() => {
if (statusRef.current === 'listening') {
void handleTurn(true)
}
}, [handleTurn])
const toggleMute = useCallback(() => {
setMuted(value => {
const next = !value
if (next) {
clearTurnTimeout()
handle.cancel()
setStatus('idle')
} else if (enabledRef.current && !busyRef.current && statusRef.current === 'idle') {
pendingStartRef.current = true
}
return next
})
}, [handle])
useEffect(() => {
if (!enabled) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.code !== 'Space' || event.repeat || event.metaKey || event.ctrlKey || event.altKey) {
return
}
if (statusRef.current !== 'listening') {
return
}
event.preventDefault()
stopTurn()
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [enabled, stopTurn])
// Drive the loop: after a voice-submitted turn, speak stable chunks as the
// assistant stream grows. Otherwise start listening when idle between turns.
useEffect(() => {
if (!enabled || muted) {
return
}
if (awaitingSpokenResponseRef.current && status !== 'speaking') {
const response = pendingResponse()
if (response) {
if (response.id !== responseIdRef.current) {
resetSpeechBuffer()
responseIdRef.current = response.id
}
if (response.text.length > spokenSourceLengthRef.current) {
appendSpeechText(response.text.slice(spokenSourceLengthRef.current))
spokenSourceLengthRef.current = response.text.length
}
const chunk = takeSpeechChunk(!response.pending && !busy)
if (chunk) {
void speak(chunk)
return
}
if (!response.pending && !busy) {
awaitingSpokenResponseRef.current = false
consumePendingResponse()
resetSpeechBuffer()
pendingStartRef.current = true
setStatus('idle')
return
}
}
if (!busy && status === 'thinking') {
awaitingSpokenResponseRef.current = false
resetSpeechBuffer()
pendingStartRef.current = true
setStatus('idle')
return
}
}
if (busy || status !== 'idle') {
return
}
if (pendingStartRef.current) {
void startListening()
}
}, [busy, consumePendingResponse, enabled, muted, pendingResponse, speak, startListening, status])
useEffect(() => {
if (enabled && !wasEnabledRef.current) {
void start()
}
if (!enabled && wasEnabledRef.current) {
void end()
}
wasEnabledRef.current = enabled
}, [enabled, end, start])
return { end, level, muted, start, status, stopTurn, toggleMute }
}
@@ -1,113 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { notify, notifyError } from '@/store/notifications'
import type { VoiceActivityState, VoiceStatus } from '../types'
import { useMicRecorder } from './use-mic-recorder'
interface VoiceRecorderOptions {
maxRecordingSeconds: number
onTranscribeAudio?: (audio: Blob) => Promise<string>
focusInput: () => void
onTranscript: (text: string) => void
}
export function useVoiceRecorder({
maxRecordingSeconds,
onTranscribeAudio,
focusInput,
onTranscript
}: VoiceRecorderOptions) {
const { handle, level, recording } = useMicRecorder()
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>('idle')
const [elapsedSeconds, setElapsedSeconds] = useState(0)
const startedAtRef = useRef(0)
const intervalRef = useRef<number | null>(null)
const timeoutRef = useRef<number | null>(null)
const clearTimers = () => {
if (intervalRef.current) {
window.clearInterval(intervalRef.current)
intervalRef.current = null
}
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
useEffect(() => () => clearTimers(), [])
const stop = async () => {
clearTimers()
const result = await handle.stop()
if (!result) {
setVoiceStatus('idle')
return
}
if (!onTranscribeAudio) {
setVoiceStatus('idle')
return
}
setVoiceStatus('transcribing')
try {
const transcript = (await onTranscribeAudio(result.audio)).trim()
if (!transcript) {
notify({ kind: 'warning', title: 'No speech detected', message: 'Try recording again.' })
} else {
onTranscript(transcript)
}
} catch (error) {
notifyError(error, 'Voice transcription failed')
} finally {
setVoiceStatus('idle')
focusInput()
}
}
const start = async () => {
if (!onTranscribeAudio) {
notify({ kind: 'warning', title: 'Voice unavailable', message: 'Voice transcription is not available yet.' })
return
}
try {
await handle.start({ onError: error => notifyError(error, 'Voice recording failed') })
startedAtRef.current = Date.now()
setElapsedSeconds(0)
setVoiceStatus('recording')
intervalRef.current = window.setInterval(() => setElapsedSeconds((Date.now() - startedAtRef.current) / 1000), 250)
const cap = Math.max(1, Math.min(Math.trunc(maxRecordingSeconds), 600))
timeoutRef.current = window.setTimeout(() => void stop(), cap * 1000)
} catch (error) {
setVoiceStatus('idle')
notifyError(error, 'Voice recording failed')
}
}
const dictate = () => {
if (recording) {
void stop()
} else if (voiceStatus === 'idle') {
void start()
}
}
const voiceActivityState: VoiceActivityState = {
elapsedSeconds,
level,
status: voiceStatus
}
return { dictate, voiceActivityState, voiceStatus }
}
File diff suppressed because it is too large Load Diff
@@ -1,79 +0,0 @@
/* liquid-glass-react emits helper nodes that ignore local utility classes. Keep
these overrides scoped by class so the rest of app styling stays utility-first. */
.composer-liquid-shell-wrap > div:not(.composer-liquid-shell) {
position: absolute !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
transform: none !important;
margin: 0 !important;
box-sizing: border-box;
}
.composer-liquid-shell-wrap:not([data-show-library-rims='true']) > span {
display: none !important;
}
.composer-liquid-shell-wrap[data-show-library-rims='true'] > span {
position: absolute !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
transform: none !important;
margin: 0 !important;
box-sizing: border-box;
display: block !important;
}
.composer-liquid-shell {
z-index: 1;
top: 0 !important;
left: 0 !important;
transform: none !important;
transition: none !important;
}
.composer-liquid-shell > svg {
position: absolute !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
transform: none !important;
}
.composer-liquid-shell > .glass,
.composer-liquid-shell > :not(svg):not(.glass) {
position: absolute !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
transform: none !important;
}
.composer-liquid-shell > .glass {
width: 100% !important;
height: 100% !important;
padding: 0 !important;
border-radius: var(--composer-glass-radius, 24px) !important;
box-shadow: none !important;
}
.composer-liquid-shell > .glass > .glass__warp {
border-radius: var(--composer-glass-radius, 24px) !important;
}
.composer-liquid-shell > .glass > div {
width: 100%;
height: 100%;
font: inherit !important;
text-shadow: none !important;
color: inherit !important;
}
@@ -1,18 +0,0 @@
import { describe, expect, it } from 'vitest'
import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
describe('renderComposerContents', () => {
it('renders refs and raw text without interpreting user text as HTML', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
renderComposerContents(editor, '@file:`<img src=x onerror=alert(1)>` <b>raw</b>')
expect(editor.querySelector('img')).toBeNull()
expect(editor.querySelector('b')).toBeNull()
expect(editor.textContent).toContain('<img src=x onerror=alert(1)>')
expect(editor.textContent).toContain('<b>raw</b>')
expect(composerPlainText(editor)).toBe('@file:`<img src=x onerror=alert(1)>` <b>raw</b>')
})
})
@@ -1,165 +0,0 @@
/**
* Helpers for the contenteditable composer surface: serialize refs to chip
* HTML, walk the DOM back to plain `@kind:value` text, and place the caret.
*
* Chip values are always wrapped in backticks/quotes so REF_RE stops at the
* fence — without that, typing after a chip would get re-absorbed on the next
* plain-text round-trip.
*/
import {
DIRECTIVE_CHIP_CLASS,
directiveIconElement,
directiveIconSvg,
formatRefValue
} from '@/components/assistant-ui/directive-text'
export const RICH_INPUT_SLOT = 'composer-rich-input'
export const REF_RE = /@(file|folder|url|image|tool|line):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }
export function escapeHtml(value: string) {
return value.replace(/[&<>"']/g, ch => ESC[ch] || ch)
}
export function unquoteRef(raw: string) {
const head = raw[0]
const tail = raw[raw.length - 1]
const quoted = (head === '`' && tail === '`') || (head === '"' && tail === '"') || (head === "'" && tail === "'")
return quoted ? raw.slice(1, -1) : raw.replace(/[,.;!?]+$/, '')
}
export function refLabel(id: string) {
return id.split(/[\\/]/).filter(Boolean).pop() || id
}
/** Always-quote variant of formatRefValue — chips need a fence even for safe values. */
export function quoteRefValue(value: string) {
if (!value.includes('`')) {
return `\`${value}\``
}
if (!value.includes('"')) {
return `"${value}"`
}
if (!value.includes("'")) {
return `'${value}'`
}
return formatRefValue(value)
}
export function refChipHtml(kind: string, rawValue: string) {
const id = unquoteRef(rawValue)
const text = `@${kind}:${quoteRefValue(id)}`
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(refLabel(id))}</span></span>`
}
export function refChipElement(kind: string, rawValue: string) {
const id = unquoteRef(rawValue)
const text = `@${kind}:${quoteRefValue(id)}`
const chip = document.createElement('span')
const label = document.createElement('span')
chip.contentEditable = 'false'
chip.dataset.refText = text
chip.dataset.refId = id
chip.dataset.refKind = kind
chip.className = DIRECTIVE_CHIP_CLASS
label.className = 'truncate'
label.textContent = refLabel(id)
chip.append(directiveIconElement(kind), label)
return chip
}
function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: string) {
const lines = text.split('\n')
lines.forEach((line, index) => {
if (index > 0) {
target.append(document.createElement('br'))
}
if (line) {
target.append(document.createTextNode(line))
}
})
}
export function appendComposerContents(target: DocumentFragment | HTMLElement, text: string) {
let cursor = 0
REF_RE.lastIndex = 0
for (const match of text.matchAll(REF_RE)) {
const index = match.index ?? 0
appendTextWithBreaks(target, text.slice(cursor, index))
target.append(refChipElement(match[1] || 'file', match[2] || ''))
cursor = index + match[0].length
}
appendTextWithBreaks(target, text.slice(cursor))
}
export function renderComposerContents(target: HTMLElement, text: string) {
target.replaceChildren()
appendComposerContents(target, text)
}
/** Serialize a draft string into chip-HTML for the contenteditable surface. */
export function composerHtml(text: string) {
let cursor = 0
let html = ''
REF_RE.lastIndex = 0
for (const match of text.matchAll(REF_RE)) {
const index = match.index ?? 0
html += escapeHtml(text.slice(cursor, index)).replace(/\n/g, '<br>')
html += refChipHtml(match[1] || 'file', match[2] || '')
cursor = index + match[0].length
}
return html + escapeHtml(text.slice(cursor)).replace(/\n/g, '<br>')
}
/** Walk a DOM subtree back to the plain `@kind:value` text it represents. */
export function composerPlainText(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent || ''
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return ''
}
const el = node as HTMLElement
if (el.dataset.refText) {
return el.dataset.refText
}
if (el.tagName === 'BR') {
return '\n'
}
const text = Array.from(node.childNodes).map(composerPlainText).join('')
const block = el.tagName === 'DIV' || el.tagName === 'P'
return block && text && el.dataset.slot !== RICH_INPUT_SLOT ? `${text}\n` : text
}
export function placeCaretEnd(element: HTMLElement) {
const range = document.createRange()
const selection = window.getSelection()
range.selectNodeContents(element)
range.collapse(false)
selection?.removeAllRanges()
selection?.addRange(range)
}
@@ -1,56 +0,0 @@
import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands'
import { triggerHaptic } from '@/lib/haptics'
import { useTheme } from '@/themes/context'
import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer'
interface SkinSlashPopoverProps {
draft: string
onSelect: (command: string) => void
}
export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) {
const { availableThemes, themeName } = useTheme()
const match = draft.match(/^\/skin\s+(\S*)$/i)
if (!match) {
return null
}
const items = desktopSkinSlashCompletions(availableThemes, themeName, match[1] ?? '')
return (
<div
aria-label="Desktop theme suggestions"
className={COMPLETION_DRAWER_CLASS}
data-slot="composer-skin-completion-drawer"
data-state="open"
role="listbox"
>
<div className="grid gap-0.5 pt-0.5">
{items.length === 0 ? (
<CompletionDrawerEmpty title="No matching themes.">
Try <span className="font-mono text-foreground/80">/skin list</span>.
</CompletionDrawerEmpty>
) : (
items.map(item => (
<button
className={COMPLETION_DRAWER_ROW_CLASS}
key={item.text}
onClick={() => {
triggerHaptic('selection')
onSelect(item.text)
}}
onMouseDown={event => event.preventDefault()}
role="option"
type="button"
>
<span className="shrink-0 font-mono font-medium leading-5 text-foreground">{item.display}</span>
<span className="min-w-0 truncate leading-5 text-muted-foreground/80">{item.meta}</span>
</button>
))
)}
</div>
</div>
)
}
@@ -1,73 +0,0 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { cn } from '@/lib/utils'
import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer'
interface ComposerTriggerPopoverProps {
activeIndex: number
items: readonly Unstable_TriggerItem[]
kind: '@' | '/'
loading: boolean
onHover: (index: number) => void
onPick: (item: Unstable_TriggerItem) => void
}
export function ComposerTriggerPopover({
activeIndex,
items,
kind,
loading,
onHover,
onPick
}: ComposerTriggerPopoverProps) {
return (
<div
className={COMPLETION_DRAWER_CLASS}
data-slot="composer-completion-drawer"
data-state="open"
onMouseDown={event => event.preventDefault()}
role="listbox"
>
{items.length === 0 ? (
<CompletionDrawerEmpty title={loading ? 'Looking up…' : 'No matches.'}>
{kind === '@' ? (
<>
Try <span className="font-mono text-foreground/80">@file:</span> or{' '}
<span className="font-mono text-foreground/80">@folder:</span>.
</>
) : (
<>
Try <span className="font-mono text-foreground/80">/help</span>.
</>
)}
</CompletionDrawerEmpty>
) : (
items.map((item, index) => {
const meta = item.metadata as { display?: string; meta?: string } | undefined
const display = meta?.display ?? (kind === '/' ? `/${item.label}` : item.label)
const description = meta?.meta || item.description
return (
<button
className={cn(
COMPLETION_DRAWER_ROW_CLASS,
index === activeIndex && 'bg-[color-mix(in_srgb,var(--dt-accent)_70%,transparent)]'
)}
data-highlighted={index === activeIndex ? '' : undefined}
key={item.id}
onClick={() => onPick(item)}
onMouseEnter={() => onHover(index)}
type="button"
>
<span className="shrink-0 truncate font-mono font-medium leading-5 text-foreground">{display}</span>
{description && (
<span className="min-w-0 truncate leading-5 text-muted-foreground/80">{description}</span>
)}
</button>
)
})
)}
</div>
)
}
@@ -1,58 +0,0 @@
import type { HermesGateway } from '@/hermes'
import type { DroppedFile } from '../hooks/use-composer-actions'
export interface ContextSuggestion {
text: string
display: string
meta?: string
}
export interface QuickModelOption {
provider: string
providerName: string
model: string
}
export interface ChatBarState {
model: {
model: string
provider: string
canSwitch: boolean
loading?: boolean
quickModels?: QuickModelOption[]
}
tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] }
voice: { enabled: boolean; active: boolean }
}
export interface ChatBarProps {
busy: boolean
disabled: boolean
focusKey?: string | null
maxRecordingSeconds?: number
state: ChatBarState
gateway?: HermesGateway | null
sessionId?: string | null
cwd?: string | null
onCancel: () => void
onAddContextRef?: (refText: string, label?: string, detail?: string) => void
onAddUrl?: (url: string) => void
onAttachImageBlob?: (blob: Blob) => Promise<boolean | void> | boolean | void
onAttachDroppedItems?: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
onPasteClipboardImage?: () => void
onPickFiles?: () => void
onPickFolders?: () => void
onPickImages?: () => void
onRemoveAttachment?: (id: string) => void
onSubmit: (value: string) => Promise<void> | void
onTranscribeAudio?: (audio: Blob) => Promise<string>
}
export type VoiceStatus = 'idle' | 'recording' | 'transcribing'
export interface VoiceActivityState {
elapsedSeconds: number
level: number
status: VoiceStatus
}
@@ -1,86 +0,0 @@
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Globe } from '@/lib/icons'
const URL_HINT = /^https?:\/\//i
export function UrlDialog({
inputRef,
onChange,
onOpenChange,
onSubmit,
open,
value
}: {
inputRef: React.RefObject<HTMLInputElement | null>
onChange: (value: string) => void
onOpenChange: (open: boolean) => void
onSubmit: () => void
open: boolean
value: string
}) {
const trimmed = value.trim()
const looksLikeUrl = trimmed.length > 0 && URL_HINT.test(trimmed)
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md gap-5">
<DialogHeader className="flex-row items-center gap-3 sm:items-center">
<span
aria-hidden
className="grid size-9 shrink-0 place-items-center rounded-xl bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15"
>
<Globe className="size-4" />
</span>
<div className="grid gap-0.5 text-left">
<DialogTitle>Attach a URL</DialogTitle>
<DialogDescription>Hermes will fetch the page and include it as context for this turn.</DialogDescription>
</div>
</DialogHeader>
<form
className="grid gap-4"
onSubmit={e => {
e.preventDefault()
onSubmit()
}}
>
<div className="grid gap-1.5">
<Input
autoComplete="off"
autoCorrect="off"
inputMode="url"
onChange={e => onChange(e.target.value)}
placeholder="https://example.com/post"
ref={inputRef}
spellCheck={false}
value={value}
/>
{trimmed.length > 0 && !looksLikeUrl && (
<p className="text-xs text-muted-foreground/85">
Include the full URL, e.g. <span className="font-mono">https://…</span>
</p>
)}
</div>
<DialogFooter>
<Button onClick={() => onOpenChange(false)} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={!looksLikeUrl} type="submit">
Attach
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -1,248 +0,0 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Loader2, Mic, Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { stopVoicePlayback } from '@/lib/voice-playback'
import { $voicePlayback } from '@/store/voice-playback'
import type { VoiceActivityState } from './types'
type BrowserAudioContext = typeof AudioContext
interface ElementAnalyser {
analyser: AnalyserNode
}
const elementAnalysers = new WeakMap<HTMLAudioElement, ElementAnalyser>()
let playbackAudioContext: AudioContext | null = null
function getPlaybackAudioContext(): AudioContext | null {
if (playbackAudioContext && playbackAudioContext.state !== 'closed') {
return playbackAudioContext
}
const audioWindow = window as Window & { webkitAudioContext?: BrowserAudioContext }
const AudioContextCtor = window.AudioContext || audioWindow.webkitAudioContext
if (!AudioContextCtor) {
return null
}
playbackAudioContext = new AudioContextCtor()
return playbackAudioContext
}
function formatElapsed(seconds: number) {
const safeSeconds = Math.max(0, Math.floor(seconds))
const minutes = Math.floor(safeSeconds / 60)
const remainingSeconds = safeSeconds % 60
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
}
function VoiceLevelBars({ level, active }: { active: boolean; level: number }) {
const normalized = Math.max(0, Math.min(level, 1))
const bars = [0.5, 0.78, 1, 0.78, 0.5]
return (
<div aria-hidden="true" className="flex h-4 items-center gap-0.5">
{bars.map((weight, index) => {
const height = active ? 0.25 + Math.min(0.68, normalized * weight) : 0.25
return (
<span
className={cn(
'w-0.5 rounded-full bg-current transition-[height,opacity] duration-100 ease-out',
active ? 'opacity-80' : 'animate-pulse opacity-45'
)}
key={index}
style={{ height: `${height * 100}%` }}
/>
)
})}
</div>
)
}
function getElementAnalyser(audioElement: HTMLAudioElement): ElementAnalyser | null {
let entry = elementAnalysers.get(audioElement)
if (!entry) {
const context = getPlaybackAudioContext()
if (!context) {
return null
}
const source = context.createMediaElementSource(audioElement)
const analyser = context.createAnalyser()
analyser.fftSize = 512
analyser.smoothingTimeConstant = 0.65
source.connect(analyser)
analyser.connect(context.destination)
entry = { analyser }
elementAnalysers.set(audioElement, entry)
}
void playbackAudioContext?.resume()
return entry
}
const WAVE_W = 88
const WAVE_H = 16
const BAR_W = 2
const BAR_GAP = 5
const STEP = BAR_W + BAR_GAP
const BARS = Math.floor((WAVE_W + BAR_GAP) / STEP)
const X0 = Math.round((WAVE_W - (BARS * STEP - BAR_GAP)) / 2)
function PlaybackWaveform({ audioElement }: { audioElement: HTMLAudioElement | null }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas || !audioElement) {
return
}
const entry = getElementAnalyser(audioElement)
const ctx = canvas.getContext('2d')
if (!entry || !ctx) {
return
}
const dpr = Math.max(1, window.devicePixelRatio || 1)
const { analyser } = entry
const buf = new Uint8Array(analyser.frequencyBinCount)
const hi = Math.floor(buf.length * 0.9)
canvas.width = Math.round(WAVE_W * dpr)
canvas.height = Math.round(WAVE_H * dpr)
canvas.style.width = `${WAVE_W}px`
canvas.style.height = `${WAVE_H}px`
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.imageSmoothingEnabled = false
ctx.fillStyle = getComputedStyle(canvas).color
let raf = 0
const tick = () => {
analyser.getByteFrequencyData(buf)
ctx.clearRect(0, 0, WAVE_W, WAVE_H)
for (let i = 0; i < BARS; i++) {
const a = Math.floor((i / BARS) * hi)
const b = Math.floor(((i + 1) / BARS) * hi)
let peak = 0
for (let j = a; j < b; j++) {
peak = Math.max(peak, buf[j] ?? 0)
}
const amp = Math.sqrt(peak / 255)
const bh = Math.max(3, Math.round((0.18 + amp * 0.82) * WAVE_H))
ctx.fillRect(X0 + i * STEP, Math.round((WAVE_H - bh) / 2), BAR_W, bh)
}
raf = requestAnimationFrame(tick)
}
tick()
return () => cancelAnimationFrame(raf)
}, [audioElement])
return <canvas aria-hidden="true" className="block h-4 w-[88px]" ref={canvasRef} />
}
export function VoiceActivity({ state }: { state: VoiceActivityState }) {
if (state.status === 'idle') {
return null
}
const recording = state.status === 'recording'
const title = recording ? 'Dictating' : 'Transcribing'
return (
<div
aria-live="polite"
className={cn(
'flex h-8 items-center gap-2 rounded-xl border border-border/55 bg-muted/55 px-2.5 text-xs text-muted-foreground',
'shadow-[inset_0_1px_0_rgba(255,255,255,0.35)] backdrop-blur-sm'
)}
role="status"
>
<div
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded-full',
recording ? 'bg-primary/15 text-primary' : 'bg-primary/10 text-primary'
)}
>
{recording ? <Mic size={12} /> : <Loader2 className="animate-spin" size={12} />}
</div>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate font-medium text-foreground/85">{title}</span>
<span className="font-mono text-[0.6875rem] text-muted-foreground/85">
{formatElapsed(state.elapsedSeconds)}
</span>
</div>
<VoiceLevelBars active={recording} level={state.level} />
</div>
)
}
export function VoicePlaybackActivity() {
const playback = useStore($voicePlayback)
if (playback.status === 'idle') {
return null
}
const preparing = playback.status === 'preparing'
const title = preparing
? 'Preparing audio'
: playback.source === 'voice-conversation'
? 'Speaking response'
: 'Reading aloud'
return (
<div
aria-live="polite"
className={cn(
'flex h-8 items-center gap-2 rounded-xl border border-primary/20 bg-primary/10 px-2.5 text-xs text-primary',
'shadow-[inset_0_1px_0_rgba(255,255,255,0.35)] backdrop-blur-sm'
)}
role="status"
>
<div className="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary">
{preparing ? <Loader2 className="animate-spin" size={12} /> : <Volume2 size={12} />}
</div>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate font-medium text-foreground/85">{title}</span>
{!preparing && <PlaybackWaveform audioElement={playback.audioElement} />}
</div>
<Button
className="h-6 shrink-0 gap-1 rounded-full px-2 text-[0.6875rem]"
onClick={stopVoicePlayback}
size="sm"
type="button"
variant="ghost"
>
<VolumeX size={12} />
Stop
</Button>
</div>
)
}
@@ -1,494 +0,0 @@
import { useCallback } from 'react'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
import { addComposerAttachment, type ComposerAttachment, removeComposerAttachment } from '@/store/composer'
import { notify, notifyError } from '@/store/notifications'
import type { ImageDetachResponse } from '../../types'
const IMAGE_EXTENSION_PATTERN = /\.(png|jpe?g|gif|webp|bmp|tiff?|svg|ico)$/i
const BLOB_MIME_EXTENSION: Record<string, string> = {
'image/bmp': '.bmp',
'image/gif': '.gif',
'image/jpeg': '.jpg',
'image/png': '.png',
'image/svg+xml': '.svg',
'image/tiff': '.tiff',
'image/webp': '.webp',
'image/x-icon': '.ico'
}
function blobExtension(blob: Blob): string {
const mime = blob.type.split(';')[0]?.trim().toLowerCase()
return (mime && BLOB_MIME_EXTENSION[mime]) || '.png'
}
function isImagePath(filePath: string): boolean {
return IMAGE_EXTENSION_PATTERN.test(filePath)
}
export interface DroppedFile {
/** Browser-native File handle. Absent for in-app drags (e.g. project tree). */
file?: File
/** Absolute filesystem path. Empty when an OS drop didn't carry one. */
path: string
/** True if the entry is a directory. Currently only set by in-app drags. */
isDirectory?: boolean
/** First line number for in-app line-ref drags (source view gutter). */
line?: number
/** Last line number for line-range drags (`line..lineEnd` inclusive). */
lineEnd?: number
}
/** MIME emitted by in-app drag sources (project tree, gutter line numbers).
* Payload is JSON `{ path; isDirectory?; line?; lineEnd? }[]`. */
export const HERMES_PATHS_MIME = 'application/x-hermes-paths'
/**
* Eagerly resolve files from a drop event into [File?, path, isDirectory?]
* triples. Internal Hermes sources (e.g. the project tree) ride on a custom
* MIME and produce path-only entries; OS drops produce File-bearing entries.
*
* Must be called synchronously from inside the drop handler — `DataTransfer`
* items are detached as soon as the handler returns, and `webUtils.getPathForFile`
* also requires the original (non-cloned) File reference.
*/
export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
const result: DroppedFile[] = []
const seenPaths = new Set<string>()
const seenFiles = new Set<File>()
const getPath = window.hermesDesktop?.getPathForFile
// In-app drags first — they carry richer metadata (isDirectory) than the
// File-based fallback can provide, and produce no overlapping native files.
try {
const internalRaw = transfer.getData(HERMES_PATHS_MIME)
if (internalRaw) {
const parsed = JSON.parse(internalRaw) as {
path?: unknown
isDirectory?: unknown
line?: unknown
lineEnd?: unknown
}[]
const positiveInt = (value: unknown) => (typeof value === 'number' && value > 0 ? Math.floor(value) : undefined)
for (const entry of parsed) {
if (!entry || typeof entry.path !== 'string' || !entry.path) {
continue
}
const line = positiveInt(entry.line)
const rawEnd = positiveInt(entry.lineEnd)
const lineEnd = line && rawEnd && rawEnd > line ? rawEnd : undefined
const dedupKey = line ? `${entry.path}:${line}-${lineEnd ?? line}` : entry.path
if (seenPaths.has(dedupKey)) {
continue
}
seenPaths.add(dedupKey)
result.push({ isDirectory: entry.isDirectory === true, line, lineEnd, path: entry.path })
}
}
} catch {
// Malformed payload — fall through to native files.
}
const fileList = transfer.files
if (fileList) {
for (let i = 0; i < fileList.length; i += 1) {
const file = fileList.item(i)
if (!file || seenFiles.has(file)) {
continue
}
seenFiles.add(file)
let path = ''
if (getPath) {
try {
path = getPath(file) || ''
} catch {
path = ''
}
}
if (path && seenPaths.has(path)) {
continue
}
if (path) {
seenPaths.add(path)
}
result.push({ file, path })
}
}
const items = transfer.items
if (items) {
for (let i = 0; i < items.length; i += 1) {
const item = items[i]
if (!item || item.kind !== 'file') {
continue
}
const file = item.getAsFile()
if (!file || seenFiles.has(file)) {
continue
}
seenFiles.add(file)
let path = ''
if (getPath) {
try {
path = getPath(file) || ''
} catch {
path = ''
}
}
if (path && seenPaths.has(path)) {
continue
}
if (path) {
seenPaths.add(path)
}
result.push({ file, path })
}
}
return result
}
interface ComposerActionsOptions {
activeSessionId: string | null
currentCwd: string
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
const addContextRefAttachment = useCallback((refText: string, label?: string, detail?: string) => {
let kind: ComposerAttachment['kind'] = 'file'
if (refText.startsWith('@folder:')) {
kind = 'folder'
}
if (refText.startsWith('@url:')) {
kind = 'url'
}
addComposerAttachment({
id: attachmentId(kind, refText),
kind,
label: label || refText.replace(/^@(file|folder|url):/, ''),
detail,
refText
})
}, [])
const pickContextPaths = useCallback(
async (kind: 'file' | 'folder') => {
const paths = await window.hermesDesktop?.selectPaths({
title: kind === 'file' ? 'Add files as context' : 'Add folders as context',
defaultPath: currentCwd || undefined,
directories: kind === 'folder'
})
if (!paths?.length) {
return
}
for (const path of paths) {
const rel = contextPath(path, currentCwd)
addComposerAttachment({
id: attachmentId(kind, rel),
kind,
label: pathLabel(path),
detail: rel,
refText: `@${kind}:${formatRefValue(rel)}`,
path
})
}
},
[currentCwd]
)
const attachContextFilePath = useCallback(
(filePath: string) => {
if (!filePath) {
return false
}
const rel = contextPath(filePath, currentCwd)
addComposerAttachment({
id: attachmentId('file', rel),
kind: 'file',
label: pathLabel(filePath),
detail: rel,
refText: `@file:${formatRefValue(rel)}`,
path: filePath
})
return true
},
[currentCwd]
)
const attachImagePath = useCallback(async (filePath: string) => {
if (!filePath) {
return false
}
const baseAttachment: ComposerAttachment = {
id: attachmentId('image', filePath),
kind: 'image',
label: pathLabel(filePath),
detail: filePath,
path: filePath
}
addComposerAttachment(baseAttachment)
try {
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
if (previewUrl) {
addComposerAttachment({ ...baseAttachment, previewUrl })
}
return true
} catch (err) {
notifyError(err, 'Image preview failed')
return true
}
}, [])
const attachImageBlob = useCallback(
async (blob: Blob) => {
if (blob.size === 0) {
return false
}
if (blob.type && !blob.type.startsWith('image/')) {
return false
}
try {
const buffer = await blob.arrayBuffer()
const data = new Uint8Array(buffer)
const savedPath = await window.hermesDesktop?.saveImageBuffer(data, blobExtension(blob))
if (!savedPath) {
notify({ kind: 'error', title: 'Image attach', message: 'Failed to write image to disk.' })
return false
}
return attachImagePath(savedPath)
} catch (err) {
notifyError(err, 'Image attach failed')
return false
}
},
[attachImagePath]
)
const pickImages = useCallback(async () => {
const paths = await window.hermesDesktop?.selectPaths({
title: 'Attach images',
defaultPath: currentCwd || undefined,
filters: [
{
name: 'Images',
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff']
}
]
})
if (!paths?.length) {
return
}
for (const path of paths) {
await attachImagePath(path)
}
}, [attachImagePath, currentCwd])
const pasteClipboardImage = useCallback(async () => {
try {
const path = await window.hermesDesktop?.saveClipboardImage()
if (!path) {
notify({
kind: 'warning',
title: 'Clipboard',
message: 'No image found in clipboard'
})
return
}
await attachImagePath(path)
} catch (err) {
notifyError(err, 'Clipboard paste failed')
}
}, [attachImagePath])
const attachContextFolderPath = useCallback(
(folderPath: string) => {
if (!folderPath) {
return false
}
const rel = contextPath(folderPath, currentCwd)
addComposerAttachment({
id: attachmentId('folder', rel),
kind: 'folder',
label: pathLabel(folderPath),
detail: rel,
refText: `@folder:${formatRefValue(rel)}`,
path: folderPath
})
return true
},
[currentCwd]
)
const attachDroppedItems = useCallback(
async (candidates: DroppedFile[]) => {
if (candidates.length === 0) {
return false
}
let attached = false
let lastFailure: string | null = null
for (const candidate of candidates) {
const { file, isDirectory, path: knownPath } = candidate
// Path-only entry (in-app drag from the file browser tree, etc.).
if (!file) {
if (isDirectory) {
if (knownPath && attachContextFolderPath(knownPath)) {
attached = true
continue
}
lastFailure = `Could not attach folder ${knownPath || ''}`
continue
}
if (knownPath && isImagePath(knownPath)) {
if (await attachImagePath(knownPath)) {
attached = true
continue
}
lastFailure = `Could not attach ${knownPath}`
continue
}
if (knownPath && attachContextFilePath(knownPath)) {
attached = true
continue
}
lastFailure = `Could not attach ${knownPath || 'file'}`
continue
}
const fallbackPath =
!knownPath && window.hermesDesktop?.getPathForFile ? window.hermesDesktop.getPathForFile(file) : ''
const filePath = knownPath || fallbackPath || ''
const isImage = file.type.startsWith('image/') || isImagePath(file.name) || (filePath && isImagePath(filePath))
if (isImage) {
if ((filePath && (await attachImagePath(filePath))) || (await attachImageBlob(file))) {
attached = true
continue
}
lastFailure = `Could not attach ${file.name || 'image'}`
continue
}
if (filePath && attachContextFilePath(filePath)) {
attached = true
continue
}
lastFailure = `Could not attach ${file.name || 'file'}`
}
if (!attached && lastFailure) {
notify({ kind: 'warning', title: 'Drop files', message: lastFailure })
}
return attached
},
[attachContextFilePath, attachContextFolderPath, attachImageBlob, attachImagePath]
)
const removeAttachment = useCallback(
async (id: string) => {
const removed = removeComposerAttachment(id)
if (
removed?.kind === 'image' &&
removed.path &&
activeSessionId &&
removed.attachedSessionId &&
removed.attachedSessionId === activeSessionId
) {
await requestGateway<ImageDetachResponse>('image.detach', {
session_id: activeSessionId,
path: removed.path
}).catch(() => undefined)
}
},
[activeSessionId, requestGateway]
)
return {
addContextRefAttachment,
attachContextFilePath,
attachDroppedItems,
attachImageBlob,
attachImagePath,
pasteClipboardImage,
pickContextPaths,
pickImages,
removeAttachment
}
}
-319
View File
@@ -1,319 +0,0 @@
import {
type AppendMessage,
AssistantRuntimeProvider,
ExportedMessageRepository,
type ThreadMessage,
useExternalStoreRuntime
} from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import type * as React from 'react'
import { Suspense, useMemo, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { Thread } from '@/components/assistant-ui/thread'
import { NotificationStack } from '@/components/notifications'
import { Button } from '@/components/ui/button'
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
import type { ChatMessage } from '@/lib/chat-messages'
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
import { ChevronDown } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $pinnedSessionIds } from '@/store/layout'
import {
$activeSessionId,
$awaitingResponse,
$busy,
$contextSuggestions,
$currentCwd,
$currentModel,
$currentProvider,
$freshDraftReady,
$gatewayState,
$introPersonality,
$introSeed,
$messages,
$selectedStoredSessionId,
$sessions
} from '@/store/session'
import type { ModelOptionsResponse } from '@/types/hermes'
import { routeSessionId } from '../routes'
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
import { ChatBar, ChatBarFallback } from './composer'
import type { ChatBarState } from './composer/types'
import type { DroppedFile } from './hooks/use-composer-actions'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
gateway: HermesGateway | null
onToggleSelectedPin: () => void
onDeleteSelectedSession: () => void
onCancel: () => void
onAddContextRef: (refText: string, label?: string, detail?: string) => void
onAddUrl: (url: string) => void
onBranchInNewChat: (messageId: string) => void
maxVoiceRecordingSeconds?: number
onAttachImageBlob: (blob: Blob) => Promise<boolean | void> | boolean | void
onAttachDroppedItems: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
onPasteClipboardImage: () => void
onPickFiles: () => void
onPickFolders: () => void
onPickImages: () => void
onRemoveAttachment: (id: string) => void
onSubmit: (text: string) => Promise<void> | void
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void>
onTranscribeAudio?: (audio: Blob) => Promise<string>
}
function threadLoadingState(
loadingSession: boolean,
busy: boolean,
awaitingResponse: boolean,
lastMessageIsUser: boolean
) {
if (loadingSession) {
return 'session'
}
// Only show the response spinner when we're actually waiting for an
// assistant reply to a user message. Previously any `busy && awaiting`
// window showed the spinner — including the brief gateway-hydration blip
// right after a session resume, which produced a visible flicker chain:
// session spinner → response spinner → content.
// Gating on `lastMessageIsUser` means the spinner only appears when the
// user actually just sent something and there's no assistant reply yet.
if (busy && awaitingResponse && lastMessageIsUser) {
return 'response'
}
return undefined
}
export function ChatView({
className,
gateway,
onToggleSelectedPin,
onDeleteSelectedSession,
onCancel,
onAddContextRef,
onAddUrl,
onAttachImageBlob,
onAttachDroppedItems,
onBranchInNewChat,
maxVoiceRecordingSeconds,
onPasteClipboardImage,
onPickFiles,
onPickFolders,
onPickImages,
onRemoveAttachment,
onSubmit,
onThreadMessagesChange,
onEdit,
onReload,
onTranscribeAudio
}: ChatViewProps) {
const location = useLocation()
const activeSessionId = useStore($activeSessionId)
const awaitingResponse = useStore($awaitingResponse)
const busy = useStore($busy)
const contextSuggestions = useStore($contextSuggestions)
const currentCwd = useStore($currentCwd)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const freshDraftReady = useStore($freshDraftReady)
const gatewayState = useStore($gatewayState)
const gatewayOpen = gatewayState === 'open'
const introPersonality = useStore($introPersonality)
const introSeed = useStore($introSeed)
const messages = useStore($messages)
const pinnedSessionIds = useStore($pinnedSessionIds)
const selectedSessionId = useStore($selectedStoredSessionId)
const sessions = useStore($sessions)
const runtimeMessageCacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>())
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
const isRoutedSessionView = Boolean(routeSessionId(location.pathname))
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
const showIntro =
freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messages.length === 0
// Session is still loading if the route references a session we haven't
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
// session exists — even if it has zero messages (a brand-new routed
// session). The flicker where `busy` flips true briefly during hydrate
// is handled by `threadLoadingState`'s `lastMessageIsUser` gate.
const loadingSession = isRoutedSessionView && messages.length === 0 && !activeSessionId
const lastMessageIsUser = messages.at(-1)?.role === 'user'
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastMessageIsUser)
const showChatBar = !loadingSession
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
const title = activeStoredSession ? sessionTitle(activeStoredSession) : ''
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
queryKey: ['model-options', activeSessionId || 'global'],
queryFn: () => {
if (!activeSessionId) {
return getGlobalModelOptions()
}
if (!gateway) {
throw new Error('Hermes gateway unavailable')
}
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
},
enabled: gatewayOpen
})
const quickModels = useMemo(
() => quickModelOptions(modelOptionsQuery.data, currentProvider, currentModel),
[currentModel, currentProvider, modelOptionsQuery.data]
)
const chatBarState = useMemo<ChatBarState>(
() => ({
model: {
model: currentModel,
provider: currentProvider,
canSwitch: gatewayOpen,
loading: !gatewayOpen || (!currentModel && !currentProvider),
quickModels
},
tools: {
enabled: true,
label: 'Add context',
suggestions: contextSuggestions
},
voice: {
enabled: true,
active: false
}
}),
[contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels]
)
const runtimeMessageRepository = useMemo(() => {
const items: { message: ThreadMessage; parentId: string | null }[] = []
const branchParentByGroup = new Map<string, string | null>()
let visibleParentId: string | null = null
let headId: string | null = null
for (const message of messages) {
let parentId = visibleParentId
if (message.role === 'assistant' && message.branchGroupId) {
if (!branchParentByGroup.has(message.branchGroupId)) {
branchParentByGroup.set(message.branchGroupId, visibleParentId)
}
parentId = branchParentByGroup.get(message.branchGroupId) ?? null
}
const cachedMessage = runtimeMessageCacheRef.current.get(message)
const runtimeMessage = cachedMessage ?? toRuntimeMessage(message)
if (!cachedMessage) {
runtimeMessageCacheRef.current.set(message, runtimeMessage)
}
items.push({ message: runtimeMessage, parentId })
if (!message.hidden) {
visibleParentId = message.id
headId = message.id
}
}
return ExportedMessageRepository.fromBranchableArray(items, { headId })
}, [messages])
const runtime = useExternalStoreRuntime<ThreadMessage>({
messageRepository: runtimeMessageRepository,
isRunning: busy,
setMessages: onThreadMessagesChange,
onNew: async () => {
// Submission is handled explicitly by ChatBar.
// Keeping this no-op avoids duplicate prompt.submit calls.
},
onEdit,
onCancel: async () => onCancel(),
onReload
})
return (
<div
className={cn(
'relative flex h-full min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent',
className
)}
>
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div className="min-w-0 flex-1">
{title && (
<SessionActionsMenu
align="start"
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
onPin={selectedSessionId ? onToggleSelectedPin : undefined}
pinned={selectedIsPinned}
sessionId={selectedSessionId || activeSessionId || ''}
sideOffset={8}
title={title}
>
<Button
className="pointer-events-auto h-7 min-w-0 gap-1.5 rounded-lg px-1 py-0 text-foreground hover:bg-accent/70 data-[state=open]:bg-accent/70 [-webkit-app-region:no-drag]"
type="button"
variant="ghost"
>
<h2 className="max-w-[62vw] truncate text-base font-semibold leading-none tracking-tight">{title}</h2>
<ChevronDown className="shrink-0 text-foreground/75" size={16} />
</Button>
</SessionActionsMenu>
)}
</div>
</header>
<NotificationStack />
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden rounded-[1.0625rem] bg-transparent contain-[layout_paint]">
<AssistantRuntimeProvider runtime={runtime}>
<Thread
intro={showIntro ? { personality: introPersonality, seed: introSeed } : undefined}
loading={threadLoading}
onBranchInNewChat={onBranchInNewChat}
sessionKey={threadKey}
/>
{showChatBar && (
<Suspense fallback={<ChatBarFallback />}>
<ChatBar
busy={busy}
cwd={currentCwd}
disabled={!gatewayOpen}
focusKey={activeSessionId}
gateway={gateway}
maxRecordingSeconds={maxVoiceRecordingSeconds}
onAddContextRef={onAddContextRef}
onAddUrl={onAddUrl}
onAttachDroppedItems={onAttachDroppedItems}
onAttachImageBlob={onAttachImageBlob}
onCancel={onCancel}
onPasteClipboardImage={onPasteClipboardImage}
onPickFiles={onPickFiles}
onPickFolders={onPickFolders}
onPickImages={onPickImages}
onRemoveAttachment={onRemoveAttachment}
onSubmit={onSubmit}
onTranscribeAudio={onTranscribeAudio}
sessionId={activeSessionId}
state={chatBarState}
/>
</Suspense>
)}
</AssistantRuntimeProvider>
</div>
</div>
)
}
@@ -1 +0,0 @@
export { ChatPreviewRail, PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH, PREVIEW_RAIL_PANE_WIDTH } from './preview'
@@ -1,82 +0,0 @@
import { atom, computed } from 'nanostores'
type Updater<T> = T | ((current: T) => T)
interface WritableStore<T> {
get: () => T
set: (value: T) => void
}
const DEFAULT_CONSOLE_HEIGHT = 240
export interface ConsoleEntry {
id: number
level: number
line?: number
message: string
source?: string
}
export interface ConsoleEntryInput {
level: number
line?: number
message: string
source?: string
}
function updateAtom<T>(store: WritableStore<T>, next: Updater<T>) {
store.set(typeof next === 'function' ? (next as (current: T) => T)(store.get()) : next)
}
export function createPreviewConsoleState() {
const $height = atom(DEFAULT_CONSOLE_HEIGHT)
const $logs = atom<ConsoleEntry[]>([])
const $logCount = computed($logs, logs => logs.length)
const $open = atom(false)
const $selectedLogIds = atom<ReadonlySet<number>>(new Set())
let nextLogId = 0
return {
$height,
$logCount,
$logs,
$open,
$selectedLogIds,
append(entry: ConsoleEntryInput) {
$logs.set([...$logs.get().slice(-199), { ...entry, id: ++nextLogId }])
},
clear() {
$logs.set([])
$selectedLogIds.set(new Set())
},
clearSelection() {
if ($selectedLogIds.get().size === 0) {
return
}
$selectedLogIds.set(new Set())
},
reset() {
nextLogId = 0
$logs.set([])
$selectedLogIds.set(new Set())
},
setHeight(next: Updater<number>) {
updateAtom($height, next)
},
setOpen(next: Updater<boolean>) {
updateAtom($open, next)
},
toggleSelection(id: number) {
const next = new Set($selectedLogIds.get())
if (!next.delete(id)) {
next.add(id)
}
$selectedLogIds.set(next)
}
}
}
export type PreviewConsoleState = ReturnType<typeof createPreviewConsoleState>
@@ -1,44 +0,0 @@
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PreviewPane } from './preview-pane'
describe('PreviewPane console state', () => {
afterEach(() => {
cleanup()
})
it('does not rebuild the pane titlebar group for streamed console logs', () => {
const setTitlebarToolGroup = vi.fn()
const rendered = render(
<PreviewPane
onClose={vi.fn()}
setTitlebarToolGroup={setTitlebarToolGroup}
target={{
kind: 'url',
label: 'Preview',
source: 'http://localhost:5174',
url: 'http://localhost:5174'
}}
/>
)
const initialCalls = setTitlebarToolGroup.mock.calls.length
const webview = rendered.container.querySelector('webview')
expect(webview).toBeInstanceOf(HTMLElement)
act(() => {
webview?.dispatchEvent(
Object.assign(new Event('console-message'), {
level: 0,
message: 'streamed log line',
sourceId: 'http://localhost:5174/src/main.tsx'
})
)
})
expect(setTitlebarToolGroup).toHaveBeenCalledTimes(initialCalls)
})
})
File diff suppressed because it is too large Load Diff
@@ -1,140 +0,0 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo } from 'react'
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
import { X } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$rightRailActiveTabId,
RIGHT_RAIL_PREVIEW_TAB_ID,
type RightRailTabId,
selectRightRailTab
} from '@/store/layout'
import {
$filePreviewTabs,
$previewReloadRequest,
$previewTarget,
closeActiveRightRailTab,
closeRightRailTab,
type PreviewTarget
} from '@/store/preview'
import { PreviewPane } from './preview-pane'
export const PREVIEW_RAIL_MIN_WIDTH = '18rem'
export const PREVIEW_RAIL_MAX_WIDTH = '38rem'
const INTRINSIC = `clamp(${PREVIEW_RAIL_MIN_WIDTH}, 36vw, 32rem)`
// Track for <Pane id="preview">. Folds the intrinsic clamp with a min-floor
// against --chat-min-width so the chat surface never gets squeezed below it.
// Subtracts the project browser width so preview yields rather than crushing
// the chat when both right-side panes are open.
export const PREVIEW_RAIL_PANE_WIDTH = `min(${INTRINSIC}, max(0px, calc(100vw - var(--pane-chat-sidebar-width) - var(--pane-file-browser-width, 0px) - var(--chat-min-width))))`
interface ChatPreviewRailProps {
onRestartServer?: (url: string, context?: string) => Promise<string>
setTitlebarToolGroup?: SetTitlebarToolGroup
}
interface RailTab {
id: RightRailTabId
label: string
target: PreviewTarget
}
function tabLabelFor(target: PreviewTarget): string {
const value = target.label || target.path || target.source || target.url
const tail = value.split(/[\\/]/).filter(Boolean).at(-1)
return tail || value || 'Preview'
}
export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatPreviewRailProps) {
const previewReloadRequest = useStore($previewReloadRequest)
const activeTabId = useStore($rightRailActiveTabId)
const filePreviewTabs = useStore($filePreviewTabs)
const previewTarget = useStore($previewTarget)
const tabs = useMemo<readonly RailTab[]>(
() => [
...(previewTarget ? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: 'Preview', target: previewTarget } as RailTab] : []),
...filePreviewTabs.map(({ id, target }) => ({ id, label: tabLabelFor(target), target }) as RailTab)
],
[filePreviewTabs, previewTarget]
)
const activeTab = tabs.find(tab => tab.id === activeTabId) ?? tabs[0]
useEffect(() => {
if (activeTab && activeTab.id !== activeTabId) {
selectRightRailTab(activeTab.id)
}
}, [activeTab, activeTabId])
if (!activeTab) {
return null
}
const isPreview = activeTab.id === RIGHT_RAIL_PREVIEW_TAB_ID
return (
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-background text-muted-foreground">
<div
className="flex h-(--titlebar-height) shrink-0 overflow-x-auto overflow-y-hidden overscroll-x-contain border-b border-border/60 bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_94%,transparent)] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="tablist"
>
{tabs.map(tab => {
const active = tab.id === activeTab.id
return (
<div
className={cn(
'group/tab relative flex h-full max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag]',
active
? 'bg-background text-foreground'
: 'border-r border-border/40 text-muted-foreground hover:bg-accent/30 hover:text-foreground'
)}
key={tab.id}
>
{active && <span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-primary/70" />}
<button
aria-selected={active}
className="flex h-full min-w-0 flex-1 items-center truncate pl-3 pr-1.5 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
title={tab.label}
type="button"
>
{tab.label}
</button>
<button
aria-label={`Close ${tab.label}`}
className={cn(
'mr-1.5 hidden size-4 shrink-0 place-items-center rounded-sm text-muted-foreground/55 transition-colors hover:bg-accent hover:text-foreground focus-visible:grid group-hover/tab:grid',
active && 'grid'
)}
onClick={() => closeRightRailTab(tab.id)}
title={`Close ${tab.label}`}
type="button"
>
<X className="size-3" />
</button>
</div>
)
})}
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<PreviewPane
embedded
onClose={closeActiveRightRailTab}
onRestartServer={isPreview ? onRestartServer : undefined}
reloadRequest={previewReloadRequest}
setTitlebarToolGroup={setTitlebarToolGroup}
target={activeTab.target}
/>
</div>
</aside>
)
}
-285
View File
@@ -1,285 +0,0 @@
import { useStore } from '@nanostores/react'
import { useMemo } from 'react'
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem
} from '@/components/ui/sidebar'
import { Skeleton } from '@/components/ui/skeleton'
import type { SessionInfo } from '@/hermes'
import { Brain, ChevronDown, Command, Layers3, Pin, Plus, RefreshCw, Settings } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$pinnedSessionIds,
$sidebarOpen,
$sidebarPinsOpen,
$sidebarRecentsOpen,
pinSession,
setSidebarPinsOpen,
setSidebarRecentsOpen,
unpinSession
} from '@/store/layout'
import { $selectedStoredSessionId, $sessions, $sessionsLoading, $workingSessionIds } from '@/store/session'
import { type AppView, ARTIFACTS_ROUTE, COMMAND_CENTER_ROUTE, SETTINGS_ROUTE, SKILLS_ROUTE } from '../../routes'
import type { SidebarNavItem } from '../../types'
import { SidebarSessionRow } from './session-row'
const SIDEBAR_NAV: SidebarNavItem[] = [
{
id: 'new-session',
label: 'New chat',
icon: Plus,
action: 'new-session'
},
{ id: 'command-center', label: 'Command Center', icon: Command, route: COMMAND_CENTER_ROUTE },
{ id: 'skills', label: 'Skills', icon: Brain, route: SKILLS_ROUTE },
{ id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE },
{ id: 'settings', label: 'Settings', icon: Settings, route: SETTINGS_ROUTE }
]
const sidebarNavItemClass =
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-sm font-medium text-muted-foreground transition-colors duration-300 ease-out hover:border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] hover:bg-[color-mix(in_srgb,var(--dt-card)_78%,transparent)] hover:text-foreground hover:transition-none'
const sidebarNavItemActiveClass =
'border-[color-mix(in_srgb,var(--dt-primary)_34%,var(--dt-border))] bg-[color-mix(in_srgb,var(--dt-primary)_10%,var(--dt-card))] text-foreground shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_40%,transparent)]'
interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
currentView: AppView
onNavigate: (item: SidebarNavItem) => void
onRefreshSessions: () => void
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
}
export function ChatSidebar({
currentView,
onNavigate,
onRefreshSessions,
onResumeSession,
onDeleteSession
}: ChatSidebarProps) {
const sidebarOpen = useStore($sidebarOpen)
const pinnedSessionIds = useStore($pinnedSessionIds)
const pinsOpen = useStore($sidebarPinsOpen)
const recentsOpen = useStore($sidebarRecentsOpen)
const selectedSessionId = useStore($selectedStoredSessionId)
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
const sessions = useStore($sessions)
const sessionsLoading = useStore($sessionsLoading)
const workingSessionIds = useStore($workingSessionIds)
const sortedSessions = useMemo(
() =>
[...sessions].sort((a, b) => {
const aTime = a.last_active || a.started_at || 0
const bTime = b.last_active || b.started_at || 0
return bTime - aTime
}),
[sessions]
)
const sessionsById = useMemo(() => new Map(sessions.map(session => [session.id, session])), [sessions])
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])
const visiblePinnedIds = pinnedSessionIds.filter(id => sessionsById.has(id))
const visiblePinnedIdSet = new Set(visiblePinnedIds)
const pinnedSessions = visiblePinnedIds
.map(id => sessionsById.get(id))
.filter((session): session is SessionInfo => Boolean(session))
const recentSessions = sortedSessions.filter(session => !visiblePinnedIdSet.has(session.id))
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
return (
<Sidebar
className={cn(
'relative h-full min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground transition-none [backdrop-filter:blur(1.5rem)_saturate(1.08)]',
sidebarOpen
? 'border-(--sidebar-edge-border) bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_97%,transparent)] opacity-100'
: 'pointer-events-none border-transparent bg-transparent opacity-0'
)}
collapsible="none"
>
<SidebarContent className="gap-0 overflow-hidden bg-transparent">
<SidebarGroup className="shrink-0 pl-4 pr-2 pb-2 pt-[calc(var(--titlebar-height)+0.25rem)]">
<SidebarGroupLabel className="h-auto px-2 pb-1 pt-1 text-[0.64rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/70">
Workspace
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="gap-px">
{SIDEBAR_NAV.map(item => {
const isInteractive = Boolean(item.action) || Boolean(item.route)
const active =
(item.id === 'command-center' && currentView === 'command-center') ||
(item.id === 'settings' && currentView === 'settings') ||
(item.id === 'skills' && currentView === 'skills') ||
(item.id === 'artifacts' && currentView === 'artifacts')
return (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
aria-disabled={!isInteractive}
className={cn(
sidebarNavItemClass,
active && sidebarNavItemActiveClass,
!isInteractive &&
'cursor-default hover:border-transparent hover:bg-transparent hover:text-muted-foreground'
)}
onClick={() => onNavigate(item)}
tooltip={item.label}
type="button"
>
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
{sidebarOpen && <span className="max-[46.25rem]:hidden">{item.label}</span>}
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{sidebarOpen && showSessionSections && (
<SidebarGroup className="shrink-0 pl-4 pr-2 pb-1 pt-0">
<SidebarSectionHeader label="Pinned" onToggle={() => setSidebarPinsOpen(!pinsOpen)} open={pinsOpen} />
{pinsOpen && (
<SidebarGroupContent className="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1">
{pinnedSessions.length === 0 && (
<div className="flex min-h-8 items-center gap-2 rounded-lg px-2 text-xs text-muted-foreground/80">
<Pin size={14} />
<span>Pin important chats from the menu</span>
</div>
)}
{pinnedSessions.map(session => (
<SidebarSessionRow
isPinned
isSelected={session.id === activeSidebarSessionId}
isWorking={workingSessionIdSet.has(session.id)}
key={session.id}
onDelete={() => onDeleteSession(session.id)}
onPin={() => unpinSession(session.id)}
onResume={() => onResumeSession(session.id)}
session={session}
/>
))}
</SidebarGroupContent>
)}
</SidebarGroup>
)}
{sidebarOpen && showSessionSections && (
<SidebarGroup className="min-h-0 flex-1 pl-4 pr-2 py-0">
<SidebarSectionHeader
action={
<Button
aria-label={sessionsLoading ? 'Refreshing sessions' : 'Refresh sessions'}
className="size-4 rounded-sm p-0 text-muted-foreground opacity-10 hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 disabled:opacity-35 [&_svg]:size-3!"
disabled={sessionsLoading}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
onRefreshSessions()
}}
size="icon-xs"
variant="ghost"
>
<RefreshCw className={cn(sessionsLoading && 'animate-spin')} />
</Button>
}
label="Recent chats"
onToggle={() => setSidebarRecentsOpen(!recentsOpen)}
open={recentsOpen}
/>
{recentsOpen && (
<SidebarGroupContent className="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75">
{showSessionSkeletons && <SidebarSessionSkeletons />}
{!showSessionSkeletons && recentSessions.length === 0 && <SidebarAllPinnedState />}
{recentSessions.map(session => (
<SidebarSessionRow
isPinned={false}
isSelected={session.id === activeSidebarSessionId}
isWorking={workingSessionIdSet.has(session.id)}
key={session.id}
onDelete={() => onDeleteSession(session.id)}
onPin={() => pinSession(session.id)}
onResume={() => onResumeSession(session.id)}
session={session}
/>
))}
</SidebarGroupContent>
)}
</SidebarGroup>
)}
</SidebarContent>
</Sidebar>
)
}
interface SidebarSectionHeaderProps extends React.ComponentProps<'div'> {
label: string
open: boolean
onToggle: () => void
action?: React.ReactNode
}
function SidebarSectionHeader({ label, open, onToggle, action }: SidebarSectionHeaderProps) {
return (
<div className="flex shrink-0 items-center justify-between px-2 pb-1 pt-1.5">
<SidebarGroupLabel asChild className="h-auto p-0 text-muted-foreground">
<button
className="group/section-label flex w-fit items-center gap-1 bg-transparent text-left text-xs font-bold leading-none"
onClick={onToggle}
type="button"
>
<span className="text-xs font-semibold uppercase leading-none">{label}</span>
<ChevronDown
className={cn('size-3 opacity-0 transition group-hover/section-label:opacity-100', !open && '-rotate-90')}
/>
</button>
</SidebarGroupLabel>
{action}
</div>
)
}
function SidebarSessionSkeletons() {
const widths = ['w-32', 'w-40', 'w-28', 'w-36', 'w-24']
return (
<div aria-hidden="true" className="grid gap-px">
{widths.map((width, index) => (
<div
className="grid min-h-7 grid-cols-[minmax(0,1fr)_1.5rem] items-center rounded-lg px-2"
key={`${width}-${index}`}
>
<Skeleton className={cn('h-3.5 rounded-full', width)} />
<Skeleton className="mx-auto size-4 rounded-md opacity-60" />
</div>
))}
</div>
)
}
function SidebarAllPinnedState() {
return (
<div className="grid min-h-24 place-items-center rounded-lg px-3 text-center text-xs text-muted-foreground">
Everything here is pinned. Unpin a chat to show it in recents.
</div>
)
}
@@ -1,204 +0,0 @@
import { IconBookmark, IconBookmarkFilled, IconCircleX, IconFileDownload, IconPencil } from '@tabler/icons-react'
import { useEffect, useRef, useState } from 'react'
import type * as React from 'react'
import type { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { CopyButton } from '@/components/ui/copy-button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { renameSession } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
import { exportSession } from '@/lib/session-export'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { setSessions } from '@/store/session'
interface SessionActionsMenuProps extends Pick<
React.ComponentProps<typeof DropdownMenuContent>,
'align' | 'sideOffset'
> {
children: ReactNode
title: string
sessionId: string
pinned?: boolean
onPin?: () => void
onDelete?: () => void
}
export function SessionActionsMenu({
children,
title,
sessionId,
pinned = false,
onPin,
onDelete,
align = 'end',
sideOffset = 6
}: SessionActionsMenuProps) {
const itemClass = 'gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4'
const [renameOpen, setRenameOpen] = useState(false)
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent align={align} aria-label={`Actions for ${title}`} className="w-44" sideOffset={sideOffset}>
<DropdownMenuItem
className={itemClass}
disabled={!onPin}
onSelect={() => {
triggerHaptic('selection')
onPin?.()
}}
>
{pinned ? <IconBookmarkFilled /> : <IconBookmark />}
<span>{pinned ? 'Unpin' : 'Pin'}</span>
</DropdownMenuItem>
<CopyButton
appearance="menu-item"
className={itemClass}
disabled={!sessionId}
errorMessage="Could not copy session ID"
label="Copy ID"
text={sessionId}
/>
<DropdownMenuItem
className={itemClass}
disabled={!sessionId}
onSelect={() => {
triggerHaptic('selection')
void exportSession(sessionId, { title })
}}
>
<IconFileDownload />
<span>Export</span>
</DropdownMenuItem>
<DropdownMenuItem
className={itemClass}
disabled={!sessionId}
onSelect={() => {
triggerHaptic('selection')
setRenameOpen(true)
}}
>
<IconPencil />
<span>Rename</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-3" />
<DropdownMenuItem
className={cn(itemClass, 'text-destructive focus:text-destructive')}
disabled={!onDelete}
onSelect={() => {
triggerHaptic('warning')
onDelete?.()
}}
variant="destructive"
>
<IconCircleX />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<RenameSessionDialog currentTitle={title} onOpenChange={setRenameOpen} open={renameOpen} sessionId={sessionId} />
</>
)
}
interface RenameSessionDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
sessionId: string
currentTitle: string
}
function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle }: RenameSessionDialogProps) {
const [value, setValue] = useState(currentTitle)
const [submitting, setSubmitting] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setValue(currentTitle)
window.setTimeout(() => inputRef.current?.select(), 0)
}
}, [currentTitle, open])
const submit = async () => {
const next = value.trim()
if (!sessionId || submitting) {
return
}
if (next === currentTitle.trim()) {
onOpenChange(false)
return
}
setSubmitting(true)
try {
const result = await renameSession(sessionId, next)
const finalTitle = result.title || next || ''
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
notify({ kind: 'success', message: 'Renamed', durationMs: 2_000 })
onOpenChange(false)
} catch (err) {
notifyError(err, 'Rename failed')
} finally {
setSubmitting(false)
}
}
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Rename session</DialogTitle>
<DialogDescription>Give this chat a memorable title. Leave empty to clear.</DialogDescription>
</DialogHeader>
<Input
autoFocus
disabled={submitting}
onChange={event => setValue(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
void submit()
} else if (event.key === 'Escape') {
onOpenChange(false)
}
}}
placeholder="Untitled session"
ref={inputRef}
value={value}
/>
<DialogFooter>
<Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={submitting} onClick={() => void submit()} type="button">
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,89 +0,0 @@
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import type { SessionInfo } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { MoreVertical } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { SessionActionsMenu } from './session-actions-menu'
export const sidebarSessionRowClass =
'group relative grid min-h-7 grid-cols-[minmax(0,1fr)_1.5rem] items-center rounded-lg transition-colors duration-300 ease-out hover:bg-accent hover:transition-none'
export const sidebarSessionFadeClass =
'after:pointer-events-none after:absolute after:inset-y-0 after:right-0 after:z-1 after:w-18 after:rounded-[inherit] after:bg-linear-to-r after:from-transparent after:via-[color-mix(in_srgb,var(--dt-sidebar-bg)_78%,transparent)] after:to-[color-mix(in_srgb,var(--dt-sidebar-bg)_96%,transparent)] after:opacity-0 after:transition-opacity after:duration-200 after:ease-out hover:after:opacity-100 focus-within:after:opacity-100'
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
session: SessionInfo
isPinned: boolean
isSelected: boolean
isWorking: boolean
onDelete: () => void
onPin: () => void
onResume: () => void
}
export function SidebarSessionRow({
session,
isPinned,
isSelected,
isWorking,
onDelete,
onPin,
onResume
}: SidebarSessionRowProps) {
const title = sessionTitle(session)
return (
<div
className={cn(
sidebarSessionRowClass,
sidebarSessionFadeClass,
isSelected && 'bg-accent',
isWorking && 'text-foreground'
)}
data-working={isWorking ? 'true' : undefined}
>
<button
className="z-0 flex min-w-0 items-center gap-1.5 bg-transparent py-1 pl-2 text-left"
onClick={event => {
if (event.shiftKey) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onPin()
return
}
onResume()
}}
type="button"
>
{isWorking && (
<span
aria-label="Session running"
className="relative size-1.5 shrink-0 rounded-full bg-primary shadow-[0_0_0.625rem_color-mix(in_srgb,var(--primary)_65%,transparent)] before:absolute before:inset-0 before:rounded-full before:bg-primary before:opacity-75 before:content-[''] before:animate-ping"
role="status"
/>
)}
<span className="truncate text-sm font-medium text-foreground/90">{title}</span>
</button>
<div className="relative z-2 grid w-6 place-items-center">
<SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
<Button
aria-label={`Actions for ${title}`}
className="size-6 rounded-md bg-transparent text-transparent transition-colors duration-150 hover:bg-accent hover:text-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground group-hover:text-muted-foreground"
size="icon"
title="Session actions"
variant="ghost"
>
<MoreVertical size={15} />
</Button>
</SessionActionsMenu>
</div>
</div>
)
}
@@ -1,883 +0,0 @@
import { useStore } from '@nanostores/react'
import {
IconBookmark,
IconBookmarkFilled,
IconDownload,
IconLoader2,
IconRefresh,
IconSparkles,
IconTrash
} from '@tabler/icons-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
getActionStatus,
getAuxiliaryModels,
getGlobalModelInfo,
getGlobalModelOptions,
getLogs,
getStatus,
restartGateway,
searchSessions,
setModelAssignment,
updateHermes
} from '@/hermes'
import type {
ActionStatusResponse,
AuxiliaryModelsResponse,
ModelOptionProvider,
SessionInfo,
SessionSearchResult as SessionSearchApiResult,
StatusResponse
} from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { Activity, AlertCircle, Cpu, Pin } from '@/lib/icons'
import { exportSession } from '@/lib/session-export'
import { cn } from '@/lib/utils'
import { upsertDesktopActionTask } from '@/store/activity'
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
import { $sessions } from '@/store/session'
import { OverlayActionButton, OverlayCard, overlayCardClass, OverlayIconButton } from '../overlays/overlay-chrome'
import { OverlaySearchInput } from '../overlays/overlay-search-input'
import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
import { ARTIFACTS_ROUTE, NEW_CHAT_ROUTE, SETTINGS_ROUTE, SKILLS_ROUTE } from '../routes'
export type CommandCenterSection = 'models' | 'sessions' | 'system'
interface CommandCenterViewProps {
initialSection?: CommandCenterSection
onClose: () => void
onDeleteSession: (sessionId: string) => Promise<void>
onMainModelChanged?: (provider: string, model: string) => void
onNavigateRoute: (path: string) => void
onOpenSession: (sessionId: string) => void
}
const SECTION_LABELS: Record<CommandCenterSection, string> = {
sessions: 'Sessions',
system: 'System',
models: 'Models'
}
const SECTION_DESCRIPTIONS: Record<CommandCenterSection, string> = {
sessions: 'Search and manage sessions',
system: 'Status, logs, and system actions',
models: 'Global and auxiliary model controls'
}
interface NavigationSearchEntry {
detail?: string
id: string
route: string
title: string
}
interface SectionSearchEntry {
detail?: string
id: string
section: CommandCenterSection
title: string
}
const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New chat', detail: 'Start a fresh session' },
{ id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' },
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' },
{ id: 'nav-artifacts', route: ARTIFACTS_ROUTE, title: 'Artifacts', detail: 'Browse generated outputs' }
]
const SECTION_SEARCH_ENTRIES: readonly SectionSearchEntry[] = [
{ id: 'section-sessions', section: 'sessions', title: 'Sessions panel', detail: 'Search, pin, and manage sessions' },
{ id: 'section-system', section: 'system', title: 'System panel', detail: 'Gateway status, logs, restart/update' },
{ id: 'section-models', section: 'models', title: 'Models panel', detail: 'Main and auxiliary model assignments' }
]
interface SessionSearchHit {
detail?: string
kind: 'session'
sessionId: string
snippet: string
title: string
}
interface RouteSearchHit {
detail?: string
kind: 'route'
route: string
title: string
}
interface SectionSearchHit {
detail?: string
kind: 'section'
section: CommandCenterSection
title: string
}
type CommandCenterSearchResult = RouteSearchHit | SectionSearchHit | SessionSearchHit
interface CommandCenterSearchProvider {
id: string
label: string
search: (query: string) => Promise<CommandCenterSearchResult[]>
}
interface CommandCenterSearchGroup {
id: string
label: string
results: CommandCenterSearchResult[]
}
function formatTimestamp(value?: number | null): string {
if (!value) {
return ''
}
const date = new Date(value * 1000)
if (Number.isNaN(date.getTime())) {
return ''
}
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
}
function splitSessionSearchResult(result: SessionSearchApiResult, sessionsById: Map<string, SessionInfo>) {
const row = sessionsById.get(result.session_id)
const title = row ? sessionTitle(row) : result.session_id
const detail = [result.model, result.source].filter(Boolean).join(' · ')
return { detail, title }
}
function matchesSearchQuery(query: string, ...values: Array<string | undefined>): boolean {
const normalized = query.trim().toLowerCase()
if (!normalized) {
return true
}
return values.some(value => value?.toLowerCase().includes(normalized))
}
function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const id = window.setTimeout(() => setDebounced(value), delayMs)
return () => window.clearTimeout(id)
}, [delayMs, value])
return debounced
}
export function CommandCenterView({
initialSection,
onClose,
onDeleteSession,
onMainModelChanged,
onNavigateRoute,
onOpenSession
}: CommandCenterViewProps) {
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)
const [section, setSection] = useState<CommandCenterSection>(initialSection ?? 'sessions')
const [query, setQuery] = useState('')
const [searchLoading, setSearchLoading] = useState(false)
const [searchGroups, setSearchGroups] = useState<CommandCenterSearchGroup[]>([])
const [status, setStatus] = useState<StatusResponse | null>(null)
const [logs, setLogs] = useState<string[]>([])
const [systemLoading, setSystemLoading] = useState(false)
const [systemError, setSystemError] = useState('')
const [systemAction, setSystemAction] = useState<ActionStatusResponse | null>(null)
const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState('')
const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null)
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
const [selectedProvider, setSelectedProvider] = useState('')
const [selectedModel, setSelectedModel] = useState('')
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
const [applyingModel, setApplyingModel] = useState(false)
const searchRequestRef = useRef(0)
const debouncedQuery = useDebouncedValue(query.trim(), 180)
const sessionsById = useMemo(() => new Map(sessions.map(session => [session.id, session])), [sessions])
const filteredSessions = useMemo(
() =>
[...sessions].sort((a, b) => {
const left = a.last_active || a.started_at || 0
const right = b.last_active || b.started_at || 0
return right - left
}),
[sessions]
)
const selectedProviderModels = useMemo(
() => providers.find(provider => provider.slug === selectedProvider)?.models ?? [],
[providers, selectedProvider]
)
const searchProviders = useMemo<readonly CommandCenterSearchProvider[]>(
() => [
{
id: 'navigation',
label: 'Navigate',
search: async searchQuery => {
const routeHits: RouteSearchHit[] = NAVIGATION_SEARCH_ENTRIES.filter(entry =>
matchesSearchQuery(searchQuery, entry.title, entry.detail, entry.route)
).map(entry => ({
detail: entry.detail,
kind: 'route',
route: entry.route,
title: entry.title
}))
const sectionHits: SectionSearchHit[] = SECTION_SEARCH_ENTRIES.filter(entry =>
matchesSearchQuery(searchQuery, entry.title, entry.detail, SECTION_LABELS[entry.section])
).map(entry => ({
detail: entry.detail,
kind: 'section',
section: entry.section,
title: entry.title
}))
return [...routeHits, ...sectionHits]
}
},
{
id: 'sessions',
label: 'Sessions',
search: async searchQuery => {
const response = await searchSessions(searchQuery)
return response.results.map(result => {
const { detail, title } = splitSessionSearchResult(result, sessionsById)
return {
detail,
kind: 'session',
sessionId: result.session_id,
snippet: result.snippet || '',
title
} satisfies SessionSearchHit
})
}
}
],
[sessionsById]
)
const refreshSystem = useCallback(async () => {
setSystemLoading(true)
setSystemError('')
try {
const [nextStatus, nextLogs] = await Promise.all([
getStatus(),
getLogs({
file: 'agent',
lines: 120
})
])
setStatus(nextStatus)
setLogs(nextLogs.lines)
} catch (error) {
setSystemError(error instanceof Error ? error.message : String(error))
} finally {
setSystemLoading(false)
}
}, [])
const refreshModels = useCallback(async () => {
setModelsLoading(true)
setModelsError('')
try {
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([
getGlobalModelInfo(),
getGlobalModelOptions(),
getAuxiliaryModels()
])
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
setProviders(modelOptions.providers || [])
setSelectedProvider(prev => prev || modelInfo.provider)
setSelectedModel(prev => prev || modelInfo.model)
setAuxiliary(auxiliaryModels)
} catch (error) {
setModelsError(error instanceof Error ? error.message : String(error))
} finally {
setModelsLoading(false)
}
}, [])
useEffect(() => {
if (initialSection && initialSection !== section) {
setSection(initialSection)
}
}, [initialSection, section])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
triggerHaptic('close')
onClose()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [onClose])
useEffect(() => {
if (!debouncedQuery) {
setSearchGroups([])
setSearchLoading(false)
return
}
const requestId = searchRequestRef.current + 1
searchRequestRef.current = requestId
setSearchLoading(true)
void Promise.all(
searchProviders.map(async provider => ({
id: provider.id,
label: provider.label,
results: await provider.search(debouncedQuery)
}))
)
.then(groups => {
if (searchRequestRef.current === requestId) {
setSearchGroups(groups.filter(group => group.results.length > 0))
}
})
.catch(() => {
if (searchRequestRef.current === requestId) {
setSearchGroups([])
}
})
.finally(() => {
if (searchRequestRef.current === requestId) {
setSearchLoading(false)
}
})
}, [debouncedQuery, searchProviders])
useEffect(() => {
if (section === 'system' && !status && !systemLoading) {
void refreshSystem()
}
}, [refreshSystem, section, status, systemLoading])
useEffect(() => {
if (section === 'models' && !mainModel && !modelsLoading) {
void refreshModels()
}
}, [mainModel, modelsLoading, refreshModels, section])
useEffect(() => {
if (!selectedProviderModels.length) {
return
}
if (!selectedProviderModels.includes(selectedModel)) {
setSelectedModel(selectedProviderModels[0])
}
}, [selectedModel, selectedProviderModels])
const showGlobalSearchResults = debouncedQuery.length > 0
const hasGlobalSearchResults = searchGroups.length > 0
const sessionListHasResults = filteredSessions.length > 0
const runSystemAction = useCallback(
async (kind: 'restart' | 'update') => {
setSystemError('')
try {
const started = kind === 'restart' ? await restartGateway() : await updateHermes()
let nextStatus: ActionStatusResponse | null = null
for (let attempt = 0; attempt < 18; attempt += 1) {
await new Promise(resolve => window.setTimeout(resolve, 1200))
const polled = await getActionStatus(started.name, 180)
nextStatus = polled
setSystemAction(polled)
upsertDesktopActionTask(polled)
if (!polled.running) {
break
}
}
if (!nextStatus) {
const pendingStatus = {
exit_code: null,
lines: ['Action started, waiting for status...'],
name: started.name,
pid: started.pid,
running: true
}
setSystemAction(pendingStatus)
upsertDesktopActionTask(pendingStatus)
}
} catch (error) {
setSystemError(error instanceof Error ? error.message : String(error))
} finally {
void refreshSystem()
}
},
[refreshSystem]
)
const applyMainModel = useCallback(async () => {
if (!selectedProvider || !selectedModel) {
return
}
setApplyingModel(true)
setModelsError('')
try {
const result = await setModelAssignment({
model: selectedModel,
provider: selectedProvider,
scope: 'main'
})
const provider = result.provider || selectedProvider
const model = result.model || selectedModel
setMainModel({ provider, model })
onMainModelChanged?.(provider, model)
await refreshModels()
} catch (error) {
setModelsError(error instanceof Error ? error.message : String(error))
} finally {
setApplyingModel(false)
}
}, [onMainModelChanged, refreshModels, selectedModel, selectedProvider])
const setAuxiliaryToMain = useCallback(
async (task: string) => {
if (!mainModel) {
return
}
setApplyingModel(true)
setModelsError('')
try {
await setModelAssignment({
model: mainModel.model,
provider: mainModel.provider,
scope: 'auxiliary',
task
})
await refreshModels()
} catch (error) {
setModelsError(error instanceof Error ? error.message : String(error))
} finally {
setApplyingModel(false)
}
},
[mainModel, refreshModels]
)
const resetAuxiliaryModels = useCallback(async () => {
if (!mainModel) {
return
}
setApplyingModel(true)
setModelsError('')
try {
await setModelAssignment({
model: mainModel.model,
provider: mainModel.provider,
scope: 'auxiliary',
task: '__reset__'
})
await refreshModels()
} catch (error) {
setModelsError(error instanceof Error ? error.message : String(error))
} finally {
setApplyingModel(false)
}
}, [mainModel, refreshModels])
const handleSearchSelect = useCallback(
(result: CommandCenterSearchResult) => {
if (result.kind === 'route') {
onNavigateRoute(result.route)
return
}
if (result.kind === 'section') {
setSection(result.section)
setQuery('')
return
}
onOpenSession(result.sessionId)
},
[onNavigateRoute, onOpenSession]
)
return (
<OverlayView
closeLabel="Close command center"
headerContent={
<OverlaySearchInput
containerClassName="w-[min(36rem,calc(100vw-32rem))] min-w-80"
loading={searchLoading}
onChange={next => setQuery(next)}
placeholder="Search sessions, views, and actions"
value={query}
/>
}
onClose={onClose}
>
<OverlaySplitLayout>
<OverlaySidebar>
{(['sessions', 'system', 'models'] as const).map(value => (
<OverlayNavItem
active={section === value}
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : Cpu}
key={value}
label={SECTION_LABELS[value]}
onClick={() => setSection(value)}
/>
))}
</OverlaySidebar>
<OverlayMain>
<header className="mb-4 flex items-center justify-between gap-2">
<div>
<h2 className="text-sm font-semibold text-foreground">{SECTION_LABELS[section]}</h2>
<p className="text-xs text-muted-foreground">{SECTION_DESCRIPTIONS[section]}</p>
</div>
{section === 'system' && (
<OverlayActionButton disabled={systemLoading} onClick={() => void refreshSystem()}>
<IconRefresh className={cn('mr-1.5 size-3.5', systemLoading && 'animate-spin')} />
{systemLoading ? 'Refreshing...' : 'Refresh'}
</OverlayActionButton>
)}
{section === 'models' && (
<OverlayActionButton disabled={modelsLoading} onClick={() => void refreshModels()}>
<IconRefresh className={cn('mr-1.5 size-3.5', modelsLoading && 'animate-spin')} />
{modelsLoading ? 'Refreshing...' : 'Refresh'}
</OverlayActionButton>
)}
</header>
{showGlobalSearchResults ? (
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
{!hasGlobalSearchResults ? (
<OverlayCard className="px-3 py-4 text-sm text-muted-foreground">
No matching results found.
</OverlayCard>
) : (
<div className="grid gap-3">
{searchGroups.map(group => (
<section className="grid gap-1.5" key={group.id}>
<h3 className="px-0.5 text-xs font-semibold tracking-[0.08em] text-muted-foreground/80 uppercase">
{group.label}
</h3>
{group.results.map(result => {
if (result.kind === 'session') {
const pinned = pinnedSessionIds.includes(result.sessionId)
return (
<OverlayCard className="p-2.5" key={`${group.id}:${result.sessionId}:${result.snippet}`}>
<button
className="w-full text-left"
onClick={() => handleSearchSelect(result)}
type="button"
>
<div className="truncate text-sm font-medium text-foreground">{result.title}</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{result.detail || result.sessionId}
</div>
{result.snippet && (
<div className="mt-1 whitespace-pre-wrap text-xs text-muted-foreground/85">
{result.snippet}
</div>
)}
</button>
<div className="mt-2 flex gap-1">
<OverlayIconButton
onClick={event => {
event.preventDefault()
event.stopPropagation()
pinned ? unpinSession(result.sessionId) : pinSession(result.sessionId)
}}
title={pinned ? 'Unpin session' : 'Pin session'}
>
{pinned ? (
<IconBookmarkFilled className="size-3.5" />
) : (
<IconBookmark className="size-3.5" />
)}
</OverlayIconButton>
<OverlayIconButton
onClick={event => {
event.preventDefault()
event.stopPropagation()
void exportSession(result.sessionId, { title: result.title })
}}
title="Export session"
>
<IconDownload className="size-3.5" />
</OverlayIconButton>
<OverlayIconButton
className="hover:text-destructive"
onClick={event => {
event.preventDefault()
event.stopPropagation()
void onDeleteSession(result.sessionId)
}}
title="Delete session"
>
<IconTrash className="size-3.5" />
</OverlayIconButton>
</div>
</OverlayCard>
)
}
return (
<button
className={cn(
overlayCardClass,
'w-full px-3 py-2 text-left transition-colors hover:bg-[color-mix(in_srgb,var(--dt-muted)_48%,var(--dt-card))]'
)}
key={`${group.id}:${result.kind}:${result.title}`}
onClick={() => handleSearchSelect(result)}
type="button"
>
<div className="text-sm font-medium text-foreground">{result.title}</div>
{result.detail && (
<div className="mt-0.5 text-xs text-muted-foreground">{result.detail}</div>
)}
</button>
)
})}
</section>
))}
</div>
)}
</div>
) : section === 'sessions' ? (
<div className="min-h-0 flex-1 overflow-y-auto">
{!sessionListHasResults ? (
<OverlayCard className="px-3 py-4 text-sm text-muted-foreground">No sessions yet.</OverlayCard>
) : (
<div className="grid gap-1.5">
{filteredSessions.map(session => {
const pinned = pinnedSessionIds.includes(session.id)
return (
<OverlayCard className="flex items-center gap-2 px-2.5 py-2" key={session.id}>
<button
className="min-w-0 flex-1 text-left"
onClick={() => onOpenSession(session.id)}
type="button"
>
<div className="truncate text-sm font-medium text-foreground">{sessionTitle(session)}</div>
<div className="truncate text-xs text-muted-foreground">
{formatTimestamp(session.last_active || session.started_at)}
</div>
</button>
<OverlayIconButton
onClick={() => (pinned ? unpinSession(session.id) : pinSession(session.id))}
title={pinned ? 'Unpin session' : 'Pin session'}
>
{pinned ? <IconBookmarkFilled className="size-3.5" /> : <IconBookmark className="size-3.5" />}
</OverlayIconButton>
<OverlayIconButton
onClick={() => void exportSession(session.id, { session, title: sessionTitle(session) })}
title="Export session"
>
<IconDownload className="size-3.5" />
</OverlayIconButton>
<OverlayIconButton
className="hover:text-destructive"
onClick={() => void onDeleteSession(session.id)}
title="Delete session"
>
<IconTrash className="size-3.5" />
</OverlayIconButton>
</OverlayCard>
)
})}
</div>
)}
</div>
) : section === 'system' ? (
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] gap-3">
<OverlayCard className="p-3 text-sm">
{status ? (
<div className="grid 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 rounded-full',
status.gateway_running ? 'bg-emerald-500' : 'bg-amber-500'
)}
/>
<span className="font-medium text-foreground">
{status.gateway_running ? 'Gateway running' : 'Gateway not running'}
</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">
Hermes {status.version} · Active sessions {status.active_sessions}
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap">
<OverlayActionButton className="h-7 px-2.5" onClick={() => void runSystemAction('restart')}>
Restart gateway
</OverlayActionButton>
<OverlayActionButton className="h-7 px-2.5" onClick={() => void runSystemAction('update')}>
Update Hermes
</OverlayActionButton>
</div>
</div>
{systemAction && (
<div className="text-xs text-muted-foreground">
{systemAction.name} ·{' '}
{systemAction.running ? 'running' : systemAction.exit_code === 0 ? 'done' : 'failed'}
</div>
)}
</div>
) : (
<div className="text-xs text-muted-foreground">Loading status...</div>
)}
</OverlayCard>
<OverlayCard className="min-h-0 overflow-hidden p-2">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Recent logs</span>
{systemError && (
<span className="inline-flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="size-3.5" />
{systemError}
</span>
)}
</div>
<pre className="h-full min-h-0 overflow-auto whitespace-pre-wrap wrap-break-word font-mono text-[0.65rem] leading-relaxed text-muted-foreground">
{logs.length ? logs.join('\n') : 'No logs loaded yet.'}
</pre>
</OverlayCard>
</div>
) : (
<div className="grid min-h-0 flex-1 grid-rows-[auto_auto_minmax(0,1fr)] gap-3">
<OverlayCard className="p-3">
{mainModel ? (
<>
<div className="text-sm font-medium text-foreground">Main model</div>
<div className="text-xs text-muted-foreground">
{mainModel.provider} / {mainModel.model}
</div>
</>
) : (
<div className="text-xs text-muted-foreground">Loading model state...</div>
)}
</OverlayCard>
<OverlayCard className="p-3">
<div className="mb-2 text-xs font-medium text-muted-foreground">Set global main model</div>
<div className="flex flex-wrap items-center gap-2">
<select
className="h-8 min-w-36 rounded-md border border-border bg-background px-2 text-xs text-foreground"
onChange={event => setSelectedProvider(event.target.value)}
value={selectedProvider}
>
{(providers.length ? providers : [{ name: '—', slug: '', models: [] }]).map(provider => (
<option key={provider.slug || 'none'} value={provider.slug}>
{provider.name}
</option>
))}
</select>
<select
className="h-8 min-w-58 rounded-md border border-border bg-background px-2 text-xs text-foreground"
onChange={event => setSelectedModel(event.target.value)}
value={selectedModel}
>
{(selectedProviderModels.length ? selectedProviderModels : ['']).map(model => (
<option key={model || 'none'} value={model}>
{model || 'No models available'}
</option>
))}
</select>
<OverlayActionButton
disabled={!selectedProvider || !selectedModel || applyingModel}
onClick={() => void applyMainModel()}
>
{applyingModel ? (
<IconLoader2 className="mr-1.5 size-3.5 animate-spin" />
) : (
<IconSparkles className="mr-1.5 size-3.5" />
)}
{applyingModel ? 'Applying...' : 'Apply'}
</OverlayActionButton>
</div>
{modelsError && <div className="mt-2 text-xs text-destructive">{modelsError}</div>}
</OverlayCard>
<OverlayCard className="min-h-0 overflow-auto p-2">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Auxiliary assignments</span>
<OverlayActionButton
disabled={!mainModel || applyingModel}
onClick={() => void resetAuxiliaryModels()}
tone="subtle"
>
Reset all
</OverlayActionButton>
</div>
<div className="grid gap-1.5">
{(auxiliary?.tasks || []).map(task => (
<OverlayCard className="flex items-center gap-2 px-2 py-1.5" key={task.task}>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-foreground">{task.task}</div>
<div className="truncate text-[0.65rem] text-muted-foreground">
{task.provider} / {task.model}
</div>
</div>
<OverlayActionButton
disabled={!mainModel || applyingModel}
onClick={() => void setAuxiliaryToMain(task.task)}
>
Set to main
</OverlayActionButton>
</OverlayCard>
))}
{!auxiliary?.tasks?.length && (
<div className="text-xs text-muted-foreground">No auxiliary assignments reported.</div>
)}
</div>
</OverlayCard>
</div>
)}
</OverlayMain>
</OverlaySplitLayout>
</OverlayView>
)
}
-561
View File
@@ -1,561 +0,0 @@
import { useStore } from '@nanostores/react'
import { useQueryClient } from '@tanstack/react-query'
import { lazy, Suspense, useCallback, useEffect, useRef } from 'react'
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay'
import { Pane, PaneMain } from '@/components/pane-shell'
import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getSessionMessages, listSessions } from '../hermes'
import { toChatMessages } from '../lib/chat-messages'
import {
$pinnedSessionIds,
FILE_BROWSER_DEFAULT_WIDTH,
FILE_BROWSER_MAX_WIDTH,
FILE_BROWSER_MIN_WIDTH,
pinSession,
SIDEBAR_DEFAULT_WIDTH,
SIDEBAR_MAX_WIDTH,
unpinSession
} from '../store/layout'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import {
$activeSessionId,
$currentCwd,
$freshDraftReady,
$gatewayState,
$selectedStoredSessionId,
setAwaitingResponse,
setBusy,
setCurrentModel,
setCurrentProvider,
setMessages,
setSessions,
setSessionsLoading
} from '../store/session'
import { ChatView } from './chat'
import { useComposerActions } from './chat/hooks/use-composer-actions'
import {
ChatPreviewRail,
PREVIEW_RAIL_MAX_WIDTH,
PREVIEW_RAIL_MIN_WIDTH,
PREVIEW_RAIL_PANE_WIDTH
} from './chat/right-rail'
import { ChatSidebar } from './chat/sidebar'
import { FileBrowserPane } from './file-browser'
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { ModelPickerOverlay } from './model-picker-overlay'
import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from './routes'
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
import { useCwdActions } from './session/hooks/use-cwd-actions'
import { useHermesConfig } from './session/hooks/use-hermes-config'
import { useMessageStream } from './session/hooks/use-message-stream'
import { useModelControls } from './session/hooks/use-model-controls'
import { usePreviewRouting } from './session/hooks/use-preview-routing'
import { usePromptActions } from './session/hooks/use-prompt-actions'
import { useRouteResume } from './session/hooks/use-route-resume'
import { useSessionActions } from './session/hooks/use-session-actions'
import { useSessionStateCache } from './session/hooks/use-session-state-cache'
import { AppShell } from './shell/app-shell'
import { useOverlayRouting } from './shell/hooks/use-overlay-routing'
import { useStatusSnapshot } from './shell/hooks/use-status-snapshot'
import { useStatusbarItems } from './shell/hooks/use-statusbar-items'
import type { StatusbarItem } from './shell/statusbar-controls'
import type { TitlebarTool } from './shell/titlebar-controls'
import { useGroupRegistry } from './shell/use-group-registry'
const AgentsView = lazy(async () => ({ default: (await import('./agents')).AgentsView }))
const ArtifactsView = lazy(async () => ({ default: (await import('./artifacts')).ArtifactsView }))
const CommandCenterView = lazy(async () => ({ default: (await import('./command-center')).CommandCenterView }))
const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView }))
const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView }))
export function DesktopController() {
const queryClient = useQueryClient()
const location = useLocation()
const navigate = useNavigate()
const busyRef = useRef(false)
const creatingSessionRef = useRef(false)
const refreshSessionsRequestRef = useRef(0)
const gatewayState = useStore($gatewayState)
const activeSessionId = useStore($activeSessionId)
const currentCwd = useStore($currentCwd)
const freshDraftReady = useStore($freshDraftReady)
const filePreviewTarget = useStore($filePreviewTarget)
const previewTarget = useStore($previewTarget)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const routedSessionId = routeSessionId(location.pathname)
const routeToken = `${location.pathname}:${location.search}:${location.hash}`
const routeTokenRef = useRef(routeToken)
routeTokenRef.current = routeToken
const getRouteToken = useCallback(() => routeTokenRef.current, [])
const {
agentsOpen,
chatOpen,
closeOverlayToPreviousRoute,
commandCenterInitialSection,
commandCenterOpen,
currentView,
openAgents,
openCommandCenterSection,
settingsOpen,
toggleCommandCenter
} = useOverlayRouting()
const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
const statusbarItemGroups = useGroupRegistry<StatusbarItem>()
const setTitlebarToolGroup = titlebarToolGroups.set
const setStatusbarItemGroup = statusbarItemGroups.set
const {
activeSessionIdRef,
ensureSessionState,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,
syncSessionStateToView,
updateSessionState
} = useSessionStateCache({
activeSessionId,
busyRef,
selectedStoredSessionId,
setAwaitingResponse,
setBusy,
setMessages
})
const { connectionRef, gatewayRef, requestGateway } = useGatewayRequest()
useEffect(() => {
window.hermesDesktop?.setPreviewShortcutActive?.(Boolean(chatOpen && (filePreviewTarget || previewTarget)))
}, [chatOpen, filePreviewTarget, previewTarget])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!$filePreviewTarget.get() && !$previewTarget.get()) {
return
}
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
event.preventDefault()
event.stopPropagation()
closeActiveRightRailTab()
}
}
const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(closeActiveRightRailTab)
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => {
unsubscribe?.()
window.removeEventListener('keydown', onKeyDown, { capture: true })
}
}, [])
const refreshSessions = useCallback(async () => {
const requestId = refreshSessionsRequestRef.current + 1
refreshSessionsRequestRef.current = requestId
setSessionsLoading(true)
try {
const result = await listSessions(50)
if (refreshSessionsRequestRef.current === requestId) {
setSessions(result.sessions)
}
} finally {
if (refreshSessionsRequestRef.current === requestId) {
setSessionsLoading(false)
}
}
}, [])
const toggleSelectedPin = useCallback(() => {
const sessionId = $selectedStoredSessionId.get()
if (!sessionId) {
return
}
if ($pinnedSessionIds.get().includes(sessionId)) {
unpinSession(sessionId)
} else {
pinSession(sessionId)
}
}, [])
const { gatewayLogLines, statusSnapshot } = useStatusSnapshot(gatewayState)
const { browseSessionCwd, changeSessionCwd, refreshProjectBranch } = useCwdActions({
activeSessionId,
activeSessionIdRef,
currentCwd,
requestGateway
})
const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({
activeSessionIdRef,
refreshProjectBranch
})
const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({
activeSessionId,
queryClient,
requestGateway
})
useContextSuggestions({
activeSessionId,
activeSessionIdRef,
currentCwd,
gatewayState,
requestGateway
})
const hydrateFromStoredSession = useCallback(
async (
attempts = 1,
storedSessionId = selectedStoredSessionIdRef.current,
runtimeSessionId = activeSessionIdRef.current
) => {
if (!storedSessionId || !runtimeSessionId) {
return
}
for (let index = 0; index < Math.max(1, attempts); index += 1) {
try {
const latest = await getSessionMessages(storedSessionId)
updateSessionState(
runtimeSessionId,
state => ({
...state,
messages: toChatMessages(latest.messages)
}),
storedSessionId
)
return
} catch {
// Best-effort fallback when live stream payloads are empty.
}
if (index < attempts - 1) {
await new Promise(resolve => window.setTimeout(resolve, 250))
}
}
},
[activeSessionIdRef, selectedStoredSessionIdRef, updateSessionState]
)
const { handleGatewayEvent } = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession,
queryClient,
refreshHermesConfig,
refreshSessions,
updateSessionState
})
const { handleDesktopGatewayEvent, restartPreviewServer } = usePreviewRouting({
activeSessionIdRef,
baseHandleGatewayEvent: handleGatewayEvent,
currentCwd,
currentView,
requestGateway,
routedSessionId,
selectedStoredSessionId
})
const {
branchCurrentSession,
createBackendSessionForSend,
openSettings,
removeSession,
resumeSession,
selectSidebarItem,
startFreshSessionDraft
} = useSessionActions({
activeSessionId,
activeSessionIdRef,
busyRef,
creatingSessionRef,
ensureSessionState,
getRouteToken,
navigate,
requestGateway,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,
syncSessionStateToView,
updateSessionState
})
const composer = useComposerActions({
activeSessionId,
currentCwd,
requestGateway
})
const branchInNewChat = useCallback(
async (messageId?: string) => {
const branched = await branchCurrentSession(messageId)
if (branched) {
await refreshSessions().catch(() => undefined)
}
return branched
},
[branchCurrentSession, refreshSessions]
)
const handleSkinCommand = useSkinCommand()
const { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } =
usePromptActions({
activeSessionId,
activeSessionIdRef,
branchCurrentSession: branchInNewChat,
busyRef,
createBackendSessionForSend,
handleSkinCommand,
requestGateway,
selectedStoredSessionIdRef,
startFreshSessionDraft,
sttEnabled,
updateSessionState
})
useGatewayBoot({
handleGatewayEvent: handleDesktopGatewayEvent,
onConnectionReady: c => {
connectionRef.current = c
},
onGatewayReady: g => {
gatewayRef.current = g
},
refreshHermesConfig,
refreshSessions
})
useEffect(() => {
if (gatewayState === 'open') {
void refreshCurrentModel()
void refreshSessions().catch(() => undefined)
}
}, [gatewayState, refreshCurrentModel, refreshSessions])
useRouteResume({
activeSessionId,
activeSessionIdRef,
creatingSessionRef,
currentView,
freshDraftReady,
gatewayState,
locationPathname: location.pathname,
resumeSession,
routedSessionId,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,
startFreshSessionDraft
})
const { leftStatusbarItems, statusbarItems } = useStatusbarItems({
agentsOpen,
browseSessionCwd,
commandCenterOpen,
extraLeftItems: statusbarItemGroups.flat.left,
extraRightItems: statusbarItemGroups.flat.right,
gatewayLogLines,
openAgents,
openCommandCenterSection,
statusSnapshot,
toggleCommandCenter
})
const sidebar = (
<ChatSidebar
currentView={currentView}
onDeleteSession={sessionId => void removeSession(sessionId)}
onNavigate={selectSidebarItem}
onRefreshSessions={() => void refreshSessions()}
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
/>
)
const overlays = (
<>
<DesktopOnboardingOverlay
enabled={gatewayState === 'open'}
onCompleted={() => {
void refreshHermesConfig()
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
requestGateway={requestGateway}
/>
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
{settingsOpen && (
<Suspense fallback={null}>
<SettingsView
onClose={closeOverlayToPreviousRoute}
onConfigSaved={() => {
void refreshHermesConfig()
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
/>
</Suspense>
)}
{commandCenterOpen && (
<Suspense fallback={null}>
<CommandCenterView
initialSection={commandCenterInitialSection}
onClose={closeOverlayToPreviousRoute}
onDeleteSession={removeSession}
onMainModelChanged={(provider, model) => {
setCurrentProvider(provider)
setCurrentModel(model)
updateModelOptionsCache(provider, model, true)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
onNavigateRoute={path => navigate(path)}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/>
</Suspense>
)}
{agentsOpen && (
<Suspense fallback={null}>
<AgentsView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
</>
)
const chatView = (
<ChatView
gateway={gatewayRef.current}
maxVoiceRecordingSeconds={voiceMaxRecordingSeconds}
onAddContextRef={composer.addContextRefAttachment}
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
onAttachDroppedItems={composer.attachDroppedItems}
onAttachImageBlob={composer.attachImageBlob}
onBranchInNewChat={messageId => void branchInNewChat(messageId)}
onCancel={() => void cancelRun()}
onDeleteSelectedSession={() => {
if (selectedStoredSessionId) {
void removeSession(selectedStoredSessionId)
}
}}
onEdit={editMessage}
onPasteClipboardImage={() => void composer.pasteClipboardImage()}
onPickFiles={() => void composer.pickContextPaths('file')}
onPickFolders={() => void composer.pickContextPaths('folder')}
onPickImages={() => void composer.pickImages()}
onReload={reloadFromMessage}
onRemoveAttachment={id => void composer.removeAttachment(id)}
onSubmit={submitText}
onThreadMessagesChange={handleThreadMessagesChange}
onToggleSelectedPin={toggleSelectedPin}
onTranscribeAudio={transcribeVoiceAudio}
/>
)
return (
<AppShell
leftStatusbarItems={leftStatusbarItems}
leftTitlebarTools={titlebarToolGroups.flat.left}
onOpenSettings={openSettings}
overlays={overlays}
statusbarItems={statusbarItems}
titlebarTools={titlebarToolGroups.flat.right}
>
<Pane
id="chat-sidebar"
maxWidth={SIDEBAR_MAX_WIDTH}
minWidth={SIDEBAR_DEFAULT_WIDTH}
resizable
side="left"
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
>
{sidebar}
</Pane>
<PaneMain>
<Routes>
<Route element={chatView} index />
<Route element={chatView} path=":sessionId" />
<Route
element={
<Suspense fallback={null}>
<SkillsView setStatusbarItemGroup={setStatusbarItemGroup} setTitlebarToolGroup={setTitlebarToolGroup} />
</Suspense>
}
path="skills"
/>
<Route
element={
<Suspense fallback={null}>
<ArtifactsView
setStatusbarItemGroup={setStatusbarItemGroup}
setTitlebarToolGroup={setTitlebarToolGroup}
/>
</Suspense>
}
path="artifacts"
/>
<Route element={null} path="settings" />
<Route element={null} path="command-center" />
<Route element={null} path="agents" />
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="new" />
<Route element={<LegacySessionRedirect />} path="sessions/:sessionId" />
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="*" />
</Routes>
</PaneMain>
<Pane
disabled={!chatOpen || (!previewTarget && !filePreviewTarget)}
id="preview"
maxWidth={PREVIEW_RAIL_MAX_WIDTH}
minWidth={PREVIEW_RAIL_MIN_WIDTH}
resizable
side="right"
width={PREVIEW_RAIL_PANE_WIDTH}
>
{chatOpen ? (
<ChatPreviewRail onRestartServer={restartPreviewServer} setTitlebarToolGroup={setTitlebarToolGroup} />
) : null}
</Pane>
<Pane
defaultOpen={false}
id="file-browser"
maxWidth={FILE_BROWSER_MAX_WIDTH}
minWidth={FILE_BROWSER_MIN_WIDTH}
resizable
side="right"
width={FILE_BROWSER_DEFAULT_WIDTH}
>
<FileBrowserPane onActivateFile={composer.attachContextFilePath} onChangeCwd={changeSessionCwd} />
</Pane>
</AppShell>
)
}
function LegacySessionRedirect() {
const { sessionId } = useParams()
return <Navigate replace to={sessionId ? sessionRoute(sessionId) : NEW_CHAT_ROUTE} />
}
-174
View File
@@ -1,174 +0,0 @@
import { useStore } from '@nanostores/react'
import { Button } from '@/components/ui/button'
import { FadeText } from '@/components/ui/fade-text'
import { FolderOpen, RefreshCw } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
import { ProjectTree } from './tree'
import { useProjectTree } from './use-project-tree'
const HEADER_ACTION_CLASS =
'pointer-events-none size-6 shrink-0 opacity-0 text-muted-foreground/75 transition-opacity hover:text-foreground focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100'
interface FileBrowserPaneProps {
/** Activates a file row — drops the path into the composer as `@file:` ref. */
onActivateFile: (path: string) => void
onChangeCwd: (path: string) => Promise<void> | void
}
export function FileBrowserPane({ onActivateFile, onChangeCwd }: FileBrowserPaneProps) {
const currentCwd = useStore($currentCwd).trim()
const hasCwd = currentCwd.length > 0
const cwdName = hasCwd
? (currentCwd
.split(/[\\/]+/)
.filter(Boolean)
.pop() ?? currentCwd)
: 'No folder selected'
const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd)
const chooseFolder = async () => {
const selected = await window.hermesDesktop?.selectPaths({
title: 'Change working directory',
defaultPath: hasCwd ? currentCwd : undefined,
directories: true,
multiple: false
})
if (selected?.[0]) {
await onChangeCwd(selected[0])
}
}
const previewFile = async (path: string) => {
try {
const preview = await normalizeOrLocalPreviewTarget(path, currentCwd || undefined)
if (!preview) {
throw new Error(`Could not preview ${path}`)
}
setCurrentSessionPreviewTarget(preview, 'file-browser', path)
} catch (error) {
notifyError(error, 'Preview unavailable')
}
}
return (
<aside
aria-label="File browser"
className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_94%,transparent)] pt-[calc(var(--titlebar-height)-0.625rem)] text-muted-foreground [backdrop-filter:blur(1.5rem)_saturate(1.08)]"
>
<header className="group/project-header shrink-0 pl-4 pr-2 pb-1 pt-0">
<div className="flex items-center gap-1.5">
<FadeText
className="flex-1 px-2 pb-1 pt-1 text-[0.64rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/70"
title={hasCwd ? currentCwd : 'No folder selected'}
>
{cwdName}
</FadeText>
<Button
aria-label="Change working directory"
className={HEADER_ACTION_CLASS}
onClick={() => void chooseFolder()}
size="icon"
title="Change working directory"
variant="ghost"
>
<FolderOpen className="size-3.5" />
</Button>
<Button
aria-label="Refresh tree"
className={HEADER_ACTION_CLASS}
disabled={!hasCwd || rootLoading}
onClick={() => void refreshRoot()}
size="icon"
title="Refresh tree"
variant="ghost"
>
<RefreshCw className={cn('size-3.5', rootLoading && 'animate-spin')} />
</Button>
</div>
</header>
<FileTreeBody
cwd={currentCwd}
data={data}
error={rootError}
loading={rootLoading}
onActivateFile={onActivateFile}
onLoadChildren={loadChildren}
onNodeOpenChange={setNodeOpen}
onPreviewFile={previewFile}
openState={openState}
/>
</aside>
)
}
interface FileTreeBodyProps {
cwd: string
data: ReturnType<typeof useProjectTree>['data']
error: string | null
loading: boolean
onActivateFile: (path: string) => void
onLoadChildren: (id: string) => void | Promise<void>
onNodeOpenChange: (id: string, open: boolean) => void
onPreviewFile?: (path: string) => void
openState: ReturnType<typeof useProjectTree>['openState']
}
function FileTreeBody({
cwd,
data,
error,
loading,
onActivateFile,
onLoadChildren,
onNodeOpenChange,
onPreviewFile,
openState
}: FileTreeBodyProps) {
if (!cwd) {
return <EmptyState body="Set a working directory from the status bar to browse files." title="No project" />
}
if (error) {
return <EmptyState body={`Could not read this folder (${error}).`} title="Unreadable" />
}
if (loading && data.length === 0) {
return <EmptyState body="Reading project…" title="Loading" />
}
if (data.length === 0) {
return <EmptyState body="This folder is empty." title="Empty" />
}
return (
<ProjectTree
data={data}
onActivateFile={onActivateFile}
onLoadChildren={onLoadChildren}
onNodeOpenChange={onNodeOpenChange}
onPreviewFile={onPreviewFile}
openState={openState}
/>
)
}
function EmptyState({ body, title }: { body: string; title: string }) {
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-1 px-4 text-center">
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/75">{title}</div>
<div className="text-[0.68rem] leading-relaxed text-muted-foreground/65">{body}</div>
</div>
)
}
-161
View File
@@ -1,161 +0,0 @@
import ignore from 'ignore'
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
export type ProjectTreeEntry = HermesReadDirEntry
interface GitignoreRule {
base: string
ig: ReturnType<typeof ignore>
}
const gitRootCache = new Map<string, Promise<string | null>>()
const gitignoreCache = new Map<string, Promise<GitignoreRule | null>>()
function decodeDataUrl(dataUrl: string) {
const match = dataUrl.match(/^data:[^,]*,(.*)$/)
const data = match?.[1] || ''
const isBase64 = dataUrl.slice(0, dataUrl.indexOf(',')).includes(';base64')
if (!isBase64) {
return decodeURIComponent(data)
}
const bytes = Uint8Array.from(atob(data), ch => ch.charCodeAt(0))
return new TextDecoder().decode(bytes)
}
function clean(path: string) {
return path.replace(/\/+$/, '') || '/'
}
/** Strict POSIX-style relative path; null if `child` is not inside `root`. */
function relativeTo(root: string, child: string) {
const r = clean(root)
const c = clean(child)
if (c === r) {
return ''
}
return c.startsWith(`${r}/`) ? c.slice(r.length + 1) : null
}
/** Repo-root → repo-root/a → repo-root/a/b → … for every dir between root and `dir`. */
function ancestorDirs(root: string, dir: string) {
const r = clean(root)
const rel = relativeTo(r, dir)
if (rel === null || rel === '') {
return [r]
}
const dirs = [r]
let current = r
for (const part of rel.split('/').filter(Boolean)) {
current = `${current}/${part}`
dirs.push(current)
}
return dirs
}
async function gitRootFor(start: string) {
if (!window.hermesDesktop?.gitRoot) {
return null
}
const key = clean(start)
let cached = gitRootCache.get(key)
if (!cached) {
cached = window.hermesDesktop.gitRoot(key)
gitRootCache.set(key, cached)
}
return cached
}
/** Read .gitignore at `dir` if it actually exists — never probe missing files. */
async function readGitignore(dir: string): Promise<GitignoreRule | null> {
if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {
return null
}
try {
const listing = await window.hermesDesktop.readDir(dir)
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {
return null
}
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
return { base: dir, ig: ignore().add(text) }
} catch {
return null
}
}
async function gitignoreFor(dir: string) {
const key = clean(dir)
let cached = gitignoreCache.get(key)
if (!cached) {
cached = readGitignore(key)
gitignoreCache.set(key, cached)
}
return cached
}
function ignoredBy(rules: GitignoreRule[], entry: HermesReadDirEntry) {
return rules.some(rule => {
const rel = relativeTo(rule.base, entry.path)
if (rel === null || rel === '') {
return false
}
return rule.ig.ignores(entry.isDirectory ? `${rel}/` : rel)
})
}
async function filterIgnored(entries: HermesReadDirEntry[], rootPath: string, dirPath: string) {
const root = await gitRootFor(rootPath)
if (!root) {
return entries
}
const rules = (await Promise.all(ancestorDirs(root, dirPath).map(gitignoreFor))).filter((r): r is GitignoreRule =>
Boolean(r)
)
return rules.length > 0 ? entries.filter(entry => !ignoredBy(rules, entry)) : entries
}
export async function readProjectDir(dirPath: string, rootPath = dirPath): Promise<HermesReadDirResult> {
if (!window.hermesDesktop) {
return { entries: [], error: 'no-bridge' }
}
const result = await window.hermesDesktop.readDir(dirPath)
return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
}
export function clearProjectDirCache(rootPath?: string) {
if (!rootPath) {
gitRootCache.clear()
gitignoreCache.clear()
return
}
const key = clean(rootPath)
gitRootCache.delete(key)
gitignoreCache.delete(key)
}
-183
View File
@@ -1,183 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { type NodeApi, type NodeRendererProps, Tree, type TreeApi } from 'react-arborist'
import { ChevronDown, ChevronRight, FileText, FolderOpen, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { TreeNode } from './use-project-tree'
const ROW_HEIGHT = 24
const INDENT = 14
interface ProjectTreeProps {
data: TreeNode[]
onActivateFile: (path: string) => void
onLoadChildren: (id: string) => void | Promise<void>
onNodeOpenChange: (id: string, open: boolean) => void
onPreviewFile?: (path: string) => void
openState: Record<string, boolean>
}
export function ProjectTree({
data,
onActivateFile,
onLoadChildren,
onNodeOpenChange,
onPreviewFile,
openState
}: ProjectTreeProps) {
const containerRef = useRef<HTMLDivElement | null>(null)
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
const [size, setSize] = useState({ height: 0, width: 0 })
useEffect(() => {
const el = containerRef.current
if (!el || typeof ResizeObserver === 'undefined') {
return
}
const observer = new ResizeObserver(([entry]) => {
const { height, width } = entry.contentRect
setSize({ height, width })
})
observer.observe(el)
return () => observer.disconnect()
}, [])
const handleToggle = useCallback(
(id: string) => {
const node = treeRef.current?.get(id)
if (!node) {
return
}
onNodeOpenChange(id, node.isOpen)
// Lazy fetch on first expand: children===undefined means "not yet loaded".
if (node.isOpen && node.data.children === undefined) {
void onLoadChildren(id)
}
},
[onLoadChildren, onNodeOpenChange]
)
const handleActivate = useCallback(
(node: NodeApi<TreeNode>) => {
if (!node.data.isDirectory) {
onPreviewFile?.(node.data.id)
}
},
[onPreviewFile]
)
return (
<div className="min-h-0 flex-1 overflow-hidden px-2" ref={containerRef}>
{size.height > 0 && size.width > 0 ? (
<Tree<TreeNode>
childrenAccessor={node => (node.isDirectory ? (node.children ?? []) : null)}
data={data}
disableDrag
disableDrop
disableEdit
height={size.height}
indent={INDENT}
initialOpenState={openState}
onActivate={handleActivate}
onToggle={handleToggle}
openByDefault={false}
padding={2}
ref={treeRef}
rowHeight={ROW_HEIGHT}
width={size.width}
>
{props => <ProjectTreeRow {...props} onAttachFile={onActivateFile} onPreviewFile={onPreviewFile} />}
</Tree>
) : null}
</div>
)
}
function ProjectTreeRow({
dragHandle,
node,
onAttachFile,
onPreviewFile,
style
}: NodeRendererProps<TreeNode> & { onAttachFile: (path: string) => void; onPreviewFile?: (path: string) => void }) {
const isFolder = node.data.isDirectory
const isPlaceholder = node.data.id.endsWith('::__loading__')
const Caret = node.isOpen ? ChevronDown : ChevronRight
return (
<div
aria-expanded={isFolder ? node.isOpen : undefined}
aria-selected={node.isSelected}
className={cn(
'group/row flex h-full cursor-pointer select-none items-center gap-1 rounded-sm px-1.5 text-[0.72rem] leading-none text-foreground/85 transition-colors hover:bg-accent/55',
node.isSelected && 'bg-accent/65 text-foreground',
isPlaceholder && 'pointer-events-none italic text-muted-foreground/70'
)}
draggable={!isPlaceholder}
onClick={event => {
event.stopPropagation()
if (isPlaceholder) {
return
}
if (isFolder) {
node.toggle()
} else {
node.select()
if (event.shiftKey) {
onAttachFile(node.data.id)
}
}
}}
onDoubleClick={event => {
event.stopPropagation()
if (!isFolder && !isPlaceholder) {
onPreviewFile?.(node.data.id)
}
}}
onDragStart={event => {
if (isPlaceholder) {
event.preventDefault()
return
}
// Custom MIME the composer's drop handler unpacks. text/plain is set
// as a fallback so dragging into other apps gets a sensible payload
// (the absolute path).
const payload = JSON.stringify([{ isDirectory: isFolder, path: node.data.id }])
event.dataTransfer.effectAllowed = 'copy'
event.dataTransfer.setData('application/x-hermes-paths', payload)
event.dataTransfer.setData('text/plain', node.data.id)
}}
ref={dragHandle}
style={style}
>
<span aria-hidden className={cn('flex w-3.5 items-center justify-center', !isFolder && 'opacity-0')}>
{isFolder && !isPlaceholder ? <Caret className="size-3 text-muted-foreground/70" /> : null}
</span>
<span aria-hidden className="flex w-3.5 items-center justify-center text-muted-foreground/85">
{isPlaceholder ? (
<Loader2 className="size-3 animate-spin" />
) : isFolder ? (
<FolderOpen className="size-3.5" />
) : (
<FileText className="size-3.5" />
)}
</span>
<span className="min-w-0 flex-1 truncate">{node.data.name}</span>
</div>
)
}
@@ -1,190 +0,0 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HermesReadDirResult } from '@/global'
import { resetProjectTreeState, useProjectTree } from './use-project-tree'
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
beforeEach(() => {
resetProjectTreeState()
readDir.mockReset()
;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir }
})
afterEach(() => {
resetProjectTreeState()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
function ok(entries: { name: string; path: string; isDirectory: boolean }[]): HermesReadDirResult {
return { entries }
}
describe('useProjectTree', () => {
it('starts empty when cwd is blank and skips IPC', async () => {
const { result } = renderHook(() => useProjectTree(''))
await waitFor(() => expect(result.current.rootLoading).toBe(false))
expect(result.current.data).toEqual([])
expect(result.current.rootError).toBeNull()
expect(readDir).not.toHaveBeenCalled()
})
it('loads root entries on mount and sorts folders before files', async () => {
readDir.mockResolvedValueOnce(
ok([
{ name: 'README.md', path: '/p/README.md', isDirectory: false },
{ name: 'src', path: '/p/src', isDirectory: true }
])
)
const { result } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.data.length).toBe(2))
expect(readDir).toHaveBeenCalledWith('/p')
// Hook trusts main-process sort order; folders/files preserved as supplied.
expect(result.current.data.map(n => n.name)).toEqual(['README.md', 'src'])
// Folder children start undefined (lazy load on first expand).
expect(result.current.data.find(n => n.name === 'src')?.children).toBeUndefined()
expect(result.current.data.find(n => n.name === 'src')?.isDirectory).toBe(true)
expect(result.current.data.find(n => n.name === 'README.md')?.isDirectory).toBe(false)
})
it('records rootError when readDir returns an error', async () => {
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
const { result } = renderHook(() => useProjectTree('/locked'))
await waitFor(() => expect(result.current.rootError).toBe('EACCES'))
expect(result.current.data).toEqual([])
})
it('lazy-loads children on loadChildren and replaces the placeholder', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'src', path: '/p/src', isDirectory: true }]))
readDir.mockResolvedValueOnce(
ok([
{ name: 'index.ts', path: '/p/src/index.ts', isDirectory: false },
{ name: 'lib', path: '/p/src/lib', isDirectory: true }
])
)
const { result } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.data.length).toBe(1))
await act(async () => {
await result.current.loadChildren('/p/src')
})
const src = result.current.data[0]
expect(src.children?.map(n => n.name)).toEqual(['index.ts', 'lib'])
expect(src.loading).toBe(false)
expect(src.error).toBeUndefined()
})
it('keeps loaded tree state across remounts for the same cwd', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'src', path: '/p/src', isDirectory: true }]))
const { result, unmount } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.data.length).toBe(1))
act(() => {
result.current.setNodeOpen('/p/src', true)
})
unmount()
const remounted = renderHook(() => useProjectTree('/p'))
expect(remounted.result.current.data.map(n => n.name)).toEqual(['src'])
expect(remounted.result.current.openState).toEqual({ '/p/src': true })
expect(readDir).toHaveBeenCalledTimes(1)
})
it('captures per-folder error code and leaves the folder expandable but empty', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }]))
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
const { result } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.data.length).toBe(1))
await act(async () => {
await result.current.loadChildren('/p/priv')
})
expect(result.current.data[0].error).toBe('EACCES')
expect(result.current.data[0].children).toEqual([])
})
it('dedupes concurrent loadChildren calls for the same id', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'src', path: '/p/src', isDirectory: true }]))
let resolveChildren: ((value: HermesReadDirResult) => void) | undefined
readDir.mockImplementationOnce(
() =>
new Promise<HermesReadDirResult>(resolve => {
resolveChildren = resolve
})
)
const { result } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.data.length).toBe(1))
await act(async () => {
// First call enters inflight, second short-circuits, third also short-circuits.
void result.current.loadChildren('/p/src')
void result.current.loadChildren('/p/src')
void result.current.loadChildren('/p/src')
resolveChildren?.(ok([{ name: 'a.ts', path: '/p/src/a.ts', isDirectory: false }]))
})
// Mount load + a single folder fetch — duplicates were dropped.
expect(readDir).toHaveBeenCalledTimes(2)
})
it('refreshRoot reloads the root and clears prior error', async () => {
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
readDir.mockResolvedValueOnce(ok([{ name: 'README.md', path: '/p/README.md', isDirectory: false }]))
const { result } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.rootError).toBe('EACCES'))
await act(async () => {
await result.current.refreshRoot()
})
expect(result.current.rootError).toBeNull()
expect(result.current.data.map(n => n.name)).toEqual(['README.md'])
})
it('reloads when cwd changes', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'one', path: '/a/one', isDirectory: false }]))
readDir.mockResolvedValueOnce(ok([{ name: 'two', path: '/b/two', isDirectory: false }]))
const { rerender, result } = renderHook(({ cwd }) => useProjectTree(cwd), { initialProps: { cwd: '/a' } })
await waitFor(() => expect(result.current.data[0]?.name).toBe('one'))
rerender({ cwd: '/b' })
await waitFor(() => expect(result.current.data[0]?.name).toBe('two'))
expect(readDir).toHaveBeenLastCalledWith('/b')
})
it('returns no-bridge gracefully when window.hermesDesktop is missing', async () => {
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
const { result } = renderHook(() => useProjectTree('/p'))
await waitFor(() => expect(result.current.rootError).toBe('no-bridge'))
expect(result.current.data).toEqual([])
})
})
@@ -1,245 +0,0 @@
import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { useCallback, useEffect, useMemo } from 'react'
import { clearProjectDirCache, readProjectDir } from './ipc'
export interface TreeNode {
/** Absolute filesystem path. Doubles as react-arborist node id. */
id: string
name: string
/** Drives arborist's leaf-vs-expandable decision via childrenAccessor. */
isDirectory: boolean
/** `undefined` = directory, children not yet loaded. `[]` = loaded empty. */
children?: TreeNode[]
/** True while a readDir for this folder is in flight. */
loading?: boolean
/** Last error code from readDir (e.g. EACCES). Cleared on next successful load. */
error?: string
}
const PLACEHOLDER_ID = '__loading__'
function makeNode(path: string, name: string, isDirectory: boolean): TreeNode {
return { id: path, isDirectory, name }
}
function patchNode(nodes: TreeNode[] | undefined | null, id: string, patch: (n: TreeNode) => TreeNode): TreeNode[] {
if (!nodes) {
return []
}
return nodes.map(n => {
if (n.id === id) {
return patch(n)
}
if (n.children && n.children.length > 0) {
return { ...n, children: patchNode(n.children, id, patch) }
}
return n
})
}
function placeholderChild(parentId: string): TreeNode {
return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…' }
}
export interface UseProjectTreeResult {
data: TreeNode[]
openState: Record<string, boolean>
rootError: string | null
rootLoading: boolean
loadChildren: (id: string) => Promise<void>
refreshRoot: () => Promise<void>
setNodeOpen: (id: string, open: boolean) => void
}
interface ProjectTreeState {
cwd: string
data: TreeNode[]
loaded: boolean
openState: Record<string, boolean>
requestId: number
rootError: string | null
rootLoading: boolean
}
const initialState: ProjectTreeState = {
cwd: '',
data: [],
loaded: false,
openState: {},
requestId: 0,
rootError: null,
rootLoading: false
}
const inflight = new Set<string>()
const $projectTree = atom<ProjectTreeState>(initialState)
let nextRootRequestId = 0
function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) {
$projectTree.set(updater($projectTree.get()))
}
function clearProjectTree() {
nextRootRequestId += 1
inflight.clear()
$projectTree.set({ ...initialState, requestId: nextRootRequestId })
}
async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}) {
if (!cwd) {
clearProjectTree()
return
}
const current = $projectTree.get()
if (!force && current.cwd === cwd && (current.loaded || current.rootLoading)) {
return
}
const requestId = nextRootRequestId + 1
nextRootRequestId = requestId
inflight.clear()
if (force || current.cwd !== cwd) {
clearProjectDirCache(cwd)
}
$projectTree.set({
cwd,
data: [],
loaded: false,
openState: current.cwd === cwd ? current.openState : {},
requestId,
rootError: null,
rootLoading: true
})
const { entries, error } = await readProjectDir(cwd, cwd)
setProjectTree(latest => {
if (latest.cwd !== cwd || latest.requestId !== requestId) {
return latest
}
return {
...latest,
data: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory)),
loaded: true,
rootError: error || null,
rootLoading: false
}
})
}
export function resetProjectTreeState() {
clearProjectTree()
clearProjectDirCache()
}
/**
* Lazy-loads a directory tree rooted at `cwd`. Children are fetched on first
* expand and cached in this feature-owned atom so unrelated chat rerenders or
* remounts cannot reset the browser. A placeholder leaf renders so the
* disclosure caret shows for unloaded folders. `refreshRoot` invalidates the
* whole tree (used after cwd change or manual refresh).
*/
export function useProjectTree(cwd: string): UseProjectTreeResult {
const state = useStore($projectTree)
const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd])
const setNodeOpen = useCallback(
(id: string, open: boolean) => {
setProjectTree(current => {
if (current.cwd !== cwd || current.openState[id] === open) {
return current
}
return {
...current,
openState: {
...current.openState,
[id]: open
}
}
})
},
[cwd]
)
const loadChildren = useCallback(
async (id: string) => {
if (!cwd || inflight.has(id)) {
return
}
inflight.add(id)
setProjectTree(current => {
if (current.cwd !== cwd) {
return current
}
return {
...current,
data: patchNode(current.data, id, n => ({ ...n, loading: true, children: [placeholderChild(n.id)] }))
}
})
const { entries, error } = await readProjectDir(id, cwd)
inflight.delete(id)
setProjectTree(current => {
if (current.cwd !== cwd) {
return current
}
return {
...current,
data: patchNode(current.data, id, n => ({
...n,
loading: false,
error: error || undefined,
children: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
}))
}
})
},
[cwd]
)
useEffect(() => {
void loadRoot(cwd)
}, [cwd])
return useMemo(
() => ({
data: state.cwd === cwd ? state.data : [],
loadChildren,
openState: state.cwd === cwd ? state.openState : {},
refreshRoot,
rootError: state.cwd === cwd ? state.rootError : null,
rootLoading: state.cwd === cwd ? state.rootLoading : false,
setNodeOpen
}),
[
cwd,
loadChildren,
refreshRoot,
setNodeOpen,
state.cwd,
state.data,
state.openState,
state.rootError,
state.rootLoading
]
)
}
@@ -1,155 +0,0 @@
import { useEffect, useRef } from 'react'
import { HermesGateway } from '@/hermes'
import {
$desktopBoot,
applyDesktopBootProgress,
completeDesktopBoot,
failDesktopBoot,
setDesktopBootStep
} from '@/store/boot'
import { setGateway } from '@/store/gateway'
import { notify, notifyError } from '@/store/notifications'
import { setConnection, setGatewayState, setSessionsLoading } from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
interface GatewayBootOptions {
handleGatewayEvent: (event: RpcEvent) => void
onConnectionReady: (
connection: Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null
) => void
onGatewayReady: (gateway: HermesGateway | null) => void
refreshHermesConfig: () => Promise<void>
refreshSessions: () => Promise<void>
}
export function useGatewayBoot({
handleGatewayEvent,
onConnectionReady,
onGatewayReady,
refreshHermesConfig,
refreshSessions
}: GatewayBootOptions) {
const callbacksRef = useRef({
handleGatewayEvent,
onConnectionReady,
onGatewayReady,
refreshHermesConfig,
refreshSessions
})
callbacksRef.current = {
handleGatewayEvent,
onConnectionReady,
onGatewayReady,
refreshHermesConfig,
refreshSessions
}
useEffect(() => {
let cancelled = false
const desktop = window.hermesDesktop
if (!desktop) {
failDesktopBoot('Desktop IPC bridge is unavailable.')
setSessionsLoading(false)
return () => void (cancelled = true)
}
const offBootProgress = desktop.onBootProgress(payload => applyDesktopBootProgress(payload))
void desktop
.getBootProgress()
.then(snapshot => applyDesktopBootProgress(snapshot))
.catch(() => undefined)
setDesktopBootStep({
phase: 'renderer.boot',
message: 'Starting desktop connection',
progress: 6
})
const gateway = new HermesGateway()
callbacksRef.current.onGatewayReady(gateway)
setGateway(gateway)
const offState = gateway.onState(st => void setGatewayState(st))
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent(event))
const offExit = desktop.onBackendExit(() => {
if ($desktopBoot.get().running || $desktopBoot.get().visible) {
failDesktopBoot('Hermes background process exited during startup.')
}
notify({
kind: 'error',
title: 'Backend stopped',
message: 'Hermes background process exited.',
durationMs: 0
})
})
async function boot() {
try {
const conn = await desktop.getConnection()
if (cancelled) {
return
}
setDesktopBootStep({
phase: 'renderer.gateway.connect',
message: 'Connecting live desktop gateway',
progress: 95
})
callbacksRef.current.onConnectionReady(conn)
setConnection(conn)
await gateway.connect(conn.wsUrl)
if (cancelled) {
return
}
setDesktopBootStep({
phase: 'renderer.config',
message: 'Loading Hermes settings',
progress: 97
})
await callbacksRef.current.refreshHermesConfig()
if (cancelled) {
return
}
setDesktopBootStep({
phase: 'renderer.sessions',
message: 'Loading recent sessions',
progress: 99
})
await callbacksRef.current.refreshSessions()
completeDesktopBoot()
} catch (err) {
if (!cancelled) {
const message = err instanceof Error ? err.message : String(err)
failDesktopBoot(message)
notifyError(err, 'Desktop boot failed')
setSessionsLoading(false)
}
}
}
void boot()
return () => {
cancelled = true
offState()
offEvent()
offExit()
offBootProgress()
gateway.close()
callbacksRef.current.onConnectionReady(null)
callbacksRef.current.onGatewayReady(null)
setGateway(null)
}
}, [])
}
@@ -1,94 +0,0 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef } from 'react'
import type { HermesGateway } from '@/hermes'
import { $gatewayState, setConnection } from '@/store/session'
export function useGatewayRequest() {
const gatewayState = useStore($gatewayState)
const gatewayRef = useRef<HermesGateway | null>(null)
const connectionRef = useRef<Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null>(
null
)
const gatewayStateRef = useRef(gatewayState)
const reconnectingRef = useRef<Promise<HermesGateway | null> | null>(null)
useEffect(() => {
gatewayStateRef.current = gatewayState
}, [gatewayState])
const ensureGatewayOpen = useCallback(async () => {
const existing = gatewayRef.current
if (!existing) {
return null
}
if (gatewayStateRef.current === 'open') {
return existing
}
if (reconnectingRef.current) {
return reconnectingRef.current
}
reconnectingRef.current = (async () => {
const desktop = window.hermesDesktop
if (!desktop) {
return null
}
try {
const conn = await desktop.getConnection()
connectionRef.current = conn
setConnection(conn)
await existing.connect(conn.wsUrl)
return existing
} catch {
connectionRef.current = null
setConnection(null)
return null
} finally {
reconnectingRef.current = null
}
})()
return reconnectingRef.current
}, [])
const requestGateway = useCallback(
async <T>(method: string, params: Record<string, unknown> = {}) => {
const gateway = gatewayRef.current
if (!gateway) {
throw new Error('Hermes gateway unavailable')
}
try {
return await gateway.request<T>(method, params)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!/not connected|connection closed/i.test(message)) {
throw error
}
const recovered = await ensureGatewayOpen()
if (!recovered) {
throw error
}
return recovered.request<T>(method, params)
}
},
[ensureGatewayOpen]
)
return { connectionRef, gatewayRef, requestGateway }
}
-1
View File
@@ -1 +0,0 @@
export { DesktopController as default } from './desktop-controller'
@@ -1,42 +0,0 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { ModelPickerDialog } from '@/components/model-picker'
import type { HermesGateway } from '@/hermes'
import {
$activeSessionId,
$currentModel,
$currentProvider,
$gatewayState,
$modelPickerOpen,
setModelPickerOpen
} from '@/store/session'
interface ModelPickerOverlayProps {
gateway?: HermesGateway
onSelect: React.ComponentProps<typeof ModelPickerDialog>['onSelect']
}
export function ModelPickerOverlay({ gateway, onSelect }: ModelPickerOverlayProps) {
const activeSessionId = useStore($activeSessionId)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const gatewayOpen = useStore($gatewayState) === 'open'
const open = useStore($modelPickerOpen)
if (!gatewayOpen) {
return null
}
return (
<ModelPickerDialog
currentModel={currentModel}
currentProvider={currentProvider}
gw={gateway}
onOpenChange={setModelPickerOpen}
onSelect={onSelect}
open={open}
sessionId={activeSessionId}
/>
)
}
@@ -1,66 +0,0 @@
import type { ButtonHTMLAttributes, ComponentProps, ReactNode } from 'react'
import { cn } from '@/lib/utils'
export const overlayCardClass =
'rounded-lg border border-[color-mix(in_srgb,var(--dt-border)_52%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)] shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_34%,transparent)]'
interface OverlayCardProps extends ComponentProps<'div'> {
children: ReactNode
}
interface OverlayActionButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
tone?: 'default' | 'danger' | 'subtle'
}
export function OverlayCard({ children, className, ...props }: OverlayCardProps) {
return (
<div className={cn(overlayCardClass, className)} {...props}>
{children}
</div>
)
}
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
}
export function OverlayIconButton({ children, className, type = 'button', ...props }: OverlayIconButtonProps) {
return (
<OverlayActionButton
className={cn('h-7 w-7 justify-center px-0 [&_svg]:size-4', className)}
tone="subtle"
type={type}
{...props}
>
{children}
</OverlayActionButton>
)
}
@@ -1,59 +0,0 @@
import type { RefObject } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Loader2, Search, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface OverlaySearchInputProps {
placeholder: string
value: string
onChange: (value: string) => void
containerClassName?: string
inputClassName?: string
loading?: boolean
onClear?: () => void
inputRef?: RefObject<HTMLInputElement | null>
}
export function OverlaySearchInput({
placeholder,
value,
onChange,
containerClassName,
inputClassName,
loading = false,
onClear,
inputRef
}: OverlaySearchInputProps) {
const clear = onClear ?? (() => onChange(''))
return (
<div className={cn('relative', containerClassName)}>
<Search className="pointer-events-none absolute left-3 top-1/2 z-1 size-3.5 -translate-y-1/2 text-muted-foreground/80" />
<Input
className={cn(
'relative z-0 h-8.5 rounded-full border border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)] py-2 pl-8 pr-12 text-sm shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_38%,transparent)] focus-visible:border-[color-mix(in_srgb,var(--dt-ring)_70%,transparent)] focus-visible:bg-background dark:border-[color-mix(in_srgb,var(--dt-border)_48%,transparent)] dark:bg-[color-mix(in_srgb,var(--dt-card)_96%,var(--dt-background))] dark:shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_10%,transparent)]',
inputClassName
)}
onChange={event => onChange(event.target.value)}
placeholder={placeholder}
ref={inputRef}
value={value}
/>
{loading ? (
<Loader2 className="pointer-events-none absolute right-3 top-1/2 z-1 size-3.5 -translate-y-1/2 animate-spin text-muted-foreground/70" />
) : value ? (
<Button
aria-label="Clear search"
className="absolute right-1.5 top-1/2 z-1 -translate-y-1/2 text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
onClick={clear}
size="icon-xs"
variant="ghost"
>
<X className="size-3.5" />
</Button>
) : null}
</div>
)
}
@@ -1,78 +0,0 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface OverlaySplitLayoutProps {
children: ReactNode
className?: string
}
interface OverlaySidebarProps {
children: ReactNode
className?: string
}
interface OverlayMainProps {
children: ReactNode
className?: string
}
interface OverlayNavItemProps {
active: boolean
icon: LucideIcon
label: string
onClick: () => void
trailing?: ReactNode
}
export function OverlaySplitLayout({ children, className }: OverlaySplitLayoutProps) {
return (
<div
className={cn(
'grid h-full min-h-0 flex-1 grid-cols-[13rem_minmax(0,1fr)] overflow-hidden rounded-[0.95rem] border border-[color-mix(in_srgb,var(--dt-border)_58%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_94%,transparent)] shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_46%,transparent),0_0.5rem_1.5rem_-1rem_color-mix(in_srgb,#000_22%,transparent)] max-[760px]:grid-cols-1 dark:border-[color-mix(in_srgb,var(--dt-border)_36%,transparent)] dark:shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_10%,transparent),0_0.5rem_1.5rem_-1rem_color-mix(in_srgb,#000_45%,transparent)]',
className
)}
>
{children}
</div>
)
}
export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
return (
<aside
className={cn(
'flex min-h-0 flex-col gap-0.5 overflow-y-auto border-r border-[color-mix(in_srgb,var(--dt-border)_48%,transparent)] bg-[color-mix(in_srgb,var(--dt-muted)_55%,var(--dt-card))] px-3.5 py-4',
className
)}
>
{children}
</aside>
)
}
export function OverlayMain({ children, className }: OverlayMainProps) {
return (
<main className={cn('flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent p-4', className)}>{children}</main>
)
}
export function OverlayNavItem({ active, icon: Icon, label, onClick, trailing }: OverlayNavItemProps) {
return (
<button
className={cn(
'flex h-8 w-full items-center justify-start gap-2 rounded-md border px-2.5 text-left text-sm font-medium transition-colors',
active
? 'border-[color-mix(in_srgb,var(--dt-primary)_34%,var(--dt-border))] bg-[color-mix(in_srgb,var(--dt-primary)_10%,var(--dt-card))] text-foreground shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_40%,transparent)]'
: 'border-transparent bg-transparent text-foreground/78 hover:border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] hover:bg-[color-mix(in_srgb,var(--dt-card)_78%,transparent)] hover:text-foreground'
)}
onClick={onClick}
type="button"
>
<Icon className={cn('size-4 shrink-0', active ? 'text-foreground/80' : 'text-muted-foreground/80')} />
<span className="min-w-0 flex-1 truncate">{label}</span>
{trailing}
</button>
)
}
@@ -1,68 +0,0 @@
import type { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { triggerHaptic } from '@/lib/haptics'
import { X } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface OverlayViewProps {
children: ReactNode
onClose: () => void
closeLabel?: string
contentClassName?: string
headerContent?: ReactNode
rootClassName?: string
}
export function OverlayView({
children,
onClose,
closeLabel = 'Close',
contentClassName,
headerContent,
rootClassName
}: OverlayViewProps) {
const closeOverlay = () => {
triggerHaptic('close')
onClose()
}
return (
<div
className="fixed inset-0 z-50 bg-black/22 p-3 backdrop-blur-[2px] sm:p-8"
onClick={event => {
if (event.target === event.currentTarget) {
closeOverlay()
}
}}
role="presentation"
>
<div
className={cn(
'relative flex h-full min-h-0 flex-col overflow-hidden rounded-2xl border border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] bg-background/96 shadow-[0_1.5rem_4rem_-2rem_color-mix(in_srgb,#000_40%,transparent)]',
rootClassName
)}
>
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[calc(var(--titlebar-height)+0.1875rem)] [-webkit-app-region:drag]">
{headerContent && (
<div className="pointer-events-auto absolute left-1/2 top-[calc(1rem+var(--titlebar-height)/2)] -translate-x-1/2 -translate-y-1/2 [-webkit-app-region:no-drag]">
{headerContent}
</div>
)}
<Button
aria-label={closeLabel}
className="pointer-events-auto absolute right-3.75 top-[calc(0.1875rem+var(--titlebar-height)/2)] h-7 w-7 -translate-y-1/2 rounded-lg text-muted-foreground hover:bg-accent/70 hover:text-foreground [-webkit-app-region:no-drag]"
onClick={closeOverlay}
size="icon"
variant="ghost"
>
<X size={16} />
</Button>
</div>
<div className={cn('min-h-0 flex flex-1 flex-col pt-(--titlebar-height)', contentClassName)}>{children}</div>
</div>
</div>
)
}
-55
View File
@@ -1,55 +0,0 @@
export const SESSION_ROUTE_PREFIX = '/'
export const NEW_CHAT_ROUTE = '/'
export const SETTINGS_ROUTE = '/settings'
export const COMMAND_CENTER_ROUTE = '/command-center'
export const SKILLS_ROUTE = '/skills'
export const ARTIFACTS_ROUTE = '/artifacts'
export const AGENTS_ROUTE = '/agents'
export type AppView = 'chat' | 'settings' | 'command-center' | 'skills' | 'artifacts' | 'agents'
export type AppRouteId = 'new' | 'settings' | 'command-center' | 'skills' | 'artifacts' | 'agents'
export interface AppRoute {
id: AppRouteId
path: string
view: AppView
}
export const APP_ROUTES = [
{ id: 'new', path: NEW_CHAT_ROUTE, view: 'chat' },
{ id: 'settings', path: SETTINGS_ROUTE, view: 'settings' },
{ id: 'command-center', path: COMMAND_CENTER_ROUTE, view: 'command-center' },
{ id: 'skills', path: SKILLS_ROUTE, view: 'skills' },
{ id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' },
{ id: 'agents', path: AGENTS_ROUTE, view: 'agents' }
] as const satisfies readonly AppRoute[]
const APP_VIEW_BY_PATH = new Map<string, AppView>(APP_ROUTES.map(route => [route.path, route.view]))
const RESERVED_PATHS: ReadonlySet<string> = new Set(APP_ROUTES.map(route => route.path))
export function isNewChatRoute(pathname: string): boolean {
return pathname === NEW_CHAT_ROUTE
}
export function routeSessionId(pathname: string): string | null {
if (!pathname.startsWith(SESSION_ROUTE_PREFIX) || RESERVED_PATHS.has(pathname)) {
return null
}
const id = pathname.slice(SESSION_ROUTE_PREFIX.length)
return id && !id.includes('/') ? decodeURIComponent(id) : null
}
export function sessionRoute(sessionId: string): string {
return `${SESSION_ROUTE_PREFIX}${encodeURIComponent(sessionId)}`
}
export function appViewForPath(pathname: string): AppView {
if (isNewChatRoute(pathname) || routeSessionId(pathname)) {
return 'chat'
}
return APP_VIEW_BY_PATH.get(pathname) ?? 'chat'
}
@@ -1,58 +0,0 @@
import { type MutableRefObject, useCallback, useEffect } from 'react'
import { $currentCwd, setContextSuggestions } from '@/store/session'
import type { ContextSuggestion } from '../../types'
interface ContextSuggestionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
currentCwd: string
gatewayState: string | undefined
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}
export function useContextSuggestions({
activeSessionId,
activeSessionIdRef,
currentCwd,
gatewayState,
requestGateway
}: ContextSuggestionsOptions) {
const refresh = useCallback(async () => {
if (!activeSessionId) {
setContextSuggestions([])
return
}
const sessionId = activeSessionId
const cwd = currentCwd || ''
// Race guard: only commit if the session+cwd we sent for still match
// by the time the gateway responds.
const stillCurrent = () => activeSessionIdRef.current === sessionId && $currentCwd.get() === cwd
try {
const result = await requestGateway<{ items?: ContextSuggestion[] }>('complete.path', {
session_id: sessionId,
word: '@file:',
cwd: cwd || undefined
})
if (stillCurrent()) {
setContextSuggestions((result.items || []).filter(i => i.text))
}
} catch {
if (stillCurrent()) {
setContextSuggestions([])
}
}
}, [activeSessionId, activeSessionIdRef, currentCwd, requestGateway])
useEffect(() => {
if (gatewayState === 'open' && activeSessionId) {
void refresh()
}
}, [activeSessionId, gatewayState, refresh])
}
@@ -1,119 +0,0 @@
import { type MutableRefObject, useCallback } from 'react'
import { notify, notifyError } from '@/store/notifications'
import { $currentCwd, setCurrentBranch, setCurrentCwd } from '@/store/session'
import type { SessionRuntimeInfo } from '@/types/hermes'
interface CwdActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
currentCwd: string
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}
export function useCwdActions({ activeSessionId, activeSessionIdRef, currentCwd, requestGateway }: CwdActionsOptions) {
const refreshProjectBranch = useCallback(
async (cwd: string) => {
const target = cwd.trim()
if (!target || activeSessionIdRef.current) {
return
}
try {
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', {
key: 'project',
cwd: target
})
if (!activeSessionIdRef.current && ($currentCwd.get() || target) === (info.cwd || target)) {
setCurrentBranch(info.branch || '')
}
} catch {
setCurrentBranch('')
}
},
[activeSessionIdRef, requestGateway]
)
const changeSessionCwd = useCallback(
async (cwd: string) => {
const trimmed = cwd.trim()
if (!trimmed) {
return
}
const persistGlobal = async () => {
const info = await requestGateway<{ branch?: string; cwd?: string; value?: string }>('config.set', {
...(activeSessionId && { session_id: activeSessionId }),
key: 'terminal.cwd',
value: trimmed
})
setCurrentCwd(info.cwd || info.value || trimmed)
if (!activeSessionId) {
setCurrentBranch(info.branch || '')
}
}
if (!activeSessionId) {
try {
await persistGlobal()
} catch (err) {
notifyError(err, 'Working directory change failed')
}
return
}
try {
const info = await requestGateway<SessionRuntimeInfo>('session.cwd.set', {
session_id: activeSessionId,
cwd: trimmed
})
setCurrentCwd(info.cwd || trimmed)
setCurrentBranch(info.branch || '')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
// Older gateways without `session.cwd.set` fall back to a global write —
// user has to restart the active session for it to take effect.
if (!message.includes('unknown method')) {
notifyError(err, 'Working directory change failed')
return
}
try {
await persistGlobal()
notify({
kind: 'warning',
title: 'Working directory saved',
message: 'Restart the desktop backend to apply cwd changes to this active session.'
})
} catch (fallbackErr) {
notifyError(fallbackErr, 'Working directory change failed')
}
}
},
[activeSessionId, requestGateway]
)
const browseSessionCwd = useCallback(async () => {
const paths = await window.hermesDesktop?.selectPaths({
title: 'Change working directory',
defaultPath: currentCwd || undefined,
directories: true,
multiple: false
})
if (paths?.[0]) {
await changeSessionCwd(paths[0])
}
}, [changeSessionCwd, currentCwd])
return { browseSessionCwd, changeSessionCwd, refreshProjectBranch }
}

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