Compare commits
20 Commits
bb/tui-gat
...
fix/window
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec1714e71f | ||
|
|
e0c03defd5 | ||
|
|
9c914c01c8 | ||
|
|
6098272454 | ||
|
|
bf43f6cfdd | ||
|
|
f5ec30dfe6 | ||
|
|
8798bea31f | ||
|
|
668e4b8d7e | ||
|
|
fab984c7f8 | ||
|
|
f0d2516a30 | ||
|
|
2e403bd0a4 | ||
|
|
2c7b479d16 | ||
|
|
225b57f314 | ||
|
|
4d7e72e14d | ||
|
|
787d964ea1 | ||
|
|
cf9b2df57a | ||
|
|
eeb723fff2 | ||
|
|
1da89528e7 | ||
|
|
5486ad2f2a | ||
|
|
fda234a210 |
@@ -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
.github/workflows/desktop-release.yml
vendored
@@ -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
|
||||
8
.github/workflows/nix-lockfile-fix.yml
vendored
@@ -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
.gitignore
vendored
@@ -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
|
||||
|
||||
27
AGENTS.md
@@ -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`.
|
||||
|
||||
16
README.md
@@ -30,15 +30,27 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
|
||||
|
||||
## Quick Install
|
||||
|
||||
### Linux, macOS, WSL2, Termux
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
Works on Linux, macOS, WSL2, and Android via Termux. The installer handles the platform-specific setup for you.
|
||||
### Windows (native, PowerShell)
|
||||
|
||||
Run this in PowerShell:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
The installer handles everything: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **and a portable Git Bash** (MinGit, unpacked to `%LOCALAPPDATA%\hermes\git` — no admin required, completely isolated from any system Git install). Hermes uses this bundled Git Bash to run shell commands.
|
||||
|
||||
If you already have Git installed, the installer detects it and uses that instead. Otherwise a ~45MB MinGit download is all you need — it won't touch or interfere with any system Git.
|
||||
|
||||
> **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies.
|
||||
>
|
||||
> **Windows:** Native Windows is not supported. Please install [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) and run the command above.
|
||||
> **Windows:** Native Windows is supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively).
|
||||
|
||||
After installation:
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ Usage::
|
||||
hermes-acp
|
||||
"""
|
||||
|
||||
# IMPORTANT: hermes_bootstrap must be the very first import — UTF-8 stdio
|
||||
# on Windows. No-op on POSIX. See hermes_bootstrap.py for full rationale.
|
||||
import hermes_bootstrap # noqa: F401
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
|
||||
@@ -1607,7 +1607,7 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
|
||||
# terminal. The background-thread runner also hides it; this
|
||||
# belt-and-suspenders path matters when a caller invokes
|
||||
# run_curator_review(synchronous=True) from the CLI.
|
||||
with open(os.devnull, "w") as _devnull, \
|
||||
with open(os.devnull, "w", encoding="utf-8") as _devnull, \
|
||||
contextlib.redirect_stdout(_devnull), \
|
||||
contextlib.redirect_stderr(_devnull):
|
||||
conv_result = review_agent.run_conversation(user_message=prompt)
|
||||
|
||||
@@ -754,7 +754,7 @@ def _load_context_cache() -> Dict[str, int]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(path) as f:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
return data.get("context_lengths", {})
|
||||
except Exception as e:
|
||||
@@ -776,7 +776,7 @@ def save_context_length(model: str, base_url: str, length: int) -> None:
|
||||
path = _get_context_cache_path()
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
yaml.dump({"context_lengths": cache}, f, default_flow_style=False)
|
||||
logger.info("Cached context length %s -> %s tokens", key, f"{length:,}")
|
||||
except Exception as e:
|
||||
@@ -800,7 +800,7 @@ def _invalidate_cached_context_length(model: str, base_url: str) -> None:
|
||||
path = _get_context_cache_path()
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
yaml.dump({"context_lengths": cache}, f, default_flow_style=False)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to invalidate context length cache entry %s: %s", key, e)
|
||||
|
||||
@@ -144,7 +144,7 @@ def nous_rate_limit_remaining() -> Optional[float]:
|
||||
"""
|
||||
path = _state_path()
|
||||
try:
|
||||
with open(path) as f:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
state = json.load(f)
|
||||
reset_at = state.get("reset_at", 0)
|
||||
remaining = reset_at - time.time()
|
||||
|
||||
@@ -617,7 +617,7 @@ def _locked_update_approvals() -> Iterator[Dict[str, Any]]:
|
||||
save_allowlist(data)
|
||||
return
|
||||
|
||||
with open(lock_path, "a+") as lock_fh:
|
||||
with open(lock_path, "a+", encoding="utf-8") as lock_fh:
|
||||
fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
data = load_allowlist()
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 MiB |
@@ -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)}`)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"arrowParens": "avoid",
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "auto",
|
||||
"printWidth": 120,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"useTabs": false
|
||||
}
|
||||
@@ -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 user’s 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.
|
||||
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 674 KiB |
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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.*']
|
||||
}
|
||||
]
|
||||
@@ -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
apps/desktop/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 883 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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'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's <code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-[0.65rem]">/agents</code> overlay.
|
||||
</p>
|
||||
</div>
|
||||
</OverlayCard>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 +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>
|
||||
)
|
||||
}
|
||||