mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-15 14:41:16 +08:00
Compare commits
7 Commits
dependabot
...
feat/billi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc3ada763d | ||
|
|
300afcc0b5 | ||
|
|
9bbfe926bc | ||
|
|
1986666c12 | ||
|
|
a0428b872c | ||
|
|
2275fa79ca | ||
|
|
d869bde319 |
@@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
|
||||
2
.github/workflows/contributor-check.yml
vendored
2
.github/workflows/contributor-check.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
check-attribution:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for git log
|
||||
|
||||
|
||||
101
.github/workflows/deploy-site.yml
vendored
101
.github/workflows/deploy-site.yml
vendored
@@ -11,20 +11,8 @@ on:
|
||||
- 'optional-skills/**'
|
||||
- '.github/workflows/deploy-site.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skills_index_run_id:
|
||||
description: 'Optional Build Skills Index run ID whose skills-index artifact should be deployed'
|
||||
required: false
|
||||
type: string
|
||||
rebuild_skills_index:
|
||||
description: 'Force a fresh multi-source crawl instead of reusing the latest healthy index'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
@@ -52,7 +40,7 @@ jobs:
|
||||
name: github-pages
|
||||
url: ${{ steps.deploy.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
@@ -67,81 +55,26 @@ jobs:
|
||||
- name: Install PyYAML for skill extraction
|
||||
run: pip install pyyaml==6.0.2 httpx==0.28.1
|
||||
|
||||
- name: Prepare skills index (unified multi-source catalog)
|
||||
- name: Build skills index (unified multi-source catalog)
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }}
|
||||
REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# The unified external catalog is expensive to crawl and can burn
|
||||
# through the repository installation's GitHub API quota when several
|
||||
# docs deploys land close together. Normal docs deploys therefore
|
||||
# reuse the latest healthy catalog: first the artifact from a
|
||||
# scheduled skills-index run, then the currently live index. Only a
|
||||
# manual force rebuild does a fresh crawl here.
|
||||
# Rebuild the unified catalog. The file is gitignored, so a fresh
|
||||
# checkout starts without it and we want the freshest crawl in
|
||||
# every deploy.
|
||||
#
|
||||
# If we do crawl, the build remains fatal. build_skills_index.py runs
|
||||
# the health check BEFORE writing and exits non-zero on source
|
||||
# collapse, keeping the last good Pages deployment live instead of
|
||||
# publishing a degenerate catalog.
|
||||
set -euo pipefail
|
||||
INDEX_PATH="website/static/api/skills-index.json"
|
||||
mkdir -p "$(dirname "$INDEX_PATH")"
|
||||
|
||||
validate_index() {
|
||||
python3 - "$INDEX_PATH" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
print(f"invalid skills index JSON: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
skills = data.get("skills")
|
||||
if not isinstance(skills, list) or len(skills) < 1500:
|
||||
count = len(skills) if isinstance(skills, list) else "missing"
|
||||
print(f"skills index too small: {count}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"skills index ready: {len(skills)} skills")
|
||||
PY
|
||||
}
|
||||
|
||||
if [ "$REBUILD_SKILLS_INDEX" = "true" ]; then
|
||||
python3 scripts/build_skills_index.py
|
||||
validate_index
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "$SKILLS_INDEX_RUN_ID" ]; then
|
||||
tmpdir="$(mktemp -d)"
|
||||
echo "Downloading skills-index artifact from run $SKILLS_INDEX_RUN_ID"
|
||||
if gh run download "$SKILLS_INDEX_RUN_ID" --name skills-index --dir "$tmpdir"; then
|
||||
candidate="$(find "$tmpdir" -name skills-index.json -type f | head -n 1 || true)"
|
||||
if [ -n "$candidate" ]; then
|
||||
cp "$candidate" "$INDEX_PATH"
|
||||
if validate_index; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo "::warning::Could not use skills-index artifact from run $SKILLS_INDEX_RUN_ID; trying live index"
|
||||
fi
|
||||
|
||||
echo "Downloading currently live skills index"
|
||||
if curl -fsSL --retry 3 --retry-delay 5 \
|
||||
"https://hermes-agent.nousresearch.com/docs/api/skills-index.json" \
|
||||
-o "$INDEX_PATH" && validate_index; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::warning::Live skills index unavailable or unhealthy; falling back to a fresh crawl"
|
||||
rm -f "$INDEX_PATH"
|
||||
# This MUST be fatal. build_skills_index.py runs a health check and
|
||||
# exits non-zero WITHOUT writing the output file when a source
|
||||
# collapses (e.g. a GitHub API rate limit zeroes the github /
|
||||
# claude-marketplace / well-known taps all at once). Letting the
|
||||
# deploy continue would either (a) ship a degenerate index missing
|
||||
# whole hubs — the June 2026 regression where OpenAI/Anthropic/
|
||||
# HuggingFace/NVIDIA tabs vanished — or (b) fall through to a
|
||||
# local-only catalog. Failing here keeps the last good deployment
|
||||
# live (GitHub Pages serves the previous build) instead of
|
||||
# publishing a broken catalog. Re-run the workflow once the
|
||||
# transient rate limit clears.
|
||||
python3 scripts/build_skills_index.py
|
||||
validate_index
|
||||
|
||||
- name: Extract skill metadata for dashboard
|
||||
run: python3 website/scripts/extract-skills.py
|
||||
|
||||
6
.github/workflows/docker-lint.yml
vendored
6
.github/workflows/docker-lint.yml
vendored
@@ -40,10 +40,10 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: hadolint
|
||||
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
|
||||
uses: hadolint/hadolint-action@54c9adbab1582c2ef04b2016b760714a4bfde3cf # v3.1.0
|
||||
with:
|
||||
dockerfile: Dockerfile
|
||||
config: .hadolint.yaml
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: shellcheck
|
||||
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # v2.0.0
|
||||
|
||||
22
.github/workflows/docker-publish.yml
vendored
22
.github/workflows/docker-publish.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
digest: ${{ steps.push.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
# to gha with a per-arch scope; the push step below reuses every
|
||||
# layer from this build.
|
||||
- name: Build image (amd64, smoke test)
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
- name: Push amd64 by digest
|
||||
id: push
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
digest: ${{ steps.push.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
# crashed the build before the smoke test (the reason the gha cache
|
||||
# was removed from arm64 PRs in the first place).
|
||||
- name: Log in to ghcr.io (build cache)
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -225,7 +225,7 @@ jobs:
|
||||
# token failure mode cannot recur.
|
||||
- name: Build image (arm64, smoke test, cache read-only PR)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -241,7 +241,7 @@ jobs:
|
||||
# PR/main build starts warm.
|
||||
- name: Build image (arm64, smoke test, cached publish)
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -260,7 +260,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -268,7 +268,7 @@ jobs:
|
||||
- name: Push arm64 by digest
|
||||
id: push
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -322,7 +322,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
2
.github/workflows/docs-site-checks.yml
vendored
2
.github/workflows/docs-site-checks.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
docs-site-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
|
||||
2
.github/workflows/history-check.yml
vendored
2
.github/workflows/history-check.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
check-common-ancestor:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # full history both sides for merge-base
|
||||
|
||||
|
||||
6
.github/workflows/lint.yml
vendored
6
.github/workflows/lint.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # need full history for merge-base + worktree
|
||||
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5
|
||||
|
||||
4
.github/workflows/nix-lockfile-fix.yml
vendored
4
.github/workflows/nix-lockfile-fix.yml
vendored
@@ -56,7 +56,7 @@ jobs:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -195,7 +195,7 @@ jobs:
|
||||
|
||||
Triggered by @${{ github.actor }} — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: ${{ steps.resolve.outputs.owner }}/${{ steps.resolve.outputs.repo }}
|
||||
ref: ${{ steps.resolve.outputs.ref }}
|
||||
|
||||
2
.github/workflows/nix.yml
vendored
2
.github/workflows/nix.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/nix-setup
|
||||
with:
|
||||
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
4
.github/workflows/skills-index.yml
vendored
4
.github/workflows/skills-index.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
@@ -53,4 +53,4 @@ jobs:
|
||||
- name: Trigger Deploy Site workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }}
|
||||
run: gh workflow run deploy-site.yml --repo ${{ github.repository }}
|
||||
|
||||
63
.github/workflows/supply-chain-audit.yml
vendored
63
.github/workflows/supply-chain-audit.yml
vendored
@@ -29,10 +29,8 @@ jobs:
|
||||
scan: ${{ steps.filter.outputs.scan }}
|
||||
# True when pyproject.toml changed in this PR
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
# True when the curated MCP catalog / bundled MCP manifests changed.
|
||||
mcp_catalog: ${{ steps.filter.outputs.mcp_catalog }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check for relevant file changes
|
||||
@@ -56,14 +54,6 @@ jobs:
|
||||
else
|
||||
echo "deps=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
MCP_CATALOG_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
|
||||
'optional-mcps/**' \
|
||||
'hermes_cli/mcp_catalog.py' || true)
|
||||
if [ -n "$MCP_CATALOG_FILES" ]; then
|
||||
echo "mcp_catalog=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "mcp_catalog=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
scan:
|
||||
name: Scan PR for critical supply chain risks
|
||||
@@ -72,7 +62,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -207,7 +197,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -278,50 +268,3 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No pyproject.toml changes, skipping dependency bounds check."
|
||||
|
||||
mcp-catalog-review:
|
||||
name: MCP catalog security review
|
||||
needs: changes
|
||||
if: needs.changes.outputs.mcp_catalog == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Require explicit MCP catalog review label
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
|
||||
if echo "$LABELS" | grep -Fxq 'mcp-catalog-reviewed'; then
|
||||
echo "MCP catalog review label present."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BODY="## ⚠️ MCP catalog security review required
|
||||
|
||||
This PR changes the bundled MCP catalog or MCP catalog installer code. MCP entries can define local commands that users later install into \`mcp_servers\`, so this needs explicit maintainer review before merge.
|
||||
|
||||
A maintainer should verify:
|
||||
- any new/changed \`optional-mcps/**/manifest.yaml\` command and args are expected,
|
||||
- stdio transports do not use shell+egress/exfiltration payloads,
|
||||
- git install refs are pinned and bootstrap commands are minimal,
|
||||
- requested env vars/secrets match the upstream MCP's documented needs.
|
||||
|
||||
After review, add the \`mcp-catalog-reviewed\` label and re-run this check."
|
||||
|
||||
gh pr comment "$PR" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
|
||||
echo "::error::MCP catalog changes require the mcp-catalog-reviewed label."
|
||||
exit 1
|
||||
|
||||
mcp-catalog-review-gate:
|
||||
name: MCP catalog security review
|
||||
needs: changes
|
||||
if: always() && needs.changes.outputs.mcp_catalog != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No MCP catalog changes, skipping MCP catalog security review."
|
||||
|
||||
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
slice: [1, 2, 3, 4, 5, 6]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore duration cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -163,7 +163,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install ripgrep (prebuilt binary)
|
||||
run: |
|
||||
|
||||
2
.github/workflows/typecheck.yml
vendored
2
.github/workflows/typecheck.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
[ui-tui, web, apps/bootstrap-installer, apps/desktop, apps/shared]
|
||||
fail-fast: false # report all failures, not just the first one
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
4
.github/workflows/upload_to_pypi.yml
vendored
4
.github/workflows/upload_to_pypi.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
name: Build distribution 📦
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
# On workflow_dispatch, check out the confirmed tag.
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
|
||||
- name: Sign with Sigstore
|
||||
if: env.skip_sign != 'true'
|
||||
uses: sigstore/gh-action-sigstore-python@5b79a39c381910c090341a2c9b0bf022c8b387e1 # v3.4.0
|
||||
uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
|
||||
with:
|
||||
inputs: >-
|
||||
./dist/*.tar.gz
|
||||
|
||||
2
.github/workflows/uv-lockfile-check.yml
vendored
2
.github/workflows/uv-lockfile-check.yml
vendored
@@ -71,7 +71,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
@@ -824,7 +824,6 @@ class HermesACPAgent(acp.Agent):
|
||||
|
||||
try:
|
||||
from model_tools import get_tool_definitions
|
||||
from agent.memory_manager import inject_memory_provider_tools
|
||||
|
||||
enabled_toolsets = _expand_acp_enabled_toolsets(
|
||||
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"],
|
||||
@@ -840,7 +839,6 @@ class HermesACPAgent(acp.Agent):
|
||||
state.agent.valid_tool_names = {
|
||||
tool["function"]["name"] for tool in state.agent.tools or []
|
||||
}
|
||||
inject_memory_provider_tools(state.agent)
|
||||
invalidate = getattr(state.agent, "_invalidate_system_prompt", None)
|
||||
if callable(invalidate):
|
||||
invalidate()
|
||||
@@ -1781,25 +1779,10 @@ class HermesACPAgent(acp.Agent):
|
||||
def _cmd_tools(self, args: str, state: SessionState) -> str:
|
||||
try:
|
||||
from model_tools import get_tool_definitions
|
||||
from types import SimpleNamespace
|
||||
from agent.memory_manager import inject_memory_provider_tools
|
||||
|
||||
toolsets = _expand_acp_enabled_toolsets(
|
||||
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
|
||||
)
|
||||
tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True)
|
||||
tool_view = SimpleNamespace(
|
||||
tools=list(tools or []),
|
||||
valid_tool_names={
|
||||
tool.get("function", {}).get("name")
|
||||
for tool in tools or []
|
||||
if isinstance(tool, dict)
|
||||
},
|
||||
enabled_toolsets=toolsets,
|
||||
_memory_manager=getattr(state.agent, "_memory_manager", None),
|
||||
)
|
||||
inject_memory_provider_tools(tool_view)
|
||||
tools = tool_view.tools
|
||||
if not tools:
|
||||
return "No tools available."
|
||||
lines = [f"Available tools ({len(tools)}):"]
|
||||
|
||||
@@ -900,9 +900,6 @@ def init_agent(
|
||||
agent.api_key = client_kwargs.get("api_key", "")
|
||||
agent.base_url = client_kwargs.get("base_url", agent.base_url)
|
||||
try:
|
||||
from agent.ssl_guard import verify_ca_bundle_with_fallback
|
||||
|
||||
verify_ca_bundle_with_fallback()
|
||||
agent.client = agent._create_openai_client(client_kwargs, reason="agent_init", shared=True)
|
||||
if not agent.quiet_mode:
|
||||
print(f"🤖 AI Agent initialized with model: {agent.model}")
|
||||
@@ -1196,8 +1193,38 @@ def init_agent(
|
||||
_ra().logger.warning("Memory provider plugin init failed: %s", _mpe)
|
||||
agent._memory_manager = None
|
||||
|
||||
from agent.memory_manager import inject_memory_provider_tools as _inject_memory_provider_tools
|
||||
_inject_memory_provider_tools(agent)
|
||||
# Inject memory provider tool schemas into the tool surface.
|
||||
# Skip tools whose names already exist (plugins may register the
|
||||
# same tools via ctx.register_tool(), which lands in agent.tools
|
||||
# through _ra().get_tool_definitions()). Duplicate function names cause
|
||||
# 400 errors on providers that enforce unique names (e.g. Xiaomi
|
||||
# MiMo via Nous Portal).
|
||||
#
|
||||
# Respect the platform's enabled_toolsets configuration (#5544):
|
||||
# enabled_toolsets is None → no filter, inject (backward compat)
|
||||
# "memory" in enabled_toolsets → user opted in, inject
|
||||
# otherwise (incl. []) → user excluded memory, skip injection
|
||||
#
|
||||
# Without this gate, `platform_toolsets: telegram: []` still leaks memory
|
||||
# provider tools (fact_store, etc.) into the tool surface — a 10x latency
|
||||
# penalty on local models and a frequent trigger of tool-call loops.
|
||||
if agent._memory_manager and agent.tools is not None and (
|
||||
agent.enabled_toolsets is None or "memory" in agent.enabled_toolsets
|
||||
):
|
||||
_existing_tool_names = {
|
||||
t.get("function", {}).get("name")
|
||||
for t in agent.tools
|
||||
if isinstance(t, dict)
|
||||
}
|
||||
for _schema in agent._memory_manager.get_all_tool_schemas():
|
||||
_tname = _schema.get("name", "")
|
||||
if _tname and _tname in _existing_tool_names:
|
||||
continue # already registered via plugin path
|
||||
_wrapped = {"type": "function", "function": _schema}
|
||||
agent.tools.append(_wrapped)
|
||||
if _tname:
|
||||
agent.valid_tool_names.add(_tname)
|
||||
_existing_tool_names.add(_tname)
|
||||
|
||||
# Skills config: nudge interval for skill creation reminders
|
||||
agent._skill_nudge_interval = 10
|
||||
|
||||
@@ -618,33 +618,12 @@ def recover_with_credential_pool(
|
||||
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
|
||||
pool_provider = (getattr(pool, "provider", "") or "").strip().lower()
|
||||
if current_provider and pool_provider and current_provider != pool_provider:
|
||||
# Custom endpoints use two naming conventions for the SAME provider:
|
||||
# the agent carries the generic ``custom`` label while the pool is
|
||||
# keyed ``custom:<name>`` (see CUSTOM_POOL_PREFIX). A literal string
|
||||
# compare treats them as a mismatch and skips recovery for every
|
||||
# custom-provider user — 401s/429s then burn the full retry cycle
|
||||
# with no rotation or refresh. Accept the pair as matching only when
|
||||
# the agent's CURRENT base_url actually resolves to this pool key,
|
||||
# so a fallback provider (or a different custom endpoint) still
|
||||
# triggers the guard.
|
||||
_custom_match = False
|
||||
if current_provider == "custom" and pool_provider.startswith("custom:"):
|
||||
try:
|
||||
from agent.credential_pool import get_custom_provider_pool_key
|
||||
_agent_base = (getattr(agent, "base_url", "") or "").strip()
|
||||
_custom_match = bool(_agent_base) and (
|
||||
(get_custom_provider_pool_key(_agent_base) or "").strip().lower()
|
||||
== pool_provider
|
||||
)
|
||||
except Exception:
|
||||
_custom_match = False
|
||||
if not _custom_match:
|
||||
_ra().logger.warning(
|
||||
"Credential pool provider mismatch: pool=%s, agent=%s — "
|
||||
"skipping pool mutation to avoid cross-provider contamination",
|
||||
pool_provider, current_provider,
|
||||
)
|
||||
return False, has_retried_429
|
||||
_ra().logger.warning(
|
||||
"Credential pool provider mismatch: pool=%s, agent=%s — "
|
||||
"skipping pool mutation to avoid cross-provider contamination",
|
||||
pool_provider, current_provider,
|
||||
)
|
||||
return False, has_retried_429
|
||||
|
||||
effective_reason = classified_reason
|
||||
if effective_reason is None:
|
||||
@@ -881,8 +860,6 @@ def try_recover_primary_transport(
|
||||
|
||||
def drop_thinking_only_and_merge_users(
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
drop_codex_reasoning_items: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Drop thinking-only assistant turns; merge any adjacent user messages left behind.
|
||||
|
||||
@@ -904,13 +881,7 @@ def drop_thinking_only_and_merge_users(
|
||||
return messages
|
||||
|
||||
# Pass 1: drop thinking-only assistant turns.
|
||||
kept = [
|
||||
m for m in messages
|
||||
if not _ra().AIAgent._is_thinking_only_assistant(
|
||||
m,
|
||||
drop_codex_reasoning_items=drop_codex_reasoning_items,
|
||||
)
|
||||
]
|
||||
kept = [m for m in messages if not _ra().AIAgent._is_thinking_only_assistant(m)]
|
||||
dropped = len(messages) - len(kept)
|
||||
if dropped == 0:
|
||||
return messages
|
||||
|
||||
@@ -751,9 +751,6 @@ def build_anthropic_client(
|
||||
from httpx import Timeout
|
||||
|
||||
normalized_base_url = _normalize_base_url_text(base_url)
|
||||
if normalized_base_url:
|
||||
import re as _re
|
||||
normalized_base_url = _re.sub(r"/v1/?$", "", normalized_base_url.rstrip("/"))
|
||||
_read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0
|
||||
kwargs = {
|
||||
"timeout": Timeout(timeout=float(_read_timeout), connect=10.0),
|
||||
|
||||
@@ -1144,8 +1144,7 @@ def _endpoint_speaks_anthropic_messages(base_url: str) -> bool:
|
||||
normalized = (base_url or "").strip().lower().rstrip("/")
|
||||
if not normalized:
|
||||
return False
|
||||
path = urlparse(normalized).path.rstrip("/")
|
||||
if path.endswith("/anthropic") or path.endswith("/anthropic/v1"):
|
||||
if normalized.endswith("/anthropic"):
|
||||
return True
|
||||
hostname = base_url_hostname(normalized)
|
||||
if hostname == "api.anthropic.com":
|
||||
@@ -3191,7 +3190,7 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
|
||||
if (main_provider and main_model
|
||||
and main_provider not in {"auto", ""}):
|
||||
resolved_provider = main_provider
|
||||
explicit_base_url = runtime_base_url or None
|
||||
explicit_base_url = None
|
||||
explicit_api_key = None
|
||||
if runtime_base_url and (main_provider == "custom" or main_provider.startswith("custom:")):
|
||||
resolved_provider = "custom"
|
||||
@@ -5005,7 +5004,7 @@ def _build_call_kwargs(
|
||||
|
||||
# Provider-specific extra_body
|
||||
merged_extra = dict(extra_body or {})
|
||||
if provider == "nous":
|
||||
if provider == "nous" or auxiliary_is_nous:
|
||||
merged_extra.setdefault("tags", []).extend(_nous_portal_tags())
|
||||
if merged_extra:
|
||||
kwargs["extra_body"] = merged_extra
|
||||
|
||||
@@ -935,14 +935,11 @@ def build_converse_kwargs(
|
||||
if system_prompt:
|
||||
kwargs["system"] = system_prompt
|
||||
|
||||
from agent.anthropic_adapter import _forbids_sampling_params
|
||||
if temperature is not None:
|
||||
kwargs["inferenceConfig"]["temperature"] = temperature
|
||||
|
||||
if not _forbids_sampling_params(model):
|
||||
if temperature is not None:
|
||||
kwargs["inferenceConfig"]["temperature"] = temperature
|
||||
|
||||
if top_p is not None:
|
||||
kwargs["inferenceConfig"]["topP"] = top_p
|
||||
if top_p is not None:
|
||||
kwargs["inferenceConfig"]["topP"] = top_p
|
||||
|
||||
if stop_sequences:
|
||||
kwargs["inferenceConfig"]["stopSequences"] = stop_sequences
|
||||
|
||||
294
agent/billing_view.py
Normal file
294
agent/billing_view.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""Surface-agnostic core for the Phase 2b terminal-billing screens.
|
||||
|
||||
One fetch/parse per concern, consumed identically by the CLI handler
|
||||
(``cli.py::_show_billing``), the TUI JSON-RPC methods
|
||||
(``tui_gateway/server.py``), and any other surface. Mirrors the proven
|
||||
``agent/account_usage.py::build_credits_view`` pattern: parse the server payload
|
||||
into a frozen dataclass; **fail open** — when not logged in or the portal is
|
||||
unreachable, return a struct with ``logged_in=False`` and let the surface degrade
|
||||
gracefully (never crash).
|
||||
|
||||
Money discipline: the server emits decimal STRINGS (``"142.5"``, not fixed 2dp).
|
||||
We keep them as :class:`decimal.Decimal` end-to-end and only format for display.
|
||||
|
||||
See docs/plans/2026-06-13-001-phase-2b-terminal-billing-tui-plan.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Decimal money helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def parse_money(value: Any) -> Optional[Decimal]:
|
||||
"""Parse a server money value (decimal string) into :class:`Decimal`.
|
||||
|
||||
Returns None for missing/invalid input. Never raises. Accepts str/int (and,
|
||||
defensively, float — though the server always sends strings).
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
# Decimal(str(...)) avoids binary-float artifacts if a float ever sneaks in.
|
||||
return Decimal(str(value).strip())
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def format_money(value: Optional[Decimal]) -> str:
|
||||
"""Format a Decimal as ``$X`` / ``$X.YY`` for display.
|
||||
|
||||
Whole dollars show no decimals; any fractional amount shows exactly 2dp:
|
||||
``Decimal("142.5")`` → ``"$142.50"``, ``Decimal("100")`` → ``"$100"``,
|
||||
``Decimal("0.01")`` → ``"$0.01"``.
|
||||
"""
|
||||
if value is None:
|
||||
return "—"
|
||||
if value == value.to_integral_value():
|
||||
# Whole dollars — no decimal point. format(..., "f") avoids 1E+3 for 1000.
|
||||
return f"${format(value.to_integral_value(), 'f')}"
|
||||
# Fractional — always show 2dp.
|
||||
return f"${format(value.quantize(Decimal('0.01')), 'f')}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Parsed sub-structures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CardInfo:
|
||||
brand: str
|
||||
last4: str
|
||||
|
||||
@property
|
||||
def masked(self) -> str:
|
||||
return f"{self.brand} ····{self.last4}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MonthlyCap:
|
||||
limit_usd: Optional[Decimal] = None
|
||||
spent_this_month_usd: Optional[Decimal] = None
|
||||
is_default_ceiling: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AutoReload:
|
||||
enabled: bool = False
|
||||
threshold_usd: Optional[Decimal] = None
|
||||
reload_to_usd: Optional[Decimal] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BillingState:
|
||||
"""Parsed ``GET /api/billing/state`` — the overview screen's data.
|
||||
|
||||
Fail-open: ``logged_in=False`` (and empty fields) when not logged in or the
|
||||
portal is unreachable.
|
||||
"""
|
||||
|
||||
logged_in: bool
|
||||
org_id: Optional[str] = None
|
||||
org_slug: Optional[str] = None
|
||||
org_name: Optional[str] = None
|
||||
role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER"
|
||||
balance_usd: Optional[Decimal] = None
|
||||
cli_billing_enabled: bool = False
|
||||
charge_presets: tuple[Decimal, ...] = ()
|
||||
min_usd: Optional[Decimal] = None
|
||||
max_usd: Optional[Decimal] = None
|
||||
card: Optional[CardInfo] = None
|
||||
monthly_cap: Optional[MonthlyCap] = None
|
||||
auto_reload: Optional[AutoReload] = None
|
||||
portal_url: Optional[str] = None
|
||||
# When the fetch failed (vs cleanly not-logged-in), the message for the surface.
|
||||
error: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
"""True for OWNER/ADMIN — the roles that can manage billing."""
|
||||
return (self.role or "").upper() in ("OWNER", "ADMIN")
|
||||
|
||||
@property
|
||||
def can_charge(self) -> bool:
|
||||
"""True when the UI should offer charge/auto-reload actions.
|
||||
|
||||
Admin role AND the per-org kill-switch on. (The server still enforces;
|
||||
this is just for graying out actions the user can't take.)
|
||||
"""
|
||||
return self.is_admin and self.cli_billing_enabled
|
||||
|
||||
|
||||
def _parse_card(raw: Any) -> Optional[CardInfo]:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
brand = raw.get("brand")
|
||||
last4 = raw.get("last4")
|
||||
if isinstance(brand, str) and isinstance(last4, str):
|
||||
return CardInfo(brand=brand, last4=last4)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
return MonthlyCap(
|
||||
limit_usd=parse_money(raw.get("limitUsd")),
|
||||
spent_this_month_usd=parse_money(raw.get("spentThisMonthUsd")),
|
||||
is_default_ceiling=bool(raw.get("isDefaultCeiling")),
|
||||
)
|
||||
|
||||
|
||||
def _parse_auto_reload(raw: Any) -> Optional[AutoReload]:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
return AutoReload(
|
||||
enabled=bool(raw.get("enabled")),
|
||||
threshold_usd=parse_money(raw.get("thresholdUsd")),
|
||||
reload_to_usd=parse_money(raw.get("reloadToUsd")),
|
||||
)
|
||||
|
||||
|
||||
def billing_state_from_payload(
|
||||
payload: dict[str, Any], *, portal_url: Optional[str] = None
|
||||
) -> BillingState:
|
||||
"""Map a raw ``/api/billing/state`` JSON dict into :class:`BillingState`."""
|
||||
raw_org = payload.get("org")
|
||||
org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {}
|
||||
raw_bounds = payload.get("bounds")
|
||||
bounds: dict[str, Any] = raw_bounds if isinstance(raw_bounds, dict) else {}
|
||||
|
||||
presets: list[Decimal] = []
|
||||
for item in payload.get("chargePresets") or ():
|
||||
parsed = parse_money(item)
|
||||
if parsed is not None:
|
||||
presets.append(parsed)
|
||||
|
||||
return BillingState(
|
||||
logged_in=True,
|
||||
org_id=org.get("id"),
|
||||
org_slug=org.get("slug"),
|
||||
org_name=org.get("name"),
|
||||
role=org.get("role"),
|
||||
balance_usd=parse_money(payload.get("balanceUsd")),
|
||||
cli_billing_enabled=bool(payload.get("cliBillingEnabled")),
|
||||
charge_presets=tuple(presets),
|
||||
min_usd=parse_money(bounds.get("minUsd")),
|
||||
max_usd=parse_money(bounds.get("maxUsd")),
|
||||
card=_parse_card(payload.get("card")),
|
||||
monthly_cap=_parse_monthly_cap(payload.get("monthlyCap")),
|
||||
auto_reload=_parse_auto_reload(payload.get("autoReload")),
|
||||
portal_url=portal_url,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fail-open builders (the surface front doors)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def build_billing_state(*, timeout: float = 15.0) -> BillingState:
|
||||
"""Fetch + parse ``/api/billing/state``. Fail-open.
|
||||
|
||||
Returns ``BillingState(logged_in=False)`` when not logged in. On a portal/HTTP
|
||||
failure, returns ``logged_in=False`` with ``error`` set so the surface can show
|
||||
a clear message rather than crashing.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.nous_billing import (
|
||||
BillingAuthError,
|
||||
BillingError,
|
||||
get_billing_state,
|
||||
resolve_portal_base_url,
|
||||
)
|
||||
except Exception:
|
||||
return BillingState(logged_in=False, error="billing client unavailable")
|
||||
|
||||
try:
|
||||
payload = get_billing_state(timeout=timeout)
|
||||
except BillingAuthError:
|
||||
return BillingState(logged_in=False)
|
||||
except BillingError as exc:
|
||||
logger.debug("billing ▸ /state fetch failed (fail-open)", exc_info=True)
|
||||
return BillingState(logged_in=False, error=str(exc))
|
||||
except Exception:
|
||||
logger.debug("billing ▸ /state unexpected error (fail-open)", exc_info=True)
|
||||
return BillingState(logged_in=False, error="could not load billing state")
|
||||
|
||||
# Prefer a server-supplied portalUrl if present; else build the standard one.
|
||||
portal_url = payload.get("portalUrl") if isinstance(payload, dict) else None
|
||||
if not portal_url:
|
||||
try:
|
||||
portal_url = _fallback_portal_url(resolve_portal_base_url())
|
||||
except Exception:
|
||||
portal_url = None
|
||||
|
||||
return billing_state_from_payload(payload, portal_url=portal_url)
|
||||
|
||||
|
||||
def _fallback_portal_url(base: str) -> str:
|
||||
"""Standard billing deep-link when the server omits ``portalUrl``."""
|
||||
return f"{base.rstrip('/')}/billing?topup=open"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Idempotency
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def new_idempotency_key() -> str:
|
||||
"""Fresh UUID for a user-confirmed purchase (reuse on retry of the SAME buy).
|
||||
|
||||
The ``Idempotency-Key`` header is mandatory on ``POST /charge``; generate one
|
||||
per confirmed purchase and reuse it across retries so a double-submit collapses
|
||||
to a single charge. Never reuse a key across different amounts (the server
|
||||
returns 409 idempotency_conflict).
|
||||
"""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Amount validation (Screen 3 custom input)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AmountValidation:
|
||||
ok: bool
|
||||
amount: Optional[Decimal] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
def validate_charge_amount(
|
||||
raw: str, *, min_usd: Optional[Decimal], max_usd: Optional[Decimal]
|
||||
) -> AmountValidation:
|
||||
"""Validate a custom charge amount against bounds + 2dp (multipleOf 0.01).
|
||||
|
||||
Mirrors the server's accept/reject so the UI can give instant feedback rather
|
||||
than round-tripping a sure-to-fail charge. The server is still authoritative.
|
||||
"""
|
||||
cleaned = (raw or "").strip().lstrip("$").strip()
|
||||
amount = parse_money(cleaned)
|
||||
if amount is None:
|
||||
return AmountValidation(ok=False, error="Enter a dollar amount, e.g. 100")
|
||||
if amount <= 0:
|
||||
return AmountValidation(ok=False, error="Amount must be greater than $0")
|
||||
# multipleOf 0.01 — reject sub-cent precision.
|
||||
if amount != amount.quantize(Decimal("0.01")):
|
||||
return AmountValidation(ok=False, error="Amount can't be smaller than a cent")
|
||||
if min_usd is not None and amount < min_usd:
|
||||
return AmountValidation(ok=False, error=f"Minimum is {format_money(min_usd)}")
|
||||
if max_usd is not None and amount > max_usd:
|
||||
return AmountValidation(ok=False, error=f"Maximum is {format_money(max_usd)}")
|
||||
return AmountValidation(ok=True, amount=amount)
|
||||
@@ -1081,7 +1081,6 @@ def _normalize_codex_response(
|
||||
message_items_raw: List[Dict[str, Any]] = []
|
||||
tool_calls: List[Any] = []
|
||||
has_incomplete_items = response_status in {"queued", "in_progress", "incomplete"}
|
||||
saw_streaming_or_item_incomplete = response_status in {"queued", "in_progress"}
|
||||
saw_commentary_phase = False
|
||||
saw_final_answer_phase = False
|
||||
saw_reasoning_item = False
|
||||
@@ -1096,7 +1095,6 @@ def _normalize_codex_response(
|
||||
|
||||
if item_status in {"queued", "in_progress", "incomplete"}:
|
||||
has_incomplete_items = True
|
||||
saw_streaming_or_item_incomplete = True
|
||||
|
||||
if item_type == "message":
|
||||
item_phase = getattr(item, "phase", None)
|
||||
@@ -1254,9 +1252,7 @@ def _normalize_codex_response(
|
||||
finish_reason = "tool_calls"
|
||||
elif leaked_tool_call_text:
|
||||
finish_reason = "incomplete"
|
||||
elif saw_streaming_or_item_incomplete:
|
||||
finish_reason = "incomplete"
|
||||
elif (has_incomplete_items or saw_commentary_phase) and not saw_final_answer_phase:
|
||||
elif has_incomplete_items or (saw_commentary_phase and not saw_final_answer_phase):
|
||||
finish_reason = "incomplete"
|
||||
elif (reasoning_items_raw or reasoning_parts or saw_reasoning_item) and not final_text:
|
||||
# Response contains only reasoning (encrypted thinking state and/or
|
||||
|
||||
@@ -40,16 +40,6 @@ from agent.model_metadata import estimate_request_tokens_rough
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Stable marker the gateway matches on to re-tag the auto-compaction lifecycle
|
||||
# status as ``kind="compacting"`` (tui_gateway/server.py::_status_update), so
|
||||
# drivers like the desktop app can show an explicit "Summarizing…" indicator
|
||||
# instead of the transcript appearing to silently reset. Keep the marker phrase
|
||||
# intact if you reword COMPACTION_STATUS.
|
||||
COMPACTION_STATUS_MARKER = "Compacting context"
|
||||
COMPACTION_STATUS = (
|
||||
f"🗜️ {COMPACTION_STATUS_MARKER} — summarizing earlier conversation so I can continue..."
|
||||
)
|
||||
|
||||
|
||||
def _compression_lock_holder(agent: Any) -> str:
|
||||
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
|
||||
@@ -334,7 +324,9 @@ def compress_context(
|
||||
f"{approx_tokens:,}" if approx_tokens else "unknown", agent.model,
|
||||
focus_topic,
|
||||
)
|
||||
agent._emit_status(COMPACTION_STATUS)
|
||||
agent._emit_status(
|
||||
"🗜️ Compacting context — summarizing earlier conversation so I can continue..."
|
||||
)
|
||||
|
||||
# ── Compression lock ────────────────────────────────────────────────
|
||||
# Atomic, state.db-backed lock per session_id. Without this, two
|
||||
@@ -639,11 +631,7 @@ def compress_context(
|
||||
return compressed, new_system_prompt
|
||||
|
||||
|
||||
def try_shrink_image_parts_in_messages(
|
||||
api_messages: list,
|
||||
*,
|
||||
max_dimension: int = 8000,
|
||||
) -> bool:
|
||||
def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
"""Re-encode all native image parts at a smaller size to recover from
|
||||
image-too-large errors (Anthropic 5 MB, unknown other providers).
|
||||
|
||||
@@ -654,8 +642,7 @@ def try_shrink_image_parts_in_messages(
|
||||
Strategy: look for ``image_url`` / ``input_image`` parts carrying a
|
||||
``data:image/...;base64,...`` payload. For each one whose encoded
|
||||
size exceeds 4 MB (a safe target that slides under Anthropic's 5 MB
|
||||
ceiling with header overhead) or whose longest side exceeds
|
||||
``max_dimension``, write the base64 to a tempfile, call
|
||||
ceiling with header overhead), write the base64 to a tempfile, call
|
||||
``vision_tools._resize_image_for_vision`` to produce a smaller data
|
||||
URL, and substitute it in place.
|
||||
|
||||
@@ -677,9 +664,10 @@ def try_shrink_image_parts_in_messages(
|
||||
# after a confirmed provider rejection, so the alternative is failure.
|
||||
target_bytes = 4 * 1024 * 1024
|
||||
# Anthropic enforces an 8000px per-side dimension cap independently of
|
||||
# the 5 MB byte cap. In many-image requests, the provider can report a
|
||||
# lower cap (observed: 2000px). The caller passes that parsed ceiling
|
||||
# when the rejection includes it.
|
||||
# the 5 MB byte cap. A tall screenshot can be well under 5 MB yet far
|
||||
# over 8000px (e.g. 1200×12000 at 0.06 MB). We check pixel dimensions
|
||||
# even when the byte budget is fine.
|
||||
max_dimension = 8000
|
||||
changed_count = 0
|
||||
# Track parts that are over the target but could NOT be shrunk under it.
|
||||
# If any survive, retrying is pointless — the same oversized payload will
|
||||
@@ -696,9 +684,9 @@ def try_shrink_image_parts_in_messages(
|
||||
# Check both byte size AND pixel dimensions.
|
||||
needs_shrink = len(url) > target_bytes # over byte budget
|
||||
if not needs_shrink:
|
||||
# Even if bytes are fine, check pixel dimensions against the
|
||||
# provider's reported per-side cap. A screenshot can be tiny in
|
||||
# bytes yet too large in pixels.
|
||||
# Even if bytes are fine, check pixel dimensions against
|
||||
# Anthropic's 8000px cap. A tall image can be tiny in bytes
|
||||
# yet huge in pixels.
|
||||
try:
|
||||
import base64 as _b64_dim
|
||||
header_d, _, data_d = url.partition(",")
|
||||
@@ -807,8 +795,6 @@ def try_shrink_image_parts_in_messages(
|
||||
|
||||
|
||||
__all__ = [
|
||||
"COMPACTION_STATUS",
|
||||
"COMPACTION_STATUS_MARKER",
|
||||
"check_compression_model_feasibility",
|
||||
"replay_compression_warning",
|
||||
"compress_context",
|
||||
|
||||
@@ -71,35 +71,6 @@ logger = logging.getLogger(__name__)
|
||||
INTERRUPT_WAITING_FOR_MODEL_PREFIX = "Operation interrupted: waiting for model response ("
|
||||
|
||||
|
||||
def _image_error_max_dimension(error: Exception) -> Optional[int]:
|
||||
"""Extract a provider-reported image dimension ceiling, if present."""
|
||||
parts = []
|
||||
for value in (
|
||||
error,
|
||||
getattr(error, "message", None),
|
||||
getattr(error, "body", None),
|
||||
):
|
||||
if value:
|
||||
try:
|
||||
parts.append(str(value))
|
||||
except Exception:
|
||||
pass
|
||||
text = " ".join(parts).lower()
|
||||
if "image" not in text or "dimension" not in text or "max allowed size" not in text:
|
||||
return None
|
||||
|
||||
match = re.search(r"max allowed size(?:\s+for [^:]+)?:\s*(\d{3,5})\s*pixels?", text)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
max_dimension = int(match.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
if 512 <= max_dimension <= 8000:
|
||||
return max_dimension
|
||||
return None
|
||||
|
||||
|
||||
def _ollama_context_limit_error(agent: Any, request_tokens: int) -> Optional[str]:
|
||||
"""Return a user-facing error when Ollama is loaded with too little context."""
|
||||
if not getattr(agent, "tools", None):
|
||||
@@ -397,42 +368,6 @@ def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List
|
||||
)
|
||||
|
||||
|
||||
# Shared recovery hint appended to every content-policy refusal message. Both
|
||||
# the HTTP-200 refusal path (``finish_reason=content_filter``) and the
|
||||
# exception path (a provider moderation error classified as
|
||||
# ``content_policy_blocked``) end with the same actionable next steps, so they
|
||||
# share one trailer to keep the guidance from drifting between the two sites.
|
||||
_CONTENT_POLICY_RECOVERY_HINT = (
|
||||
"Try rephrasing the request, narrowing the context, or "
|
||||
"adding a fallback provider with `hermes fallback add`."
|
||||
)
|
||||
|
||||
|
||||
def _content_policy_blocked_result(
|
||||
messages: List[Dict],
|
||||
api_call_count: int,
|
||||
*,
|
||||
final_response: str,
|
||||
error_detail: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the terminal turn result for a content-policy block.
|
||||
|
||||
A content-policy refusal is deterministic for the unchanged prompt, so the
|
||||
turn ends here (no retry). Both the HTTP-200 refusal handler and the
|
||||
exception-path handler return the identical shape — a failed, non-completed
|
||||
turn carrying the user-facing message and a ``content_policy_blocked:``
|
||||
prefixed error — so they funnel through this one builder.
|
||||
"""
|
||||
return {
|
||||
"final_response": final_response,
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": False,
|
||||
"failed": True,
|
||||
"error": f"content_policy_blocked: {error_detail}",
|
||||
}
|
||||
|
||||
|
||||
def run_conversation(
|
||||
agent,
|
||||
user_message: str,
|
||||
@@ -772,10 +707,7 @@ def run_conversation(
|
||||
# a thinking-only turn. Runs on the per-call copy only — the
|
||||
# stored conversation history keeps the reasoning block for the
|
||||
# UI transcript and session persistence.
|
||||
api_messages = agent._drop_thinking_only_and_merge_users(
|
||||
api_messages,
|
||||
drop_codex_reasoning_items=agent.api_mode != "codex_responses",
|
||||
)
|
||||
api_messages = agent._drop_thinking_only_and_merge_users(api_messages)
|
||||
|
||||
# Normalize message whitespace and tool-call JSON for consistent
|
||||
# prefix matching. Ensures bit-perfect prefixes across turns,
|
||||
@@ -1384,106 +1316,6 @@ def run_conversation(
|
||||
)
|
||||
finish_reason = "length"
|
||||
|
||||
# ── Content-policy refusal (HTTP 200) ──────────────────
|
||||
# The model — or the provider's safety system — returned a
|
||||
# *successful* response whose stop/finish reason is a refusal:
|
||||
# Anthropic ``stop_reason="refusal"`` → ``content_filter``;
|
||||
# OpenAI / portal ``finish_reason="content_filter"`` or a
|
||||
# populated ``message.refusal`` (mapped in the chat_completions
|
||||
# transport); Bedrock ``guardrail_intervened``. The content is
|
||||
# typically empty, so without this branch the response falls
|
||||
# through to the empty-response / invalid-response retry loops
|
||||
# and is mis-surfaced as "rate limited" / "no content after
|
||||
# retries" — burning paid attempts reproducing a deterministic
|
||||
# refusal. Surface it clearly and stop. Mirrors the
|
||||
# exception-based ``content_policy_blocked`` recovery: try a
|
||||
# configured fallback once, otherwise return the refusal.
|
||||
if finish_reason == "content_filter":
|
||||
_refusal_transport = agent._get_transport()
|
||||
if agent.api_mode == "anthropic_messages":
|
||||
_refusal_result = _refusal_transport.normalize_response(
|
||||
response, strip_tool_prefix=agent._is_anthropic_oauth
|
||||
)
|
||||
else:
|
||||
_refusal_result = _refusal_transport.normalize_response(response)
|
||||
_refusal_text = (getattr(_refusal_result, "content", None) or "").strip()
|
||||
# Some refusals carry the explanation only in the reasoning
|
||||
# channel; fall back to it so the user sees *something*.
|
||||
if not _refusal_text:
|
||||
_refusal_text = (agent._extract_reasoning(_refusal_result) or "").strip()
|
||||
|
||||
agent._invoke_api_request_error_hook(
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
api_request_id=api_request_id,
|
||||
api_call_count=api_call_count,
|
||||
api_start_time=api_start_time,
|
||||
api_kwargs=api_kwargs,
|
||||
error_type="ContentPolicyBlocked",
|
||||
error_message=_refusal_text or "model declined to respond (content_filter)",
|
||||
status_code=None,
|
||||
retry_count=retry_count,
|
||||
max_retries=max_retries,
|
||||
retryable=False,
|
||||
reason=FailoverReason.content_policy_blocked.value,
|
||||
)
|
||||
|
||||
if thinking_spinner:
|
||||
thinking_spinner.stop("")
|
||||
thinking_spinner = None
|
||||
if agent.thinking_callback:
|
||||
agent.thinking_callback("")
|
||||
|
||||
# Deterministic for the unchanged prompt — never retry.
|
||||
# Try a configured fallback once (a different model may not
|
||||
# refuse); otherwise surface the refusal terminally.
|
||||
if agent._has_pending_fallback():
|
||||
agent._buffer_status(
|
||||
"⚠️ Model declined to respond (safety refusal) — trying fallback..."
|
||||
)
|
||||
if agent._try_activate_fallback():
|
||||
retry_count = 0
|
||||
compression_attempts = 0
|
||||
_retry.primary_recovery_attempted = False
|
||||
continue
|
||||
|
||||
agent._flush_status_buffer()
|
||||
_refusal_log = (
|
||||
_refusal_text[:500] + "..."
|
||||
if len(_refusal_text) > 500
|
||||
else _refusal_text
|
||||
)
|
||||
logger.warning(
|
||||
"%sModel declined to respond (finish_reason=content_filter). "
|
||||
"model=%s provider=%s refusal=%s",
|
||||
agent.log_prefix, agent.model, agent.provider,
|
||||
_refusal_log or "(no text)",
|
||||
)
|
||||
agent._emit_status(
|
||||
"⚠️ The model declined to respond to this request (safety refusal)."
|
||||
)
|
||||
|
||||
_refusal_detail = (
|
||||
f"Model's explanation: {_refusal_text}"
|
||||
if _refusal_text
|
||||
else "The model returned no explanation."
|
||||
)
|
||||
_refusal_response = (
|
||||
"⚠️ The model declined to respond to this request "
|
||||
"(safety refusal — not a Hermes/gateway failure).\n\n"
|
||||
f"{_refusal_detail}\n\n"
|
||||
f"{_CONTENT_POLICY_RECOVERY_HINT}"
|
||||
)
|
||||
|
||||
agent._cleanup_task_resources(effective_task_id)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
return _content_policy_blocked_result(
|
||||
messages,
|
||||
api_call_count,
|
||||
final_response=_refusal_response,
|
||||
error_detail=_refusal_text or "model declined (content_filter)",
|
||||
)
|
||||
|
||||
if finish_reason == "length":
|
||||
if getattr(response, "id", "") == PARTIAL_STREAM_STUB_ID:
|
||||
agent._vprint(
|
||||
@@ -2235,11 +2067,7 @@ def run_conversation(
|
||||
and not _retry.image_shrink_retry_attempted
|
||||
):
|
||||
_retry.image_shrink_retry_attempted = True
|
||||
image_max_dimension = _image_error_max_dimension(api_error) or 8000
|
||||
if agent._try_shrink_image_parts_in_messages(
|
||||
api_messages,
|
||||
max_dimension=image_max_dimension,
|
||||
):
|
||||
if agent._try_shrink_image_parts_in_messages(api_messages):
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}📐 Image(s) exceeded provider size limit — "
|
||||
f"shrank and retrying...",
|
||||
@@ -3255,17 +3083,20 @@ def run_conversation(
|
||||
if classified.reason == FailoverReason.content_policy_blocked:
|
||||
_summary = agent._summarize_api_error(api_error)
|
||||
_policy_response = (
|
||||
"⚠️ The model provider's safety filter blocked this request "
|
||||
"(not a Hermes/gateway failure).\n\n"
|
||||
f"⚠️ The model provider's safety filter blocked this request "
|
||||
f"(not a Hermes/gateway failure).\n\n"
|
||||
f"Provider message: {_summary}\n\n"
|
||||
f"{_CONTENT_POLICY_RECOVERY_HINT}"
|
||||
)
|
||||
return _content_policy_blocked_result(
|
||||
messages,
|
||||
api_call_count,
|
||||
final_response=_policy_response,
|
||||
error_detail=_summary,
|
||||
f"Try rephrasing the request, narrowing the context, or "
|
||||
f"adding a fallback provider with `hermes fallback add`."
|
||||
)
|
||||
return {
|
||||
"final_response": _policy_response,
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": False,
|
||||
"failed": True,
|
||||
"error": f"content_policy_blocked: {_summary}",
|
||||
}
|
||||
return {
|
||||
"final_response": None,
|
||||
"messages": messages,
|
||||
|
||||
@@ -70,6 +70,16 @@ def _resolve_args() -> list[str]:
|
||||
|
||||
def _resolve_home_dir() -> str:
|
||||
"""Return a stable HOME for child ACP processes."""
|
||||
|
||||
try:
|
||||
from hermes_constants import get_subprocess_home
|
||||
|
||||
profile_home = get_subprocess_home()
|
||||
if profile_home:
|
||||
return profile_home
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
home = os.environ.get("HOME", "").strip()
|
||||
if home:
|
||||
return home
|
||||
@@ -95,10 +105,7 @@ def _resolve_home_dir() -> str:
|
||||
|
||||
def _build_subprocess_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
home = _resolve_home_dir()
|
||||
env["HOME"] = home
|
||||
from hermes_constants import apply_subprocess_home_env
|
||||
apply_subprocess_home_env(env)
|
||||
env["HOME"] = _resolve_home_dir()
|
||||
return env
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
class SSLConfigurationError(Exception):
|
||||
"""Raised when SSL/TLS certificate bundle configuration fails."""
|
||||
pass
|
||||
@@ -46,6 +46,11 @@ def build_write_denied_paths(home: str) -> set[str]:
|
||||
# Top-level Anthropic PKCE credential store remains sensitive even
|
||||
# when a profile is active; default/non-profile sessions still read it.
|
||||
str(hermes_root / ".anthropic_oauth.json"),
|
||||
os.path.join(home, ".bashrc"),
|
||||
os.path.join(home, ".zshrc"),
|
||||
os.path.join(home, ".profile"),
|
||||
os.path.join(home, ".bash_profile"),
|
||||
os.path.join(home, ".zprofile"),
|
||||
os.path.join(home, ".netrc"),
|
||||
os.path.join(home, ".pgpass"),
|
||||
os.path.join(home, ".npmrc"),
|
||||
@@ -99,6 +104,12 @@ def is_write_denied(path: str) -> bool:
|
||||
if resolved.startswith(prefix):
|
||||
return True
|
||||
|
||||
# Hermes control-plane files: block both the ACTIVE profile's view
|
||||
# (hermes_home) AND the global root view. Without the root pass, a
|
||||
# profile-mode session leaves <root>/auth.json + <root>/config.yaml
|
||||
# writable — letting a prompt-injected write_file overwrite the global
|
||||
# files that every profile inherits from (same shape as #15981).
|
||||
control_file_names = ("auth.json", "config.yaml", "webhook_subscriptions.json")
|
||||
mcp_tokens_dir_name = "mcp-tokens"
|
||||
|
||||
hermes_dirs = []
|
||||
@@ -111,6 +122,12 @@ def is_write_denied(path: str) -> bool:
|
||||
continue
|
||||
|
||||
for base_real in hermes_dirs:
|
||||
for name in control_file_names:
|
||||
try:
|
||||
if resolved == os.path.realpath(os.path.join(base_real, name)):
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name))
|
||||
if resolved == mcp_real or resolved.startswith(mcp_real + os.sep):
|
||||
|
||||
@@ -41,16 +41,6 @@ DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535
|
||||
|
||||
|
||||
def bare_gemini_model_id(model: str) -> str:
|
||||
"""Strip Gemini's own provider prefix from an aggregator-style model id."""
|
||||
name = (model or "").strip()
|
||||
lowered = name.lower()
|
||||
for prefix in ("google/", "gemini/"):
|
||||
if lowered.startswith(prefix):
|
||||
return name[len(prefix):].strip() or name
|
||||
return name
|
||||
|
||||
|
||||
def is_native_gemini_base_url(base_url: str) -> bool:
|
||||
"""Return True when the endpoint speaks Gemini's native REST API."""
|
||||
normalized = str(base_url or "").strip().rstrip("/").lower()
|
||||
@@ -340,7 +330,7 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st
|
||||
system_instruction = None
|
||||
joined_system = "\n".join(part for part in system_text_parts if part).strip()
|
||||
if joined_system:
|
||||
system_instruction = {"role": "system", "parts": [{"text": joined_system}]}
|
||||
system_instruction = {"parts": [{"text": joined_system}]}
|
||||
return contents, system_instruction
|
||||
|
||||
|
||||
@@ -924,7 +914,6 @@ class GeminiNativeClient:
|
||||
thinking_config=thinking_config,
|
||||
)
|
||||
|
||||
model = bare_gemini_model_id(model)
|
||||
if stream:
|
||||
return self._stream_completion(model=model, request=request, timeout=timeout)
|
||||
|
||||
|
||||
@@ -44,66 +44,6 @@ logger = logging.getLogger(__name__)
|
||||
_SYNC_DRAIN_TIMEOUT_S = 5.0
|
||||
|
||||
|
||||
def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]]) -> bool:
|
||||
"""Return whether external memory-provider tools should be exposed."""
|
||||
if enabled_toolsets is None:
|
||||
return True
|
||||
if not enabled_toolsets:
|
||||
return False
|
||||
if "memory" in enabled_toolsets:
|
||||
return True
|
||||
|
||||
try:
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
return any("memory" in resolve_toolset(name) for name in enabled_toolsets)
|
||||
except Exception:
|
||||
logger.debug("Failed to resolve enabled toolsets for memory-provider tools", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def inject_memory_provider_tools(agent: Any) -> int:
|
||||
"""Append external memory-provider tool schemas to an agent tool surface."""
|
||||
memory_manager = getattr(agent, "_memory_manager", None)
|
||||
tools = getattr(agent, "tools", None)
|
||||
if not memory_manager or tools is None:
|
||||
return 0
|
||||
|
||||
existing_tool_names = {
|
||||
tool.get("function", {}).get("name")
|
||||
for tool in tools
|
||||
if isinstance(tool, dict)
|
||||
}
|
||||
if (
|
||||
"memory" not in existing_tool_names
|
||||
and not memory_provider_tools_enabled(getattr(agent, "enabled_toolsets", None))
|
||||
):
|
||||
return 0
|
||||
|
||||
get_schemas = getattr(memory_manager, "get_all_tool_schemas", None)
|
||||
if not callable(get_schemas):
|
||||
return 0
|
||||
|
||||
valid_tool_names = getattr(agent, "valid_tool_names", None)
|
||||
if valid_tool_names is None:
|
||||
valid_tool_names = set()
|
||||
agent.valid_tool_names = valid_tool_names
|
||||
|
||||
added = 0
|
||||
for schema in get_schemas():
|
||||
if not isinstance(schema, dict):
|
||||
continue
|
||||
tool_name = schema.get("name", "")
|
||||
if not tool_name or tool_name in existing_tool_names:
|
||||
continue
|
||||
tools.append({"type": "function", "function": schema})
|
||||
valid_tool_names.add(tool_name)
|
||||
existing_tool_names.add(tool_name)
|
||||
added += 1
|
||||
|
||||
return added
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context fencing helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -135,14 +135,7 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
|
||||
|
||||
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Infer a reasonable ``type`` if this schema node has none."""
|
||||
node_type = node.get("type")
|
||||
if isinstance(node_type, list):
|
||||
concrete = next(
|
||||
(t for t in node_type if isinstance(t, str) and t not in {"", "null"}),
|
||||
"string",
|
||||
)
|
||||
return {**node, "type": concrete}
|
||||
if "type" in node and node_type not in {None, ""}:
|
||||
if "type" in node and node["type"] not in {None, ""}:
|
||||
return node
|
||||
|
||||
# Heuristic: presence of ``properties`` → object, ``items`` → array, ``enum``
|
||||
|
||||
@@ -508,22 +508,13 @@ PLATFORM_HINTS = {
|
||||
),
|
||||
"telegram": (
|
||||
"You are on a text messaging communication platform, Telegram. "
|
||||
"Standard Markdown is automatically converted to Telegram formatting. "
|
||||
"Standard markdown is automatically converted to Telegram format. "
|
||||
"Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, "
|
||||
"`inline code`, ```code blocks```, [links](url), and ## headers. "
|
||||
"Telegram now supports rich Markdown, so lean into it: whenever it "
|
||||
"makes the answer clearer or easier to scan, actively reach for real "
|
||||
"Markdown tables (pipe `| col | col |` syntax), bullet and numbered "
|
||||
"lists, task lists (`- [ ]` / `- [x]`), headings, nested blockquotes, "
|
||||
"collapsible details, footnotes/references, math/formulas (`$...$`, "
|
||||
"`$$...$$`), underline, subscript/superscript, marked (highlighted) "
|
||||
"text, and anchors. Default to structured formatting over dense "
|
||||
"paragraphs for any comparison, set of steps, key/value summary, or "
|
||||
"tabular data. Prefer real Markdown tables and task lists over "
|
||||
"hand-built bullet substitutes when presenting structured data; these "
|
||||
"degrade gracefully (tables become readable bullet groups) when rich "
|
||||
"rendering is unavailable, but advanced constructs like math and "
|
||||
"collapsible details may render as plain source text in that case. "
|
||||
"Telegram has NO table syntax — prefer bullet lists or labeled "
|
||||
"key: value pairs over pipe tables (any tables you do emit are "
|
||||
"auto-rewritten into row-group bullets, which you can produce "
|
||||
"directly for cleaner output). "
|
||||
"You can send media files natively: to deliver a file to the user, "
|
||||
"include MEDIA:/absolute/path/to/file in your response. Images "
|
||||
"(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice "
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
"""Preventive SSL CA certificate checks for Hermes Agent.
|
||||
|
||||
This module catches broken CA bundle paths before OpenAI/httpx turns them into
|
||||
opaque ``FileNotFoundError: [Errno 2] No such file or directory`` failures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
|
||||
from agent.errors import SSLConfigurationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CA_BUNDLE_ENV_VARS = (
|
||||
"HERMES_CA_BUNDLE",
|
||||
"SSL_CERT_FILE",
|
||||
"REQUESTS_CA_BUNDLE",
|
||||
"CURL_CA_BUNDLE",
|
||||
)
|
||||
|
||||
_SKIP_VALUES = {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _skip_ssl_guard_enabled() -> bool:
|
||||
return os.getenv("HERMES_SKIP_SSL_GUARD", "").strip().lower() in _SKIP_VALUES
|
||||
|
||||
|
||||
def _repair_hint() -> str:
|
||||
return (
|
||||
"Repair: python -m pip install --force-reinstall certifi openai httpx\n"
|
||||
"If you configured a custom corporate CA bundle, fix or unset the "
|
||||
"broken CA bundle environment variable."
|
||||
)
|
||||
|
||||
|
||||
def _ssl_err(message: str) -> SSLConfigurationError:
|
||||
"""Create a consistent, user-actionable SSL configuration error."""
|
||||
return SSLConfigurationError(f"{message}\n{_repair_hint()}")
|
||||
|
||||
|
||||
def _validate_bundle_path(label: str, value: str, *, require_substantial: bool = False) -> None:
|
||||
path = Path(value).expanduser()
|
||||
if not path.exists():
|
||||
raise _ssl_err(f"{label} points to a missing CA bundle: {value}")
|
||||
if not path.is_file():
|
||||
raise _ssl_err(f"{label} does not point to a CA bundle file: {value}")
|
||||
if require_substantial and path.stat().st_size < 1024:
|
||||
raise _ssl_err(f"{label} at {value} appears corrupted (too small)")
|
||||
try:
|
||||
ctx = ssl.create_default_context(cafile=str(path))
|
||||
except Exception as exc:
|
||||
raise _ssl_err(f"{label} CA bundle at {value} cannot be loaded: {exc}") from exc
|
||||
if not ctx.get_ca_certs():
|
||||
raise _ssl_err(f"{label} CA bundle at {value} did not load any certificates")
|
||||
|
||||
|
||||
def verify_ca_bundle() -> None:
|
||||
"""Verify configured and bundled CA certificates are present and loadable.
|
||||
|
||||
Raises:
|
||||
SSLConfigurationError: If an explicit CA-bundle environment variable
|
||||
points at a bad path, or if certifi's bundled ``cacert.pem`` is
|
||||
missing/corrupt.
|
||||
"""
|
||||
if _skip_ssl_guard_enabled():
|
||||
logger.debug("SSL CA bundle guard skipped via HERMES_SKIP_SSL_GUARD")
|
||||
return
|
||||
|
||||
for env_var in _CA_BUNDLE_ENV_VARS:
|
||||
value = os.getenv(env_var)
|
||||
if value:
|
||||
_validate_bundle_path(env_var, value)
|
||||
|
||||
try:
|
||||
import certifi
|
||||
except Exception as exc:
|
||||
raise _ssl_err(f"certifi is not importable: {exc}") from exc
|
||||
|
||||
ca_bundle = str(certifi.where())
|
||||
_validate_bundle_path("certifi", ca_bundle, require_substantial=True)
|
||||
|
||||
|
||||
def verify_ca_bundle_with_fallback() -> None:
|
||||
"""Backward-compatible wrapper for older call sites.
|
||||
|
||||
The old PR name mentioned a platform fallback, but allowing startup with a
|
||||
broken certifi bundle still leaves httpx/OpenAI and requests call sites
|
||||
failing later. Keep the wrapper name but enforce the same check.
|
||||
"""
|
||||
verify_ca_bundle()
|
||||
@@ -186,21 +186,10 @@ class AnthropicTransport(ProviderTransport):
|
||||
def validate_response(self, response: Any) -> bool:
|
||||
"""Check Anthropic response structure is valid.
|
||||
|
||||
An empty content list is legitimate for terminal stop reasons that
|
||||
carry no text payload:
|
||||
|
||||
- ``end_turn`` — the model's canonical "nothing more to add" after a
|
||||
tool turn that already delivered the user-facing text.
|
||||
- ``refusal`` — the model declined to respond (Claude 4.5+). The
|
||||
Messages API returns an empty ``content`` list with this stop
|
||||
reason. Treating it as invalid sends a deterministic refusal into
|
||||
the invalid-response retry loop, which reproduces the refusal on
|
||||
every attempt and surfaces a misleading "rate limited / invalid
|
||||
response" error instead of the refusal. ``normalize_response`` maps
|
||||
``refusal`` → ``content_filter`` so the agent loop's refusal handler
|
||||
can surface it.
|
||||
|
||||
Treating either as invalid falsely retries a completed response.
|
||||
An empty content list is legitimate when ``stop_reason == "end_turn"``
|
||||
— the model's canonical way of signalling "nothing more to add" after
|
||||
a tool turn that already delivered the user-facing text. Treating it
|
||||
as invalid falsely retries a completed response.
|
||||
"""
|
||||
if response is None:
|
||||
return False
|
||||
@@ -208,7 +197,7 @@ class AnthropicTransport(ProviderTransport):
|
||||
if not isinstance(content_blocks, list):
|
||||
return False
|
||||
if not content_blocks:
|
||||
return getattr(response, "stop_reason", None) in {"end_turn", "refusal"}
|
||||
return getattr(response, "stop_reason", None) == "end_turn"
|
||||
return True
|
||||
|
||||
def extract_cache_stats(self, response: Any) -> Optional[Dict[str, int]]:
|
||||
|
||||
@@ -664,42 +664,8 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
if rd:
|
||||
provider_data["reasoning_details"] = rd
|
||||
|
||||
# OpenAI structured-refusal field. When a model declines, the SDK
|
||||
# populates ``message.refusal`` with the explanation and leaves
|
||||
# ``content`` empty. OpenAI-compatible proxies that front Anthropic /
|
||||
# Bedrock (e.g. Nous Portal) surface a Claude refusal this way — or via
|
||||
# ``finish_reason="content_filter"`` — instead of the native
|
||||
# ``stop_reason="refusal"``. Without capturing it the refusal looks
|
||||
# like an empty response, so the agent loop retries a deterministic
|
||||
# refusal three times and gives up with "no content after retries".
|
||||
# Promote it to content + a ``content_filter`` finish reason so the
|
||||
# loop's refusal handler surfaces it clearly and stops. ``refusal`` is
|
||||
# ``None`` for normal responses, so this is a no-op in the common case.
|
||||
content = msg.content
|
||||
refusal = getattr(msg, "refusal", None)
|
||||
if refusal is None and hasattr(msg, "model_extra"):
|
||||
_msg_extra = getattr(msg, "model_extra", None) or {}
|
||||
if isinstance(_msg_extra, dict):
|
||||
refusal = _msg_extra.get("refusal")
|
||||
if isinstance(refusal, str) and refusal.strip():
|
||||
# Record the refusal explanation regardless — it's useful provider
|
||||
# metadata even when the model also returned a usable payload.
|
||||
provider_data["refusal"] = refusal
|
||||
_has_text = isinstance(content, str) and content.strip()
|
||||
_has_tool_calls = bool(tool_calls)
|
||||
# Only promote to a terminal ``content_filter`` when the refusal is
|
||||
# the *sole* payload — no visible text and no tool calls. A response
|
||||
# that carries real content (or tool calls) alongside a refusal note
|
||||
# is a normal, usable turn: surfacing it as a failed safety refusal
|
||||
# would discard the model's actual work. In the empty-payload case,
|
||||
# adopt the refusal as content so the loop has something to show.
|
||||
if not _has_text and not _has_tool_calls:
|
||||
content = refusal
|
||||
if finish_reason in (None, "stop"):
|
||||
finish_reason = "content_filter"
|
||||
|
||||
return NormalizedResponse(
|
||||
content=content,
|
||||
content=msg.content,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish_reason,
|
||||
reasoning=reasoning,
|
||||
|
||||
@@ -218,10 +218,22 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
kwargs.pop("timeout", None)
|
||||
|
||||
if is_codex_backend:
|
||||
# chatgpt.com/backend-api/codex rejects body-level
|
||||
# ``extra_headers`` with HTTP 400. Correlation/cache routing for
|
||||
# this backend must not be sent through the Responses payload.
|
||||
kwargs.pop("extra_headers", None)
|
||||
prompt_cache_key = kwargs.get("prompt_cache_key")
|
||||
cache_scope_id = str(prompt_cache_key or session_id or "").strip()
|
||||
if cache_scope_id:
|
||||
existing_extra_headers = kwargs.get("extra_headers")
|
||||
merged_extra_headers: Dict[str, str] = {}
|
||||
if isinstance(existing_extra_headers, dict):
|
||||
merged_extra_headers.update(
|
||||
{
|
||||
str(key): str(value)
|
||||
for key, value in existing_extra_headers.items()
|
||||
if key and value is not None
|
||||
}
|
||||
)
|
||||
merged_extra_headers["session_id"] = cache_scope_id
|
||||
merged_extra_headers["x-client-request-id"] = cache_scope_id
|
||||
kwargs["extra_headers"] = merged_extra_headers
|
||||
|
||||
max_tokens = params.get("max_tokens")
|
||||
if max_tokens is not None and not is_codex_backend:
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
//! Driven when the installer is launched as `Hermes-Setup.exe --update` (see
|
||||
//! `AppMode` in lib.rs). The desktop app hands off to us — it exits, then we:
|
||||
//!
|
||||
//! 1. wait for the old Hermes desktop process to fully exit (so both the
|
||||
//! venv shim and packaged app.asar are free; otherwise `hermes update`
|
||||
//! or repair bootstrap can race locked files),
|
||||
//! 1. wait for the old Hermes desktop process to fully exit (so the venv
|
||||
//! shim is free; otherwise `hermes update` aborts with exit code 2),
|
||||
//! 2. run `hermes update --yes --gateway` (Python/repo update; this does NOT
|
||||
//! rebuild apps/desktop by design — see cmd_update in hermes_cli/main.py),
|
||||
//! 3. run `hermes desktop --build-only` (the rebuild step update skips),
|
||||
@@ -39,8 +38,8 @@ use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};
|
||||
/// hermes_cli/main.py (sys.exit(2)). We surface a targeted message for this.
|
||||
const UPDATE_EXIT_CONCURRENT: i32 = 2;
|
||||
|
||||
/// How long to wait for the old desktop process to release files under the
|
||||
/// install tree before giving up and letting `hermes update`'s own guard decide.
|
||||
/// How long to wait for the old desktop process to release the venv shim
|
||||
/// before giving up and letting `hermes update`'s own guard decide.
|
||||
const DESKTOP_EXIT_WAIT: Duration = Duration::from_secs(20);
|
||||
const DESKTOP_EXIT_POLL: Duration = Duration::from_millis(500);
|
||||
|
||||
@@ -151,10 +150,8 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
// ---- pre-step: wait for the old desktop to die -----------------------
|
||||
// The desktop exec'd us then called app.exit(), but process teardown is
|
||||
// async on Windows. If it still holds the venv shim, `hermes update`
|
||||
// aborts with exit 2. If it still holds the packaged app.asar,
|
||||
// install.ps1's repair/re-clone path cannot move/remove the install tree.
|
||||
// Give both handles a bounded window to clear.
|
||||
wait_for_install_locks_free(&install_root, &app, "update").await;
|
||||
// aborts with exit 2. Give it a bounded window to clear.
|
||||
wait_for_venv_free(&install_root, &app).await;
|
||||
|
||||
// ---- stage 1: hermes update -----------------------------------------
|
||||
// Pass --branch so `hermes update` targets the branch this installer was
|
||||
@@ -176,8 +173,8 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
vec!["update".into(), "--yes".into(), "--gateway".into()];
|
||||
// --force skips `hermes update`'s Windows running-exe guard (which would
|
||||
// `sys.exit(2)` and dead-end the handoff). By contract the desktop has
|
||||
// already exited and waited for the install locks to clear before launching
|
||||
// us, and wait_for_install_locks_free below force-kills any straggler — so by the
|
||||
// already exited and waited for the venv shim to unlock before launching
|
||||
// us, and wait_for_venv_free below force-kills any straggler — so by the
|
||||
// time `hermes update` runs there is no legitimate hermes.exe to protect,
|
||||
// and the guard would only produce a false "Hermes is still running" stop.
|
||||
update_args.push("--force".into());
|
||||
@@ -394,57 +391,48 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll until the venv shim AND packaged desktop app bundle are no longer locked
|
||||
/// (Windows) or a bounded timeout elapses. On non-Windows this is a short fixed
|
||||
/// grace since file locking isn't the failure mode there.
|
||||
pub(crate) async fn wait_for_install_locks_free(install_root: &Path, app: &AppHandle, stage: &str) {
|
||||
let lock_targets = install_lock_probe_paths(install_root);
|
||||
/// Poll until the venv shim is no longer locked (Windows) or a bounded timeout
|
||||
/// elapses. On non-Windows this is a short fixed grace since file locking
|
||||
/// isn't the failure mode there.
|
||||
async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
|
||||
let shim = venv_hermes(install_root);
|
||||
let deadline = Instant::now() + DESKTOP_EXIT_WAIT;
|
||||
|
||||
emit_log(app, Some(stage), LogStream::Stdout, "[handoff] waiting for Hermes to exit…");
|
||||
emit_log(app, Some("update"), LogStream::Stdout, "[update] waiting for Hermes to exit…");
|
||||
|
||||
loop {
|
||||
let locked = locked_paths(&lock_targets);
|
||||
if locked.is_empty() {
|
||||
if !is_locked(&shim) {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
// Last resort: a backend hermes.exe (or the desktop Hermes.exe
|
||||
// itself) is still holding one of the update-sensitive files. The
|
||||
// desktop should have reaped its tree before handing off, but
|
||||
// SIGTERM races / detached grandchildren / AV handles can leave a
|
||||
// straggler. Rather than "proceed anyway" straight into uv's
|
||||
// "Access is denied" or install.ps1's locked app.asar failure,
|
||||
// force-kill every Hermes.exe except ourselves, then give the OS a
|
||||
// beat to unload the image.
|
||||
// Last resort: a backend hermes.exe (or a grandchild it spawned)
|
||||
// is still holding the shim. The desktop should have reaped its
|
||||
// tree before handing off, but SIGTERM races / detached
|
||||
// grandchildren / AV handles can leave a straggler. Rather than
|
||||
// "proceed anyway" straight into uv's "Access is denied", force-kill
|
||||
// every hermes.exe except ourselves, then give the OS a beat to
|
||||
// unload the image.
|
||||
emit_log(
|
||||
app,
|
||||
Some(stage),
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
&format!(
|
||||
"[handoff] Hermes still holding install files ({}); force-killing stragglers…",
|
||||
format_locked_paths(&locked)
|
||||
),
|
||||
"[update] Hermes still holding the venv shim; force-killing stragglers…",
|
||||
);
|
||||
force_kill_other_hermes();
|
||||
tokio::time::sleep(Duration::from_millis(800)).await;
|
||||
let locked_after_kill = locked_paths(&lock_targets);
|
||||
if locked_after_kill.is_empty() {
|
||||
if !is_locked(&shim) {
|
||||
emit_log(
|
||||
app,
|
||||
Some(stage),
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
"[handoff] install files freed after force-kill",
|
||||
"[update] venv shim freed after force-kill",
|
||||
);
|
||||
} else {
|
||||
emit_log(
|
||||
app,
|
||||
Some(stage),
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
&format!(
|
||||
"[handoff] install files still locked ({}); proceeding (--force + quarantine will handle it)",
|
||||
format_locked_paths(&locked_after_kill)
|
||||
),
|
||||
"[update] venv shim still locked; proceeding (--force + quarantine will handle it)",
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -453,44 +441,13 @@ pub(crate) async fn wait_for_install_locks_free(install_root: &Path, app: &AppHa
|
||||
}
|
||||
}
|
||||
|
||||
fn install_lock_probe_paths(install_root: &Path) -> Vec<PathBuf> {
|
||||
let mut paths = vec![venv_hermes(install_root)];
|
||||
paths.extend(desktop_app_payload_paths(install_root));
|
||||
paths
|
||||
}
|
||||
|
||||
fn desktop_app_payload_paths(install_root: &Path) -> Vec<PathBuf> {
|
||||
let release = install_root.join("apps").join("desktop").join("release");
|
||||
if cfg!(target_os = "windows") {
|
||||
vec![
|
||||
release.join("win-unpacked").join("resources").join("app.asar"),
|
||||
release.join("win-arm64-unpacked").join("resources").join("app.asar"),
|
||||
]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
vec![
|
||||
release.join("mac").join("Hermes.app").join("Contents").join("Resources").join("app.asar"),
|
||||
release.join("mac-arm64").join("Hermes.app").join("Contents").join("Resources").join("app.asar"),
|
||||
]
|
||||
} else {
|
||||
vec![release.join("linux-unpacked").join("resources").join("app.asar")]
|
||||
}
|
||||
}
|
||||
|
||||
fn locked_paths(paths: &[PathBuf]) -> Vec<PathBuf> {
|
||||
paths.iter().filter(|p| is_locked(p)).cloned().collect()
|
||||
}
|
||||
|
||||
fn format_locked_paths(paths: &[PathBuf]) -> String {
|
||||
paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// Force-kill any `hermes.exe` other than this process. Windows-only; a no-op
|
||||
/// elsewhere (POSIX has no mandatory-lock contention). We can't selectively
|
||||
/// target "the backend" by PID here — the desktop already exited and we never
|
||||
/// knew its children — so we kill the whole `hermes.exe` image tree via
|
||||
/// taskkill, excluding our own PID.
|
||||
///
|
||||
/// Safe w.r.t. our own update child: this runs inside the install-lock wait,
|
||||
/// Safe w.r.t. our own update child: this runs inside `wait_for_venv_free`,
|
||||
/// which completes BEFORE we spawn `venv\Scripts\hermes.exe update`. At this
|
||||
/// point no update-driven hermes.exe exists yet, so the only hermes.exe images
|
||||
/// are stragglers from the old desktop — exactly what we want gone. (`/FI PID
|
||||
@@ -934,29 +891,6 @@ mod tests {
|
||||
assert!(!is_locked(Path::new("/nonexistent/does/not/exist/xyz")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_probe_paths_include_desktop_app_payload() {
|
||||
let root = Path::new("/x/hermes-agent");
|
||||
let probes = install_lock_probe_paths(root);
|
||||
|
||||
assert!(
|
||||
probes.iter().any(|p| p == &venv_hermes(root)),
|
||||
"venv shim remains part of the update lock probe"
|
||||
);
|
||||
assert!(
|
||||
probes.iter().any(|p| p.ends_with(Path::new("resources/app.asar"))),
|
||||
"packaged app.asar must be probed so repair/re-clone waits for the old desktop to exit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_paths_ignores_missing_payloads() {
|
||||
let root = Path::new("/nonexistent/hermes-agent");
|
||||
let probes = install_lock_probe_paths(root);
|
||||
|
||||
assert!(locked_paths(&probes).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_update_branch_from_space_or_equals_args() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -67,16 +67,6 @@ function buildDesktopBackendPath({
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) } = {}) {
|
||||
if (!hermesHome) return hermesHome
|
||||
const resolved = pathModule.resolve(String(hermesHome))
|
||||
const parent = pathModule.dirname(resolved)
|
||||
if (pathModule.basename(parent).toLowerCase() === 'profiles') {
|
||||
return pathModule.dirname(parent)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function buildDesktopBackendEnv({
|
||||
hermesHome,
|
||||
pythonPathEntries = [],
|
||||
@@ -107,6 +97,5 @@ module.exports = {
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
delimiterForPlatform,
|
||||
normalizeHermesHomeRoot,
|
||||
pathEnvKey
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ const {
|
||||
appendUniquePathEntries,
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
normalizeHermesHomeRoot,
|
||||
pathEnvKey
|
||||
} = require('./backend-env.cjs')
|
||||
|
||||
@@ -67,21 +66,6 @@ test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () =
|
||||
assert.ok(env.PATH.includes('/opt/homebrew/bin'))
|
||||
})
|
||||
|
||||
test('normalizeHermesHomeRoot maps profile homes back to the global Hermes root', () => {
|
||||
assert.equal(
|
||||
normalizeHermesHomeRoot('/Users/test/.hermes/profiles/oracle', { pathModule: path.posix }),
|
||||
'/Users/test/.hermes'
|
||||
)
|
||||
assert.equal(
|
||||
normalizeHermesHomeRoot('C:\\Users\\test\\AppData\\Local\\hermes\\profiles\\oracle', { pathModule: path.win32 }),
|
||||
'C:\\Users\\test\\AppData\\Local\\hermes'
|
||||
)
|
||||
assert.equal(
|
||||
normalizeHermesHomeRoot('/Users/test/.hermes', { pathModule: path.posix }),
|
||||
'/Users/test/.hermes'
|
||||
)
|
||||
})
|
||||
|
||||
test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => {
|
||||
const env = buildDesktopBackendEnv({
|
||||
hermesHome: 'C:\\Users\\test\\AppData\\Local\\hermes',
|
||||
|
||||
@@ -38,7 +38,7 @@ const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
|
||||
const { waitForDashboardPort } = require('./backend-ready.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
|
||||
const { buildDesktopBackendEnv } = require('./backend-env.cjs')
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const { worktreesForIpc } = require('./git-worktrees.cjs')
|
||||
@@ -240,7 +240,7 @@ if (INSTALL_STAMP) {
|
||||
// HERMES_HOME beneath the throwaway userData dir so a fresh-install run never
|
||||
// touches the user's real ~/.hermes / %LOCALAPPDATA%\hermes.
|
||||
function resolveHermesHome() {
|
||||
if (process.env.HERMES_HOME) return normalizeHermesHomeRoot(process.env.HERMES_HOME)
|
||||
if (process.env.HERMES_HOME) return path.resolve(process.env.HERMES_HOME)
|
||||
if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home')
|
||||
if (IS_WINDOWS && process.env.LOCALAPPDATA) {
|
||||
const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes')
|
||||
@@ -1835,44 +1835,6 @@ async function applyUpdates(opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handOffWindowsBootstrapRecovery(reason) {
|
||||
if (!IS_WINDOWS || !IS_PACKAGED) return false
|
||||
|
||||
const updater = resolveUpdaterBinary()
|
||||
if (!updater) return false
|
||||
|
||||
const updateRoot = resolveUpdateRoot()
|
||||
const { branch: configuredBranch } = readDesktopUpdateConfig()
|
||||
const branch = directoryExists(path.join(updateRoot, '.git'))
|
||||
? await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH)
|
||||
: configuredBranch || DEFAULT_UPDATE_BRANCH
|
||||
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
|
||||
const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes')
|
||||
const updaterArgs = fileExists(venvHermes) ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
|
||||
|
||||
await releaseBackendLockForUpdate(updateRoot)
|
||||
|
||||
const child = spawn(updater, updaterArgs, {
|
||||
cwd: HERMES_HOME,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME,
|
||||
PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH].filter(Boolean).join(path.delimiter)
|
||||
},
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: false
|
||||
})
|
||||
child.unref()
|
||||
|
||||
rememberLog(`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`)
|
||||
setTimeout(() => {
|
||||
app.quit()
|
||||
}, 600)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Resolve the hermes CLI to drive an in-app update: prefer the venv shim in
|
||||
// the install we're updating, fall back to `hermes` on PATH.
|
||||
function resolveHermesCliBinary(updateRoot) {
|
||||
@@ -2470,14 +2432,6 @@ async function ensureRuntime(backend) {
|
||||
if (backend.kind === 'bootstrap-needed') {
|
||||
rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap')
|
||||
|
||||
if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) {
|
||||
const handoffError = new Error('Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.')
|
||||
handoffError.isBootstrapFailure = true
|
||||
handoffError.bootstrapHandedOff = true
|
||||
bootstrapFailure = handoffError
|
||||
throw handoffError
|
||||
}
|
||||
|
||||
// Eagerly flip the bootstrap UI state to 'active' so the renderer
|
||||
// shows the install overlay BEFORE the runner finishes fetching the
|
||||
// manifest (which on slow networks can take tens of seconds and would
|
||||
@@ -5609,30 +5563,11 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
|
||||
ipcMain.handle('hermes:notify', (_event, payload) => {
|
||||
if (!Notification.isSupported()) return false
|
||||
// Action buttons render only on signed macOS builds; elsewhere they're dropped
|
||||
// and the body click still works.
|
||||
const actions = Array.isArray(payload?.actions) ? payload.actions : []
|
||||
const notification = new Notification({
|
||||
new Notification({
|
||||
title: payload?.title || 'Hermes',
|
||||
body: payload?.body || '',
|
||||
silent: Boolean(payload?.silent),
|
||||
actions: actions.map(action => ({ type: 'button', text: String(action?.text || '') }))
|
||||
})
|
||||
notification.on('click', () => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
focusWindow(mainWindow)
|
||||
if (payload?.sessionId) {
|
||||
mainWindow.webContents.send('hermes:focus-session', payload.sessionId)
|
||||
}
|
||||
})
|
||||
notification.on('action', (_actionEvent, index) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
const action = actions[index]
|
||||
if (action?.id) {
|
||||
mainWindow.webContents.send('hermes:notification-action', { sessionId: payload?.sessionId, actionId: action.id })
|
||||
}
|
||||
})
|
||||
notification.show()
|
||||
silent: Boolean(payload?.silent)
|
||||
}).show()
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -94,16 +94,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
ipcRenderer.on('hermes:window-state-changed', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:window-state-changed', listener)
|
||||
},
|
||||
onFocusSession: callback => {
|
||||
const listener = (_event, sessionId) => callback(sessionId)
|
||||
ipcRenderer.on('hermes:focus-session', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:focus-session', listener)
|
||||
},
|
||||
onNotificationAction: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:notification-action', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:notification-action', listener)
|
||||
},
|
||||
onPreviewFileChanged: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:preview-file-changed', listener)
|
||||
|
||||
@@ -42,9 +42,6 @@ test('intentional or interactive desktop child processes stay documented', () =>
|
||||
const source = readElectronFile('main.cjs')
|
||||
|
||||
assert.match(source, /windowsHide: false/)
|
||||
assert.match(source, /handOffWindowsBootstrapRecovery/)
|
||||
assert.match(source, /'--repair', '--branch'/)
|
||||
assert.match(source, /'--update', '--branch'/)
|
||||
assert.match(source, /nodePty\.spawn\(command, args/)
|
||||
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
|
||||
})
|
||||
|
||||
@@ -99,7 +99,6 @@
|
||||
"unicode-animations": "^1.0.3",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit-parents": "^6.0.2",
|
||||
"use-stick-to-bottom": "^1.1.6",
|
||||
"vfile": "^6.0.3",
|
||||
"web-haptics": "^0.0.6"
|
||||
},
|
||||
|
||||
@@ -24,7 +24,6 @@ afterEach(cleanup)
|
||||
// state stays stale while the DOM already holds the text.
|
||||
function Harness({
|
||||
busy = false,
|
||||
disabled = false,
|
||||
queued = [],
|
||||
onSubmit,
|
||||
onQueue,
|
||||
@@ -32,7 +31,6 @@ function Harness({
|
||||
onDrain
|
||||
}: {
|
||||
busy?: boolean
|
||||
disabled?: boolean
|
||||
queued?: readonly string[]
|
||||
onSubmit: (text: string) => void
|
||||
onQueue: (text: string) => void
|
||||
@@ -54,10 +52,6 @@ function Harness({
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const editor = editorRef.current
|
||||
if (editor) {
|
||||
const domText = composerPlainText(editor)
|
||||
@@ -90,10 +84,6 @@ function Harness({
|
||||
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
|
||||
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
|
||||
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!busy && !hasLivePayload && queued.length > 0) {
|
||||
onDrain()
|
||||
|
||||
@@ -196,23 +186,4 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
|
||||
expect(onDrain).toHaveBeenCalledTimes(1)
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps reconnect drafts editable but blocks Enter submit until the gateway returns', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const onDrain = vi.fn()
|
||||
const { getByTestId } = render(
|
||||
<Harness disabled onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
|
||||
)
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
await act(async () => {
|
||||
editor.textContent = 'draft while reconnecting'
|
||||
fireEvent.input(editor)
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
})
|
||||
|
||||
expect(editor.textContent).toBe('draft while reconnecting')
|
||||
expect(onDrain).not.toHaveBeenCalled()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,8 +85,6 @@ import {
|
||||
import { QueuePanel } from './queue-panel'
|
||||
import {
|
||||
composerPlainText,
|
||||
deleteSelectionInEditor,
|
||||
insertPlainTextAtCaret,
|
||||
normalizeComposerEditorDom,
|
||||
placeCaretEnd,
|
||||
refChipElement,
|
||||
@@ -137,12 +135,6 @@ function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind {
|
||||
return 'command'
|
||||
}
|
||||
|
||||
/** A `/` query is at its arg stage once it's past the command name. */
|
||||
const slashArgStage = (query: string) => query.includes(' ')
|
||||
|
||||
/** The `/command` token of a slash query (`personality x` → `/personality`). */
|
||||
const slashCommandToken = (query: string) => `/${query.split(/\s+/, 1)[0]?.toLowerCase() ?? ''}`
|
||||
|
||||
interface QueueEditState {
|
||||
attachments: ComposerAttachment[]
|
||||
draft: string
|
||||
@@ -255,8 +247,6 @@ export function ChatBar({
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const newSessionPlaceholders = t.composer.newSessionPlaceholders
|
||||
const followUpPlaceholders = t.composer.followUpPlaceholders
|
||||
const reconnecting = gatewayState === 'closed' || gatewayState === 'error'
|
||||
const inputDisabled = disabled && !reconnecting
|
||||
|
||||
// Resting placeholder: a starter for brand-new sessions, a continuation for
|
||||
// existing ones. Picked once and only re-rolled when we genuinely move to a
|
||||
@@ -287,13 +277,11 @@ export function ChatBar({
|
||||
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
|
||||
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
|
||||
|
||||
// When the transport is disabled it's because the gateway isn't open.
|
||||
// Distinguish a cold start ("Starting Hermes...") from a dropped connection
|
||||
// we're trying to restore. During reconnect, keep the textbox editable so a
|
||||
// flaky network doesn't block drafting; only submit/backend actions stay
|
||||
// disabled until the gateway is open again.
|
||||
// When the bar is disabled it's because the gateway isn't open. Distinguish a
|
||||
// cold start ("Starting Hermes...") from a dropped connection we're trying to
|
||||
// restore (e.g. after the Mac slept) so the stuck state reads as recoverable.
|
||||
const placeholder = disabled
|
||||
? reconnecting
|
||||
? gatewayState === 'closed' || gatewayState === 'error'
|
||||
? t.composer.placeholderReconnecting
|
||||
: t.composer.placeholderStarting
|
||||
: restingPlaceholder
|
||||
@@ -335,13 +323,13 @@ export function ChatBar({
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!inputDisabled) {
|
||||
if (!disabled) {
|
||||
focusInput()
|
||||
}
|
||||
}, [focusInput, focusKey, focusRequestId, inputDisabled])
|
||||
}, [disabled, focusInput, focusKey, focusRequestId])
|
||||
|
||||
useEffect(() => {
|
||||
if (inputDisabled) {
|
||||
if (disabled) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -361,7 +349,7 @@ export function ChatBar({
|
||||
offFocus()
|
||||
offInsert()
|
||||
}
|
||||
}, [appendExternalText, inputDisabled])
|
||||
}, [appendExternalText, disabled])
|
||||
|
||||
// Keep draftRef in sync with the assistant-ui composer state for callers
|
||||
// that read the latest text outside the React render cycle. We don't push
|
||||
@@ -540,6 +528,48 @@ export function ChatBar({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||
|
||||
if (imageBlobs.length > 0) {
|
||||
event.preventDefault()
|
||||
|
||||
if (onAttachImageBlob) {
|
||||
triggerHaptic('selection')
|
||||
|
||||
for (const blob of imageBlobs) {
|
||||
void onAttachImageBlob(blob)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Trim surrounding whitespace so a copy that dragged along leading/trailing
|
||||
// blank lines (common when selecting from terminals, code blocks, web pages)
|
||||
// doesn't dump multiline padding into the composer. Internal newlines are
|
||||
// preserved — only the edges are cleaned up.
|
||||
const pastedText = event.clipboardData.getData('text').trim()
|
||||
|
||||
if (!pastedText) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (DATA_IMAGE_URL_RE.test(pastedText)) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
document.execCommand('insertText', false, pastedText)
|
||||
const nextDraft = composerPlainText(event.currentTarget)
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
}
|
||||
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
@@ -576,15 +606,7 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
const before = textBeforeCaret(editor)
|
||||
const found = detectTrigger(before ?? composerPlainText(editor))
|
||||
|
||||
// The arg-stage popover is only useful for commands with an options screen.
|
||||
// For a no-arg command it would dead-end on "No matches", so drop it — the
|
||||
// directive is already complete.
|
||||
const detected =
|
||||
found?.kind === '/' && slashArgStage(found.query) && !desktopSlashCommandTakesArgs(slashCommandToken(found.query))
|
||||
? null
|
||||
: found
|
||||
const detected = detectTrigger(before ?? composerPlainText(editor))
|
||||
|
||||
setTrigger(detected)
|
||||
|
||||
@@ -624,46 +646,6 @@ export function ChatBar({
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||
|
||||
if (imageBlobs.length > 0) {
|
||||
event.preventDefault()
|
||||
|
||||
if (onAttachImageBlob) {
|
||||
triggerHaptic('selection')
|
||||
|
||||
for (const blob of imageBlobs) {
|
||||
void onAttachImageBlob(blob)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Trim surrounding whitespace so a copy that dragged along leading/trailing
|
||||
// blank lines (common when selecting from terminals, code blocks, web pages)
|
||||
// doesn't dump multiline padding into the composer. Internal newlines are
|
||||
// preserved — only the edges are cleaned up.
|
||||
const pastedText = event.clipboardData.getData('text').trim()
|
||||
|
||||
if (!pastedText) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (DATA_IMAGE_URL_RE.test(pastedText)) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
insertPlainTextAtCaret(event.currentTarget, pastedText)
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null =
|
||||
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
|
||||
|
||||
@@ -679,12 +661,6 @@ export function ChatBar({
|
||||
|
||||
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
|
||||
|
||||
// Suppress the "No matches" empty state once a slash command is past its name:
|
||||
// a no-arg command has nothing to offer, and a fully-typed arg commits on
|
||||
// Space/Tab — neither should dead-end on a popover.
|
||||
const argStageEmpty =
|
||||
trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
|
||||
|
||||
const closeTrigger = () => {
|
||||
setTrigger(null)
|
||||
setTriggerItems([])
|
||||
@@ -695,25 +671,6 @@ export function ChatBar({
|
||||
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
|
||||
}, [triggerItems.length])
|
||||
|
||||
// Commit the literally-typed `/command arg` as a directive chip — used when
|
||||
// the completion list is empty because the arg is already fully typed (the
|
||||
// backend completer drops exact matches). Reuses the chip path via a
|
||||
// synthetic item whose serialized form is the verbatim text.
|
||||
const commitTypedSlashDirective = () => {
|
||||
if (trigger?.kind !== '/') {
|
||||
return
|
||||
}
|
||||
|
||||
const text = `/${trigger.query.trimEnd()}`
|
||||
|
||||
replaceTriggerWithChip({
|
||||
id: text,
|
||||
type: 'slash',
|
||||
label: text.slice(1),
|
||||
metadata: { command: slashCommandToken(trigger.query), display: text, meta: '', group: '', action: '', rawText: text }
|
||||
})
|
||||
}
|
||||
|
||||
const replaceTriggerWithChip = (item: Unstable_TriggerItem) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
@@ -832,18 +789,6 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large
|
||||
// drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through.
|
||||
if (
|
||||
(event.key === 'Backspace' || event.key === 'Delete') &&
|
||||
deleteSelectionInEditor(event.currentTarget)
|
||||
) {
|
||||
event.preventDefault()
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Shift+K drains the next queued message. Plain Cmd/Ctrl+K is
|
||||
// reserved for the global command palette.
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'k') {
|
||||
@@ -873,15 +818,7 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// Enter / Tab / Space all accept the highlighted item: a no-arg command
|
||||
// commits its directive chip, an arg-taking command expands to its
|
||||
// options step, and an arg option commits the full `/cmd arg` chip. Space
|
||||
// is slash-only (an `@` mention takes a literal space) and gated to a
|
||||
// non-empty query so a bare `/ ` still types a space.
|
||||
const acceptOnSpace = event.key === ' ' && trigger.kind === '/' && Boolean(trigger.query.trim())
|
||||
const accept = event.key === 'Enter' || event.key === 'Tab' || acceptOnSpace
|
||||
|
||||
if (accept) {
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
const item = triggerItems[triggerActive]
|
||||
@@ -902,24 +839,6 @@ export function ChatBar({
|
||||
}
|
||||
}
|
||||
|
||||
// Arg stage with nothing left to suggest — a fully-typed arg the backend
|
||||
// completer no longer echoes (it drops the exact match), e.g.
|
||||
// `/personality creative`. Space/Tab still commit what's typed as a single
|
||||
// directive chip; Enter falls through to submit (send it as-is).
|
||||
if (
|
||||
trigger?.kind === '/' &&
|
||||
!triggerItems.length &&
|
||||
(event.key === ' ' || event.key === 'Tab') &&
|
||||
slashArgStage(trigger.query) &&
|
||||
trigger.query.trim()
|
||||
) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
commitTypedSlashDirective()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ArrowUp/ArrowDown navigate, in priority order: the queue (edit entries in
|
||||
// place) then sent-message history. The history ring is derived from live
|
||||
// session messages each press — single source of truth, no mirror.
|
||||
@@ -1015,10 +934,6 @@ export function ChatBar({
|
||||
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
|
||||
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
|
||||
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!busy && !hasLivePayload && queuedPrompts.length > 0) {
|
||||
void drainNextQueued()
|
||||
|
||||
@@ -1561,10 +1476,6 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
// Source the text from the DOM editor, not React state. The AUI composer
|
||||
// state (`draft`) and the derived `hasComposerPayload` lag the DOM by a
|
||||
// render, so on fast typing or IME composition the final keystroke(s) may
|
||||
@@ -1745,7 +1656,6 @@ export function ChatBar({
|
||||
const input = (
|
||||
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
|
||||
<div
|
||||
aria-disabled={inputDisabled ? true : undefined}
|
||||
aria-label={t.composer.message}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
@@ -1756,7 +1666,7 @@ export function ChatBar({
|
||||
stacked && 'pl-3',
|
||||
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
|
||||
)}
|
||||
contentEditable={!inputDisabled}
|
||||
contentEditable={!disabled}
|
||||
data-placeholder={placeholder}
|
||||
data-slot={RICH_INPUT_SLOT}
|
||||
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
||||
@@ -1842,7 +1752,7 @@ export function ChatBar({
|
||||
ref={composerRef}
|
||||
>
|
||||
{showHelpHint && <HelpHint />}
|
||||
{trigger && !argStageEmpty && (
|
||||
{trigger && (
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={triggerActive}
|
||||
items={triggerItems}
|
||||
|
||||
@@ -3,24 +3,12 @@ import { describe, expect, it } from 'vitest'
|
||||
import { insertInlineRefsIntoEditor } from './inline-refs'
|
||||
import {
|
||||
composerPlainText,
|
||||
deleteSelectionInEditor,
|
||||
insertPlainTextAtCaret,
|
||||
normalizeComposerEditorDom,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
|
||||
const caretIn = (editor: HTMLElement) => {
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()!
|
||||
|
||||
range.selectNodeContents(editor)
|
||||
range.collapse(false)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
describe('renderComposerContents', () => {
|
||||
it('renders refs and raw text without interpreting user text as HTML', () => {
|
||||
const editor = document.createElement('div')
|
||||
@@ -71,64 +59,3 @@ describe('insertInlineRefsIntoEditor', () => {
|
||||
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertPlainTextAtCaret', () => {
|
||||
it('inserts multiline text as text nodes + br', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
insertPlainTextAtCaret(editor, 'one\ntwo\nthree')
|
||||
|
||||
expect(editor.querySelectorAll('br').length).toBe(2)
|
||||
expect(composerPlainText(editor)).toBe('one\ntwo\nthree')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('replaces the selected span', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.textContent = 'abXYef'
|
||||
document.body.append(editor)
|
||||
|
||||
const text = editor.firstChild!
|
||||
const selection = window.getSelection()!
|
||||
const range = document.createRange()
|
||||
|
||||
range.setStart(text, 2)
|
||||
range.setEnd(text, 4)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
insertPlainTextAtCaret(editor, 'cd')
|
||||
|
||||
expect(composerPlainText(editor)).toBe('abcdef')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteSelectionInEditor', () => {
|
||||
it('clears a non-collapsed range and leaves a collapsed caret', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.textContent = 'hello world'
|
||||
document.body.append(editor)
|
||||
|
||||
const selection = window.getSelection()!
|
||||
const range = document.createRange()
|
||||
|
||||
range.selectNodeContents(editor)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
expect(deleteSelectionInEditor(editor)).toBe(true)
|
||||
expect(composerPlainText(editor)).toBe('')
|
||||
expect(selection.getRangeAt(0).collapsed).toBe(true)
|
||||
expect(deleteSelectionInEditor(editor)).toBe(false)
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -132,63 +132,6 @@ export function renderComposerContents(target: HTMLElement, text: string) {
|
||||
appendComposerContents(target, text)
|
||||
}
|
||||
|
||||
/** Caret range when the selection lives inside `editor`; else null. */
|
||||
function composerSelectionRange(editor: HTMLElement) {
|
||||
const selection = window.getSelection()
|
||||
const range = selection?.rangeCount ? selection.getRangeAt(0) : null
|
||||
|
||||
if (!selection || !range || !editor.contains(range.commonAncestorContainer)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { range, selection }
|
||||
}
|
||||
|
||||
/** Insert plain text at the caret (replacing any selection). Pastes use this
|
||||
* instead of `execCommand('insertText')` — Chromium's editing pipeline is
|
||||
* ~O(n²) on large multiline blobs. */
|
||||
export function insertPlainTextAtCaret(editor: HTMLElement, text: string) {
|
||||
const hit = composerSelectionRange(editor)
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
appendTextWithBreaks(fragment, text)
|
||||
|
||||
const tail = fragment.lastChild
|
||||
|
||||
if (hit) {
|
||||
hit.range.deleteContents()
|
||||
hit.range.insertNode(fragment)
|
||||
} else {
|
||||
editor.append(fragment)
|
||||
}
|
||||
|
||||
if (tail) {
|
||||
const caret = document.createRange()
|
||||
caret.setStartAfter(tail)
|
||||
caret.collapse(true)
|
||||
const selection = hit?.selection ?? window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(caret)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a non-collapsed selection in-editor. Skips collapsed carets so word/
|
||||
* line delete (Opt/Cmd+Backspace) stays native. Returns whether anything ran. */
|
||||
export function deleteSelectionInEditor(editor: HTMLElement) {
|
||||
const hit = composerSelectionRange(editor)
|
||||
|
||||
if (!hit || hit.range.collapsed) {
|
||||
return false
|
||||
}
|
||||
|
||||
hit.range.deleteContents()
|
||||
hit.range.collapse(true)
|
||||
hit.selection.removeAllRanges()
|
||||
hit.selection.addRange(hit.range)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Serialize a draft string into chip-HTML for the contenteditable surface. */
|
||||
export function composerHtml(text: string) {
|
||||
let cursor = 0
|
||||
|
||||
@@ -165,13 +165,8 @@ interface ChatRuntimeBoundaryProps {
|
||||
onEdit: (message: AppendMessage) => Promise<void>
|
||||
onReload: (parentId: string | null) => Promise<void>
|
||||
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
|
||||
/** Route points at an unloaded session — render empty until resume swaps in
|
||||
* the new transcript, so the previous session's messages don't linger. */
|
||||
suppressMessages: boolean
|
||||
}
|
||||
|
||||
const NO_MESSAGES: ChatMessage[] = []
|
||||
|
||||
/**
|
||||
* Owns the $messages subscription and the assistant-ui external-store runtime.
|
||||
*
|
||||
@@ -188,11 +183,9 @@ function ChatRuntimeBoundary({
|
||||
onCancel,
|
||||
onEdit,
|
||||
onReload,
|
||||
onThreadMessagesChange,
|
||||
suppressMessages
|
||||
onThreadMessagesChange
|
||||
}: ChatRuntimeBoundaryProps) {
|
||||
const storeMessages = useStore($messages)
|
||||
const messages = suppressMessages ? NO_MESSAGES : storeMessages
|
||||
const messages = useStore($messages)
|
||||
const runtimeMessageCacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>())
|
||||
|
||||
const runtimeMessageRepository = useMemo(() => {
|
||||
@@ -293,14 +286,7 @@ export function ChatView({
|
||||
const messagesEmpty = useStore($messagesEmpty)
|
||||
const lastVisibleIsUser = useStore($lastVisibleMessageIsUser)
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
const routedSessionId = routeSessionId(location.pathname)
|
||||
const isRoutedSessionView = Boolean(routedSessionId)
|
||||
|
||||
// The URL points at a session the store hasn't loaded yet (sidebar / cmd-K /
|
||||
// direct nav). Derived in render so the swap reads instantly: the same frame
|
||||
// the id changes we drop the old transcript and show the loader, instead of
|
||||
// waiting for the resume effect (which paints a frame later) to clear them.
|
||||
const routeSessionMismatch = isRoutedSessionView && routedSessionId !== selectedSessionId
|
||||
const isRoutedSessionView = Boolean(routeSessionId(location.pathname))
|
||||
|
||||
const showIntro = freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
|
||||
|
||||
@@ -309,7 +295,7 @@ export function ChatView({
|
||||
// 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 last-visible-user gate.
|
||||
const loadingSession = isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
|
||||
const loadingSession = isRoutedSessionView && messagesEmpty && !activeSessionId
|
||||
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser)
|
||||
const showChatBar = !loadingSession
|
||||
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
|
||||
@@ -415,7 +401,6 @@ export function ChatView({
|
||||
onEdit={onEdit}
|
||||
onReload={onReload}
|
||||
onThreadMessagesChange={onThreadMessagesChange}
|
||||
suppressMessages={routeSessionMismatch}
|
||||
>
|
||||
<Thread
|
||||
clampToComposer={showChatBar}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
import { onScrollToBottomRequest, resetThreadScroll, setThreadAtBottom } from '@/store/thread-scroll'
|
||||
|
||||
import { ScrollToBottomButton } from './scroll-to-bottom-button'
|
||||
|
||||
function pendingApproval() {
|
||||
$activeSessionId.set('sess-1')
|
||||
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearAllPrompts()
|
||||
resetThreadScroll()
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
// `getByRole('button')` excludes aria-hidden nodes, so "queryByRole null" is the
|
||||
// control's hidden (parked-at-bottom) state.
|
||||
describe('ScrollToBottomButton', () => {
|
||||
it('stays hidden while parked at the bottom', () => {
|
||||
render(<ScrollToBottomButton />)
|
||||
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('is a plain jump-to-bottom control when scrolled up with no approval', () => {
|
||||
setThreadAtBottom(false)
|
||||
render(<ScrollToBottomButton />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Scroll to bottom' })).toBeTruthy()
|
||||
expect(screen.queryByText('Approval needed')).toBeNull()
|
||||
})
|
||||
|
||||
it('morphs into the approval pill when scrolled up with a pending approval', () => {
|
||||
pendingApproval()
|
||||
setThreadAtBottom(false)
|
||||
render(<ScrollToBottomButton />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Approval needed' })).toBeTruthy()
|
||||
expect(screen.getByText('Approval needed')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not morph while a pending approval is still in view (at bottom)', () => {
|
||||
pendingApproval()
|
||||
render(<ScrollToBottomButton />)
|
||||
|
||||
// Parked at bottom → control hidden, so it can't claim "approval needed".
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('re-arms sticky-bottom on click', () => {
|
||||
const handler = vi.fn()
|
||||
const stop = onScrollToBottomRequest(handler)
|
||||
setThreadAtBottom(false)
|
||||
render(<ScrollToBottomButton />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
stop()
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,6 @@ import { Codicon } from '@/components/ui/codicon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $approvalRequest } from '@/store/prompts'
|
||||
import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-scroll'
|
||||
|
||||
/**
|
||||
@@ -16,13 +15,6 @@ import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-
|
||||
* / background cards. Visible only while the user has scrolled meaningfully
|
||||
* away from the bottom; clicking re-arms sticky-bottom and pins the viewport.
|
||||
*
|
||||
* When the turn is BLOCKED on an approval, this same control morphs into an
|
||||
* "Approval needed" pill — the only response surface is the inline Run/Reject
|
||||
* bar on the parked tool row, which is always the bottom-most content, so the
|
||||
* existing scroll-to-bottom action lands the user right on it. One control, no
|
||||
* collision, no second scroll path (native scrollIntoView would scroll
|
||||
* overflow:hidden ancestors that can't scroll back and wreck the layout).
|
||||
*
|
||||
* Enter/exit motion lives in styles.css under `.thread-jump-button` — a
|
||||
* directional scale (contract in from 1.1, contract out to 0.9) keyed off
|
||||
* `data-state`. `idle` (never-shown) stays silent so it can't flash on mount;
|
||||
@@ -31,11 +23,6 @@ import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-
|
||||
export function ScrollToBottomButton() {
|
||||
const { t } = useI18n()
|
||||
const visible = useStore($threadJumpButtonVisible)
|
||||
const request = useStore($approvalRequest)
|
||||
// Scrolled away while an approval is pending → the inline Run/Reject bar is
|
||||
// below the fold. Relabel so the user knows the session needs them, not just
|
||||
// that there's more to read.
|
||||
const approval = visible && Boolean(request)
|
||||
const hasShownRef = useRef(false)
|
||||
|
||||
if (visible) {
|
||||
@@ -43,17 +30,15 @@ export function ScrollToBottomButton() {
|
||||
}
|
||||
|
||||
const state = visible ? 'in' : hasShownRef.current ? 'out' : 'idle'
|
||||
const label = approval ? t.assistant.approval.jumpToApproval : t.assistant.thread.scrollToBottom
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-hidden={!visible}
|
||||
aria-label={label}
|
||||
aria-label={t.assistant.thread.scrollToBottom}
|
||||
className={cn(
|
||||
'thread-jump-button absolute left-1/2 z-20 grid place-items-center backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
|
||||
approval
|
||||
? 'h-8 grid-flow-col gap-1.5 rounded-full border border-primary/40 bg-(--composer-fill) px-3 text-primary hover:bg-primary/10'
|
||||
: 'size-8 rounded-full border border-border/65 bg-(--composer-fill) text-muted-foreground hover:text-foreground',
|
||||
'thread-jump-button absolute left-1/2 z-20 grid size-8 place-items-center rounded-full',
|
||||
'border border-border/65 bg-(--composer-fill) text-muted-foreground hover:text-foreground',
|
||||
'backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
|
||||
!visible && 'pointer-events-none'
|
||||
)}
|
||||
data-state={state}
|
||||
@@ -67,8 +52,7 @@ export function ScrollToBottomButton() {
|
||||
tabIndex={visible ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="arrow-down" size={approval ? '0.875rem' : '1rem'} />
|
||||
{approval && <span className="text-xs font-medium">{label}</span>}
|
||||
<Codicon name="arrow-down" size="1rem" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -284,7 +284,6 @@ export function ProfileRail() {
|
||||
selectProfile(name)
|
||||
}}
|
||||
open={createOpen}
|
||||
profiles={profiles}
|
||||
/>
|
||||
|
||||
<RenameProfileDialog
|
||||
@@ -468,10 +467,6 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
|
||||
aria-label={p.actionsFor(label)}
|
||||
className="w-40"
|
||||
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
|
||||
// Menu close refocuses the trigger — which doubles as the popover
|
||||
// anchor — so the picker reads it as focus-outside and dies on open.
|
||||
// Suppress the refocus and the picker survives.
|
||||
onCloseAutoFocus={event => event.preventDefault()}
|
||||
>
|
||||
<ContextMenuItem onSelect={() => setPickerOpen(true)}>
|
||||
<Codicon name="symbol-color" size="0.875rem" />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { writeClipboardText } from '@/components/ui/copy-button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -49,17 +49,26 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
const r = t.sidebar.row
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
|
||||
const pinItem: ItemSpec = {
|
||||
disabled: !onPin,
|
||||
icon: 'pin',
|
||||
label: pinned ? r.unpin : r.pin,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
}
|
||||
}
|
||||
|
||||
const items: ItemSpec[] = [
|
||||
{
|
||||
disabled: !onPin,
|
||||
icon: 'pin',
|
||||
label: pinned ? r.unpin : r.pin,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
}
|
||||
},
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'copy',
|
||||
label: r.copyId,
|
||||
onSelect: event => {
|
||||
event.preventDefault()
|
||||
triggerHaptic('selection')
|
||||
void writeClipboardText(sessionId).catch(err => notifyError(err, r.copyIdFailed))
|
||||
}
|
||||
},
|
||||
...(canOpenSessionWindow()
|
||||
? [
|
||||
{
|
||||
@@ -113,28 +122,13 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
}
|
||||
]
|
||||
|
||||
const renderMenuItem = (Item: MenuItem, { className, disabled, icon, label, onSelect, variant }: ItemSpec) => (
|
||||
<Item className={className} disabled={disabled} key={label} onSelect={onSelect} variant={variant}>
|
||||
<Codicon name={icon} size="0.875rem" />
|
||||
<span>{label}</span>
|
||||
</Item>
|
||||
)
|
||||
|
||||
const renderItems = (Item: MenuItem) => (
|
||||
<>
|
||||
{renderMenuItem(Item, pinItem)}
|
||||
<CopyButton
|
||||
appearance={Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
|
||||
disabled={!sessionId}
|
||||
errorMessage={r.copyIdFailed}
|
||||
key={r.copyId}
|
||||
label={r.copyId}
|
||||
onCopyError={err => notifyError(err, r.copyIdFailed)}
|
||||
text={sessionId}
|
||||
/>
|
||||
{items.map(spec => renderMenuItem(Item, spec))}
|
||||
</>
|
||||
)
|
||||
const renderItems = (Item: MenuItem) =>
|
||||
items.map(({ className, disabled, icon, label, onSelect, variant }) => (
|
||||
<Item className={className} disabled={disabled} key={label} onSelect={onSelect} variant={variant}>
|
||||
<Codicon name={icon} size="0.875rem" />
|
||||
<span>{label}</span>
|
||||
</Item>
|
||||
))
|
||||
|
||||
const renameDialog = (
|
||||
<RenameSessionDialog
|
||||
|
||||
@@ -118,10 +118,6 @@ const paletteFilter = (value: string, search: string, keywords?: string[]): numb
|
||||
return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0
|
||||
}
|
||||
|
||||
// Hermes session ids: <YYYYMMDD>_<HHMMSS>_<6 hex>. Used to offer a direct
|
||||
// "Go to session ‹id›" jump for ids that aren't in the recent-200 list.
|
||||
const SESSION_ID_RE = /^\d{8}_\d{6}_[a-f0-9]{6}$/
|
||||
|
||||
type SessionRow = Awaited<ReturnType<typeof listAllProfileSessions>>['sessions'][number]
|
||||
|
||||
const toSessionEntry = (session: SessionRow): SessionEntry => ({
|
||||
@@ -417,24 +413,6 @@ export function CommandPalette() {
|
||||
|
||||
const result: PaletteGroup[] = []
|
||||
|
||||
// Paste a raw session id → jump straight to it, even if it predates the
|
||||
// recent-200 window the lists below are built from.
|
||||
const directId = search.trim()
|
||||
|
||||
if (SESSION_ID_RE.test(directId)) {
|
||||
result.push({
|
||||
items: [
|
||||
{
|
||||
icon: MessageCircle,
|
||||
id: `goto-${directId}`,
|
||||
keywords: ['session', 'id', 'go to', directId],
|
||||
label: `${t.commandCenter.goToSession} ${directId}`,
|
||||
run: go(sessionRoute(directId))
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
if (sessions.length > 0) {
|
||||
result.push({
|
||||
heading: t.commandCenter.sections.sessions,
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
SIDEBAR_SESSIONS_PAGE_SIZE,
|
||||
unpinSession
|
||||
} from '../store/layout'
|
||||
import { respondToApprovalAction } from '../store/native-notifications'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
@@ -270,26 +269,6 @@ export function DesktopController() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Notification click: the main process already focused the window; jump to its session.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => {
|
||||
if (sessionId) {
|
||||
navigate(sessionRoute(sessionId))
|
||||
}
|
||||
})
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [navigate])
|
||||
|
||||
// Notification action button (Approve/Reject) — resolve in place, no navigation.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onNotificationAction?.(({ actionId, sessionId }) => {
|
||||
void respondToApprovalAction(sessionId ?? null, actionId)
|
||||
})
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
|
||||
// hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint).
|
||||
// Build the equivalent /blueprint slash command from the payload and drop
|
||||
// it into the composer — the user reviews/edits, then sends; the agent (or
|
||||
|
||||
@@ -2,15 +2,14 @@ import { useEffect, useState } from 'react'
|
||||
|
||||
import { ActionStatus } from '@/components/ui/action-status'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { createProfile, updateProfileSoul } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertTriangle } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ProfileInfo } from '@/types/hermes'
|
||||
|
||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
||||
|
||||
@@ -24,18 +23,16 @@ export function isValidProfileName(name: string): boolean {
|
||||
export function CreateProfileDialog({
|
||||
onClose,
|
||||
onCreated,
|
||||
open,
|
||||
profiles = []
|
||||
open
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreated?: (name: string) => Promise<void> | void
|
||||
open: boolean
|
||||
profiles?: ProfileInfo[]
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const [name, setName] = useState('')
|
||||
const [cloneFrom, setCloneFrom] = useState<null | string>('default')
|
||||
const [cloneFromDefault, setCloneFromDefault] = useState(true)
|
||||
const [soul, setSoul] = useState('')
|
||||
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
@@ -46,7 +43,7 @@ export function CreateProfileDialog({
|
||||
}
|
||||
|
||||
setName('')
|
||||
setCloneFrom('default')
|
||||
setCloneFromDefault(true)
|
||||
setSoul('')
|
||||
setError(null)
|
||||
setStatus('idle')
|
||||
@@ -69,7 +66,7 @@ export function CreateProfileDialog({
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await createProfile({ name: trimmed, clone_from: cloneFrom })
|
||||
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
|
||||
|
||||
if (soul.trim()) {
|
||||
await updateProfileSoul(trimmed, soul)
|
||||
@@ -110,25 +107,17 @@ export function CreateProfileDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
|
||||
{p.cloneFrom}
|
||||
</label>
|
||||
<Select onValueChange={value => setCloneFrom(value === '__none__' ? null : value)} value={cloneFrom ?? '__none__'}>
|
||||
<SelectTrigger className="h-9 rounded-md" id="new-profile-clone-from">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">{p.cloneFromNone}</SelectItem>
|
||||
{profiles.map(profile => (
|
||||
<SelectItem key={profile.name} value={profile.name}>
|
||||
{profile.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{p.cloneFromDesc}</p>
|
||||
</div>
|
||||
<label className="flex cursor-pointer select-none items-start gap-2.5 px-0.5 py-1">
|
||||
<Checkbox
|
||||
checked={cloneFromDefault}
|
||||
className="mt-0.5 shrink-0"
|
||||
onCheckedChange={checked => setCloneFromDefault(checked === true)}
|
||||
/>
|
||||
<span className="grid gap-0.5 leading-snug">
|
||||
<span className="text-sm font-medium">{p.cloneFromDefault}</span>
|
||||
<span className="text-xs text-muted-foreground">{p.cloneFromDefaultDesc}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-soul">
|
||||
@@ -138,7 +127,7 @@ export function CreateProfileDialog({
|
||||
className="min-h-28 font-mono text-xs leading-5"
|
||||
id="new-profile-soul"
|
||||
onChange={event => setSoul(event.target.value)}
|
||||
placeholder={p.soulPlaceholder(cloneFrom ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
|
||||
placeholder={p.soulPlaceholder(cloneFromDefault ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
|
||||
value={soul}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
createProfile,
|
||||
@@ -83,14 +82,14 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
}, [profiles, selectedName])
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (name: string, cloneFrom: null | string) => {
|
||||
async (name: string, cloneFromDefault: boolean) => {
|
||||
const trimmed = name.trim()
|
||||
|
||||
if (!isValidProfileName(trimmed)) {
|
||||
throw new Error(p.nameHint)
|
||||
}
|
||||
|
||||
await createProfile({ name: trimmed, clone_from: cloneFrom })
|
||||
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
|
||||
notify({ kind: 'success', title: p.created, message: trimmed })
|
||||
setSelectedName(trimmed)
|
||||
await refresh()
|
||||
@@ -181,9 +180,8 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
|
||||
<CreateProfileDialog
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreate={async (name, cloneFrom) => handleCreate(name, cloneFrom)}
|
||||
onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)}
|
||||
open={createOpen}
|
||||
profiles={profiles ?? []}
|
||||
/>
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
@@ -455,18 +453,16 @@ function SoulEditor({ profileName }: { profileName: string }) {
|
||||
function CreateProfileDialog({
|
||||
onClose,
|
||||
onCreate,
|
||||
open,
|
||||
profiles
|
||||
open
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreate: (name: string, cloneFrom: null | string) => Promise<void>
|
||||
onCreate: (name: string, cloneFromDefault: boolean) => Promise<void>
|
||||
open: boolean
|
||||
profiles: ProfileInfo[]
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const [name, setName] = useState('')
|
||||
const [cloneFrom, setCloneFrom] = useState<null | string>('default')
|
||||
const [cloneFromDefault, setCloneFromDefault] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
@@ -476,7 +472,7 @@ function CreateProfileDialog({
|
||||
}
|
||||
|
||||
setName('')
|
||||
setCloneFrom('default')
|
||||
setCloneFromDefault(true)
|
||||
setError(null)
|
||||
setSaving(false)
|
||||
}, [open])
|
||||
@@ -497,7 +493,7 @@ function CreateProfileDialog({
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onCreate(trimmed, cloneFrom)
|
||||
await onCreate(trimmed, cloneFromDefault)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : p.failedCreate)
|
||||
@@ -532,25 +528,18 @@ function CreateProfileDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
|
||||
{p.cloneFrom}
|
||||
</label>
|
||||
<Select onValueChange={value => setCloneFrom(value === '__none__' ? null : value)} value={cloneFrom ?? '__none__'}>
|
||||
<SelectTrigger className="h-9 rounded-md" id="new-profile-clone-from">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">{p.cloneFromNone}</SelectItem>
|
||||
{profiles.map(profile => (
|
||||
<SelectItem key={profile.name} value={profile.name}>
|
||||
{profile.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{p.cloneFromDesc}</p>
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-sm">
|
||||
<input
|
||||
checked={cloneFromDefault}
|
||||
className="size-4 accent-primary"
|
||||
onChange={event => setCloneFromDefault(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">{p.cloneFromDefault}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{p.cloneFromDefaultDesc}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { QueryClient } from '@tanstack/react-query'
|
||||
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
|
||||
import { translateNow } from '@/i18n'
|
||||
import {
|
||||
appendAssistantTextPart,
|
||||
appendReasoningPart,
|
||||
@@ -16,21 +15,13 @@ import {
|
||||
upsertToolPart
|
||||
} from '@/lib/chat-messages'
|
||||
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { playCompletionSound } from '@/lib/completion-sound'
|
||||
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
|
||||
import {
|
||||
dedupeGeneratedImageEchoesInParts,
|
||||
generatedImageEchoSources,
|
||||
stripGeneratedImageEchoes
|
||||
} from '@/lib/generated-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { parseTodos } from '@/lib/todos'
|
||||
import { setClarifyRequest } from '@/store/clarify'
|
||||
import { setSessionCompacting } from '@/store/compaction'
|
||||
import { refreshBackgroundProcesses } from '@/store/composer-status'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { dispatchNativeNotification } from '@/store/native-notifications'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
||||
@@ -334,8 +325,6 @@ export function useMessageStream({
|
||||
const flushHandleRef = useRef<number | null>(null)
|
||||
const lastFlushAtRef = useRef<number>(0)
|
||||
const nativeSubagentSessionsRef = useRef<Set<string>>(new Set())
|
||||
// Turns that auto-compacted: skip post-turn hydrate so live scrollback survives.
|
||||
const compactedTurnRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const flushQueuedDeltas = useCallback(
|
||||
(sessionId?: string) => {
|
||||
@@ -354,7 +343,7 @@ export function useMessageStream({
|
||||
if (queued.assistant) {
|
||||
mutateStream(
|
||||
id,
|
||||
parts => dedupeGeneratedImageEchoesInParts(appendAssistantTextPart(parts, queued.assistant)),
|
||||
parts => appendAssistantTextPart(parts, queued.assistant),
|
||||
() => [assistantTextPart(queued.assistant)]
|
||||
)
|
||||
}
|
||||
@@ -518,7 +507,7 @@ export function useMessageStream({
|
||||
|
||||
mutateStream(
|
||||
sessionId,
|
||||
parts => dedupeGeneratedImageEchoesInParts(upsertToolPart(parts, payload, phase)),
|
||||
parts => upsertToolPart(parts, payload, phase),
|
||||
() => upsertToolPart([], payload, phase),
|
||||
{ pending: m => phase !== 'complete' || (m.pending ?? false) }
|
||||
)
|
||||
@@ -551,11 +540,9 @@ export function useMessageStream({
|
||||
const finalText = renderMediaTags(text).trim()
|
||||
const completionError = completionErrorText(finalText)
|
||||
const normalize = (value: string) => value.replace(/\s+/g, ' ').trim()
|
||||
const dedupeReference = normalize(finalText)
|
||||
|
||||
const replaceTextPart = (parts: ChatMessagePart[]) => {
|
||||
const visibleFinalText = stripGeneratedImageEchoes(finalText, generatedImageEchoSources(parts)).trim()
|
||||
const dedupeReference = normalize(visibleFinalText)
|
||||
|
||||
const kept = parts.filter(part => {
|
||||
if (part.type === 'text') {
|
||||
return false
|
||||
@@ -570,7 +557,7 @@ export function useMessageStream({
|
||||
return !(r && (dedupeReference.startsWith(r) || r.startsWith(dedupeReference)))
|
||||
})
|
||||
|
||||
return visibleFinalText ? [...kept, assistantTextPart(visibleFinalText)] : kept
|
||||
return finalText ? [...kept, assistantTextPart(finalText)] : kept
|
||||
}
|
||||
|
||||
const completeMessage = (message: ChatMessage): ChatMessage =>
|
||||
@@ -642,22 +629,18 @@ export function useMessageStream({
|
||||
|
||||
void refreshSessions().catch(() => undefined)
|
||||
|
||||
if (compactedTurnRef.current.delete(sessionId)) {
|
||||
shouldHydrate = false
|
||||
}
|
||||
|
||||
if (shouldHydrate) {
|
||||
void hydrateFromStoredSession(3, completedState.storedSessionId, sessionId)
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: text.slice(0, 140) || translateNow('notifications.native.turnDoneBody'),
|
||||
kind: 'turnDone',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.turnDoneTitle')
|
||||
})
|
||||
if (document.hidden && sessionId === activeSessionIdRef.current) {
|
||||
void window.hermesDesktop?.notify({
|
||||
title: 'Hermes finished',
|
||||
body: text.slice(0, 140) || 'The response is ready.'
|
||||
})
|
||||
}
|
||||
},
|
||||
[hydrateFromStoredSession, refreshSessions, updateSessionState]
|
||||
[activeSessionIdRef, hydrateFromStoredSession, refreshSessions, updateSessionState]
|
||||
)
|
||||
|
||||
const failAssistantMessage = useCallback(
|
||||
@@ -832,8 +815,6 @@ export function useMessageStream({
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
clearSessionSubagents(sessionId)
|
||||
setSessionCompacting(sessionId, false)
|
||||
compactedTurnRef.current.delete(sessionId)
|
||||
nativeSubagentSessionsRef.current.delete(sessionId)
|
||||
|
||||
if (isActiveEvent) {
|
||||
@@ -879,11 +860,12 @@ export function useMessageStream({
|
||||
// session so a background turn finishing can't wipe the active chat's
|
||||
// prompt, and vice versa.
|
||||
clearAllPrompts(sessionId)
|
||||
setSessionCompacting(sessionId, false)
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
playCompletionSound()
|
||||
if (isActiveEvent) {
|
||||
triggerHaptic('streamDone')
|
||||
}
|
||||
|
||||
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
|
||||
completeAssistantMessage(sessionId, finalText)
|
||||
@@ -914,7 +896,10 @@ export function useMessageStream({
|
||||
|
||||
// terminal/process tool calls are the only things that spawn or reap
|
||||
// background processes — sync the composer status stack right after.
|
||||
if (!sessionInterrupted(sessionId) && (payload?.name === 'terminal' || payload?.name === 'process')) {
|
||||
if (
|
||||
!sessionInterrupted(sessionId) &&
|
||||
(payload?.name === 'terminal' || payload?.name === 'process')
|
||||
) {
|
||||
void refreshBackgroundProcesses(sessionId)
|
||||
}
|
||||
}
|
||||
@@ -966,13 +951,6 @@ export function useMessageStream({
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: question,
|
||||
kind: 'input',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.inputTitle')
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'approval.request') {
|
||||
// Dangerous-command / execute_code approval. The Python side is blocked
|
||||
@@ -981,31 +959,17 @@ export function useMessageStream({
|
||||
// Park it per-session (like clarify) so a *background* profile's turn can
|
||||
// raise it and wait — the sidebar flags "needs input" and the inline bar
|
||||
// surfaces once the user focuses that chat.
|
||||
const command = typeof payload?.command === 'string' ? payload.command : ''
|
||||
const description = typeof payload?.description === 'string' ? payload.description : 'dangerous command'
|
||||
|
||||
setApprovalRequest({
|
||||
// false only when a tirith warning forbids it; backend omits the field otherwise.
|
||||
allowPermanent: payload?.allow_permanent !== false,
|
||||
command,
|
||||
description,
|
||||
command: typeof payload?.command === 'string' ? payload.command : '',
|
||||
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
|
||||
sessionId: sessionId ?? null
|
||||
})
|
||||
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
actions: [
|
||||
{ id: 'approve', text: translateNow('notifications.native.approveAction') },
|
||||
{ id: 'reject', text: translateNow('notifications.native.rejectAction') }
|
||||
],
|
||||
body: command || description,
|
||||
kind: 'approval',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.approvalTitle')
|
||||
})
|
||||
} else if (event.type === 'sudo.request') {
|
||||
// Sudo password capture (tools/terminal_tool.py). Blocked on
|
||||
// sudo.respond {request_id, password}.
|
||||
@@ -1017,13 +981,6 @@ export function useMessageStream({
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: translateNow('notifications.native.inputBody'),
|
||||
kind: 'input',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.inputTitle')
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'secret.request') {
|
||||
// Skill credential capture (tools/skills_tool.py). Blocked on
|
||||
@@ -1031,26 +988,16 @@ export function useMessageStream({
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
|
||||
if (requestId) {
|
||||
const envVar = typeof payload?.env_var === 'string' ? payload.env_var : ''
|
||||
const promptText = typeof payload?.prompt === 'string' ? payload.prompt : ''
|
||||
|
||||
setSecretRequest({
|
||||
requestId,
|
||||
envVar,
|
||||
prompt: promptText,
|
||||
envVar: typeof payload?.env_var === 'string' ? payload.env_var : '',
|
||||
prompt: typeof payload?.prompt === 'string' ? payload.prompt : '',
|
||||
sessionId: sessionId ?? null
|
||||
})
|
||||
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: promptText || envVar || translateNow('notifications.native.inputBody'),
|
||||
kind: 'input',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.inputTitle')
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'terminal.read.request') {
|
||||
// read_terminal tool: serialize the renderer's xterm buffer and answer
|
||||
@@ -1068,12 +1015,9 @@ export function useMessageStream({
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'status.update') {
|
||||
if (sessionId && payload?.kind === 'compacting') {
|
||||
setSessionCompacting(sessionId, true)
|
||||
compactedTurnRef.current.add(sessionId)
|
||||
} else if (sessionId && payload?.kind === 'process') {
|
||||
// The gateway's notification poller announces background process
|
||||
// completions / watch matches here — re-sync the status stack.
|
||||
// The gateway's notification poller announces background process
|
||||
// completions / watch matches here — re-sync the status stack.
|
||||
if (sessionId && payload?.kind === 'process') {
|
||||
void refreshBackgroundProcesses(sessionId)
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
@@ -1085,17 +1029,8 @@ export function useMessageStream({
|
||||
// the failed turn (same intent as the message.complete clear).
|
||||
if (sessionId) {
|
||||
clearAllPrompts(sessionId)
|
||||
setSessionCompacting(sessionId, false)
|
||||
compactedTurnRef.current.delete(sessionId)
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: errorMessage,
|
||||
kind: 'turnError',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.turnErrorTitle')
|
||||
})
|
||||
|
||||
if (looksLikeProviderSetup) {
|
||||
requestDesktopOnboarding(errorMessage)
|
||||
} else if (isActiveEvent) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { NavigateFunction } from 'react-router-dom'
|
||||
|
||||
import { deleteSession, getSession, getSessionMessages, setSessionArchived } from '@/hermes'
|
||||
import { deleteSession, getSessionMessages, listAllProfileSessions, setSessionArchived } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
|
||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
@@ -12,7 +12,7 @@ import { clearQueuedPrompts } from '@/store/composer-queue'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$currentCwd,
|
||||
$messages,
|
||||
@@ -236,42 +236,18 @@ async function resolveStoredSession(storedSessionId: string): Promise<SessionInf
|
||||
return cached
|
||||
}
|
||||
|
||||
// Direct by-id on the live backend — one row lookup, no list scan. Covers
|
||||
// single-profile users and any id on the active profile (e.g. an old session
|
||||
// past the sidebar's recent window). 404 just means it's not on this profile.
|
||||
try {
|
||||
const session = await getSession(storedSessionId)
|
||||
const result = await listAllProfileSessions(500, 0, 'include', 'recent', 'all')
|
||||
const resolved = result.sessions.find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
|
||||
upsertResolvedSession(session, storedSessionId)
|
||||
|
||||
return session
|
||||
} catch {
|
||||
// Not on the active profile — fall through to the cross-profile probe.
|
||||
}
|
||||
|
||||
// Multi-profile only: probe each other profile by id (still one cheap lookup
|
||||
// each) rather than pulling every profile's recent sessions. The first hit
|
||||
// carries its owning `profile`, which routes the resume to the right backend.
|
||||
const activeKey = normalizeProfileKey($activeGatewayProfile.get())
|
||||
|
||||
const otherProfiles = $profiles
|
||||
.get()
|
||||
.map(profile => normalizeProfileKey(profile.name))
|
||||
.filter(key => key !== activeKey)
|
||||
|
||||
for (const profile of otherProfiles) {
|
||||
try {
|
||||
const session = await getSession(storedSessionId, profile)
|
||||
|
||||
upsertResolvedSession(session, storedSessionId)
|
||||
|
||||
return session
|
||||
} catch {
|
||||
// Not on this profile; try the next.
|
||||
if (resolved) {
|
||||
upsertResolvedSession(resolved, storedSessionId)
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
return resolved
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
type SessionRuntimeStatePatch = Partial<
|
||||
@@ -547,31 +523,8 @@ export function useSessionActions({
|
||||
const isCurrentResume = () =>
|
||||
resumeRequestRef.current === requestId && selectedStoredSessionIdRef.current === storedSessionId
|
||||
|
||||
// Paint the click before the profile-resolve / gateway-swap awaits below,
|
||||
// so there's zero dead air: highlight the row instantly (the sidebar reads
|
||||
// $selectedStoredSessionId) and, for a cold target, drop the previous
|
||||
// transcript so the thread shows its loader instead of the old session
|
||||
// lingering until resume lands. A warm-cached target keeps its transcript —
|
||||
// the cached fast-path repaints it this same tick. Setting the ref here is
|
||||
// also what use-route-resume's self-heal assumes ("set synchronously at
|
||||
// resume entry").
|
||||
setFreshDraftReady(false)
|
||||
clearNotifications()
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
|
||||
const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
|
||||
if (!warmRuntimeId || !sessionStateByRuntimeIdRef.current.get(warmRuntimeId)) {
|
||||
setActiveSessionId(null)
|
||||
activeSessionIdRef.current = null
|
||||
setMessages([])
|
||||
}
|
||||
|
||||
// Swap the single live gateway to this session's profile before any
|
||||
// gateway call (no-op when it's already on that profile / single-profile).
|
||||
// resolveStoredSession finds the row by id (cheap), so an uncached pasted
|
||||
// id loads as fast as a sidebar click instead of hanging on a list scan.
|
||||
const storedForProfile = await resolveStoredSession(storedSessionId)
|
||||
const sessionProfile = storedForProfile?.profile
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tip } from '@/components/ui/tooltip'
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, Bell, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
|
||||
import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
@@ -20,7 +20,6 @@ import { SECTIONS } from './constants'
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
|
||||
import { McpSettings } from './mcp-settings'
|
||||
import { NotificationsSettings } from './notifications-settings'
|
||||
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
|
||||
import { SessionsSettings } from './sessions-settings'
|
||||
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
|
||||
@@ -31,7 +30,6 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
'gateway',
|
||||
'keys',
|
||||
'mcp',
|
||||
'notifications',
|
||||
'sessions',
|
||||
'about'
|
||||
]
|
||||
@@ -103,12 +101,6 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<OverlayNavItem
|
||||
active={activeView === 'notifications'}
|
||||
icon={Bell}
|
||||
label={t.settings.nav.notifications}
|
||||
onClick={() => setActiveView('notifications')}
|
||||
/>
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<OverlayNavItem
|
||||
active={activeView === 'providers'}
|
||||
@@ -233,8 +225,6 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
<KeysSettings view={keysView} />
|
||||
) : activeView === 'mcp' ? (
|
||||
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
|
||||
) : activeView === 'notifications' ? (
|
||||
<NotificationsSettings />
|
||||
) : (
|
||||
<SessionsSettings />
|
||||
)}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { COMPLETION_SOUND_VARIANTS, previewCompletionSound } from '@/lib/completion-sound'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Bell, Play } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $completionSoundVariantId, setCompletionSoundVariantId } from '@/store/completion-sound'
|
||||
import {
|
||||
$nativeNotifyPrefs,
|
||||
NATIVE_NOTIFICATION_KINDS,
|
||||
sendTestNativeNotification,
|
||||
setNativeNotifyEnabled,
|
||||
setNativeNotifyKind
|
||||
} from '@/store/native-notifications'
|
||||
import { notify } from '@/store/notifications'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||
|
||||
const CAPTION = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)'
|
||||
|
||||
function Caption({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <p className={cn(CAPTION, className)}>{children}</p>
|
||||
}
|
||||
|
||||
function ToggleRow(props: {
|
||||
checked: boolean
|
||||
description: string
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onChange: (on: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<Switch
|
||||
aria-label={props.label}
|
||||
checked={props.checked}
|
||||
disabled={props.disabled}
|
||||
onCheckedChange={on => {
|
||||
triggerHaptic('selection')
|
||||
props.onChange(on)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
description={props.description}
|
||||
title={props.label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotificationsSettings() {
|
||||
const { t } = useI18n()
|
||||
const prefs = useStore($nativeNotifyPrefs)
|
||||
const completionSoundVariantId = useStore($completionSoundVariantId)
|
||||
const copy = t.settings.notifications
|
||||
|
||||
const runTest = async () => {
|
||||
triggerHaptic('open')
|
||||
const ok = await sendTestNativeNotification(copy.testTitle, copy.testBody)
|
||||
notify({ kind: ok ? 'info' : 'error', message: ok ? copy.testSent : copy.testUnsupported })
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<SectionHeading icon={Bell} title={copy.title} />
|
||||
<Caption className="mb-2 leading-(--conversation-caption-line-height)">{copy.intro}</Caption>
|
||||
|
||||
<ToggleRow
|
||||
checked={prefs.enabled}
|
||||
description={copy.enableAllDesc}
|
||||
label={copy.enableAll}
|
||||
onChange={setNativeNotifyEnabled}
|
||||
/>
|
||||
|
||||
<div className="my-1 h-px bg-border/30" />
|
||||
|
||||
{NATIVE_NOTIFICATION_KINDS.map(kind => (
|
||||
<ToggleRow
|
||||
checked={prefs.enabled && prefs.kinds[kind]}
|
||||
description={copy.kinds[kind].description}
|
||||
disabled={!prefs.enabled}
|
||||
key={kind}
|
||||
label={copy.kinds[kind].label}
|
||||
onChange={on => setNativeNotifyKind(kind, on)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="my-1 h-px bg-border/30" />
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Select
|
||||
onValueChange={value => {
|
||||
const variantId = Number.parseInt(value, 10)
|
||||
|
||||
setCompletionSoundVariantId(variantId)
|
||||
previewCompletionSound(variantId)
|
||||
triggerHaptic('selection')
|
||||
}}
|
||||
value={String(completionSoundVariantId)}
|
||||
>
|
||||
<SelectTrigger className={cn('min-w-56', CONTROL_TEXT)}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{COMPLETION_SOUND_VARIANTS.map(variant => (
|
||||
<SelectItem key={variant.id} value={String(variant.id)}>
|
||||
{variant.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
className="gap-1.5"
|
||||
onClick={() => {
|
||||
previewCompletionSound()
|
||||
triggerHaptic('crisp')
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
{copy.completionSoundPreview}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
description={copy.completionSoundDesc}
|
||||
title={copy.completionSoundTitle}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<Button className="self-start" onClick={() => void runTest()} size="sm" type="button" variant="outline">
|
||||
<Bell />
|
||||
{copy.test}
|
||||
</Button>
|
||||
<Caption>{copy.focusedHint}</Caption>
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { OAuthProvider } from '@/types/hermes'
|
||||
|
||||
const listOAuthProviders = vi.fn()
|
||||
const disconnectOAuthProvider = vi.fn()
|
||||
const getEnvVars = vi.fn()
|
||||
const startManualProviderOAuth = vi.fn()
|
||||
const onboarding = atom({ manual: false })
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
disconnectOAuthProvider: (providerId: string) => disconnectOAuthProvider(providerId),
|
||||
getEnvVars: () => getEnvVars(),
|
||||
listOAuthProviders: () => listOAuthProviders()
|
||||
}))
|
||||
|
||||
vi.mock('@/store/onboarding', () => ({
|
||||
$desktopOnboarding: onboarding,
|
||||
startManualProviderOAuth: (providerId: string) => startManualProviderOAuth(providerId)
|
||||
}))
|
||||
|
||||
function provider(id: string, loggedIn: boolean, patch: Partial<OAuthProvider> = {}): OAuthProvider {
|
||||
return {
|
||||
cli_command: `hermes auth add ${id}`,
|
||||
disconnectable: true,
|
||||
docs_url: '',
|
||||
flow: 'device_code',
|
||||
id,
|
||||
name: id === 'nous' ? 'Nous Portal' : 'MiniMax',
|
||||
status: {
|
||||
logged_in: loggedIn
|
||||
},
|
||||
...patch
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
onboarding.set({ manual: false })
|
||||
getEnvVars.mockResolvedValue({})
|
||||
disconnectOAuthProvider.mockResolvedValue({ ok: true, provider: 'nous' })
|
||||
listOAuthProviders.mockResolvedValue({
|
||||
providers: [provider('nous', true), provider('minimax-oauth', false)]
|
||||
})
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
async function renderProvidersSettings() {
|
||||
const { ProvidersSettings } = await import('./providers-settings')
|
||||
|
||||
return render(<ProvidersSettings onViewChange={vi.fn()} view="accounts" />)
|
||||
}
|
||||
|
||||
describe('ProvidersSettings', () => {
|
||||
it('disconnects a connected provider account and refreshes the accounts list', async () => {
|
||||
await renderProvidersSettings()
|
||||
|
||||
const remove = await screen.findByRole('button', { name: 'Remove Nous Portal' })
|
||||
fireEvent.click(remove)
|
||||
|
||||
await waitFor(() => expect(disconnectOAuthProvider).toHaveBeenCalledWith('nous'))
|
||||
expect(listOAuthProviders).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps provider selection separate from account removal', async () => {
|
||||
await renderProvidersSettings()
|
||||
|
||||
fireEvent.click(await screen.findByText('Nous Portal'))
|
||||
|
||||
expect(startManualProviderOAuth).toHaveBeenCalledWith('nous')
|
||||
expect(disconnectOAuthProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not offer removal for externally managed providers', async () => {
|
||||
listOAuthProviders.mockResolvedValue({
|
||||
providers: [
|
||||
provider('qwen-oauth', true, {
|
||||
cli_command: 'hermes auth add qwen-oauth',
|
||||
disconnect_hint: 'Use `hermes auth add qwen-oauth` or that provider\'s CLI to remove it.',
|
||||
disconnectable: false,
|
||||
flow: 'external',
|
||||
name: 'Qwen (via Qwen CLI)'
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
await renderProvidersSettings()
|
||||
|
||||
expect(await screen.findByText('Qwen Code')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull()
|
||||
expect(screen.getByText(/managed outside Hermes/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,20 +1,18 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
FEATURED_ID,
|
||||
FeaturedProviderRow,
|
||||
KeyProviderRow,
|
||||
ProviderRow,
|
||||
providerTitle,
|
||||
sortProviders
|
||||
} from '@/components/desktop-onboarding-overlay'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { disconnectOAuthProvider, listOAuthProviders } from '@/hermes'
|
||||
import { listOAuthProviders } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Check, ChevronDown, ChevronRight, KeyRound, Loader2, Terminal, Trash2 } from '@/lib/icons'
|
||||
import { ChevronDown, KeyRound } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding'
|
||||
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
|
||||
|
||||
@@ -87,17 +85,7 @@ function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGr
|
||||
// Selecting a provider hands off to the shared onboarding overlay, which runs
|
||||
// that provider's real sign-in flow; the key affordances open the API-key
|
||||
// catalog below.
|
||||
function OAuthPicker({
|
||||
disconnecting,
|
||||
onDisconnect,
|
||||
onWantApiKey,
|
||||
providers
|
||||
}: {
|
||||
disconnecting: null | string
|
||||
onDisconnect: (provider: OAuthProvider) => void
|
||||
onWantApiKey: () => void
|
||||
providers: OAuthProvider[]
|
||||
}) {
|
||||
function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; providers: OAuthProvider[] }) {
|
||||
const { t } = useI18n()
|
||||
const p = t.settings.providers
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
@@ -109,7 +97,7 @@ function OAuthPicker({
|
||||
|
||||
const select = (p: OAuthProvider) => startManualProviderOAuth(p.id)
|
||||
|
||||
const featured = ordered.find(p => p.id === FEATURED_ID && !p.status?.logged_in) ?? null
|
||||
const featured = ordered.find(p => p.id === FEATURED_ID) ?? null
|
||||
const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered
|
||||
// Keep connected accounts grouped and always visible; only the unconnected
|
||||
// providers hide behind the disclosure, so the page leads with what's set up.
|
||||
@@ -142,13 +130,7 @@ function OAuthPicker({
|
||||
{p.connected}
|
||||
</p>
|
||||
{connected.map(p => (
|
||||
<ConnectedProviderRow
|
||||
disconnecting={disconnecting === p.id}
|
||||
key={p.id}
|
||||
onDisconnect={onDisconnect}
|
||||
onSelect={select}
|
||||
provider={p}
|
||||
/>
|
||||
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
@@ -176,63 +158,6 @@ function OAuthPicker({
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectedProviderRow({
|
||||
disconnecting,
|
||||
onDisconnect,
|
||||
onSelect,
|
||||
provider
|
||||
}: {
|
||||
disconnecting: boolean
|
||||
onDisconnect: (provider: OAuthProvider) => void
|
||||
onSelect: (provider: OAuthProvider) => void
|
||||
provider: OAuthProvider
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const title = providerTitle(provider)
|
||||
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
|
||||
const canDisconnect = provider.disconnectable ?? provider.flow !== 'external'
|
||||
|
||||
const disconnectHint = provider.flow === 'external'
|
||||
? t.settings.providers.removeExternal(title, provider.cli_command)
|
||||
: t.settings.providers.removeKeyManaged(title)
|
||||
|
||||
return (
|
||||
<div className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] transition-colors hover:bg-(--ui-control-hover-background)">
|
||||
<button className="min-w-0 px-3 py-2.5 text-left" onClick={() => onSelect(provider)} type="button">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-[length:var(--conversation-text-font-size)] font-semibold">{title}</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<Check className="size-3" />
|
||||
{t.settings.providers.connected}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
|
||||
{!canDisconnect && (
|
||||
<p className="mt-0.5 truncate text-[0.68rem] leading-5 text-muted-foreground/70">
|
||||
{disconnectHint}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center gap-1 pr-2">
|
||||
<Trail className="size-4 text-muted-foreground transition group-hover:text-foreground" />
|
||||
{canDisconnect && (
|
||||
<Button
|
||||
aria-label={`${t.common.remove} ${title}`}
|
||||
disabled={disconnecting}
|
||||
onClick={() => onDisconnect(provider)}
|
||||
size="icon-xs"
|
||||
title={`${t.common.remove} ${title}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{disconnecting ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NoProviderKeys() {
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -248,26 +173,20 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
|
||||
const { rowProps, vars } = useEnvCredentials()
|
||||
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
|
||||
const [openProvider, setOpenProvider] = useState<null | string>(null)
|
||||
const [disconnecting, setDisconnecting] = useState<null | string>(null)
|
||||
// The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we
|
||||
// re-read connection state when the user finishes (or dismisses) a sign-in
|
||||
// they launched from this page — otherwise the cards keep their stale status.
|
||||
const onboardingActive = useStore($desktopOnboarding).manual
|
||||
|
||||
const refreshOAuthProviders = useCallback(async () => {
|
||||
// OAuth providers are best-effort — a failure here just hides the panel.
|
||||
const { providers } = await listOAuthProviders()
|
||||
setOauthProviders(providers)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (onboardingActive) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
// OAuth providers are best-effort — a failure here just hides the panel.
|
||||
void (async () => {
|
||||
if (onboardingActive) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { providers } = await listOAuthProviders()
|
||||
|
||||
@@ -282,26 +201,6 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
|
||||
return () => void (cancelled = true)
|
||||
}, [onboardingActive])
|
||||
|
||||
async function handleDisconnect(provider: OAuthProvider) {
|
||||
const name = providerTitle(provider)
|
||||
|
||||
if (!window.confirm(t.settings.providers.removeConfirm(name))) {
|
||||
return
|
||||
}
|
||||
|
||||
setDisconnecting(provider.id)
|
||||
|
||||
try {
|
||||
await disconnectOAuthProvider(provider.id)
|
||||
notify({ durationMs: 3_000, kind: 'success', title: t.settings.providers.removedTitle, message: t.settings.providers.removedMessage(name) })
|
||||
await refreshOAuthProviders().catch(() => undefined)
|
||||
} catch (err) {
|
||||
notifyError(err, t.settings.providers.failedRemove(name))
|
||||
} finally {
|
||||
setDisconnecting(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!vars) {
|
||||
return <LoadingState label={t.settings.providers.loading} />
|
||||
}
|
||||
@@ -338,12 +237,7 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<OAuthPicker
|
||||
disconnecting={disconnecting}
|
||||
onDisconnect={provider => void handleDisconnect(provider)}
|
||||
onWantApiKey={() => onViewChange('keys')}
|
||||
providers={oauthProviders}
|
||||
/>
|
||||
<OAuthPicker onWantApiKey={() => onViewChange('keys')} providers={oauthProviders} />
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,15 +4,7 @@ import type { HermesGateway } from '@/hermes'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
export type SettingsView =
|
||||
| 'about'
|
||||
| 'gateway'
|
||||
| 'keys'
|
||||
| 'mcp'
|
||||
| 'notifications'
|
||||
| 'providers'
|
||||
| 'sessions'
|
||||
| `config:${string}`
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'providers' | 'sessions' | `config:${string}`
|
||||
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
|
||||
|
||||
export interface SettingsPageProps {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState, type ComponentProps } from 'react'
|
||||
import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -36,30 +36,14 @@ function readClarifyArgs(args: unknown): ClarifyArgs {
|
||||
}
|
||||
|
||||
// Choice and "Other" rows share a layout; only color/hover differs.
|
||||
const OPTION_ROW_CLASS = 'flex w-full items-start gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors'
|
||||
|
||||
const CLARIFY_SHELL_CLASS =
|
||||
'relative mb-3 mt-2 rounded-[0.5rem] border border-border/70 bg-card/40 text-sm shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]'
|
||||
|
||||
function ClarifyShell({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div className={cn(CLARIFY_SHELL_CLASS, className)} data-slot="clarify-inline" {...props}>
|
||||
<span aria-hidden className="arc-border" />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const OPTION_ROW_CLASS = 'flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors'
|
||||
|
||||
function RadioDot({ selected }: { selected: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'mt-0.5 grid size-3.5 shrink-0 place-items-center rounded-full border transition-colors',
|
||||
'grid size-3.5 shrink-0 place-items-center rounded-full border transition-colors',
|
||||
selected ? 'border-primary' : 'border-muted-foreground/40'
|
||||
)}
|
||||
>
|
||||
@@ -115,11 +99,9 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
// Race: tool.start fires a tick before clarify.request, so request_id
|
||||
// arrives slightly after the tool block mounts. Hold the whole panel on a
|
||||
// spinner until the gateway request is wired — showing disabled choices or
|
||||
// a "loading question" stub is worse than a brief wait.
|
||||
// arrives slightly after the tool block mounts. Show the question (from
|
||||
// args) but disable submit until we have the request id from the gateway.
|
||||
const ready = Boolean(matchingRequest?.requestId)
|
||||
const loading = !ready && !submitting
|
||||
|
||||
const respond = useCallback(
|
||||
async (answer: string) => {
|
||||
@@ -156,11 +138,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
|
||||
const handleTextareaKey = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.nativeEvent.isComposing) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
const trimmed = draft.trim()
|
||||
|
||||
@@ -184,20 +162,12 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
[draft, respond]
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ClarifyShell
|
||||
aria-label={copy.loadingQuestion}
|
||||
className="grid min-h-24 place-items-center px-3 py-6"
|
||||
role="status"
|
||||
>
|
||||
<Loader2 aria-hidden className="size-5 animate-spin text-muted-foreground/80" />
|
||||
</ClarifyShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ClarifyShell className="grid gap-6 px-3 py-2.5">
|
||||
<div
|
||||
className="relative mb-3 mt-2 grid gap-6 rounded-[0.5rem] border border-border/70 bg-card/40 px-3 py-2.5 text-sm shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]"
|
||||
data-slot="clarify-inline"
|
||||
>
|
||||
<span aria-hidden className="arc-border" />
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
@@ -205,7 +175,9 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
>
|
||||
<HelpCircle className="size-3.5" />
|
||||
</span>
|
||||
<span className="flex-1 whitespace-pre-wrap font-medium leading-snug text-foreground">{question}</span>
|
||||
<span className="flex-1 whitespace-pre-wrap font-medium leading-snug text-foreground">
|
||||
{question || <em className="font-normal text-muted-foreground/70">{copy.loadingQuestion}</em>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!typing && hasChoices && (
|
||||
@@ -218,7 +190,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
selectedChoice === choice && 'bg-accent/60'
|
||||
)}
|
||||
data-choice
|
||||
disabled={submitting}
|
||||
disabled={!ready || submitting}
|
||||
key={`${index}-${choice}`}
|
||||
onClick={() => {
|
||||
setSelectedChoice(choice)
|
||||
@@ -228,7 +200,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
>
|
||||
<RadioDot selected={selectedChoice === choice} />
|
||||
<span className="flex-1 wrap-anywhere">{choice}</span>
|
||||
{selectedChoice === choice && <Check aria-hidden className="mt-0.5 size-4 shrink-0 text-primary" />}
|
||||
{selectedChoice === choice && <Check aria-hidden className="size-4 shrink-0 text-primary" />}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
@@ -259,9 +231,8 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-1 text-[0.6875rem] text-muted-foreground/85">
|
||||
<KbdCombo combo="enter" size="sm" />
|
||||
<KbdCombo combo="shift+enter" size="sm" />
|
||||
{t.composer.hotkeyDescs['composer.sendNewline']}
|
||||
<KbdCombo combo="mod+enter" size="sm" />
|
||||
{copy.shortcutSuffix}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasChoices && (
|
||||
@@ -278,10 +249,16 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
{copy.back}
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={submitting} onClick={() => void respond('')} size="sm" type="button" variant="ghost">
|
||||
<Button
|
||||
disabled={!ready || submitting}
|
||||
onClick={() => void respond('')}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{copy.skip}
|
||||
</Button>
|
||||
<Button disabled={submitting || !draft.trim()} size="sm" type="submit">
|
||||
<Button disabled={!ready || submitting || !draft.trim()} size="sm" type="submit">
|
||||
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : copy.send}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -293,7 +270,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="-mr-2"
|
||||
disabled={submitting}
|
||||
disabled={!ready || submitting}
|
||||
onClick={() => void respond('')}
|
||||
size="xs"
|
||||
type="button"
|
||||
@@ -303,6 +280,6 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ClarifyShell>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,9 +58,9 @@ Element.prototype.animate = function animate() {
|
||||
} as unknown as Animation
|
||||
}
|
||||
|
||||
// jsdom returns 0 for offset*; some layout code reads those to size the
|
||||
// jsdom returns 0 for offset*; the virtualizer reads those to size its
|
||||
// viewport. Fall through to client* (which tests can override) or a sane
|
||||
// default so message rows render with non-zero dimensions.
|
||||
// default so virtualized items render.
|
||||
function stubOffsetDimension(
|
||||
prop: 'offsetHeight' | 'offsetWidth',
|
||||
clientProp: 'clientHeight' | 'clientWidth',
|
||||
@@ -216,32 +216,6 @@ function assistantTodoMessage(
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantImageMessage(running = false): ThreadMessage {
|
||||
return {
|
||||
id: `assistant-image-${running ? 'running' : 'done'}`,
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'image-1',
|
||||
toolName: 'image_generate',
|
||||
args: { prompt: 'draw a cat' },
|
||||
argsText: JSON.stringify({ prompt: 'draw a cat' }),
|
||||
...(running ? {} : { result: { image: 'https://cdn.example/cat.png', success: true } })
|
||||
}
|
||||
],
|
||||
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function StreamingHarness() {
|
||||
const [messages, setMessages] = useState<ThreadMessage[]>([userMessage()])
|
||||
const [isRunning, setIsRunning] = useState(true)
|
||||
@@ -280,6 +254,20 @@ function StreamingHarness() {
|
||||
)
|
||||
}
|
||||
|
||||
function StaticThreadHarness() {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [userMessage(), assistantMessage('complete response', false)],
|
||||
isRunning: false,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TodoHarness({ message }: { message: ThreadMessage }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [message],
|
||||
@@ -421,11 +409,222 @@ describe('assistant-ui streaming renderer', () => {
|
||||
expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).')
|
||||
})
|
||||
|
||||
// Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned
|
||||
// by the use-stick-to-bottom library and covered by its own test suite. We
|
||||
// don't re-assert its scrollTop mechanics here — doing so in jsdom (no real
|
||||
// layout, spring animation via rAF) only produces brittle change-detector
|
||||
// tests. The rendering/streaming-content tests below remain the contract.
|
||||
it('does not pull the viewport back down after the user scrolls up during streaming', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 800
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
await wait(0)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.wheel(viewport, { deltaY: -120 })
|
||||
viewport.scrollTop = 420
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
scrollHeight = 1_200
|
||||
|
||||
await act(async () => {
|
||||
for (const observer of resizeObservers) {
|
||||
observer.trigger(1_200)
|
||||
}
|
||||
})
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(420)
|
||||
})
|
||||
|
||||
it('does not auto-follow idle layout shifts', async () => {
|
||||
const { container } = render(<StaticThreadHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 420
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
scrollHeight = 1_200
|
||||
|
||||
await act(async () => {
|
||||
for (const observer of resizeObservers) {
|
||||
observer.trigger(1_200)
|
||||
}
|
||||
})
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(420)
|
||||
})
|
||||
|
||||
it('does not follow streaming content growth even while parked at the bottom', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let clientHeight = 200
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => clientHeight
|
||||
})
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
|
||||
// Park the user at the bottom of the current content.
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 800
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
clientHeight = 240
|
||||
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 760
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
// Content grows as tokens stream in. Streaming auto-follow is removed, so
|
||||
// the viewport must NOT chase the new bottom — it stays where the user
|
||||
// last left it.
|
||||
scrollHeight = 1_200
|
||||
|
||||
await act(async () => {
|
||||
for (const observer of resizeObservers) {
|
||||
observer.trigger(1_200)
|
||||
}
|
||||
})
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(760)
|
||||
})
|
||||
|
||||
it('honors the first upward wheel scroll even when a programmatic bottom-pin scroll event is still pending', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
await wait(0)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.wheel(viewport, { deltaY: -120 })
|
||||
viewport.scrollTop = 420
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
scrollHeight = 1_200
|
||||
|
||||
await act(async () => {
|
||||
for (const observer of resizeObservers) {
|
||||
observer.trigger(1_200)
|
||||
}
|
||||
})
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(420)
|
||||
})
|
||||
|
||||
it('does not snap to the bottom on final code-highlight growth after a run completes', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 800
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
await wait(650)
|
||||
|
||||
// Completion re-measures (Shiki highlight) and grows the content. The
|
||||
// post-run bottom lock is removed, so the viewport stays put instead of
|
||||
// snapping to the new bottom.
|
||||
scrollHeight = 1_700
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(800)
|
||||
})
|
||||
|
||||
it('does not restart bottom-follow after completion when the user scrolled up', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
Object.defineProperty(viewport, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => scrollHeight
|
||||
})
|
||||
|
||||
await wait(80)
|
||||
|
||||
await act(async () => {
|
||||
viewport.scrollTop = 800
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.wheel(viewport, { deltaY: -120 })
|
||||
viewport.scrollTop = 420
|
||||
fireEvent.scroll(viewport)
|
||||
})
|
||||
|
||||
await wait(650)
|
||||
|
||||
scrollHeight = 1_700
|
||||
await wait(0)
|
||||
|
||||
expect(viewport.scrollTop).toBe(420)
|
||||
})
|
||||
|
||||
it('renders an incomplete streaming fenced code block as a code card', async () => {
|
||||
const { container } = render(<RunningMessageHarness message={assistantMessage('```ts\nconst answer = 42\n')} />)
|
||||
@@ -441,19 +640,14 @@ describe('assistant-ui streaming renderer', () => {
|
||||
it('renders an incomplete streaming reasoning fenced code block as a code card', async () => {
|
||||
const { container } = render(<RunningReasoningHarness />)
|
||||
const ui = within(container)
|
||||
const thinkingToggle = ui.getByRole('button', { name: /thinking/i })
|
||||
|
||||
if (thinkingToggle.getAttribute('aria-expanded') !== 'true') {
|
||||
fireEvent.click(thinkingToggle)
|
||||
}
|
||||
fireEvent.click(ui.getByRole('button', { name: /thinking/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-slot="code-card"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toContain('const answer = 42')
|
||||
})
|
||||
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toContain('const answer = 42')
|
||||
expect(container.textContent).not.toContain('```ts')
|
||||
})
|
||||
|
||||
@@ -506,16 +700,4 @@ describe('assistant-ui streaming renderer', () => {
|
||||
|
||||
expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders completed image generation results in the tool slot', async () => {
|
||||
const { container } = render(<MessageHarness message={assistantImageMessage()} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: 'Generated image' }).getAttribute('src')).toBe(
|
||||
'https://cdn.example/cat.png'
|
||||
)
|
||||
})
|
||||
expect(container.querySelector('[data-slot="aui_generated-image"]')).toBeTruthy()
|
||||
expect(screen.queryByRole('status', { name: /rendering image/i })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
|
||||
import {
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
memo,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { useStickToBottom } from 'use-stick-to-bottom'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
onScrollToBottomRequest,
|
||||
onThreadEditClose,
|
||||
onThreadEditOpen,
|
||||
resetThreadScroll,
|
||||
setThreadAtBottom
|
||||
} from '@/store/thread-scroll'
|
||||
|
||||
import { MessageRenderBoundary } from './message-render-boundary'
|
||||
|
||||
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
|
||||
|
||||
type MessageGroup = { id: string; weight: number } & (
|
||||
| { index: number; kind: 'standalone' }
|
||||
| { indices: number[]; kind: 'turn' }
|
||||
)
|
||||
|
||||
// DOM is bounded by a rendered-PART budget, not a message/turn count: a single
|
||||
// assistant message folds every tool call into a part, so heavy sessions are
|
||||
// ~40 turns / ~100 messages but ~1000 parts — and parts are what drive node
|
||||
// count. "Show earlier" prepends another page; whole turns stay intact so the
|
||||
// sticky human bubble never loses its turn. This is the long-session perf lever
|
||||
// WITHOUT a virtualizer — pure rendering, never touches scrollTop, so it can't
|
||||
// fight use-stick-to-bottom (the single scroll owner).
|
||||
const RENDER_BUDGET = 300
|
||||
|
||||
interface ThreadMessageListProps {
|
||||
clampToComposer: boolean
|
||||
components: ThreadMessageComponents
|
||||
emptyPlaceholder?: ReactNode
|
||||
loadingIndicator?: ReactNode
|
||||
sessionKey?: string | null
|
||||
}
|
||||
|
||||
// Group each user message with the assistant turn(s) that follow it so the
|
||||
// human bubble can `position: sticky` against the scroller across its whole
|
||||
// turn (see StickyHumanMessageContainer in thread.tsx).
|
||||
function buildGroups(signature: string): MessageGroup[] {
|
||||
if (!signature) {
|
||||
return []
|
||||
}
|
||||
|
||||
const messages = signature.split('\n').map(row => {
|
||||
const [index, id, role, weight] = row.split(':')
|
||||
|
||||
return { id, index: Number(index), role, weight: Number(weight) || 1 }
|
||||
})
|
||||
|
||||
const groups: MessageGroup[] = []
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
|
||||
if (message.role !== 'user') {
|
||||
groups.push({ id: message.id, index: message.index, kind: 'standalone', weight: message.weight })
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const indices = [message.index]
|
||||
let weight = message.weight
|
||||
|
||||
while (i + 1 < messages.length && messages[i + 1].role !== 'user') {
|
||||
weight += messages[++i].weight
|
||||
indices.push(messages[i].index)
|
||||
}
|
||||
|
||||
groups.push({ id: message.id, indices, kind: 'turn', weight })
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
clampToComposer,
|
||||
components,
|
||||
emptyPlaceholder,
|
||||
loadingIndicator,
|
||||
sessionKey
|
||||
}) => {
|
||||
const messageSignature = useAuiState(s =>
|
||||
s.thread.messages
|
||||
.map((message, index) => `${index}:${message.id}:${message.role}:${message.content?.length ?? 1}`)
|
||||
.join('\n')
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const groups = buildGroups(messageSignature)
|
||||
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
|
||||
|
||||
// use-stick-to-bottom owns scrollTop (single writer): follow while locked,
|
||||
// escape on user scroll-up, re-lock at bottom. Snap instantly, not spring — a
|
||||
// spring can't tell live-token growth from a session-switch bulk relayout, and
|
||||
// chasing the latter reads as the view scrolling to random spots before
|
||||
// settling. Its refs hang off our own DOM so the sticky human bubbles survive.
|
||||
const { scrollRef, contentRef, isAtBottom, scrollToBottom, stopScroll } = useStickToBottom({
|
||||
initial: 'instant',
|
||||
resize: 'instant'
|
||||
})
|
||||
|
||||
const [renderBudget, setRenderBudget] = useState(RENDER_BUDGET)
|
||||
|
||||
// Walk turns newest-first, summing their part weights until the budget is met;
|
||||
// everything before that first kept turn is hidden.
|
||||
let firstVisible = groups.length
|
||||
|
||||
for (let i = groups.length - 1, weight = 0; i >= 0; i--) {
|
||||
weight += groups[i].weight
|
||||
firstVisible = i
|
||||
|
||||
if (weight >= renderBudget) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const hiddenCount = firstVisible
|
||||
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
|
||||
const restoreFromBottomRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
|
||||
useEffect(() => () => resetThreadScroll(), [])
|
||||
|
||||
// Floating jump button (outside this subtree) → return to the bottom.
|
||||
useEffect(() => onScrollToBottomRequest(() => void scrollToBottom()), [scrollToBottom])
|
||||
|
||||
const endEditHold = useCallback(() => {
|
||||
scrollRef.current?.removeAttribute('data-editing')
|
||||
}, [scrollRef])
|
||||
|
||||
// Inline edit grows a sticky bubble. Escape before focus/layout so the
|
||||
// resize-follow can't snap scrollTop; native anchoring holds the viewport.
|
||||
const beginEditHold = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
endEditHold()
|
||||
stopScroll()
|
||||
el.setAttribute('data-editing', 'true')
|
||||
}, [endEditHold, scrollRef, stopScroll])
|
||||
|
||||
useEffect(() => onThreadEditOpen(beginEditHold), [beginEditHold])
|
||||
useEffect(() => onThreadEditClose(endEditHold), [endEditHold])
|
||||
useEffect(() => () => endEditHold(), [endEditHold])
|
||||
// New run → snap to the latest turn.
|
||||
useAuiEvent('thread.runStart', () => void scrollToBottom())
|
||||
|
||||
// Reset the cap and pin to bottom on mount + every session switch (messages
|
||||
// swap in place on a long-lived runtime, so sessionKey is the only signal).
|
||||
// The swap is multi-step and lays out over many frames; letting the library
|
||||
// follow re-pins every frame to a moving target — visible as ~10 scroll jumps.
|
||||
// Instead: quiet it, glue to the true bottom until the height holds steady,
|
||||
// then hand back locked. Live streaming afterward uses the normal resize follow.
|
||||
useLayoutEffect(() => {
|
||||
setRenderBudget(RENDER_BUDGET)
|
||||
|
||||
const el = scrollRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
stopScroll()
|
||||
el.scrollTop = el.scrollHeight
|
||||
|
||||
let frame = 0
|
||||
let stableFrames = 0
|
||||
let lastHeight = el.scrollHeight
|
||||
|
||||
const settle = () => {
|
||||
const node = scrollRef.current
|
||||
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
const height = node.scrollHeight
|
||||
|
||||
stableFrames = height === lastHeight ? stableFrames + 1 : 0
|
||||
lastHeight = height
|
||||
node.scrollTop = height
|
||||
|
||||
// ~5 steady frames ≈ layout has settled; the frame cap bounds slow loads.
|
||||
if (stableFrames >= 5 || ++frame > 90) {
|
||||
void scrollToBottom('instant')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(settle)
|
||||
}
|
||||
|
||||
let rafId = requestAnimationFrame(settle)
|
||||
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [scrollRef, scrollToBottom, sessionKey, stopScroll])
|
||||
|
||||
// Prepend an older page while preserving the on-screen position. The user is
|
||||
// scrolled up (reading history) so the stick-to-bottom lock is escaped and
|
||||
// won't fight this manual restore.
|
||||
const showEarlier = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
restoreFromBottomRef.current = el ? el.scrollHeight - el.scrollTop : null
|
||||
setRenderBudget(budget => budget + RENDER_BUDGET)
|
||||
}, [scrollRef])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current
|
||||
|
||||
if (el && restoreFromBottomRef.current != null) {
|
||||
el.scrollTop = el.scrollHeight - restoreFromBottomRef.current
|
||||
restoreFromBottomRef.current = null
|
||||
}
|
||||
}, [scrollRef, renderBudget])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
|
||||
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
|
||||
>
|
||||
<div
|
||||
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||
data-following={isAtBottom ? 'true' : 'false'}
|
||||
data-slot="aui_thread-viewport"
|
||||
ref={scrollRef as React.RefCallback<HTMLDivElement>}
|
||||
>
|
||||
{renderEmpty ? (
|
||||
<div
|
||||
className="mx-auto grid h-full w-full max-w-(--composer-width) grid-rows-[minmax(0,1fr)_auto] min-w-0 gap-(--conversation-turn-gap) px-6 py-8"
|
||||
data-slot="aui_thread-content"
|
||||
>
|
||||
{emptyPlaceholder}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]'
|
||||
)}
|
||||
data-slot="aui_thread-content"
|
||||
ref={contentRef as React.RefCallback<HTMLDivElement>}
|
||||
>
|
||||
{hiddenCount > 0 && (
|
||||
<button
|
||||
className="mx-auto mb-(--conversation-turn-gap) rounded-full border border-border/65 bg-(--composer-fill) px-3 py-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={showEarlier}
|
||||
type="button"
|
||||
>
|
||||
{t.assistant.thread.showEarlier}
|
||||
</button>
|
||||
)}
|
||||
{visibleGroups.map(group => (
|
||||
<div
|
||||
className="flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)"
|
||||
key={group.id}
|
||||
>
|
||||
<MessageRenderBoundary resetKey={messageSignature}>
|
||||
{group.kind === 'turn' ? (
|
||||
<div
|
||||
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
|
||||
data-slot="aui_turn-pair"
|
||||
>
|
||||
{group.indices.map(index => (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
|
||||
)}
|
||||
</MessageRenderBoundary>
|
||||
</div>
|
||||
))}
|
||||
{loadingIndicator}
|
||||
{clampToComposer && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="shrink-0"
|
||||
data-slot="aui_composer-clearance"
|
||||
style={{ height: 'var(--thread-last-message-clearance)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ThreadMessageList = memo(ThreadMessageListInner)
|
||||
481
apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx
Normal file
481
apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
|
||||
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual'
|
||||
import {
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
memo,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef
|
||||
} from 'react'
|
||||
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
onScrollToBottomRequest,
|
||||
resetThreadScroll,
|
||||
setThreadJumpButtonVisible,
|
||||
setThreadScrolledUp
|
||||
} from '@/store/thread-scroll'
|
||||
|
||||
import { MessageRenderBoundary } from './message-render-boundary'
|
||||
|
||||
const ESTIMATED_ITEM_HEIGHT = 220
|
||||
const OVERSCAN = 4
|
||||
const AT_BOTTOM_THRESHOLD = 4
|
||||
// Reveal the floating jump button only once scrolled meaningfully away — above
|
||||
// AT_BOTTOM_THRESHOLD so a sub-pixel settle never flashes it.
|
||||
const JUMP_BUTTON_THRESHOLD = 10
|
||||
|
||||
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
|
||||
|
||||
type MessageGroup = { id: string; index: number; kind: 'standalone' } | { id: string; indices: number[]; kind: 'turn' }
|
||||
|
||||
interface VirtualizedThreadProps {
|
||||
clampToComposer: boolean
|
||||
components: ThreadMessageComponents
|
||||
emptyPlaceholder?: ReactNode
|
||||
loadingIndicator?: ReactNode
|
||||
sessionKey?: string | null
|
||||
}
|
||||
|
||||
function buildGroups(signature: string): MessageGroup[] {
|
||||
if (!signature) {
|
||||
return []
|
||||
}
|
||||
|
||||
const messages = signature.split('\n').map(row => {
|
||||
const [index, id, role] = row.split(':')
|
||||
|
||||
return { id, index: Number(index), role }
|
||||
})
|
||||
|
||||
const groups: MessageGroup[] = []
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
|
||||
if (message.role !== 'user') {
|
||||
groups.push({ id: message.id, index: message.index, kind: 'standalone' })
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const indices = [message.index]
|
||||
|
||||
while (i + 1 < messages.length && messages[i + 1].role !== 'user') {
|
||||
indices.push(messages[++i].index)
|
||||
}
|
||||
|
||||
groups.push({ id: message.id, indices, kind: 'turn' })
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
|
||||
clampToComposer,
|
||||
components,
|
||||
emptyPlaceholder,
|
||||
loadingIndicator,
|
||||
sessionKey
|
||||
}) => {
|
||||
const messageSignature = useAuiState(s =>
|
||||
s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n')
|
||||
)
|
||||
|
||||
const isRunning = useAuiState(s => s.thread.isRunning)
|
||||
|
||||
const groups = useMemo(() => buildGroups(messageSignature), [messageSignature])
|
||||
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// Shared ref so scrollToFn can check whether the user is parked at the
|
||||
// bottom without needing a ref from inside useThreadScrollAnchor.
|
||||
const stickyBottomRef = useRef(true)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: groups.length,
|
||||
estimateSize: () => ESTIMATED_ITEM_HEIGHT,
|
||||
getItemKey: index => groups[index]?.id ?? index,
|
||||
getScrollElement: () => scrollerRef.current,
|
||||
// Seed the rect so the initial range mounts something before
|
||||
// `observeElementRect` reports the real layout (it overrides this).
|
||||
initialRect: { height: 600, width: 800 },
|
||||
overscan: OVERSCAN,
|
||||
// When the virtualizer adjusts scroll due to item measurement changes,
|
||||
// skip the adjustment if the user is at the bottom. Our ResizeObserver +
|
||||
// pinToBottom loop handles scroll anchoring; letting the virtualizer also
|
||||
// adjust creates a feedback loop where the two fight each other,
|
||||
// producing visible rubber-banding (the view snaps to the composer
|
||||
// then jumps back up).
|
||||
scrollToFn: (offset, _options, instance) => {
|
||||
const el = instance.scrollElement
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
if (stickyBottomRef.current) {
|
||||
const maxScroll = el.scrollHeight - el.clientHeight
|
||||
const distFromBottom = maxScroll - el.scrollTop
|
||||
|
||||
if (distFromBottom <= AT_BOTTOM_THRESHOLD && offset < maxScroll) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
;(el as HTMLElement).scrollTo(0, offset)
|
||||
}
|
||||
})
|
||||
|
||||
useThreadScrollAnchor({
|
||||
enabled: !renderEmpty,
|
||||
groupCount: groups.length,
|
||||
isRunning,
|
||||
scrollerRef,
|
||||
sessionKey: sessionKey ?? null,
|
||||
stickyBottomRef,
|
||||
virtualizer
|
||||
})
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems()
|
||||
const totalSize = virtualizer.getTotalSize()
|
||||
const paddingTop = virtualItems[0]?.start ?? 0
|
||||
const paddingBottom = Math.max(0, totalSize - (virtualItems.at(-1)?.end ?? 0))
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
|
||||
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
|
||||
>
|
||||
<div
|
||||
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||
data-slot="aui_thread-viewport"
|
||||
ref={scrollerRef}
|
||||
>
|
||||
{renderEmpty ? (
|
||||
<div
|
||||
className="mx-auto grid h-full w-full max-w-(--composer-width) grid-rows-[minmax(0,1fr)_auto] min-w-0 gap-(--conversation-turn-gap) px-6 py-8"
|
||||
data-slot="aui_thread-content"
|
||||
>
|
||||
{emptyPlaceholder}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]'
|
||||
)}
|
||||
data-slot="aui_thread-content"
|
||||
>
|
||||
{/* Natural-flow virtualization: mounted items render as normal
|
||||
flex siblings so `position: sticky` on the human bubble
|
||||
resolves against the scroller without transform interference.
|
||||
Padding spacers reserve scroll space for unmounted items. */}
|
||||
<div style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
|
||||
{virtualItems.map(virtualItem => {
|
||||
const group = groups[virtualItem.index]
|
||||
|
||||
if (!group) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)"
|
||||
data-index={virtualItem.index}
|
||||
key={virtualItem.key}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<MessageRenderBoundary resetKey={messageSignature}>
|
||||
{group.kind === 'turn' ? (
|
||||
<div
|
||||
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
|
||||
data-slot="aui_turn-pair"
|
||||
>
|
||||
{group.indices.map(index => (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
|
||||
)}
|
||||
</MessageRenderBoundary>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{loadingIndicator}
|
||||
{clampToComposer && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="shrink-0"
|
||||
data-slot="aui_composer-clearance"
|
||||
style={{ height: 'var(--thread-last-message-clearance)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VirtualizedThread = memo(VirtualizedThreadInner)
|
||||
|
||||
function scrollElementToBottom(el: HTMLDivElement) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
|
||||
interface ScrollAnchorOptions {
|
||||
enabled: boolean
|
||||
groupCount: number
|
||||
isRunning: boolean
|
||||
scrollerRef: React.RefObject<HTMLDivElement | null>
|
||||
sessionKey: string | null
|
||||
stickyBottomRef: React.MutableRefObject<boolean>
|
||||
virtualizer: Virtualizer<HTMLDivElement, Element>
|
||||
}
|
||||
|
||||
function useThreadScrollAnchor({
|
||||
enabled,
|
||||
groupCount,
|
||||
isRunning,
|
||||
scrollerRef,
|
||||
sessionKey,
|
||||
stickyBottomRef,
|
||||
virtualizer
|
||||
}: ScrollAnchorOptions) {
|
||||
// `stickyBottomRef` = parked at bottom, content growth should follow. Cleared on
|
||||
// user-driven upward scroll; re-armed when they reach bottom again.
|
||||
// This is a shared ref — scrollToFn reads it to prevent the virtualizer's
|
||||
// measurement adjustments from fighting our pinToBottom.
|
||||
const lastTopRef = useRef(0)
|
||||
const lastHeightRef = useRef(0)
|
||||
const lastClientHeightRef = useRef(0)
|
||||
// Counter that tracks how many scroll events we expect to be ours rather
|
||||
// than the user's. `pinToBottom` writes `el.scrollTop`, which fires an
|
||||
// async `scroll` event; without this guard the on-scroll handler can race
|
||||
// with the programmatic write (because content also grew, the *resulting*
|
||||
// scrollTop can be lower than `lastTopRef` from the previous frame) and
|
||||
// misread the programmatic pin as the user scrolling up — which disarms
|
||||
// sticky-bottom and the user's just-submitted message slides above the
|
||||
// fold. See `apps/desktop/scripts/measure-jump.mjs` for the repro
|
||||
// (distFromBottom 0 → 49 within one frame, sticking forever).
|
||||
const programmaticScrollPendingRef = useRef(0)
|
||||
const prevSessionKeyRef = useRef(sessionKey)
|
||||
const prevGroupCountRef = useRef(0)
|
||||
|
||||
const pinToBottom = useCallback(() => {
|
||||
const el = scrollerRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
// Already parked at the bottom: writing `scrollTop` is a no-op and the
|
||||
// browser fires NO scroll event, so arming the programmatic gate here would
|
||||
// leave it permanently set. Repeated pins (streaming heartbeats, the
|
||||
// post-run lock loop) then accumulate the gate, and the next genuine user
|
||||
// scroll-up is misread as one of our programmatic scrolls — re-arming
|
||||
// sticky-bottom and yanking the viewport back down. Refresh trackers, bail.
|
||||
const distFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight)
|
||||
|
||||
if (distFromBottom <= AT_BOTTOM_THRESHOLD) {
|
||||
lastTopRef.current = el.scrollTop
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
lastClientHeightRef.current = el.clientHeight
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Hold the disarm gate across the scroll event the next line will fire.
|
||||
// Set to 1 rather than incrementing: coalesced writes within a frame fire a
|
||||
// single scroll event, so a counter > 1 can never drain and would swallow a
|
||||
// later real user scroll.
|
||||
programmaticScrollPendingRef.current = 1
|
||||
scrollElementToBottom(el)
|
||||
lastTopRef.current = el.scrollTop
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
lastClientHeightRef.current = el.clientHeight
|
||||
}, [scrollerRef])
|
||||
|
||||
const jumpToBottom = useCallback(() => {
|
||||
setMutableRef(stickyBottomRef, true)
|
||||
|
||||
if (groupCount > 0) {
|
||||
virtualizer.scrollToIndex(groupCount - 1, { align: 'end', behavior: 'auto' })
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (stickyBottomRef.current) {
|
||||
pinToBottom()
|
||||
}
|
||||
})
|
||||
}, [groupCount, pinToBottom, stickyBottomRef, virtualizer])
|
||||
|
||||
useEffect(() => () => resetThreadScroll(), [])
|
||||
|
||||
// Track at-bottom state, dim composer when scrolled up, disarm on user
|
||||
// scroll/wheel/touch.
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current
|
||||
|
||||
if (!el) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const disarm = () => {
|
||||
setMutableRef(stickyBottomRef, false)
|
||||
programmaticScrollPendingRef.current = 0
|
||||
}
|
||||
|
||||
// Dim the composer the instant we leave the bottom; reveal the jump button
|
||||
// only once scrolled meaningfully away.
|
||||
const publishScrollDistance = (dist: number) => {
|
||||
setThreadScrolledUp(dist > AT_BOTTOM_THRESHOLD)
|
||||
setThreadJumpButtonVisible(dist > JUMP_BUTTON_THRESHOLD)
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
const top = el.scrollTop
|
||||
|
||||
// If this scroll event is the consequence of `pinToBottom` writing
|
||||
// `el.scrollTop`, treat it as ours: don't disarm. The RO + rAF pin
|
||||
// loop will re-pin on the next frame if the browser clamped us
|
||||
// short of bottom (because content grew in the same frame).
|
||||
// Without this guard the post-pin scrollTop gets misread as the
|
||||
// user scrolling up, disarming sticky-bottom permanently and
|
||||
// leaving the just-submitted message below the fold.
|
||||
if (programmaticScrollPendingRef.current > 0) {
|
||||
programmaticScrollPendingRef.current -= 1
|
||||
lastTopRef.current = top
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
lastClientHeightRef.current = el.clientHeight
|
||||
// Always re-arm — sticky-bottom should hold through clamp races.
|
||||
setMutableRef(stickyBottomRef, true)
|
||||
publishScrollDistance(el.scrollHeight - (top + el.clientHeight))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Disarm on ANY upward movement (even 1px), but only while content +
|
||||
// viewport height are stable — virtualizer measurement, streaming
|
||||
// markdown, and composer/window resize all shift scrollTop as a layout
|
||||
// side effect. Wheel-up and touchmove disarm immediately too (below).
|
||||
const heightGrew = el.scrollHeight > lastHeightRef.current
|
||||
const clientHeightChanged = Math.abs(el.clientHeight - lastClientHeightRef.current) > 1
|
||||
|
||||
if (!heightGrew && !clientHeightChanged && top < lastTopRef.current) {
|
||||
setMutableRef(stickyBottomRef, false)
|
||||
}
|
||||
|
||||
lastTopRef.current = top
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
lastClientHeightRef.current = el.clientHeight
|
||||
|
||||
const distFromBottom = el.scrollHeight - (top + el.clientHeight)
|
||||
|
||||
// Re-arm follow only once genuinely back at the bottom.
|
||||
if (distFromBottom <= AT_BOTTOM_THRESHOLD) {
|
||||
setMutableRef(stickyBottomRef, true)
|
||||
}
|
||||
|
||||
publishScrollDistance(distFromBottom)
|
||||
}
|
||||
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (event.deltaY < 0) {
|
||||
disarm()
|
||||
}
|
||||
}
|
||||
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
el.addEventListener('wheel', onWheel, { passive: true })
|
||||
el.addEventListener('touchmove', disarm, { passive: true })
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
el.removeEventListener('touchmove', disarm)
|
||||
}
|
||||
}, [scrollerRef, stickyBottomRef])
|
||||
|
||||
// No streaming auto-follow: chasing content growth while parked at the bottom
|
||||
// rubber-bands (the tail and the virtualizer's own measurement adjustments
|
||||
// fight for scrollTop). The one-time new-turn jump below already lands a fresh
|
||||
// message in view; from there the viewport stays put unless the user jumps.
|
||||
|
||||
// The floating jump button asks us to return to the bottom; same re-arm + pin
|
||||
// path as a new turn.
|
||||
useEffect(() => onScrollToBottomRequest(jumpToBottom), [jumpToBottom])
|
||||
|
||||
// Jump to bottom on session change OR when an empty thread first gets
|
||||
// content. Both share the same intent and the same effect.
|
||||
useEffect(() => {
|
||||
const sessionChanged = prevSessionKeyRef.current !== sessionKey
|
||||
const becameNonEmpty = prevGroupCountRef.current === 0 && groupCount > 0
|
||||
|
||||
prevSessionKeyRef.current = sessionKey
|
||||
prevGroupCountRef.current = groupCount
|
||||
|
||||
if (enabled && (sessionChanged || becameNonEmpty)) {
|
||||
jumpToBottom()
|
||||
}
|
||||
}, [enabled, groupCount, jumpToBottom, sessionKey])
|
||||
|
||||
// Pre-paint pin: when groupCount increases while armed (a new turn arriving
|
||||
// from the user submit or assistant turn start), pin BEFORE the browser
|
||||
// commits the layout to screen. Using useLayoutEffect rather than useEffect
|
||||
// so this runs synchronously after React commits the DOM mutation but before
|
||||
// the browser paints. Without this, there's a ~50ms visual window where the
|
||||
// new message sits below the fold.
|
||||
//
|
||||
// We pin TWICE in this critical path — once synchronously, then once on
|
||||
// the next rAF. The second pin catches the case where React mounts the
|
||||
// new message in the second commit (after our layout effect ran), which
|
||||
// grows scrollHeight again; without the rAF pin the user briefly sees a
|
||||
// ~15 px gap below the new message. This fires once per user submit / new
|
||||
// turn arrival — it is NOT streaming-token follow (that path is removed
|
||||
// above), so a turn that streams a long response after this initial jump
|
||||
// will not chase the bottom.
|
||||
const prevGroupCountForLayoutRef = useRef(groupCount)
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (groupCount > prevGroupCountForLayoutRef.current && stickyBottomRef.current) {
|
||||
// Defer to rAF so that browser scroll/wheel events from the current
|
||||
// frame are processed first. Without this deferral, a trackpad
|
||||
// scroll-up during streaming can race with this effect: the wheel
|
||||
// event hasn't fired yet so stickyBottomRef is still true, and the
|
||||
// immediate pinToBottom() would snap the viewport back to bottom
|
||||
// against the user's intent.
|
||||
requestAnimationFrame(() => {
|
||||
if (stickyBottomRef.current) {
|
||||
pinToBottom()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
prevGroupCountForLayoutRef.current = groupCount
|
||||
}, [enabled, groupCount, pinToBottom, stickyBottomRef])
|
||||
|
||||
// Intentionally NO post-run bottom lock. Earlier builds kept pinning to
|
||||
// the bottom for POST_RUN_BOTTOM_LOCK_MS after `isRunning` flipped false to
|
||||
// chase final Shiki re-highlight measurement. With streaming follow gone,
|
||||
// re-pinning at completion would yank the viewport back to the bottom even
|
||||
// though the user is reading earlier content — the opposite of what's
|
||||
// wanted. The one-time submit / new-turn jump already covers landing a
|
||||
// fresh message in view.
|
||||
const prevIsRunningForLayoutRef = useRef(isRunning)
|
||||
useLayoutEffect(() => {
|
||||
prevIsRunningForLayoutRef.current = isRunning
|
||||
}, [isRunning])
|
||||
|
||||
useAuiEvent('thread.runStart', jumpToBottom)
|
||||
}
|
||||
@@ -63,14 +63,15 @@ import { uploadComposerAttachment } from '@/app/session/hooks/use-prompt-actions
|
||||
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
|
||||
import { ThreadMessageList } from '@/components/assistant-ui/thread-list'
|
||||
import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer'
|
||||
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
|
||||
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
import { GeneratedImage } from '@/components/chat/generated-image-result'
|
||||
import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/chat/generated-image-context'
|
||||
import { ImageGenerationPlaceholder } from '@/components/chat/image-generation-placeholder'
|
||||
import { Intro, type IntroProps } from '@/components/chat/intro'
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
@@ -96,11 +97,9 @@ import { extractPreviewTargets } from '@/lib/preview-targets'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
||||
import { $compactionActive } from '@/store/compaction'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $connection } from '@/store/session'
|
||||
import { notifyThreadEditClose, notifyThreadEditOpen } from '@/store/thread-scroll'
|
||||
import { $voicePlayback } from '@/store/voice-playback'
|
||||
|
||||
type ThreadLoadingState = 'response' | 'session'
|
||||
@@ -201,16 +200,18 @@ export const Thread: FC<{
|
||||
) : undefined
|
||||
|
||||
return (
|
||||
<div className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
|
||||
<ThreadMessageList
|
||||
clampToComposer={clampToComposer}
|
||||
components={messageComponents}
|
||||
emptyPlaceholder={emptyPlaceholder}
|
||||
loadingIndicator={loading === 'response' ? <ResponseLoadingIndicator /> : null}
|
||||
sessionKey={sessionKey}
|
||||
/>
|
||||
{loading === 'session' && <CenteredThreadSpinner />}
|
||||
</div>
|
||||
<GeneratedImageProvider>
|
||||
<div className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
|
||||
<VirtualizedThread
|
||||
clampToComposer={clampToComposer}
|
||||
components={messageComponents}
|
||||
emptyPlaceholder={emptyPlaceholder}
|
||||
loadingIndicator={loading === 'response' ? <ResponseLoadingIndicator /> : null}
|
||||
sessionKey={sessionKey}
|
||||
/>
|
||||
{loading === 'session' && <CenteredThreadSpinner />}
|
||||
</div>
|
||||
</GeneratedImageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -274,7 +275,10 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
return pickPrimaryPreviewTarget(extractPreviewTargets(completedText))
|
||||
}, [completedText])
|
||||
|
||||
const getMessageText = useCallback(() => messageContentText(messageRuntime.getState().content), [messageRuntime])
|
||||
const getMessageText = useCallback(
|
||||
() => messageContentText(messageRuntime.getState().content),
|
||||
[messageRuntime]
|
||||
)
|
||||
|
||||
const enterRef = useEnterAnimation(isRunning, `assistant-message:${messageId}`)
|
||||
|
||||
@@ -337,25 +341,13 @@ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentProp
|
||||
</div>
|
||||
)
|
||||
|
||||
// Fixed label while auto-compaction runs — decoupled from backend status text.
|
||||
const COMPACTION_LABEL = 'Summarizing thread'
|
||||
|
||||
const CompactionHint: FC = () => (
|
||||
<span className="shimmer min-w-0 truncate text-muted-foreground/55">{COMPACTION_LABEL}</span>
|
||||
)
|
||||
|
||||
const ResponseLoadingIndicator: FC = () => {
|
||||
const { t } = useI18n()
|
||||
const elapsed = useElapsedSeconds()
|
||||
const compacting = useStore($compactionActive)
|
||||
|
||||
return (
|
||||
<StatusRow
|
||||
data-slot="aui_response-loading"
|
||||
label={compacting ? COMPACTION_LABEL : t.assistant.thread.loadingResponse}
|
||||
>
|
||||
<StatusRow data-slot="aui_response-loading" label={t.assistant.thread.loadingResponse}>
|
||||
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
||||
{compacting && <CompactionHint />}
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
@@ -390,7 +382,6 @@ const StreamStallIndicator: FC = () => {
|
||||
})
|
||||
|
||||
const [stalled, setStalled] = useState(false)
|
||||
const compacting = useStore($compactionActive)
|
||||
|
||||
useEffect(() => {
|
||||
setStalled(false)
|
||||
@@ -399,32 +390,35 @@ const StreamStallIndicator: FC = () => {
|
||||
return () => window.clearTimeout(id)
|
||||
}, [activity])
|
||||
|
||||
const active = stalled || compacting
|
||||
const elapsed = useElapsedSeconds(active)
|
||||
const elapsed = useElapsedSeconds(stalled)
|
||||
|
||||
if (!active) {
|
||||
if (!stalled) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusRow
|
||||
className="mt-1.5"
|
||||
data-slot="aui_stream-stall"
|
||||
label={compacting ? COMPACTION_LABEL : 'Hermes is thinking'}
|
||||
>
|
||||
<StatusRow className="mt-1.5" data-slot="aui_stream-stall" label="Hermes is thinking">
|
||||
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
||||
{compacting && <CompactionHint />}
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ args, result }) => {
|
||||
const aspectRatio = typeof args?.aspect_ratio === 'string' ? args.aspect_ratio : undefined
|
||||
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
|
||||
const generatedImage = useGeneratedImageContext()
|
||||
const running = result === undefined
|
||||
|
||||
useEffect(() => {
|
||||
generatedImage?.setPending(running)
|
||||
}, [generatedImage, running])
|
||||
|
||||
if (!running) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-1.5">
|
||||
<GeneratedImage aspectRatio={aspectRatio} result={result} />
|
||||
<ImageGenerationPlaceholder />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -962,10 +956,7 @@ const UserMessage: FC<{
|
||||
// backtick `code` and ``` fenced ``` blocks, with directive chips
|
||||
// (`@file:` etc.) still resolved inside the plain-text spans.
|
||||
<div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
|
||||
{/* Match the edit composer's collapsed line box (min-h-[1.25rem]) so
|
||||
clicking to edit can't grow the bubble by a sub-pixel and reflow the
|
||||
turn 1px. */}
|
||||
<div className="min-h-[1.25rem]" ref={clampInnerRef}>
|
||||
<div ref={clampInnerRef}>
|
||||
<UserMessageText className="wrap-anywhere" text={messageText} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -995,7 +986,6 @@ const UserMessage: FC<{
|
||||
aria-label={copy.editMessage}
|
||||
className={bubbleClassName}
|
||||
onClick={() => triggerHaptic('selection')}
|
||||
onPointerDown={() => notifyThreadEditOpen()}
|
||||
title={copy.editMessage}
|
||||
type="button"
|
||||
>
|
||||
@@ -1185,8 +1175,6 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
const at = useAtCompletions({ cwd, gateway, sessionId })
|
||||
const slash = useSlashCompletions({ gateway })
|
||||
|
||||
useEffect(() => () => notifyThreadEditClose(), [])
|
||||
|
||||
const focusEditor = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
@@ -1712,8 +1700,9 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
aria-label={copy.editMessage}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
autoFocus
|
||||
className={cn(
|
||||
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] text-foreground/95 outline-none',
|
||||
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none',
|
||||
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
|
||||
'**:data-ref-text:cursor-default',
|
||||
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
import { clearDismissedToolRows } from '@/store/tool-dismiss'
|
||||
import { $toolDisclosureStates } from '@/store/tool-view'
|
||||
|
||||
import { Thread } from './thread'
|
||||
@@ -105,84 +104,6 @@ function groupedPendingMessage(): ThreadMessage {
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function pendingOnlyMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-pending-only',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'term-only',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'sleep 10' },
|
||||
argsText: JSON.stringify({ command: 'sleep 10' })
|
||||
}
|
||||
],
|
||||
status: { type: 'running' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function completedOnlyMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-completed-only',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'read-only',
|
||||
toolName: 'read_file',
|
||||
args: { path: '/etc/hosts' },
|
||||
argsText: JSON.stringify({ path: '/etc/hosts' }),
|
||||
result: { content: '127.0.0.1 localhost' }
|
||||
}
|
||||
],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function failedOnlyMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-failed-only',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'term-failed',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'exit 1' },
|
||||
argsText: JSON.stringify({ command: 'exit 1' }),
|
||||
isError: true,
|
||||
result: { stderr: 'boom' }
|
||||
}
|
||||
],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function GroupHarness({ message }: { message: ThreadMessage }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [message],
|
||||
@@ -201,14 +122,12 @@ beforeEach(() => {
|
||||
clearAllPrompts()
|
||||
$activeSessionId.set('sess-1')
|
||||
$toolDisclosureStates.set({})
|
||||
clearDismissedToolRows()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearAllPrompts()
|
||||
$activeSessionId.set(null)
|
||||
clearDismissedToolRows()
|
||||
})
|
||||
|
||||
describe('flat tool list approval surfacing', () => {
|
||||
@@ -236,64 +155,4 @@ describe('flat tool list approval surfacing', () => {
|
||||
expect(bar?.closest('[hidden]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('lets completed tool rows be dismissed', async () => {
|
||||
const { container } = render(<GroupHarness message={completedOnlyMessage()} />)
|
||||
|
||||
const dismiss = await screen.findByLabelText('Dismiss')
|
||||
|
||||
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(1)
|
||||
|
||||
fireEvent.click(dismiss)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a dismissed row hidden after a remount (virtualization)', async () => {
|
||||
// The thread virtualizes, so a row's component unmounts/remounts as it
|
||||
// scrolls. Dismissal must persist across that — component-local state would
|
||||
// forget it and the row would pop back. Simulate the remount by unmounting
|
||||
// and rendering the same message fresh.
|
||||
const first = render(<GroupHarness message={completedOnlyMessage()} />)
|
||||
|
||||
fireEvent.click(await screen.findByLabelText('Dismiss'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull()
|
||||
})
|
||||
|
||||
first.unmount()
|
||||
|
||||
const { container } = render(<GroupHarness message={completedOnlyMessage()} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull()
|
||||
})
|
||||
|
||||
it('lets failed tool rows be dismissed', async () => {
|
||||
render(<GroupHarness message={failedOnlyMessage()} />)
|
||||
|
||||
const dismiss = await screen.findByLabelText('Dismiss')
|
||||
|
||||
fireEvent.click(dismiss)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show dismiss for pending tool rows', async () => {
|
||||
const { container } = render(<GroupHarness message={pendingOnlyMessage()} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
expect(screen.queryByLabelText('Dismiss')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -84,19 +84,6 @@ describe('PendingToolApproval', () => {
|
||||
expect($approvalRequest.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('reveals the full command inline when the Command toggle is clicked', () => {
|
||||
const longCommand = 'python -c "' + 'x'.repeat(400) + '"'
|
||||
setRequest(longCommand)
|
||||
render(<PendingToolApproval part={part('terminal')} />)
|
||||
|
||||
// Collapsed by default: the full command is not in the DOM yet.
|
||||
expect(screen.queryByText(longCommand)).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Command/ }))
|
||||
|
||||
expect(screen.getByText(longCommand)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('sends choice "deny" on Reject', async () => {
|
||||
const request = mockGateway()
|
||||
setRequest()
|
||||
|
||||
@@ -16,7 +16,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { ChevronDown, Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
|
||||
@@ -61,15 +60,9 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
// "Always allow" persists the pattern to ~/.hermes/config.yaml permanently, so
|
||||
// it goes through a confirm step rather than firing straight from the menu.
|
||||
const [confirmAlways, setConfirmAlways] = useState(false)
|
||||
// The pending tool row only shows a single truncated line of the command, and
|
||||
// a pending row can't be expanded (no result yet), so the full command was
|
||||
// previously only reachable via the "Always allow" modal. Let the user reveal
|
||||
// it inline instead — "expand, Run" (2 clicks) rather than the modal dance.
|
||||
const [showCommand, setShowCommand] = useState(false)
|
||||
const busy = submitting !== null
|
||||
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
|
||||
const allowPermanent = request.allowPermanent !== false
|
||||
const hasCommand = request.command.trim().length > 0
|
||||
|
||||
const respond = useCallback(
|
||||
async (choice: ApprovalChoice) => {
|
||||
@@ -126,89 +119,70 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
}, [confirmAlways, respond])
|
||||
|
||||
return (
|
||||
<div className="mt-1 ps-5" data-slot="tool-approval-inline">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
|
||||
<Button
|
||||
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
|
||||
disabled={busy}
|
||||
onClick={() => void respond('once')}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
|
||||
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
|
||||
</Button>
|
||||
<span aria-hidden className="w-px self-stretch bg-primary/20" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={copy.moreOptions}
|
||||
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
|
||||
disabled={busy}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-44">
|
||||
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
|
||||
{allowPermanent && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
// Defer one tick so the menu fully unmounts before the dialog
|
||||
// mounts — otherwise Radix's focus-return races the dialog and
|
||||
// dismisses it via onInteractOutside.
|
||||
setTimeout(() => setConfirmAlways(true), 0)
|
||||
}}
|
||||
>
|
||||
{copy.alwaysAllowMenu}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
|
||||
{copy.reject}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center gap-2.5 ps-5" data-slot="tool-approval-inline">
|
||||
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
|
||||
<Button
|
||||
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
|
||||
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
|
||||
disabled={busy}
|
||||
onClick={() => void respond('deny')}
|
||||
onClick={() => void respond('once')}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : copy.reject}
|
||||
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
|
||||
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
|
||||
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
|
||||
</Button>
|
||||
|
||||
{hasCommand && (
|
||||
<Button
|
||||
aria-expanded={showCommand}
|
||||
className="h-6 gap-1 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
|
||||
onClick={() => setShowCommand(value => !value)}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{copy.command}
|
||||
<ChevronDown className={cn('size-3 transition-transform', showCommand && 'rotate-180')} />
|
||||
</Button>
|
||||
)}
|
||||
<span aria-hidden className="w-px self-stretch bg-primary/20" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={copy.moreOptions}
|
||||
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
|
||||
disabled={busy}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-44">
|
||||
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
|
||||
{allowPermanent && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
// Defer one tick so the menu fully unmounts before the dialog
|
||||
// mounts — otherwise Radix's focus-return races the dialog and
|
||||
// dismisses it via onInteractOutside.
|
||||
setTimeout(() => setConfirmAlways(true), 0)
|
||||
}}
|
||||
>
|
||||
{copy.alwaysAllowMenu}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
|
||||
{copy.reject}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{showCommand && hasCommand && (
|
||||
<pre className="mt-1.5 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-2.5 py-1.5 font-mono text-xs leading-snug text-foreground">
|
||||
{request.command.trim()}
|
||||
</pre>
|
||||
)}
|
||||
<Button
|
||||
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
|
||||
disabled={busy}
|
||||
onClick={() => void respond('deny')}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : copy.reject}
|
||||
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
|
||||
</Button>
|
||||
|
||||
<Dialog onOpenChange={setConfirmAlways} open={confirmAlways}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copy.alwaysTitle}</DialogTitle>
|
||||
<DialogDescription>{copy.alwaysDescription(request.description)}</DialogDescription>
|
||||
<DialogDescription>
|
||||
{copy.alwaysDescription(request.description)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{request.command.trim() && (
|
||||
|
||||
@@ -12,20 +12,16 @@ import { DiffLines } from '@/components/chat/diff-lines'
|
||||
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { ToolIcon } from '@/components/ui/tool-icon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
|
||||
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $toolInlineDiffs } from '@/store/tool-diffs'
|
||||
import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss'
|
||||
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
|
||||
|
||||
import { PendingToolApproval } from './tool-approval'
|
||||
@@ -197,16 +193,13 @@ function useDisclosureOpen(disclosureId: string, fallbackOpen = false): boolean
|
||||
function ToolEntry({ part }: ToolEntryProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.tool
|
||||
const statusCopy = t.statusStack
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const messageRunning = useAuiState(selectMessageRunning)
|
||||
const embedded = useContext(ToolEmbedContext)
|
||||
const toolViewMode = useStore($toolViewMode)
|
||||
const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(part)}`
|
||||
const dismissed = useStore($toolRowDismissed(disclosureId))
|
||||
const open = useDisclosureOpen(disclosureId)
|
||||
const isPending = messageRunning && part.result === undefined
|
||||
const canDismiss = !isPending && !embedded
|
||||
// Only animate entries that mount while their message is actively
|
||||
// streaming — historical sessions mount with `messageRunning === false`,
|
||||
// so they paint statically without a settle cascade. The wrapping group
|
||||
@@ -289,33 +282,9 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
// the disclosure caret hard to hit. Copy now lives in the expanded body's
|
||||
// top-right, where it can't fight the caret for the right edge.
|
||||
const trailing =
|
||||
isPending && !embedded ? <ActivityTimerText className={TOOL_HEADER_DURATION_CLASS} seconds={elapsed} /> : undefined
|
||||
|
||||
// Once a turn has settled, a hover/focus-revealed dismiss lets the user clear
|
||||
// a completed/failed row that would otherwise sit at the tail of the chat.
|
||||
// It goes in the in-flow `action` slot (not `trailing`) so it can't overlap
|
||||
// the disclosure caret's hit-target — see the comment above `trailing`.
|
||||
const dismissAction = canDismiss ? (
|
||||
<Tip label={statusCopy.dismiss}>
|
||||
<Button
|
||||
aria-label={statusCopy.dismiss}
|
||||
className="size-5 rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:text-(--ui-text-primary) hover:opacity-100 group-hover/disclosure-row:opacity-80 group-focus-within/disclosure-row:opacity-80"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
dismissToolRow(disclosureId)
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : undefined
|
||||
|
||||
if (dismissed) {
|
||||
return null
|
||||
}
|
||||
isPending && !embedded ? (
|
||||
<ActivityTimerText className={TOOL_HEADER_DURATION_CLASS} seconds={elapsed} />
|
||||
) : undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -328,7 +297,6 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
>
|
||||
<div className={cn(open && 'border-b border-(--ui-stroke-tertiary) px-2 py-1.5')}>
|
||||
<DisclosureRow
|
||||
action={dismissAction}
|
||||
onToggle={hasExpandableContent ? () => setToolDisclosureOpen(disclosureId, !open) : undefined}
|
||||
open={open}
|
||||
trailing={trailing}
|
||||
|
||||
@@ -14,19 +14,12 @@ import { cn } from '@/lib/utils'
|
||||
// title text, NOT the full row — and reaches just past the chevron with
|
||||
// `-mx-1.5 px-1.5` so it reads as a soft hit-target rather than a slab
|
||||
// stretching to the message edge.
|
||||
// - `trailing` overlays the right edge (absolute) and must stay
|
||||
// non-interactive (e.g. a duration timer) — an opacity-0-but-clickable
|
||||
// control there steals clicks from the caret. Interactive controls go in
|
||||
// `action`, which lays out *in flow* at the far right so it never sits on
|
||||
// top of the caret's hit-target, no matter how long the title is.
|
||||
export function DisclosureRow({
|
||||
action,
|
||||
children,
|
||||
onToggle,
|
||||
open,
|
||||
trailing
|
||||
}: {
|
||||
action?: ReactNode
|
||||
children: ReactNode
|
||||
onToggle?: () => void
|
||||
open: boolean
|
||||
@@ -62,11 +55,6 @@ export function DisclosureRow({
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{action && (
|
||||
<span className="ml-auto flex h-(--conversation-line-height) shrink-0 items-center self-start pl-1.5">
|
||||
{action}
|
||||
</span>
|
||||
)}
|
||||
{trailing && (
|
||||
<span className="absolute right-1 top-0 flex h-(--conversation-line-height) items-center">{trailing}</span>
|
||||
)}
|
||||
|
||||
19
apps/desktop/src/components/chat/generated-image-context.tsx
Normal file
19
apps/desktop/src/components/chat/generated-image-context.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, type ReactNode, useContext, useMemo, useState } from 'react'
|
||||
|
||||
type Value = {
|
||||
isPending: boolean
|
||||
setPending: (pending: boolean) => void
|
||||
}
|
||||
|
||||
const Ctx = createContext<Value | null>(null)
|
||||
|
||||
export function GeneratedImageProvider({ children }: { children: ReactNode }) {
|
||||
const [isPending, setPending] = useState(false)
|
||||
const value = useMemo(() => ({ isPending, setPending }), [isPending])
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
|
||||
}
|
||||
|
||||
export const useGeneratedImageContext = () => useContext(Ctx)
|
||||
@@ -1,174 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
|
||||
import { DiffusionCanvas } from '@/components/chat/image-generation-placeholder'
|
||||
import { ImageActionButton, ImageLightbox } from '@/components/chat/zoomable-image'
|
||||
import { useImageDownload } from '@/hooks/use-image-download'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { generatedImageFromResult } from '@/lib/generated-images'
|
||||
import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway, mediaExternalUrl, mediaName } from '@/lib/media'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Aspect hint from the tool args sizes the frame *before* the image loads, so
|
||||
// the placeholder and the resolved image occupy the same box — no layout shift.
|
||||
const ASPECT_HINTS: Record<string, number> = {
|
||||
landscape: 16 / 9,
|
||||
square: 1,
|
||||
portrait: 9 / 16
|
||||
}
|
||||
|
||||
function hintedRatio(aspectRatio?: string): number {
|
||||
return ASPECT_HINTS[String(aspectRatio ?? '').toLowerCase().trim()] ?? ASPECT_HINTS.landscape
|
||||
}
|
||||
|
||||
function isInlineSrc(path: string): boolean {
|
||||
return /^(?:https?|data):/i.test(path)
|
||||
}
|
||||
|
||||
async function resolveImageSrc(path: string): Promise<string> {
|
||||
if (isInlineSrc(path)) {
|
||||
return path
|
||||
}
|
||||
|
||||
if (window.hermesDesktop && isRemoteGateway()) {
|
||||
return gatewayMediaDataUrl(path)
|
||||
}
|
||||
|
||||
if (!window.hermesDesktop?.readFileDataUrl) {
|
||||
return mediaExternalUrl(path)
|
||||
}
|
||||
|
||||
return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
|
||||
}
|
||||
|
||||
export const GeneratedImage: FC<{ aspectRatio?: string; result?: unknown }> = ({ aspectRatio, result }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.desktop
|
||||
const image = result === undefined ? null : generatedImageFromResult(result)
|
||||
const pending = result === undefined
|
||||
|
||||
const [ratio, setRatio] = useState(() => hintedRatio(aspectRatio))
|
||||
const [src, setSrc] = useState(() => (image && isInlineSrc(image) ? image : ''))
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [canvasGone, setCanvasGone] = useState(false)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false)
|
||||
const { download, saving } = useImageDownload(src)
|
||||
|
||||
useEffect(() => setRatio(hintedRatio(aspectRatio)), [aspectRatio])
|
||||
|
||||
// Resolve the deliverable path (local read / gateway proxy / remote URL). The
|
||||
// <img> stays mounted under the placeholder and only fades in once it decodes,
|
||||
// so the frame keeps its hinted size and never jumps.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setFailed(false)
|
||||
setLoaded(false)
|
||||
setCanvasGone(false)
|
||||
setSrc(image && isInlineSrc(image) ? image : '')
|
||||
|
||||
if (!image || isInlineSrc(image)) {
|
||||
return
|
||||
}
|
||||
|
||||
void resolveImageSrc(image)
|
||||
.then(resolved => !cancelled && setSrc(resolved))
|
||||
.catch(() => !cancelled && setFailed(true))
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [image])
|
||||
|
||||
// Completed but no usable image (generation failed): the agent's prose carries
|
||||
// the explanation, so render nothing here.
|
||||
if (!pending && !image) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (failed && image) {
|
||||
return (
|
||||
<a
|
||||
className="mt-2 inline-block font-semibold text-foreground underline underline-offset-4 decoration-current/20 wrap-anywhere"
|
||||
href="#"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
void window.hermesDesktop?.openExternal(mediaExternalUrl(image))
|
||||
}}
|
||||
>
|
||||
{copy.openImage}: {mediaName(image)}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
aria-label={pending ? t.assistant.tool.renderingImage : undefined}
|
||||
aria-live={pending ? 'polite' : undefined}
|
||||
className="group/image relative block max-w-full overflow-hidden rounded-2xl transition-[width,height] duration-300 ease-out"
|
||||
data-slot="aui_generated-image"
|
||||
role={pending ? 'status' : undefined}
|
||||
style={{
|
||||
aspectRatio: ratio,
|
||||
// Width is capped so the derived height (width / ratio) never exceeds
|
||||
// --image-preview-height; the box then matches the image exactly with
|
||||
// no letterboxing.
|
||||
width: `min(calc(var(--image-preview-height) * ${ratio}), var(--image-preview-max-width), 100%)`
|
||||
}}
|
||||
>
|
||||
{!canvasGone && (
|
||||
<div
|
||||
className={cn('absolute inset-0 transition-opacity duration-500 ease-out', loaded && 'opacity-0')}
|
||||
onTransitionEnd={() => loaded && setCanvasGone(true)}
|
||||
>
|
||||
<DiffusionCanvas />
|
||||
</div>
|
||||
)}
|
||||
{src && (
|
||||
<button
|
||||
className="absolute inset-0 block size-full cursor-zoom-in"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
title={copy.openImage}
|
||||
type="button"
|
||||
>
|
||||
<img
|
||||
alt="Generated image"
|
||||
className={cn(
|
||||
'absolute inset-0 size-full object-contain opacity-0 transition-opacity duration-500 ease-out',
|
||||
loaded && 'opacity-100'
|
||||
)}
|
||||
draggable={false}
|
||||
onError={() => setFailed(true)}
|
||||
onLoad={event => {
|
||||
const { naturalHeight, naturalWidth } = event.currentTarget
|
||||
|
||||
if (naturalWidth && naturalHeight) {
|
||||
setRatio(naturalWidth / naturalHeight)
|
||||
}
|
||||
|
||||
setLoaded(true)
|
||||
}}
|
||||
src={src}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{loaded && src && (
|
||||
<ImageActionButton className="group-hover/image:opacity-100" copy={copy} onClick={download} saving={saving} />
|
||||
)}
|
||||
</span>
|
||||
{src && (
|
||||
<ImageLightbox
|
||||
alt="Generated image"
|
||||
copy={copy}
|
||||
onClick={download}
|
||||
onOpenChange={setLightboxOpen}
|
||||
open={lightboxOpen}
|
||||
saving={saving}
|
||||
src={src}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type FC, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
type Rgb = { r: number; g: number; b: number }
|
||||
|
||||
@@ -141,9 +142,6 @@ const drawAsciiDiffusion = (
|
||||
const cellHeight = fontSize * 1.28
|
||||
const cols = Math.ceil(width / cellWidth)
|
||||
const rows = Math.ceil(height / cellHeight)
|
||||
// Normalise both axes by the shorter side so the radial bloom stays a circle
|
||||
// (not a squished ellipse) when the frame isn't landscape.
|
||||
const short = Math.min(width, height)
|
||||
const centerX = 0.53 + Math.sin(time * 0.055) * 0.02
|
||||
const centerY = 0.5 + Math.cos(time * 0.048) * 0.02
|
||||
const timestep = Math.floor(time * 1.15)
|
||||
@@ -157,10 +155,10 @@ const drawAsciiDiffusion = (
|
||||
for (let col = -1; col <= cols + 1; col += 1) {
|
||||
const x = col * cellWidth + cellWidth * 0.5
|
||||
const y = row * cellHeight + cellHeight * 0.5
|
||||
const sx = (x - centerX * width) / short
|
||||
const sy = (y - centerY * height) / short
|
||||
const dx = sx * 1.2
|
||||
const dy = sy * 0.95
|
||||
const nx = x / width
|
||||
const ny = y / height
|
||||
const dx = (nx - centerX) * 1.2
|
||||
const dy = (ny - centerY) * 0.95
|
||||
const radius = Math.hypot(dx, dy)
|
||||
const angle = Math.atan2(dy, dx)
|
||||
|
||||
@@ -171,7 +169,7 @@ const drawAsciiDiffusion = (
|
||||
const contour =
|
||||
Math.exp(-((Math.sin(angle * 3 + radius * 17 - time * 0.17) * 0.5 + 0.5 - radius) ** 2) / 0.016) * 0.38
|
||||
|
||||
const stem = Math.exp(-((sx + 0.05) ** 2 / 0.004 + (sy - 0.25) ** 2 / 0.08)) * 0.46
|
||||
const stem = Math.exp(-((nx - centerX + 0.05) ** 2 / 0.004 + (ny - centerY - 0.25) ** 2 / 0.08)) * 0.46
|
||||
|
||||
const latent = clamp(bloom + contour + stem, 0, 1)
|
||||
const staticA = hash2(col + timestep * 19, row - timestep * 11)
|
||||
@@ -243,7 +241,7 @@ const drawAsciiDiffusion = (
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
}
|
||||
|
||||
export const DiffusionCanvas: FC = () => {
|
||||
const DiffusionCanvas: FC = () => {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const sizeRef = useRef({ width: 0, height: 0 })
|
||||
const themeRef = useRef<Theme>(FALLBACKS)
|
||||
@@ -307,3 +305,15 @@ export const DiffusionCanvas: FC = () => {
|
||||
|
||||
return <canvas className="absolute inset-0 h-full w-full" ref={canvasRef} />
|
||||
}
|
||||
|
||||
export const ImageGenerationPlaceholder: FC = () => {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<div aria-label={t.assistant.tool.renderingImage} aria-live="polite" className="w-full max-w-136 self-start" role="status">
|
||||
<div className="relative h-(--image-preview-height) overflow-hidden rounded-4xl border border-border/55 shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_45%,transparent),inset_0_0_0_0.0625rem_color-mix(in_srgb,var(--dt-border)_34%,transparent),inset_0_-0.75rem_1.75rem_color-mix(in_srgb,var(--dt-primary)_5%,transparent)]">
|
||||
<DiffusionCanvas />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,17 +3,55 @@
|
||||
import { type ComponentProps, useState } from 'react'
|
||||
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { useImageDownload } from '@/hooks/use-image-download'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Download } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
function imageFilename(src?: string): string {
|
||||
if (!src) {
|
||||
return 'image'
|
||||
}
|
||||
|
||||
try {
|
||||
const { pathname } = new URL(src, window.location.href)
|
||||
|
||||
return pathname.split('/').filter(Boolean).pop() || 'image'
|
||||
} catch {
|
||||
return src.split(/[\\/]/).filter(Boolean).pop() || 'image'
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingIpcHandler(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
|
||||
|
||||
return message.includes("No handler registered for 'hermes:saveImageFromUrl'")
|
||||
}
|
||||
|
||||
async function startBrowserDownload(src: string) {
|
||||
const response = await fetch(src)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not fetch image: ${response.status}`)
|
||||
}
|
||||
|
||||
const blobUrl = URL.createObjectURL(await response.blob())
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = imageFilename(src)
|
||||
link.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
|
||||
}
|
||||
|
||||
export interface ZoomableImageProps extends ComponentProps<'img'> {
|
||||
containerClassName?: string
|
||||
slot?: string
|
||||
}
|
||||
|
||||
export interface ImageActionCopy {
|
||||
interface ImageActionCopy {
|
||||
downloadImage: string
|
||||
savingImage: string
|
||||
}
|
||||
@@ -21,10 +59,70 @@ export interface ImageActionCopy {
|
||||
export function ZoomableImage({ className, containerClassName, src, alt, slot, ...props }: ZoomableImageProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.desktop
|
||||
const { download, saving } = useImageDownload(src)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false)
|
||||
const canOpen = Boolean(src)
|
||||
|
||||
async function handleDownload() {
|
||||
if (!src || saving) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
if (window.hermesDesktop?.saveImageFromUrl) {
|
||||
const saved = await window.hermesDesktop.saveImageFromUrl(src)
|
||||
|
||||
if (saved) {
|
||||
notify({ kind: 'success', title: copy.imageSaved, message: imageFilename(src) })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await startBrowserDownload(src)
|
||||
} catch (error) {
|
||||
if (isMissingIpcHandler(error)) {
|
||||
try {
|
||||
await startBrowserDownload(src)
|
||||
notify({
|
||||
kind: 'info',
|
||||
title: copy.downloadStarted,
|
||||
message: copy.restartToUseSaveImage
|
||||
})
|
||||
} catch (fallbackError) {
|
||||
notifyError(fallbackError, copy.restartToSaveImages)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notifyError(error, copy.imageDownloadFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const lightbox = src ? (
|
||||
<Dialog onOpenChange={setLightboxOpen} open={lightboxOpen}>
|
||||
<DialogContent
|
||||
className="block w-auto max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] overflow-visible border-0 bg-transparent p-0 shadow-none"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="group/lightbox relative inline-block">
|
||||
<img
|
||||
alt={alt ?? ''}
|
||||
className="block max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
|
||||
onClick={() => setLightboxOpen(false)}
|
||||
src={src}
|
||||
/>
|
||||
<ImageActionButton copy={copy} onClick={handleDownload} saving={saving} variant="lightbox" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
@@ -40,79 +138,30 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
|
||||
>
|
||||
<img alt={alt ?? ''} className={className} src={src} {...props} />
|
||||
</button>
|
||||
{src && (
|
||||
<ImageActionButton className="group-hover/image:opacity-100" copy={copy} onClick={download} saving={saving} />
|
||||
)}
|
||||
{src && <ImageActionButton copy={copy} onClick={handleDownload} saving={saving} variant="inline" />}
|
||||
</span>
|
||||
{src && (
|
||||
<ImageLightbox
|
||||
alt={alt}
|
||||
copy={copy}
|
||||
onClick={download}
|
||||
onOpenChange={setLightboxOpen}
|
||||
open={lightboxOpen}
|
||||
saving={saving}
|
||||
src={src}
|
||||
/>
|
||||
)}
|
||||
{lightbox}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ImageLightbox({
|
||||
alt,
|
||||
function ImageActionButton({
|
||||
copy,
|
||||
onClick,
|
||||
onOpenChange,
|
||||
open,
|
||||
saving,
|
||||
src
|
||||
variant
|
||||
}: {
|
||||
alt?: string
|
||||
copy: ImageActionCopy
|
||||
onClick: () => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
open: boolean
|
||||
saving: boolean
|
||||
src: string
|
||||
}) {
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent
|
||||
className="block w-auto max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] overflow-visible border-0 bg-transparent p-0 shadow-none"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="group/lightbox relative inline-block">
|
||||
<img
|
||||
alt={alt ?? ''}
|
||||
className="block max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
|
||||
onClick={() => onOpenChange(false)}
|
||||
src={src}
|
||||
/>
|
||||
<ImageActionButton className="group-hover/lightbox:opacity-100" copy={copy} onClick={onClick} saving={saving} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function ImageActionButton({
|
||||
className,
|
||||
copy,
|
||||
onClick,
|
||||
saving
|
||||
}: {
|
||||
className?: string
|
||||
copy: ImageActionCopy
|
||||
onClick: () => void
|
||||
saving: boolean
|
||||
variant: 'inline' | 'lightbox'
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-label={saving ? copy.savingImage : copy.downloadImage}
|
||||
className={cn(
|
||||
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50',
|
||||
className
|
||||
variant === 'inline' ? 'group-hover/image:opacity-100' : 'group-hover/lightbox:opacity-100'
|
||||
)}
|
||||
disabled={saving}
|
||||
onClick={event => {
|
||||
|
||||
@@ -180,7 +180,7 @@ const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
|
||||
|
||||
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
|
||||
|
||||
export const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
|
||||
const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
|
||||
const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
|
||||
|
||||
export const sortProviders = (providers: OAuthProvider[]) =>
|
||||
|
||||
@@ -3,23 +3,23 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
import { $desktopOnboarding } from '@/store/onboarding'
|
||||
import { setGatewayState } from '@/store/session'
|
||||
import { $gatewayState, setGatewayState } from '@/store/session'
|
||||
|
||||
import { BootFailureOverlay } from './boot-failure-overlay'
|
||||
import { GatewayConnectingOverlay } from './gateway-connecting-overlay'
|
||||
|
||||
// Repro for the "remote gateway → stuck on CONNECTING, no way to settings"
|
||||
// report. The connecting overlay (z-1200, full-screen, pointer-events on) used
|
||||
// to be shown whenever `gatewayState !== 'open' && !boot.error`. The ONLY escape
|
||||
// report. The connecting overlay (z-1200, full-screen, pointer-events on) is
|
||||
// shown whenever `gatewayState !== 'open' && !boot.error`. The ONLY escape
|
||||
// hatch — BootFailureOverlay, which has "Use local gateway" / "Sign in" /
|
||||
// "Retry" — only renders when `boot.error` is set.
|
||||
//
|
||||
// useGatewayBoot only calls failDesktopBoot() (which sets boot.error) when the
|
||||
// INITIAL boot() throws. After the first successful connect (bootCompleted),
|
||||
// any later socket drop goes through scheduleReconnect(), which loops FOREVER
|
||||
// against the dead remote. So gatewayState sits at 'closed'/'error' with
|
||||
// boot.error null. The fix keeps the initial-boot overlay out of post-boot
|
||||
// reconnects, leaving chat/settings usable while the reconnect loop runs.
|
||||
// against the dead remote and never sets boot.error. So gatewayState sits at
|
||||
// 'closed'/'error' with boot.error null → CONNECTING forever, recovery overlay
|
||||
// never appears, settings unreachable.
|
||||
|
||||
function resetStores() {
|
||||
setGatewayState('idle')
|
||||
@@ -75,7 +75,7 @@ describe('connecting overlay vs recovery surface', () => {
|
||||
expect(isConnectingShown()).toBe(false)
|
||||
})
|
||||
|
||||
it('post-boot socket drops do not re-cover the app with the initial CONNECTING overlay', () => {
|
||||
it('REPRO: remote socket drops AFTER a successful boot → stuck on CONNECTING, no recovery, no settings', () => {
|
||||
// 1. Initial boot succeeded: gateway opened, boot completed (no error).
|
||||
setGatewayState('open')
|
||||
const { rerender } = render(
|
||||
@@ -97,14 +97,14 @@ describe('connecting overlay vs recovery surface', () => {
|
||||
</>
|
||||
)
|
||||
|
||||
// The initial-boot connecting overlay stays out of the way, so settings and
|
||||
// the composer remain reachable during the reconnect loop.
|
||||
expect(isConnectingShown()).toBe(false)
|
||||
// The connecting overlay reappears and latches...
|
||||
expect(isConnectingShown()).toBe(true)
|
||||
// ...with NO recovery surface, because boot.error was never set.
|
||||
expect(isRecoveryShown()).toBe(false)
|
||||
|
||||
// 3. Reconnect loops against the dead remote: gatewayState bounces closed
|
||||
// → error → closed. Until the escalation path sets boot.error, the app
|
||||
// remains usable instead of modal-blocked.
|
||||
// 3. Reconnect loops forever against the dead remote: gatewayState bounces
|
||||
// closed → error → closed, boot.error never gets set. The user is
|
||||
// pinned on CONNECTING with no path to Settings indefinitely.
|
||||
setGatewayState('error')
|
||||
rerender(
|
||||
<>
|
||||
@@ -113,7 +113,7 @@ describe('connecting overlay vs recovery surface', () => {
|
||||
</>
|
||||
)
|
||||
expect($desktopBoot.get().error).toBeNull()
|
||||
expect(isConnectingShown()).toBe(false)
|
||||
expect(isConnectingShown()).toBe(true)
|
||||
expect(isRecoveryShown()).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -52,13 +52,7 @@ export function GatewayConnectingOverlay() {
|
||||
const [tail, setTail] = useState(TAIL)
|
||||
const [phase, setPhase] = useState<Phase>('live')
|
||||
|
||||
// The full-screen connecting overlay is for initial boot only. After a
|
||||
// healthy boot, flaky networks / sleep-wake can drop the socket and flip the
|
||||
// gateway state back to closed/error while the app reconnects. Do not cover
|
||||
// the chat then — users should still be able to type drafts, open settings,
|
||||
// and recover instead of staring at a modal CONNECTING screen.
|
||||
const initialBootActive = boot.visible || boot.running || boot.progress < 100
|
||||
const connecting = gatewayState !== 'open' && !boot.error && initialBootActive
|
||||
const connecting = gatewayState !== 'open' && !boot.error
|
||||
// Latches once we've actually shown the overlay, so the brief frame where
|
||||
// gatewayState flips to "open" (connecting -> false) before the exit phase
|
||||
// kicks in doesn't unmount us and cause a flash.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ContextMenuItem } from '@/components/ui/context-menu'
|
||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
@@ -10,7 +9,7 @@ import { Check, Copy, X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type CopyPayload = string | (() => Promise<string> | string)
|
||||
type CopyButtonAppearance = 'button' | 'icon' | 'inline' | 'menu-item' | 'context-menu-item' | 'tool-row'
|
||||
type CopyButtonAppearance = 'button' | 'icon' | 'inline' | 'menu-item' | 'tool-row'
|
||||
type CopyStatus = 'copied' | 'error' | 'idle'
|
||||
const COPIED_RESET_MS = 1_500
|
||||
|
||||
@@ -160,11 +159,9 @@ export function CopyButton({
|
||||
status === 'copied' ? t.common.copied : status === 'error' ? resolvedErrorMessage : (title ?? resolvedLabel)
|
||||
const ariaLabel = status === 'idle' ? resolvedLabel : feedbackLabel
|
||||
|
||||
if (appearance === 'menu-item' || appearance === 'context-menu-item') {
|
||||
const MenuItem = appearance === 'menu-item' ? DropdownMenuItem : ContextMenuItem
|
||||
|
||||
if (appearance === 'menu-item') {
|
||||
return (
|
||||
<MenuItem
|
||||
<DropdownMenuItem
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
onSelect={event => {
|
||||
@@ -173,7 +170,7 @@ export function CopyButton({
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</MenuItem>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
5
apps/desktop/src/global.d.ts
vendored
5
apps/desktop/src/global.d.ts
vendored
@@ -88,8 +88,6 @@ declare global {
|
||||
) => () => void
|
||||
signalDeepLinkReady?: () => Promise<{ ok: boolean }>
|
||||
onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void
|
||||
onFocusSession?: (callback: (sessionId: string) => void) => () => void
|
||||
onNotificationAction?: (callback: (payload: { actionId: string; sessionId?: string }) => void) => () => void
|
||||
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
|
||||
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
|
||||
onPowerResume?: (callback: () => void) => () => void
|
||||
@@ -415,9 +413,6 @@ export interface HermesNotification {
|
||||
title?: string
|
||||
body?: string
|
||||
silent?: boolean
|
||||
kind?: string
|
||||
sessionId?: string
|
||||
actions?: { id: string; text: string }[]
|
||||
}
|
||||
|
||||
export interface HermesPreviewTarget {
|
||||
|
||||
@@ -210,19 +210,6 @@ export function searchSessions(query: string): Promise<SessionSearchResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
// Resolves a single session row by id on one backend (the active profile, or
|
||||
// the given `profile`). The backend resolves exact ids and unique prefixes and
|
||||
// 404s when the id isn't on that profile — so a cheap by-id lookup replaces the
|
||||
// cross-profile list scan when locating an unknown id's owner.
|
||||
export function getSession(id: string, profile?: string | null): Promise<SessionInfo> {
|
||||
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
|
||||
|
||||
return window.hermesDesktop.api<SessionInfo>({
|
||||
...(profile ? { profile } : {}),
|
||||
path: `/api/sessions/${encodeURIComponent(id)}${suffix}`
|
||||
})
|
||||
}
|
||||
|
||||
// Reads another profile's transcript. For a remote profile Electron reroutes
|
||||
// this GET to the remote backend (which serves its own state.db); for a local
|
||||
// profile the primary opens that profile's state.db via ?profile=. Omit for
|
||||
@@ -393,14 +380,6 @@ export function listOAuthProviders(): Promise<OAuthProvidersResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
export function disconnectOAuthProvider(providerId: string): Promise<{ ok: boolean; provider: string }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; provider: string }>({
|
||||
...profileScoped(),
|
||||
path: `/api/providers/oauth/${encodeURIComponent(providerId)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
export function startOAuthLogin(providerId: string): Promise<OAuthStartResponse> {
|
||||
return window.hermesDesktop.api<OAuthStartResponse>({
|
||||
...profileScoped(),
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
export function imageFilename(src?: string): string {
|
||||
if (!src) {
|
||||
return 'image'
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(src, window.location.href).pathname.split('/').filter(Boolean).pop() || 'image'
|
||||
} catch {
|
||||
return src.split(/[\\/]/).filter(Boolean).pop() || 'image'
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingIpcHandler(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
|
||||
|
||||
return message.includes("No handler registered for 'hermes:saveImageFromUrl'")
|
||||
}
|
||||
|
||||
async function startBrowserDownload(src: string) {
|
||||
const response = await fetch(src)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not fetch image: ${response.status}`)
|
||||
}
|
||||
|
||||
const blobUrl = URL.createObjectURL(await response.blob())
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = imageFilename(src)
|
||||
link.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
|
||||
}
|
||||
|
||||
/** Save an image to disk via the desktop IPC bridge, falling back to a browser
|
||||
* download when the handler is unavailable (older shell / web preview). */
|
||||
export function useImageDownload(src?: string) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.desktop
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const download = useCallback(async () => {
|
||||
if (!src || saving) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
if (window.hermesDesktop?.saveImageFromUrl) {
|
||||
if (await window.hermesDesktop.saveImageFromUrl(src)) {
|
||||
notify({ kind: 'success', title: copy.imageSaved, message: imageFilename(src) })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await startBrowserDownload(src)
|
||||
} catch (error) {
|
||||
if (isMissingIpcHandler(error)) {
|
||||
try {
|
||||
await startBrowserDownload(src)
|
||||
notify({ kind: 'info', title: copy.downloadStarted, message: copy.restartToUseSaveImage })
|
||||
} catch (fallbackError) {
|
||||
notifyError(fallbackError, copy.restartToSaveImages)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notifyError(error, copy.imageDownloadFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [copy, saving, src])
|
||||
|
||||
return { download, saving }
|
||||
}
|
||||
@@ -131,18 +131,6 @@ export const en: Translations = {
|
||||
transcriptionUnavailable: 'Voice transcription is not available yet.',
|
||||
tryRecordingAgain: 'Try recording again.',
|
||||
unavailable: 'Voice unavailable'
|
||||
},
|
||||
native: {
|
||||
approvalTitle: 'Approval needed',
|
||||
approveAction: 'Approve',
|
||||
rejectAction: 'Reject',
|
||||
inputTitle: 'Input needed',
|
||||
inputBody: 'Hermes is waiting for your response.',
|
||||
turnDoneTitle: 'Hermes finished',
|
||||
turnDoneBody: 'The response is ready.',
|
||||
turnErrorTitle: 'Turn failed',
|
||||
backgroundDoneTitle: 'Background task finished',
|
||||
backgroundFailedTitle: 'Background task failed'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -275,46 +263,7 @@ export const en: Translations = {
|
||||
keysSettings: 'Settings',
|
||||
mcp: 'MCP',
|
||||
archivedChats: 'Archived Chats',
|
||||
about: 'About',
|
||||
notifications: 'Notifications'
|
||||
},
|
||||
notifications: {
|
||||
title: 'Notifications',
|
||||
intro:
|
||||
'Native desktop notifications, separate from in-app toasts. These are device-local — each computer keeps its own settings.',
|
||||
enableAll: 'Enable notifications',
|
||||
enableAllDesc: 'Master switch. Turn this off to silence every notification below.',
|
||||
focusedHint: 'Completion alerts only fire while Hermes is in the background.',
|
||||
kinds: {
|
||||
approval: {
|
||||
label: 'Approval needed',
|
||||
description: 'A command is waiting for you to approve or reject it.'
|
||||
},
|
||||
input: {
|
||||
label: 'Input needed',
|
||||
description: 'Hermes asked a question or needs a password or secret.'
|
||||
},
|
||||
turnDone: {
|
||||
label: 'Response ready',
|
||||
description: 'A turn finished while Hermes was in the background.'
|
||||
},
|
||||
turnError: {
|
||||
label: 'Turn failed',
|
||||
description: 'A turn ended with an error.'
|
||||
},
|
||||
backgroundDone: {
|
||||
label: 'Background task finished',
|
||||
description: 'A backgrounded terminal command completed.'
|
||||
}
|
||||
},
|
||||
test: 'Send test notification',
|
||||
testTitle: 'Hermes',
|
||||
testBody: 'Notifications are working.',
|
||||
testSent: 'Test sent. If nothing appears, check your OS notification permissions and Focus/Do Not Disturb.',
|
||||
testUnsupported: 'This system does not support native notifications.',
|
||||
completionSoundTitle: 'Completion Sound',
|
||||
completionSoundDesc: 'Plays when an agent turn finishes. Pick a preset and preview it here.',
|
||||
completionSoundPreview: 'Preview'
|
||||
about: 'About'
|
||||
},
|
||||
sections: {
|
||||
model: 'Model',
|
||||
@@ -564,12 +513,6 @@ export const en: Translations = {
|
||||
collapse: 'Collapse',
|
||||
connectAnother: 'Connect another provider',
|
||||
otherProviders: 'Other providers',
|
||||
removeConfirm: provider => `Remove ${provider}?`,
|
||||
removeExternal: (provider, command) => `${provider} is managed outside Hermes. Remove it with ${command}.`,
|
||||
removeKeyManaged: provider => `${provider} is configured from an API key. Remove it from API Keys.`,
|
||||
removedTitle: 'Account removed',
|
||||
removedMessage: provider => `${provider} was removed.`,
|
||||
failedRemove: provider => `Could not remove ${provider}`,
|
||||
noProviderKeys: 'No provider API keys available.',
|
||||
loading: 'Loading providers...'
|
||||
},
|
||||
@@ -700,7 +643,6 @@ export const en: Translations = {
|
||||
back: 'Back',
|
||||
searchPlaceholder: 'Search sessions, views, and actions',
|
||||
goTo: 'Go to',
|
||||
goToSession: 'Go to session',
|
||||
commandCenter: 'Command Center',
|
||||
appearance: 'Appearance',
|
||||
settings: 'Settings',
|
||||
@@ -960,9 +902,6 @@ export const en: Translations = {
|
||||
deleting: 'Deleting...',
|
||||
createDesc: 'Profiles are independent Hermes environments: separate config, skills, and SOUL.md.',
|
||||
nameLabel: 'Name',
|
||||
cloneFrom: 'Clone from',
|
||||
cloneFromNone: 'None (blank)',
|
||||
cloneFromDesc: 'Copies config, skills, and SOUL.md from the selected source profile.',
|
||||
cloneFromDefault: 'Clone from default',
|
||||
cloneFromDefaultDesc: 'Copy config, skills, and SOUL.md from your default profile.',
|
||||
invalidName: hint => `Invalid name. ${hint}`,
|
||||
@@ -1716,7 +1655,6 @@ export const en: Translations = {
|
||||
assistant: {
|
||||
thread: {
|
||||
loadingSession: 'Loading session',
|
||||
showEarlier: 'Show earlier messages',
|
||||
loadingResponse: 'Hermes is loading a response',
|
||||
thinking: 'Thinking',
|
||||
today: time => `Today, ${time}`,
|
||||
@@ -1747,11 +1685,9 @@ export const en: Translations = {
|
||||
gatewayDisconnected: 'Hermes gateway is not connected',
|
||||
sendFailed: 'Could not send approval response',
|
||||
run: 'Run',
|
||||
command: 'Command',
|
||||
moreOptions: 'More approval options',
|
||||
allowSession: 'Allow this session',
|
||||
alwaysAllowMenu: 'Always allow…',
|
||||
jumpToApproval: 'Approval needed',
|
||||
reject: 'Reject',
|
||||
alwaysTitle: 'Always allow this command?',
|
||||
alwaysDescription: pattern =>
|
||||
|
||||
@@ -132,18 +132,6 @@ export const ja = defineLocale({
|
||||
transcriptionUnavailable: '音声文字起こしはまだ利用できません。',
|
||||
tryRecordingAgain: 'もう一度録音してください。',
|
||||
unavailable: '音声は利用できません'
|
||||
},
|
||||
native: {
|
||||
approvalTitle: '承認が必要です',
|
||||
approveAction: '承認',
|
||||
rejectAction: '拒否',
|
||||
inputTitle: '入力が必要です',
|
||||
inputBody: 'Hermes が応答を待っています。',
|
||||
turnDoneTitle: 'Hermes が完了しました',
|
||||
turnDoneBody: '応答の準備ができました。',
|
||||
turnErrorTitle: 'ターンが失敗しました',
|
||||
backgroundDoneTitle: 'バックグラウンドタスクが完了しました',
|
||||
backgroundFailedTitle: 'バックグラウンドタスクが失敗しました'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -189,47 +177,7 @@ export const ja = defineLocale({
|
||||
keysSettings: '設定',
|
||||
mcp: 'MCP',
|
||||
archivedChats: 'アーカイブ済みチャット',
|
||||
about: '情報',
|
||||
notifications: '通知'
|
||||
},
|
||||
notifications: {
|
||||
title: '通知',
|
||||
intro:
|
||||
'アプリ内トーストとは別の、ネイティブのデスクトップ通知です。設定は端末ごとに保存されます。',
|
||||
enableAll: '通知を有効にする',
|
||||
enableAllDesc: 'マスタースイッチ。オフにすると以下のすべての通知を無効にします。',
|
||||
focusedHint: '完了通知は Hermes がバックグラウンドにあるときのみ表示されます。',
|
||||
kinds: {
|
||||
approval: {
|
||||
label: '承認が必要',
|
||||
description: 'コマンドが承認または拒否を待っています。'
|
||||
},
|
||||
input: {
|
||||
label: '入力が必要',
|
||||
description: 'Hermes が質問したか、パスワードやシークレットを必要としています。'
|
||||
},
|
||||
turnDone: {
|
||||
label: '応答完了',
|
||||
description: 'Hermes がバックグラウンドのときにターンが完了しました。'
|
||||
},
|
||||
turnError: {
|
||||
label: 'ターン失敗',
|
||||
description: 'ターンがエラーで終了しました。'
|
||||
},
|
||||
backgroundDone: {
|
||||
label: 'バックグラウンドタスク完了',
|
||||
description: 'バックグラウンドのターミナルコマンドが完了しました。'
|
||||
}
|
||||
},
|
||||
test: 'テスト通知を送信',
|
||||
testTitle: 'Hermes',
|
||||
testBody: '通知は正常に動作しています。',
|
||||
testSent:
|
||||
'テストを送信しました。表示されない場合は、OS の通知許可と集中モード/おやすみモードを確認してください。',
|
||||
testUnsupported: 'このシステムはネイティブ通知に対応していません。',
|
||||
completionSoundTitle: '完了サウンド',
|
||||
completionSoundDesc: 'エージェントのターン終了時に再生されます。プリセットを選んでここで試聴できます。',
|
||||
completionSoundPreview: '試聴'
|
||||
about: '情報'
|
||||
},
|
||||
sections: {
|
||||
model: 'モデル',
|
||||
@@ -694,12 +642,6 @@ export const ja = defineLocale({
|
||||
collapse: '折りたたむ',
|
||||
connectAnother: '別のプロバイダーを接続',
|
||||
otherProviders: 'その他のプロバイダー',
|
||||
removeConfirm: provider => `${provider} を削除しますか?`,
|
||||
removeExternal: (provider, command) => `${provider} は Hermes の外部で管理されています。${command} で削除してください。`,
|
||||
removeKeyManaged: provider => `${provider} は API キーで設定されています。API Keys から削除してください。`,
|
||||
removedTitle: 'アカウントを削除しました',
|
||||
removedMessage: provider => `${provider} を削除しました。`,
|
||||
failedRemove: provider => `${provider} を削除できませんでした`,
|
||||
noProviderKeys: '利用可能なプロバイダー API キーがありません。',
|
||||
loading: 'プロバイダーを読み込み中...'
|
||||
},
|
||||
@@ -831,7 +773,6 @@ export const ja = defineLocale({
|
||||
back: '戻る',
|
||||
searchPlaceholder: 'セッション、ビュー、アクションを検索',
|
||||
goTo: '移動',
|
||||
goToSession: 'セッションへ移動',
|
||||
commandCenter: 'コマンドセンター',
|
||||
appearance: '外観',
|
||||
settings: '設定',
|
||||
@@ -1099,9 +1040,6 @@ export const ja = defineLocale({
|
||||
deleting: '削除中...',
|
||||
createDesc: 'プロファイルは独立した Hermes 環境です:設定、スキル、SOUL.md が別々になります。',
|
||||
nameLabel: '名前',
|
||||
cloneFrom: '複製元',
|
||||
cloneFromNone: 'なし(空)',
|
||||
cloneFromDesc: '選択したプロファイルから設定、スキル、SOUL.md をコピーします。',
|
||||
cloneFromDefault: 'デフォルトプロファイルから設定を複製',
|
||||
cloneFromDefaultDesc: 'デフォルトプロファイルから設定、スキル、SOUL.md をコピーします。',
|
||||
invalidName: hint => `無効なプロファイル名。${hint}`,
|
||||
@@ -1858,7 +1796,6 @@ export const ja = defineLocale({
|
||||
assistant: {
|
||||
thread: {
|
||||
loadingSession: 'セッションを読み込み中',
|
||||
showEarlier: '以前のメッセージを表示',
|
||||
loadingResponse: 'Hermes が応答を読み込み中',
|
||||
thinking: '考え中',
|
||||
today: time => `今日 ${time}`,
|
||||
@@ -1888,11 +1825,9 @@ export const ja = defineLocale({
|
||||
gatewayDisconnected: 'Hermes ゲートウェイが接続されていません',
|
||||
sendFailed: '承認応答を送信できませんでした',
|
||||
run: '実行',
|
||||
command: 'コマンド',
|
||||
moreOptions: 'その他の承認オプション',
|
||||
allowSession: 'このセッションで許可',
|
||||
alwaysAllowMenu: '常に許可…',
|
||||
jumpToApproval: '承認が必要',
|
||||
reject: '拒否',
|
||||
alwaysTitle: 'このコマンドを常に許可しますか?',
|
||||
alwaysDescription: pattern =>
|
||||
|
||||
@@ -143,20 +143,6 @@ export interface Translations {
|
||||
tryRecordingAgain: string
|
||||
unavailable: string
|
||||
}
|
||||
// Native OS notification copy (titles + generic fallback bodies). Dynamic
|
||||
// bodies (the agent's reply, a command, an error) are passed through raw.
|
||||
native: {
|
||||
approvalTitle: string
|
||||
approveAction: string
|
||||
rejectAction: string
|
||||
inputTitle: string
|
||||
inputBody: string
|
||||
turnDoneTitle: string
|
||||
turnDoneBody: string
|
||||
turnErrorTitle: string
|
||||
backgroundDoneTitle: string
|
||||
backgroundFailedTitle: string
|
||||
}
|
||||
}
|
||||
|
||||
titlebar: {
|
||||
@@ -216,26 +202,6 @@ export interface Translations {
|
||||
mcp: string
|
||||
archivedChats: string
|
||||
about: string
|
||||
notifications: string
|
||||
}
|
||||
notifications: {
|
||||
title: string
|
||||
intro: string
|
||||
enableAll: string
|
||||
enableAllDesc: string
|
||||
focusedHint: string
|
||||
kinds: Record<
|
||||
'approval' | 'backgroundDone' | 'input' | 'turnDone' | 'turnError',
|
||||
{ label: string; description: string }
|
||||
>
|
||||
test: string
|
||||
testTitle: string
|
||||
testBody: string
|
||||
testSent: string
|
||||
testUnsupported: string
|
||||
completionSoundTitle: string
|
||||
completionSoundDesc: string
|
||||
completionSoundPreview: string
|
||||
}
|
||||
sections: Record<string, string>
|
||||
searchPlaceholder: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string>
|
||||
@@ -447,12 +413,6 @@ export interface Translations {
|
||||
collapse: string
|
||||
connectAnother: string
|
||||
otherProviders: string
|
||||
removeConfirm: (provider: string) => string
|
||||
removeExternal: (provider: string, command: string) => string
|
||||
removeKeyManaged: (provider: string) => string
|
||||
removedTitle: string
|
||||
removedMessage: (provider: string) => string
|
||||
failedRemove: (provider: string) => string
|
||||
noProviderKeys: string
|
||||
loading: string
|
||||
}
|
||||
@@ -580,7 +540,6 @@ export interface Translations {
|
||||
back: string
|
||||
searchPlaceholder: string
|
||||
goTo: string
|
||||
goToSession: string
|
||||
commandCenter: string
|
||||
appearance: string
|
||||
settings: string
|
||||
@@ -735,9 +694,6 @@ export interface Translations {
|
||||
deleting: string
|
||||
createDesc: string
|
||||
nameLabel: string
|
||||
cloneFrom: string
|
||||
cloneFromNone: string
|
||||
cloneFromDesc: string
|
||||
cloneFromDefault: string
|
||||
cloneFromDefaultDesc: string
|
||||
invalidName: (hint: string) => string
|
||||
@@ -1358,7 +1314,6 @@ export interface Translations {
|
||||
assistant: {
|
||||
thread: {
|
||||
loadingSession: string
|
||||
showEarlier: string
|
||||
loadingResponse: string
|
||||
thinking: string
|
||||
today: (time: string) => string
|
||||
@@ -1389,11 +1344,9 @@ export interface Translations {
|
||||
gatewayDisconnected: string
|
||||
sendFailed: string
|
||||
run: string
|
||||
command: string
|
||||
moreOptions: string
|
||||
allowSession: string
|
||||
alwaysAllowMenu: string
|
||||
jumpToApproval: string
|
||||
reject: string
|
||||
alwaysTitle: string
|
||||
alwaysDescription: (pattern: string) => string
|
||||
|
||||
@@ -127,18 +127,6 @@ export const zhHant = defineLocale({
|
||||
transcriptionUnavailable: '語音轉寫暫不可用。',
|
||||
tryRecordingAgain: '請再錄製一次。',
|
||||
unavailable: '語音不可用'
|
||||
},
|
||||
native: {
|
||||
approvalTitle: '需要核准',
|
||||
approveAction: '核准',
|
||||
rejectAction: '拒絕',
|
||||
inputTitle: '需要輸入',
|
||||
inputBody: 'Hermes 正在等待你的回應。',
|
||||
turnDoneTitle: 'Hermes 已完成',
|
||||
turnDoneBody: '回覆已就緒。',
|
||||
turnErrorTitle: '本輪失敗',
|
||||
backgroundDoneTitle: '背景工作已完成',
|
||||
backgroundFailedTitle: '背景工作失敗'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -184,45 +172,7 @@ export const zhHant = defineLocale({
|
||||
keysSettings: '設定',
|
||||
mcp: 'MCP',
|
||||
archivedChats: '已封存聊天',
|
||||
about: '關於',
|
||||
notifications: '通知'
|
||||
},
|
||||
notifications: {
|
||||
title: '通知',
|
||||
intro: '原生桌面通知,與應用程式內提示不同。設定會依裝置保存,每台電腦各自獨立。',
|
||||
enableAll: '啟用通知',
|
||||
enableAllDesc: '總開關。關閉後會靜音下方所有通知。',
|
||||
focusedHint: '完成提醒僅在 Hermes 位於背景時觸發。',
|
||||
kinds: {
|
||||
approval: {
|
||||
label: '需要核准',
|
||||
description: '有指令正在等待你核准或拒絕。'
|
||||
},
|
||||
input: {
|
||||
label: '需要輸入',
|
||||
description: 'Hermes 提出了問題,或需要密碼或密鑰。'
|
||||
},
|
||||
turnDone: {
|
||||
label: '回覆就緒',
|
||||
description: 'Hermes 在背景時完成了一輪對話。'
|
||||
},
|
||||
turnError: {
|
||||
label: '本輪失敗',
|
||||
description: '本輪以錯誤結束。'
|
||||
},
|
||||
backgroundDone: {
|
||||
label: '背景工作完成',
|
||||
description: '背景終端機指令已完成。'
|
||||
}
|
||||
},
|
||||
test: '傳送測試通知',
|
||||
testTitle: 'Hermes',
|
||||
testBody: '通知運作正常。',
|
||||
testSent: '測試已傳送。若沒有出現,請檢查系統通知權限與專注模式/勿擾模式。',
|
||||
testUnsupported: '此系統不支援原生通知。',
|
||||
completionSoundTitle: '完成提示音',
|
||||
completionSoundDesc: '代理回合結束時播放。可在此選擇預設並預覽。',
|
||||
completionSoundPreview: '預覽'
|
||||
about: '關於'
|
||||
},
|
||||
sections: {
|
||||
model: '模型',
|
||||
@@ -671,12 +621,6 @@ export const zhHant = defineLocale({
|
||||
collapse: '收合',
|
||||
connectAnother: '連結其他提供方',
|
||||
otherProviders: '其他提供方',
|
||||
removeConfirm: provider => `移除 ${provider}?`,
|
||||
removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。請使用 ${command} 移除。`,
|
||||
removeKeyManaged: provider => `${provider} 由 API 金鑰設定。請從 API Keys 中移除。`,
|
||||
removedTitle: '帳號已移除',
|
||||
removedMessage: provider => `${provider} 已移除。`,
|
||||
failedRemove: provider => `無法移除 ${provider}`,
|
||||
noProviderKeys: '沒有可用的提供方 API 金鑰。',
|
||||
loading: '正在載入提供方...'
|
||||
},
|
||||
@@ -804,7 +748,6 @@ export const zhHant = defineLocale({
|
||||
back: '返回',
|
||||
searchPlaceholder: '搜尋工作階段、檢視和動作',
|
||||
goTo: '前往',
|
||||
goToSession: '前往工作階段',
|
||||
commandCenter: '命令中心',
|
||||
appearance: '外觀',
|
||||
settings: '設定',
|
||||
@@ -1055,9 +998,6 @@ export const zhHant = defineLocale({
|
||||
deleting: '刪除中…',
|
||||
createDesc: '設定檔是獨立的 Hermes 環境:各自擁有獨立的設定、技能和 SOUL.md。',
|
||||
nameLabel: '名稱',
|
||||
cloneFrom: '複製來源',
|
||||
cloneFromNone: '無(空白)',
|
||||
cloneFromDesc: '從選取的來源設定檔複製設定、技能和 SOUL.md。',
|
||||
cloneFromDefault: '從預設設定檔複製設定',
|
||||
cloneFromDefaultDesc: '從您的預設設定檔複製設定、技能和 SOUL.md。',
|
||||
invalidName: hint => `設定檔名稱無效。${hint}`,
|
||||
@@ -1800,7 +1740,6 @@ export const zhHant = defineLocale({
|
||||
assistant: {
|
||||
thread: {
|
||||
loadingSession: '正在載入工作階段',
|
||||
showEarlier: '顯示較早的訊息',
|
||||
loadingResponse: 'Hermes 正在載入回覆',
|
||||
thinking: '思考中',
|
||||
today: time => `今天,${time}`,
|
||||
@@ -1830,11 +1769,9 @@ export const zhHant = defineLocale({
|
||||
gatewayDisconnected: 'Hermes 閘道未連線',
|
||||
sendFailed: '無法傳送核准回應',
|
||||
run: '執行',
|
||||
command: '指令',
|
||||
moreOptions: '更多核准選項',
|
||||
allowSession: '允許本工作階段',
|
||||
alwaysAllowMenu: '一律允許…',
|
||||
jumpToApproval: '需要核准',
|
||||
reject: '拒絕',
|
||||
alwaysTitle: '一律允許此指令?',
|
||||
alwaysDescription: pattern =>
|
||||
|
||||
@@ -127,18 +127,6 @@ export const zh: Translations = {
|
||||
transcriptionUnavailable: '语音转写暂不可用。',
|
||||
tryRecordingAgain: '请再录一次。',
|
||||
unavailable: '语音不可用'
|
||||
},
|
||||
native: {
|
||||
approvalTitle: '需要批准',
|
||||
approveAction: '批准',
|
||||
rejectAction: '拒绝',
|
||||
inputTitle: '需要输入',
|
||||
inputBody: 'Hermes 正在等待你的回应。',
|
||||
turnDoneTitle: 'Hermes 已完成',
|
||||
turnDoneBody: '回复已就绪。',
|
||||
turnErrorTitle: '本轮失败',
|
||||
backgroundDoneTitle: '后台任务已完成',
|
||||
backgroundFailedTitle: '后台任务失败'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -271,45 +259,7 @@ export const zh: Translations = {
|
||||
keysSettings: '设置',
|
||||
mcp: 'MCP',
|
||||
archivedChats: '已归档对话',
|
||||
about: '关于',
|
||||
notifications: '通知'
|
||||
},
|
||||
notifications: {
|
||||
title: '通知',
|
||||
intro: '原生桌面通知,区别于应用内提示。设置按设备保存,每台电脑各自独立。',
|
||||
enableAll: '启用通知',
|
||||
enableAllDesc: '总开关。关闭后将静音下方所有通知。',
|
||||
focusedHint: '完成提醒仅在 Hermes 处于后台时触发。',
|
||||
kinds: {
|
||||
approval: {
|
||||
label: '需要批准',
|
||||
description: '有命令正在等待你批准或拒绝。'
|
||||
},
|
||||
input: {
|
||||
label: '需要输入',
|
||||
description: 'Hermes 提出了问题,或需要密码或密钥。'
|
||||
},
|
||||
turnDone: {
|
||||
label: '回复就绪',
|
||||
description: 'Hermes 在后台时完成了一轮对话。'
|
||||
},
|
||||
turnError: {
|
||||
label: '本轮失败',
|
||||
description: '本轮以错误结束。'
|
||||
},
|
||||
backgroundDone: {
|
||||
label: '后台任务完成',
|
||||
description: '后台终端命令已完成。'
|
||||
}
|
||||
},
|
||||
test: '发送测试通知',
|
||||
testTitle: 'Hermes',
|
||||
testBody: '通知工作正常。',
|
||||
testSent: '测试已发送。如果没有出现,请检查系统通知权限和专注模式/勿扰模式。',
|
||||
testUnsupported: '此系统不支持原生通知。',
|
||||
completionSoundTitle: '完成提示音',
|
||||
completionSoundDesc: '智能体回合结束时播放。可在此选择预设并预览。',
|
||||
completionSoundPreview: '预览'
|
||||
about: '关于'
|
||||
},
|
||||
sections: {
|
||||
model: '模型',
|
||||
@@ -758,12 +708,6 @@ export const zh: Translations = {
|
||||
collapse: '收起',
|
||||
connectAnother: '连接其他提供方',
|
||||
otherProviders: '其他提供方',
|
||||
removeConfirm: provider => `移除 ${provider}?`,
|
||||
removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。请使用 ${command} 移除。`,
|
||||
removeKeyManaged: provider => `${provider} 由 API 密钥配置。请从 API Keys 中移除。`,
|
||||
removedTitle: '账号已移除',
|
||||
removedMessage: provider => `${provider} 已移除。`,
|
||||
failedRemove: provider => `无法移除 ${provider}`,
|
||||
noProviderKeys: '没有可用的提供方 API 密钥。',
|
||||
loading: '正在加载提供方...'
|
||||
},
|
||||
@@ -891,7 +835,6 @@ export const zh: Translations = {
|
||||
back: '返回',
|
||||
searchPlaceholder: '搜索会话、视图与操作',
|
||||
goTo: '前往',
|
||||
goToSession: '前往会话',
|
||||
commandCenter: '命令中心',
|
||||
appearance: '外观',
|
||||
settings: '设置',
|
||||
@@ -1148,9 +1091,6 @@ export const zh: Translations = {
|
||||
deleting: '删除中…',
|
||||
createDesc: '配置档案是相互独立的 Hermes 环境:各自拥有独立的配置、技能和 SOUL.md。',
|
||||
nameLabel: '名称',
|
||||
cloneFrom: '克隆来源',
|
||||
cloneFromNone: '无(空白)',
|
||||
cloneFromDesc: '从选中的来源配置档案复制配置、技能和 SOUL.md。',
|
||||
cloneFromDefault: '从默认档案克隆',
|
||||
cloneFromDefaultDesc: '从你的默认配置档案复制配置、技能和 SOUL.md。',
|
||||
invalidName: hint => `名称无效。${hint}`,
|
||||
@@ -1895,7 +1835,6 @@ export const zh: Translations = {
|
||||
assistant: {
|
||||
thread: {
|
||||
loadingSession: '正在加载会话',
|
||||
showEarlier: '显示更早的消息',
|
||||
loadingResponse: 'Hermes 正在加载回复',
|
||||
thinking: '思考中',
|
||||
today: time => `今天,${time}`,
|
||||
@@ -1926,11 +1865,9 @@ export const zh: Translations = {
|
||||
gatewayDisconnected: 'Hermes 网关未连接',
|
||||
sendFailed: '无法发送审批响应',
|
||||
run: '运行',
|
||||
command: '命令',
|
||||
moreOptions: '更多审批选项',
|
||||
allowSession: '允许本会话',
|
||||
alwaysAllowMenu: '始终允许…',
|
||||
jumpToApproval: '需要审批',
|
||||
reject: '拒绝',
|
||||
alwaysTitle: '始终允许此命令?',
|
||||
alwaysDescription: pattern =>
|
||||
|
||||
@@ -95,38 +95,6 @@ describe('toChatMessages', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the generated image on the tool row while preserving agent prose', () => {
|
||||
const [message] = toChatMessages([
|
||||
{
|
||||
content: '',
|
||||
role: 'assistant',
|
||||
timestamp: 1,
|
||||
tool_calls: [{ id: 'img-1', function: { name: 'image_generate', arguments: '{"prompt":"draw a cat"}' } }]
|
||||
},
|
||||
{
|
||||
content: '{"success":true,"image":"https://cdn.example/cat.png"}',
|
||||
role: 'tool',
|
||||
timestamp: 2,
|
||||
tool_call_id: 'img-1',
|
||||
tool_name: 'image_generate'
|
||||
},
|
||||
{
|
||||
content: 'Here you go.\n\n',
|
||||
role: 'assistant',
|
||||
timestamp: 3
|
||||
}
|
||||
])
|
||||
|
||||
const toolPart = message.parts.find(
|
||||
(part): part is Extract<ChatMessagePart, { type: 'tool-call' }> =>
|
||||
part.type === 'tool-call' && part.toolName === 'image_generate'
|
||||
)
|
||||
|
||||
expect(toolPart?.result).toMatchObject({ image: 'https://cdn.example/cat.png', success: true })
|
||||
// The duplicated image is stripped, but the agent's words survive.
|
||||
expect(chatMessageText(message)).toBe('Here you go.')
|
||||
})
|
||||
|
||||
it('coerces non-string message content without throwing', () => {
|
||||
const [message] = toChatMessages([
|
||||
{
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ThreadMessageLike } from '@assistant-ui/react'
|
||||
|
||||
import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images'
|
||||
import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media'
|
||||
import { parseTodos } from '@/lib/todos'
|
||||
import type { SessionMessage, UsageStats } from '@/types/hermes'
|
||||
@@ -812,12 +811,8 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
||||
})
|
||||
flushPendingTools(messages.length)
|
||||
|
||||
const withoutGeneratedImageEchoes = result.map(message =>
|
||||
message.role === 'assistant' ? { ...message, parts: dedupeGeneratedImageEchoesInParts(message.parts) } : message
|
||||
)
|
||||
|
||||
return withUniqueToolCallIds(
|
||||
withoutGeneratedImageEchoes.filter(m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text'))
|
||||
result.filter(m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text'))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,519 +0,0 @@
|
||||
// Completion sound bank for agent turn-end cues.
|
||||
// Fourteen curated presets for A/B in Settings → Appearance. Default is variant 1.
|
||||
|
||||
import { $completionSoundVariantId, resolveCompletionSoundVariantId } from '@/store/completion-sound'
|
||||
import { $hapticsMuted } from '@/store/haptics'
|
||||
|
||||
type OscType = OscillatorType
|
||||
|
||||
let ctx: AudioContext | null = null
|
||||
|
||||
function getCtx(): AudioContext | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
if (!ctx) {
|
||||
const Ctor = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||
|
||||
if (!Ctor) {
|
||||
return null
|
||||
}
|
||||
|
||||
ctx = new Ctor()
|
||||
}
|
||||
|
||||
// Autoplay policies can leave the context suspended until a gesture; a
|
||||
// resume() here recovers it once the user has interacted with the window.
|
||||
if (ctx.state === 'suspended') {
|
||||
void ctx.resume().catch(() => undefined)
|
||||
}
|
||||
|
||||
return ctx
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// One enveloped oscillator voice → master. Linear attack into an exponential
|
||||
// decay keeps the tail smooth and avoids the click you get ramping to zero.
|
||||
function voice(ac: AudioContext, master: GainNode, t0: number, spec: ToneSpec) {
|
||||
const osc = ac.createOscillator()
|
||||
const env = ac.createGain()
|
||||
const start = t0 + (spec.start ?? 0)
|
||||
const peak = spec.gain ?? 0.5
|
||||
const attack = spec.attack ?? 0.006
|
||||
const end = start + spec.dur
|
||||
|
||||
osc.type = spec.type ?? 'sine'
|
||||
osc.frequency.setValueAtTime(spec.freq, start)
|
||||
|
||||
env.gain.setValueAtTime(0.0001, start)
|
||||
env.gain.exponentialRampToValueAtTime(Math.max(peak, 0.0002), start + attack)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, end)
|
||||
|
||||
osc.connect(env)
|
||||
env.connect(master)
|
||||
osc.start(start)
|
||||
osc.stop(end + 0.02)
|
||||
}
|
||||
|
||||
// Soft pluck: brief triangle strike with an upward glide into the bloom.
|
||||
function pluckVoice(ac: AudioContext, master: GainNode, t0: number, spec: PluckSpec) {
|
||||
const osc = ac.createOscillator()
|
||||
const env = ac.createGain()
|
||||
const start = t0 + (spec.start ?? 0)
|
||||
const attack = spec.attack ?? 0.004
|
||||
const glide = spec.glide ?? 0.16
|
||||
const end = start + spec.decay
|
||||
|
||||
osc.type = 'triangle'
|
||||
osc.frequency.setValueAtTime(spec.freqFrom, start)
|
||||
osc.frequency.exponentialRampToValueAtTime(spec.freqTo, start + glide)
|
||||
|
||||
env.gain.setValueAtTime(0.0001, start)
|
||||
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + attack)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, end)
|
||||
|
||||
osc.connect(env)
|
||||
env.connect(master)
|
||||
osc.start(start)
|
||||
osc.stop(end + 0.02)
|
||||
}
|
||||
|
||||
// Slow-swell harmonic bloom — the dreamy tail after the pluck.
|
||||
function bloomVoice(ac: AudioContext, master: GainNode, t0: number, spec: BloomSpec) {
|
||||
const osc = ac.createOscillator()
|
||||
const env = ac.createGain()
|
||||
const start = t0 + (spec.start ?? 0)
|
||||
const hold = spec.hold ?? 0.08
|
||||
const end = start + spec.attack + hold + spec.decay
|
||||
|
||||
osc.type = spec.type ?? 'sine'
|
||||
osc.frequency.setValueAtTime(spec.freq, start)
|
||||
|
||||
if (spec.freqTo) {
|
||||
osc.frequency.exponentialRampToValueAtTime(spec.freqTo, start + spec.attack + hold * 0.6)
|
||||
}
|
||||
|
||||
osc.detune.setValueAtTime(spec.detune ?? 0, start)
|
||||
|
||||
env.gain.setValueAtTime(0.0001, start)
|
||||
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + spec.attack)
|
||||
env.gain.setValueAtTime(Math.max(spec.gain, 0.0002), start + spec.attack + hold)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, end)
|
||||
|
||||
osc.connect(env)
|
||||
env.connect(master)
|
||||
osc.start(start)
|
||||
osc.stop(end + 0.02)
|
||||
}
|
||||
|
||||
// One-shot white-noise source of a given length, the raw material for the
|
||||
// bandpassed air/whoosh gestures below.
|
||||
function noiseSource(ac: AudioContext, seconds: number): AudioBufferSourceNode {
|
||||
const length = Math.floor(ac.sampleRate * seconds)
|
||||
const buffer = ac.createBuffer(1, length, ac.sampleRate)
|
||||
const data = buffer.getChannelData(0)
|
||||
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
data[i] = Math.random() * 2 - 1
|
||||
}
|
||||
|
||||
const source = ac.createBufferSource()
|
||||
source.buffer = buffer
|
||||
|
||||
return source
|
||||
}
|
||||
|
||||
// A whisper of bandpassed noise for PS5-menu airiness.
|
||||
function airPuff(ac: AudioContext, master: GainNode, t0: number, spec: AirPuffSpec) {
|
||||
const source = noiseSource(ac, 0.12)
|
||||
const filter = ac.createBiquadFilter()
|
||||
const env = ac.createGain()
|
||||
const start = t0 + (spec.start ?? 0)
|
||||
const end = start + spec.decay
|
||||
|
||||
filter.type = 'bandpass'
|
||||
filter.frequency.setValueAtTime(spec.freq, start)
|
||||
filter.Q.setValueAtTime(spec.q ?? 1.2, start)
|
||||
|
||||
env.gain.setValueAtTime(0.0001, start)
|
||||
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + 0.018)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, end)
|
||||
|
||||
source.connect(filter)
|
||||
filter.connect(env)
|
||||
env.connect(master)
|
||||
source.start(start)
|
||||
source.stop(end + 0.02)
|
||||
}
|
||||
|
||||
// Filtered noise sweep — soft send / whoosh gestures.
|
||||
function whooshVoice(ac: AudioContext, master: GainNode, t0: number, spec: WhooshSpec) {
|
||||
const source = noiseSource(ac, 0.4)
|
||||
const filter = ac.createBiquadFilter()
|
||||
const env = ac.createGain()
|
||||
const start = t0 + (spec.start ?? 0)
|
||||
const end = start + spec.decay
|
||||
|
||||
filter.type = 'bandpass'
|
||||
filter.frequency.setValueAtTime(spec.freqFrom, start)
|
||||
filter.frequency.exponentialRampToValueAtTime(spec.freqTo, end)
|
||||
filter.Q.setValueAtTime(spec.q ?? 0.8, start)
|
||||
|
||||
env.gain.setValueAtTime(0.0001, start)
|
||||
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + 0.03)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, end)
|
||||
|
||||
source.connect(filter)
|
||||
filter.connect(env)
|
||||
env.connect(master)
|
||||
source.start(start)
|
||||
source.stop(end + 0.02)
|
||||
}
|
||||
|
||||
// Pitch-sweep chirp — modem / sci-fi gestures.
|
||||
function sweepVoice(ac: AudioContext, master: GainNode, t0: number, spec: SweepSpec) {
|
||||
const osc = ac.createOscillator()
|
||||
const env = ac.createGain()
|
||||
const start = t0 + (spec.start ?? 0)
|
||||
const attack = spec.attack ?? 0.003
|
||||
const end = start + spec.decay
|
||||
|
||||
osc.type = spec.type ?? 'triangle'
|
||||
osc.frequency.setValueAtTime(spec.freqFrom, start)
|
||||
osc.frequency.exponentialRampToValueAtTime(spec.freqTo, end - 0.02)
|
||||
|
||||
env.gain.setValueAtTime(0.0001, start)
|
||||
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + attack)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, end)
|
||||
|
||||
osc.connect(env)
|
||||
env.connect(master)
|
||||
osc.start(start)
|
||||
osc.stop(end + 0.02)
|
||||
}
|
||||
|
||||
let reverbImpulse: AudioBuffer | null = null
|
||||
|
||||
// Subtle wet send so the chimes sit in a room rather than a tin can. The impulse
|
||||
// is generated once and cached; each play gets a fresh, disposable convolver.
|
||||
function makeReverb(ac: AudioContext): ConvolverNode {
|
||||
if (!reverbImpulse) {
|
||||
const seconds = 1.6
|
||||
const length = Math.floor(ac.sampleRate * seconds)
|
||||
reverbImpulse = ac.createBuffer(2, length, ac.sampleRate)
|
||||
|
||||
for (let channel = 0; channel < 2; channel += 1) {
|
||||
const data = reverbImpulse.getChannelData(channel)
|
||||
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
// White noise with a steep exponential decay → smooth, short tail.
|
||||
data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 2.6
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const convolver = ac.createConvolver()
|
||||
convolver.buffer = reverbImpulse
|
||||
|
||||
return convolver
|
||||
}
|
||||
|
||||
export interface CompletionSoundVariant {
|
||||
id: number
|
||||
name: string
|
||||
// `master` is warm (runs through low-pass + room tail).
|
||||
play: (ac: AudioContext, master: GainNode, t0: number) => void
|
||||
}
|
||||
|
||||
// Note frequencies (equal temperament). Everything lives in a low-mid register
|
||||
// (C3–C5) so the chimes feel warm and "appy" rather than bright and arcade-y.
|
||||
const A2 = 110
|
||||
const A3 = 220
|
||||
const A4 = 440
|
||||
const A5 = 880
|
||||
const B5 = 987.77
|
||||
const C3 = 130.81
|
||||
const C4 = 261.63
|
||||
const E4 = 329.63
|
||||
const E5 = 659.25
|
||||
const E6 = 1318.51
|
||||
const G4 = 392
|
||||
const G5 = 783.99
|
||||
const C5 = 523.25
|
||||
const C6 = 1046.5
|
||||
|
||||
export const COMPLETION_SOUND_VARIANTS: readonly CompletionSoundVariant[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Two-note comfort',
|
||||
play: (ac, master, t0) => {
|
||||
voice(ac, master, t0, { freq: E4, dur: 0.22, gain: 0.05, attack: 0.03, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.08, { freq: C4, dur: 0.52, gain: 0.07, attack: 0.08, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.08, { freq: C3, dur: 0.46, gain: 0.02, attack: 0.1, type: 'sine' })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Glass ping',
|
||||
play: (ac, master, t0) => {
|
||||
voice(ac, master, t0, { freq: C6, dur: 0.55, gain: 0.032, attack: 0.002, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.01, { freq: E5, dur: 0.42, gain: 0.018, attack: 0.004, type: 'sine' })
|
||||
airPuff(ac, master, t0, { freq: 3200, gain: 0.004, decay: 0.1, q: 1.4 })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Soft marimba',
|
||||
play: (ac, master, t0) => {
|
||||
pluckVoice(ac, master, t0, { freqFrom: E5, freqTo: G5, gain: 0.03, decay: 0.14, glide: 0.08 })
|
||||
bloomVoice(ac, master, t0 + 0.04, { freq: C5, gain: 0.028, attack: 0.08, hold: 0.04, decay: 0.62 })
|
||||
bloomVoice(ac, master, t0 + 0.06, { freq: G4, gain: 0.014, attack: 0.12, hold: 0.06, decay: 0.55 })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Tri-tone message',
|
||||
play: (ac, master, t0) => {
|
||||
voice(ac, master, t0, { freq: C6, dur: 0.14, gain: 0.045, attack: 0.004, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.1, { freq: A5, dur: 0.16, gain: 0.04, attack: 0.004, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.2, { freq: G5, dur: 0.22, gain: 0.035, attack: 0.006, type: 'sine' })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Airy whoosh',
|
||||
play: (ac, master, t0) => {
|
||||
whooshVoice(ac, master, t0, { freqFrom: 4200, freqTo: 900, gain: 0.022, decay: 0.28, q: 0.7 })
|
||||
voice(ac, master, t0 + 0.12, { freq: A5, dur: 0.35, gain: 0.02, attack: 0.02, type: 'sine' })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Discovery cluster',
|
||||
play: (ac, master, t0) => {
|
||||
const clusterDetunes = [-14, -5, 0, 7, 12]
|
||||
|
||||
clusterDetunes.forEach((detune, i) => {
|
||||
bloomVoice(ac, master, t0 + i * 0.03, {
|
||||
freq: A3,
|
||||
gain: 0.012,
|
||||
attack: 0.38,
|
||||
hold: 0.12,
|
||||
decay: 1.05,
|
||||
detune
|
||||
})
|
||||
})
|
||||
bloomVoice(ac, master, t0 + 0.1, { freq: E4, gain: 0.008, attack: 0.45, hold: 0.08, decay: 0.9, detune: 3 })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Systems online',
|
||||
play: (ac, master, t0) => {
|
||||
voice(ac, master, t0, { freq: C5, dur: 0.16, gain: 0.04, attack: 0.006, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.09, { freq: G5, dur: 0.28, gain: 0.042, attack: 0.008, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.09, { freq: C4, dur: 0.24, gain: 0.012, attack: 0.01, type: 'sine' })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'IBM terminal',
|
||||
play: (ac, master, t0) => {
|
||||
voice(ac, master, t0, { freq: B5, dur: 0.12, gain: 0.038, attack: 0.002, type: 'square' })
|
||||
voice(ac, master, t0 + 0.14, { freq: E5, dur: 0.1, gain: 0.028, attack: 0.002, type: 'square' })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: 'Modem chirp',
|
||||
play: (ac, master, t0) => {
|
||||
sweepVoice(ac, master, t0, { freqFrom: 320, freqTo: 2200, gain: 0.024, decay: 0.16, type: 'triangle' })
|
||||
sweepVoice(ac, master, t0 + 0.1, { freqFrom: 480, freqTo: 1400, gain: 0.014, decay: 0.12, type: 'sine' })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: 'Wind chimes',
|
||||
play: (ac, master, t0) => {
|
||||
const chimes = [G5, C6, E5, A5]
|
||||
|
||||
chimes.forEach((frequency, i) => {
|
||||
voice(ac, master, t0 + i * 0.13, {
|
||||
freq: frequency,
|
||||
dur: 0.72,
|
||||
gain: 0.028 - i * 0.003,
|
||||
attack: 0.003,
|
||||
type: 'sine'
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: 'Singing bowl',
|
||||
play: (ac, master, t0) => {
|
||||
bloomVoice(ac, master, t0, { freq: A3, gain: 0.022, attack: 0.58, hold: 0.16, decay: 1.35 })
|
||||
bloomVoice(ac, master, t0 + 0.08, { freq: E4, gain: 0.01, attack: 0.62, hold: 0.12, decay: 1.2, detune: 4 })
|
||||
bloomVoice(ac, master, t0 + 0.14, { freq: A4, gain: 0.006, attack: 0.68, hold: 0.08, decay: 1.05, detune: -3 })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: 'Harp lift',
|
||||
play: (ac, master, t0) => {
|
||||
const notes = [C5, E5, G5, C6]
|
||||
|
||||
notes.forEach((frequency, i) => {
|
||||
voice(ac, master, t0 + i * 0.075, {
|
||||
freq: frequency,
|
||||
dur: 0.38,
|
||||
gain: 0.034 - i * 0.004,
|
||||
attack: 0.012,
|
||||
type: 'sine'
|
||||
})
|
||||
})
|
||||
|
||||
bloomVoice(ac, master, t0 + 0.2, { freq: C4, gain: 0.01, attack: 0.18, hold: 0.06, decay: 0.7 })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
name: 'Sonar ping',
|
||||
play: (ac, master, t0) => {
|
||||
voice(ac, master, t0, { freq: A2, dur: 0.95, gain: 0.036, attack: 0.008, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.42, { freq: A3, dur: 0.55, gain: 0.014, attack: 0.01, type: 'sine' })
|
||||
airPuff(ac, master, t0, { freq: 600, gain: 0.005, decay: 0.2, q: 0.5 })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
name: 'Music box',
|
||||
play: (ac, master, t0) => {
|
||||
const notes = [E6, C6, G5, E5]
|
||||
|
||||
notes.forEach((frequency, i) => {
|
||||
pluckVoice(ac, master, t0 + i * 0.09, {
|
||||
freqFrom: frequency,
|
||||
freqTo: frequency * 0.998,
|
||||
gain: 0.02 - i * 0.002,
|
||||
decay: 0.2,
|
||||
glide: 0.06
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
] as const
|
||||
|
||||
function playVariant(variantId: number) {
|
||||
const variant = COMPLETION_SOUND_VARIANTS.find(v => v.id === variantId)
|
||||
|
||||
if (!variant) {
|
||||
return
|
||||
}
|
||||
|
||||
const ac = getCtx()
|
||||
|
||||
if (!ac) {
|
||||
return
|
||||
}
|
||||
|
||||
// Signal path: voices → master → low-pass → (dry + reverb send) → out.
|
||||
const master = ac.createGain()
|
||||
const tone = ac.createBiquadFilter()
|
||||
tone.type = 'lowpass'
|
||||
tone.frequency.setValueAtTime(3800, ac.currentTime)
|
||||
tone.Q.setValueAtTime(0.32, ac.currentTime)
|
||||
master.gain.setValueAtTime(0.48, ac.currentTime)
|
||||
master.connect(tone)
|
||||
|
||||
const dry = ac.createGain()
|
||||
dry.gain.setValueAtTime(0.88, ac.currentTime)
|
||||
tone.connect(dry)
|
||||
dry.connect(ac.destination)
|
||||
|
||||
const reverb = makeReverb(ac)
|
||||
const wet = ac.createGain()
|
||||
wet.gain.setValueAtTime(0.34, ac.currentTime)
|
||||
tone.connect(reverb)
|
||||
reverb.connect(wet)
|
||||
wet.connect(ac.destination)
|
||||
|
||||
variant.play(ac, master, ac.currentTime + 0.01)
|
||||
}
|
||||
|
||||
// Audition the selected variant from settings. Bypasses the haptics mute toggle so
|
||||
// sound design can be compared even when turn-end cues are silenced.
|
||||
export function previewCompletionSound(variantId?: number) {
|
||||
playVariant(resolveCompletionSoundVariantId(variantId ?? $completionSoundVariantId.get()))
|
||||
}
|
||||
|
||||
// Plays the selected completion cue on any `message.complete`.
|
||||
export function playCompletionSound() {
|
||||
if ($hapticsMuted.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
playVariant($completionSoundVariantId.get())
|
||||
}
|
||||
|
||||
interface AirPuffSpec {
|
||||
decay: number
|
||||
freq: number
|
||||
gain: number
|
||||
q?: number
|
||||
start?: number
|
||||
}
|
||||
|
||||
interface BloomSpec {
|
||||
attack: number
|
||||
decay: number
|
||||
detune?: number
|
||||
freq: number
|
||||
freqTo?: number
|
||||
gain: number
|
||||
hold?: number
|
||||
start?: number
|
||||
type?: OscType
|
||||
}
|
||||
|
||||
interface PluckSpec {
|
||||
attack?: number
|
||||
decay: number
|
||||
freqFrom: number
|
||||
freqTo: number
|
||||
gain: number
|
||||
glide?: number
|
||||
start?: number
|
||||
}
|
||||
|
||||
interface SweepSpec {
|
||||
attack?: number
|
||||
decay: number
|
||||
freqFrom: number
|
||||
freqTo: number
|
||||
gain: number
|
||||
start?: number
|
||||
type?: OscType
|
||||
}
|
||||
|
||||
interface ToneSpec {
|
||||
attack?: number
|
||||
dur: number
|
||||
freq: number
|
||||
gain?: number
|
||||
start?: number
|
||||
type?: OscType
|
||||
}
|
||||
|
||||
interface WhooshSpec {
|
||||
decay: number
|
||||
freqFrom: number
|
||||
freqTo: number
|
||||
gain: number
|
||||
q?: number
|
||||
start?: number
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
dedupeGeneratedImageEchoesInParts,
|
||||
generatedImageEchoSources,
|
||||
generatedImageFromResult,
|
||||
stripGeneratedImageEchoes
|
||||
} from './generated-images'
|
||||
|
||||
describe('generatedImageFromResult', () => {
|
||||
it('prefers the host-visible image path', () => {
|
||||
expect(
|
||||
generatedImageFromResult({
|
||||
agent_visible_image: '/container/cache/cat.png',
|
||||
host_image: '/Users/me/.hermes/cache/images/cat.png',
|
||||
image: '/Users/me/.hermes/cache/images/cat.png',
|
||||
success: true
|
||||
})
|
||||
).toBe('/Users/me/.hermes/cache/images/cat.png')
|
||||
})
|
||||
|
||||
it('ignores failed image generation results', () => {
|
||||
expect(generatedImageFromResult({ image: 'https://cdn.example/cat.png', success: false })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripGeneratedImageEchoes', () => {
|
||||
it('removes repeated generated image markdown without removing prose', () => {
|
||||
expect(
|
||||
stripGeneratedImageEchoes('Here you go.\n\n', [
|
||||
'https://cdn.example/cat.png'
|
||||
])
|
||||
).toBe('Here you go.')
|
||||
})
|
||||
|
||||
it('removes media links for generated local image paths', () => {
|
||||
expect(
|
||||
stripGeneratedImageEchoes('Saved image: [Image: cat.png](#media:%2Ftmp%2Fcat.png)', ['/tmp/cat.png'])
|
||||
).toBe('Saved image:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generatedImageEchoSources', () => {
|
||||
it('collects every path variant the model might restate', () => {
|
||||
expect(
|
||||
generatedImageEchoSources([
|
||||
{
|
||||
result: { agent_visible_image: '/sandbox/cat.png', host_image: '/host/cat.png', image: '/host/cat.png', success: true },
|
||||
toolName: 'image_generate',
|
||||
type: 'tool-call'
|
||||
}
|
||||
])
|
||||
).toEqual(['/host/cat.png', '/sandbox/cat.png'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('dedupeGeneratedImageEchoesInParts', () => {
|
||||
it('keeps the agent prose while removing the duplicated image', () => {
|
||||
expect(
|
||||
dedupeGeneratedImageEchoesInParts([
|
||||
{ text: 'Here is your peacock!  Enjoy.', type: 'text' },
|
||||
{ result: { host_image: '/host/p.png', image: '/host/p.png', success: true }, toolName: 'image_generate', type: 'tool-call' }
|
||||
])
|
||||
).toEqual([
|
||||
{ text: 'Here is your peacock! Enjoy.', type: 'text' },
|
||||
{ result: { host_image: '/host/p.png', image: '/host/p.png', success: true }, toolName: 'image_generate', type: 'tool-call' }
|
||||
])
|
||||
})
|
||||
|
||||
it('strips a sandbox path the model restated instead of the host path', () => {
|
||||
expect(
|
||||
dedupeGeneratedImageEchoesInParts([
|
||||
{ text: '', type: 'text' },
|
||||
{
|
||||
result: { agent_visible_image: '/sandbox/cat.png', host_image: '/host/cat.png', image: '/host/cat.png', success: true },
|
||||
toolName: 'image_generate',
|
||||
type: 'tool-call'
|
||||
}
|
||||
])
|
||||
).toEqual([
|
||||
{
|
||||
result: { agent_visible_image: '/sandbox/cat.png', host_image: '/host/cat.png', image: '/host/cat.png', success: true },
|
||||
toolName: 'image_generate',
|
||||
type: 'tool-call'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves pending generations untouched so the agent prose survives', () => {
|
||||
const parts = [
|
||||
{ text: 'Another peacock, coming up!', type: 'text' },
|
||||
{ result: undefined, toolName: 'image_generate', type: 'tool-call' }
|
||||
]
|
||||
|
||||
expect(dedupeGeneratedImageEchoesInParts(parts)).toEqual(parts)
|
||||
})
|
||||
})
|
||||
@@ -1,116 +0,0 @@
|
||||
type ToolLike = {
|
||||
result?: unknown
|
||||
toolName?: unknown
|
||||
type?: unknown
|
||||
}
|
||||
|
||||
type TextLike = {
|
||||
text?: unknown
|
||||
type?: unknown
|
||||
}
|
||||
|
||||
// Path-ish result fields the model may echo into its prose. Display prefers the
|
||||
// host path (gateway-deliverable); stripping must catch every variant so a
|
||||
// sandbox path the model restated doesn't slip through as a duplicate image.
|
||||
const DISPLAY_KEYS = ['host_image', 'image'] as const
|
||||
const ECHO_KEYS = ['host_image', 'image', 'agent_visible_image'] as const
|
||||
|
||||
function recordFromUnknown(value: unknown): Record<string, unknown> | null {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function stringFields(record: Record<string, unknown>, keys: readonly string[]): string[] {
|
||||
return keys.map(key => record[key]).filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
|
||||
}
|
||||
|
||||
function regexEscape(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return [...new Set(values.filter(Boolean))]
|
||||
}
|
||||
|
||||
function imageResult(part: ToolLike): Record<string, unknown> | null {
|
||||
if (part.type !== 'tool-call' || part.toolName !== 'image_generate') {
|
||||
return null
|
||||
}
|
||||
|
||||
const record = recordFromUnknown(part.result)
|
||||
|
||||
return record && record.success !== false ? record : null
|
||||
}
|
||||
|
||||
/** Display source for a completed `image_generate` result (host path wins). */
|
||||
export function generatedImageFromResult(result: unknown): string | null {
|
||||
const record = recordFromUnknown(result)
|
||||
|
||||
if (!record || record.success === false) {
|
||||
return null
|
||||
}
|
||||
|
||||
return stringFields(record, DISPLAY_KEYS)[0] ?? null
|
||||
}
|
||||
|
||||
/** Every path/URL a generated image might appear as in prose, for de-duping. */
|
||||
export function generatedImageEchoSources(parts: readonly ToolLike[]): string[] {
|
||||
return unique(parts.flatMap(part => stringFields(imageResult(part) ?? {}, ECHO_KEYS)))
|
||||
}
|
||||
|
||||
/** Strip a generated image out of prose so it only ever shows in the tool slot.
|
||||
* Once a generation succeeded (`sources` is non-empty) we drop every embedded
|
||||
* image and media link from that message — the model frequently restates the
|
||||
* remote URL while the result holds the local path, so matching the exact
|
||||
* source is not enough. Bare occurrences of the known paths/URLs are removed
|
||||
* too. Surrounding prose is preserved. */
|
||||
export function stripGeneratedImageEchoes(text: string, sources: readonly string[]): string {
|
||||
if (!text || sources.length === 0) {
|
||||
return text
|
||||
}
|
||||
|
||||
let next = text
|
||||
.replace(/!\[[^\]\n]*\]\([^)\n]*\)/g, '')
|
||||
.replace(/\[[^\]\n]*\]\(\s*#media:[^)\n]*\)/g, '')
|
||||
|
||||
for (const source of unique([...sources])) {
|
||||
next = next.replace(new RegExp(String.raw`(^|[\s([{])<?${regexEscape(source)}>?(?=$|[\s)\]},.!?])`, 'g'), '$1')
|
||||
}
|
||||
|
||||
return next
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.replace(/[ \t]{2,}/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** Strip generated-image echoes from text parts, dropping any part left empty.
|
||||
* The image lives in the tool slot; prose keeps the agent's actual words. */
|
||||
export function dedupeGeneratedImageEchoesInParts<T extends TextLike & ToolLike>(parts: readonly T[]): T[] {
|
||||
const sources = generatedImageEchoSources(parts)
|
||||
|
||||
if (!sources.length) {
|
||||
return [...parts]
|
||||
}
|
||||
|
||||
return parts
|
||||
.map(part =>
|
||||
part.type === 'text' && typeof part.text === 'string'
|
||||
? { ...part, text: stripGeneratedImageEchoes(part.text, sources) }
|
||||
: part
|
||||
)
|
||||
.filter(part => part.type !== 'text' || (typeof part.text === 'string' && part.text.trim().length > 0))
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
IconAt as AtSign,
|
||||
IconWaveSine as AudioLines,
|
||||
IconChartBar as BarChart3,
|
||||
IconBell as Bell,
|
||||
IconBrain as Brain,
|
||||
IconBug as Bug,
|
||||
IconCheck as Check,
|
||||
@@ -111,7 +110,6 @@ export {
|
||||
AtSign,
|
||||
AudioLines,
|
||||
BarChart3,
|
||||
Bell,
|
||||
Brain,
|
||||
Bug,
|
||||
Check,
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $compactingSessions, $compactionActive, setSessionCompacting } from './compaction'
|
||||
import { $activeSessionId } from './session'
|
||||
|
||||
describe('compaction store', () => {
|
||||
beforeEach(() => {
|
||||
$compactingSessions.set({})
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$compactingSessions.set({})
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
it('tracks compaction per session independently', () => {
|
||||
setSessionCompacting('session-a', true)
|
||||
setSessionCompacting('session-b', true)
|
||||
|
||||
expect($compactingSessions.get()).toEqual({ 'session-a': true, 'session-b': true })
|
||||
})
|
||||
|
||||
it('exposes only the active session via the focus-scoped view', () => {
|
||||
setSessionCompacting('session-a', true)
|
||||
|
||||
expect($compactionActive.get()).toBe(false)
|
||||
|
||||
$activeSessionId.set('session-a')
|
||||
expect($compactionActive.get()).toBe(true)
|
||||
|
||||
$activeSessionId.set('session-b')
|
||||
expect($compactionActive.get()).toBe(false)
|
||||
})
|
||||
|
||||
it('clears a session without disturbing the others', () => {
|
||||
setSessionCompacting('session-a', true)
|
||||
setSessionCompacting('session-b', true)
|
||||
|
||||
setSessionCompacting('session-a', false)
|
||||
|
||||
expect($compactingSessions.get()).toEqual({ 'session-b': true })
|
||||
})
|
||||
|
||||
it('is a no-op when clearing an unknown session', () => {
|
||||
setSessionCompacting('session-a', true)
|
||||
const before = $compactingSessions.get()
|
||||
|
||||
setSessionCompacting('session-missing', false)
|
||||
|
||||
expect($compactingSessions.get()).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { $activeSessionId } from './session'
|
||||
|
||||
// Per-session flag while auto-compaction runs mid-turn. Without it the
|
||||
// transcript looks like it reset; per-session so a background chat can't
|
||||
// clobber the foreground view.
|
||||
const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
|
||||
|
||||
export const $compactingSessions = atom<Record<string, true>>({})
|
||||
|
||||
export const $compactionActive = computed(
|
||||
[$compactingSessions, $activeSessionId],
|
||||
(sessions, activeId) => keyFor(activeId) in sessions
|
||||
)
|
||||
|
||||
export function setSessionCompacting(sessionId: string | null | undefined, active: boolean): void {
|
||||
const key = keyFor(sessionId)
|
||||
const sessions = $compactingSessions.get()
|
||||
|
||||
if (active) {
|
||||
if (key in sessions) {
|
||||
return
|
||||
}
|
||||
|
||||
$compactingSessions.set({ ...sessions, [key]: true })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!(key in sessions)) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = { ...sessions }
|
||||
delete next[key]
|
||||
$compactingSessions.set(next)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
|
||||
const STORAGE_KEY = 'hermes.desktop.completionSoundVariantId'
|
||||
|
||||
export const DEFAULT_COMPLETION_SOUND_VARIANT_ID = 1
|
||||
|
||||
// Range mirrors COMPLETION_SOUND_VARIANTS in lib/completion-sound.ts. Validating
|
||||
// by range (not membership) keeps this store free of a dependency on the lib,
|
||||
// which imports the atom back — a membership check would close that cycle.
|
||||
const VARIANT_COUNT = 14
|
||||
|
||||
export function resolveCompletionSoundVariantId(variantId: number): number {
|
||||
return Number.isInteger(variantId) && variantId >= 1 && variantId <= VARIANT_COUNT
|
||||
? variantId
|
||||
: DEFAULT_COMPLETION_SOUND_VARIANT_ID
|
||||
}
|
||||
|
||||
function load(): number {
|
||||
const stored = storedString(STORAGE_KEY)
|
||||
|
||||
return stored ? resolveCompletionSoundVariantId(Number.parseInt(stored, 10)) : DEFAULT_COMPLETION_SOUND_VARIANT_ID
|
||||
}
|
||||
|
||||
export const $completionSoundVariantId = atom(load())
|
||||
|
||||
$completionSoundVariantId.subscribe(id => persistString(STORAGE_KEY, String(id)))
|
||||
|
||||
export function setCompletionSoundVariantId(variantId: number) {
|
||||
$completionSoundVariantId.set(resolveCompletionSoundVariantId(variantId))
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { translateNow } from '@/i18n'
|
||||
import type { TodoItem, TodoStatus } from '@/lib/todos'
|
||||
|
||||
import { $gateway } from './gateway'
|
||||
import { dispatchNativeNotification } from './native-notifications'
|
||||
import { $subagentsBySession, type SubagentProgress } from './subagents'
|
||||
import { $todosBySession } from './todos'
|
||||
|
||||
@@ -163,24 +161,6 @@ export function reconcileBackgroundProcesses(sid: string, procs: GatewayProcessE
|
||||
|
||||
const prev = $backgroundStatusBySession.get()[sid] ?? []
|
||||
|
||||
// running → exited since the last snapshot = a background process just finished.
|
||||
const prevState = new Map(prev.map(item => [item.id, item.state]))
|
||||
|
||||
for (const [id, item] of fresh) {
|
||||
if (item.state !== 'running' && prevState.get(id) === 'running') {
|
||||
dispatchNativeNotification({
|
||||
body: item.title,
|
||||
kind: 'backgroundDone',
|
||||
sessionId: sid,
|
||||
title: translateNow(
|
||||
item.state === 'failed'
|
||||
? 'notifications.native.backgroundFailedTitle'
|
||||
: 'notifications.native.backgroundDoneTitle'
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const kept = prev.flatMap(old => {
|
||||
const next = fresh.get(old.id)
|
||||
fresh.delete(old.id)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user