mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-14 06:09:11 +08:00
Compare commits
88 Commits
hermes-e2e
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66c2d88732 | ||
|
|
4d68984ec7 | ||
|
|
6ff39c31ad | ||
|
|
c41a6534cf | ||
|
|
2f9d18711f | ||
|
|
46d758bb3e | ||
|
|
7d4e60e44a | ||
|
|
79c3ed3cc9 | ||
|
|
d62979a6f3 | ||
|
|
9c50521704 | ||
|
|
88dbf95105 | ||
|
|
e20e0bd744 | ||
|
|
0fd34e8c5a | ||
|
|
7ba5df0d52 | ||
|
|
4474873d2c | ||
|
|
8e5b7592f8 | ||
|
|
286ecd26d8 | ||
|
|
8b2a3c9c51 | ||
|
|
74180ebf0b | ||
|
|
f03f161b39 | ||
|
|
1e29ab38c7 | ||
|
|
8e821cd2f5 | ||
|
|
ffef9da9b7 | ||
|
|
8207ae888d | ||
|
|
05470aa1b6 | ||
|
|
b4e95a2efe | ||
|
|
23305cfeab | ||
|
|
156f4fba92 | ||
|
|
a23c0b378c | ||
|
|
9bfff6e16c | ||
|
|
a652131c42 | ||
|
|
573c4e6511 | ||
|
|
0a963d8c9a | ||
|
|
c196269d8d | ||
|
|
906bee9cf7 | ||
|
|
046f444ddc | ||
|
|
15439bee47 | ||
|
|
87893fe4cb | ||
|
|
d810f2b262 | ||
|
|
b3f5e17bb9 | ||
|
|
81cdbbddc8 | ||
|
|
d6df38bb6b | ||
|
|
c7bee8f961 | ||
|
|
434c684bfa | ||
|
|
db7714d5f1 | ||
|
|
343803b23c | ||
|
|
a942bfd9cc | ||
|
|
a35b370284 | ||
|
|
b2d151abe2 | ||
|
|
44bd478039 | ||
|
|
889a13696b | ||
|
|
82d570165e | ||
|
|
c574170050 | ||
|
|
e4c168b1f4 | ||
|
|
08e8bedae8 | ||
|
|
62e937bf2b | ||
|
|
24f74eb888 | ||
|
|
6e41ca956b | ||
|
|
6db65e687c | ||
|
|
09bcf5a937 | ||
|
|
4d67ac6172 | ||
|
|
6c00077d38 | ||
|
|
9e484f052a | ||
|
|
ab06ef8ed6 | ||
|
|
afe53708ee | ||
|
|
5affecb443 | ||
|
|
96cc7ee1e3 | ||
|
|
880107ab24 | ||
|
|
4ddb03390a | ||
|
|
c6007e5c1a | ||
|
|
e2145a5c9c | ||
|
|
55a18e6860 | ||
|
|
b097d7b033 | ||
|
|
cc726aad68 | ||
|
|
81436e143e | ||
|
|
9ff0ba0827 | ||
|
|
e3ed7722b5 | ||
|
|
7a2d498b9d | ||
|
|
e96fe06e49 | ||
|
|
9102d4a588 | ||
|
|
d221e369b8 | ||
|
|
b1fe2107d6 | ||
|
|
73969771a5 | ||
|
|
2ee69d0579 | ||
|
|
52c7976f40 | ||
|
|
2ecb4e62bb | ||
|
|
bfcc9f92b4 | ||
|
|
984e6cb5b8 |
@@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for git log
|
||||
|
||||
|
||||
2
.github/workflows/deploy-site.yml
vendored
2
.github/workflows/deploy-site.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
name: github-pages
|
||||
url: ${{ steps.deploy.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: hadolint
|
||||
uses: hadolint/hadolint-action@54c9adbab1582c2ef04b2016b760714a4bfde3cf # v3.1.0
|
||||
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
|
||||
with:
|
||||
dockerfile: Dockerfile
|
||||
config: .hadolint.yaml
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: shellcheck
|
||||
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # v2.0.0
|
||||
|
||||
26
.github/workflows/docker-publish.yml
vendored
26
.github/workflows/docker-publish.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
digest: ${{ steps.push.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- 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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
# (see `_SKIP_PARTS` in scripts/run_tests_parallel.py) because each
|
||||
# shard would otherwise reach the session-scoped ``built_image``
|
||||
# fixture in ``tests/docker/conftest.py`` and start a 3-7min
|
||||
# ``docker build`` under a 180s pytest-timeout cap — guaranteed to
|
||||
# ``docker build`` — guaranteed to
|
||||
# die in fixture setup.
|
||||
#
|
||||
# Piggybacking here avoids a second image build: the smoke test
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
run: |
|
||||
uv venv .venv --python 3.11
|
||||
source .venv/bin/activate
|
||||
# ``dev`` extra pulls in pytest, pytest-asyncio, pytest-timeout —
|
||||
# ``dev`` extra pulls in pytest, pytest-asyncio —
|
||||
# everything tests/docker/ needs. We deliberately avoid ``all``
|
||||
# here because the docker tests only drive the container via
|
||||
# subprocess and don't import hermes_agent's optional deps.
|
||||
@@ -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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
digest: ${{ steps.push.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- 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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/nix-setup
|
||||
with:
|
||||
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
2
.github/workflows/skills-index.yml
vendored
2
.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
|
||||
6
.github/workflows/supply-chain-audit.yml
vendored
6
.github/workflows/supply-chain-audit.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
# True when pyproject.toml changed in this PR
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check for relevant file changes
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -197,7 +197,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
34
.github/workflows/tests.yml
vendored
34
.github/workflows/tests.yml
vendored
@@ -4,13 +4,13 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'docs/**'
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'docs/**'
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -30,13 +30,17 @@ jobs:
|
||||
slice: [1, 2, 3, 4, 5, 6]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Restore duration cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: test_durations.json
|
||||
# Single stable key. main always overwrites, PRs always find it.
|
||||
# main always writes a new suffix, but jobs pick the latest one with the same prefix
|
||||
# quote from https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#cache-hits-and-misses
|
||||
# If you provide restore-keys, the cache action sequentially searches for any caches that match the list of restore-keys.
|
||||
# If there are no exact matches, the action searches for partial matches of the restore keys.
|
||||
# When the action finds a partial match, the most recent cache is restored to the path directory.
|
||||
key: test-durations
|
||||
|
||||
- name: Install ripgrep (prebuilt binary)
|
||||
@@ -54,7 +58,7 @@ jobs:
|
||||
rg --version
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
with:
|
||||
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
|
||||
# Keyed on the dependency manifests, so the cache is reused until
|
||||
@@ -115,7 +119,7 @@ jobs:
|
||||
NOUS_API_KEY: ""
|
||||
|
||||
- name: Upload per-slice durations
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-durations-slice-${{ matrix.slice }}
|
||||
path: test_durations.json
|
||||
@@ -129,7 +133,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all slice durations
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: test-durations-slice-*
|
||||
path: durations
|
||||
@@ -149,17 +153,17 @@ jobs:
|
||||
"
|
||||
|
||||
- name: Save merged duration cache
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: test_durations.json
|
||||
key: test-durations
|
||||
key: test-durations-${{ github.run_id }}
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Install ripgrep (prebuilt binary)
|
||||
run: |
|
||||
@@ -176,7 +180,7 @@ jobs:
|
||||
rg --version
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
with:
|
||||
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
|
||||
# Keyed on the dependency manifests, so the cache is reused until
|
||||
@@ -215,4 +219,4 @@ jobs:
|
||||
env:
|
||||
OPENROUTER_API_KEY: ""
|
||||
OPENAI_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
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@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
|
||||
uses: sigstore/gh-action-sigstore-python@5b79a39c381910c090341a2c9b0bf022c8b387e1 # v3.4.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -132,3 +132,7 @@ scripts/out/
|
||||
# stores the published notes. They are not a build artifact and must never be
|
||||
# committed to the repo root. See the hermes-release skill.
|
||||
RELEASE_v*.md
|
||||
|
||||
# Desktop demo-run scratch output (hermes writes demo/*.txt during recorded
|
||||
# walkthroughs). Throwaway artifacts, never part of the app.
|
||||
apps/desktop/demo/
|
||||
|
||||
@@ -145,7 +145,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
|
||||
account info to show (fail-open: caller just shows nothing).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.nous_account import nous_portal_billing_url
|
||||
from hermes_cli.nous_account import nous_portal_topup_url
|
||||
|
||||
if account_info is None or not getattr(account_info, "logged_in", False):
|
||||
return None
|
||||
@@ -213,7 +213,8 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
|
||||
if not windows and not details:
|
||||
return None
|
||||
|
||||
details.append(f"Manage / top up: {nous_portal_billing_url(account_info)}")
|
||||
details.append(f"Top up: {nous_portal_topup_url(account_info)}")
|
||||
details.append("(or run /credits)")
|
||||
|
||||
plan = getattr(sub, "plan", None) if sub is not None else None
|
||||
return AccountUsageSnapshot(
|
||||
@@ -337,6 +338,93 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CreditsView:
|
||||
"""Surface-agnostic data for the ``/credits`` command.
|
||||
|
||||
One portal fetch, one parse — consumed identically by the CLI panel, the
|
||||
gateway button, and any other money surface. Fail-open: when not logged in
|
||||
or the portal is unreachable, ``logged_in`` is False / ``topup_url`` is None
|
||||
and callers degrade gracefully.
|
||||
"""
|
||||
|
||||
logged_in: bool
|
||||
balance_lines: tuple[str, ...] = ()
|
||||
identity_line: Optional[str] = None
|
||||
topup_url: Optional[str] = None
|
||||
depleted: bool = False
|
||||
|
||||
|
||||
def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView:
|
||||
"""Build the /credits view: balance block + identity line + top-up URL.
|
||||
|
||||
Reuses the same account fetch + snapshot + URL builder as the /usage credits
|
||||
block, so the numbers always match. The balance block is the rendered
|
||||
snapshot MINUS its trailing top-up/command-hint lines (the /credits surface
|
||||
supplies its own affordance). Fail-open → ``CreditsView(logged_in=False)``.
|
||||
"""
|
||||
not_logged_in = CreditsView(logged_in=False)
|
||||
try:
|
||||
from hermes_cli.auth import get_provider_auth_state
|
||||
|
||||
tok = (get_provider_auth_state("nous") or {}).get("access_token")
|
||||
if not (isinstance(tok, str) and tok.strip()):
|
||||
return not_logged_in
|
||||
except Exception:
|
||||
return not_logged_in
|
||||
|
||||
try:
|
||||
import concurrent.futures
|
||||
|
||||
from hermes_cli.nous_account import (
|
||||
get_nous_portal_account_info,
|
||||
nous_portal_topup_url,
|
||||
)
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(
|
||||
timeout=timeout
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True)
|
||||
return not_logged_in
|
||||
|
||||
if account is None or not getattr(account, "logged_in", False):
|
||||
return not_logged_in
|
||||
|
||||
snapshot = build_nous_credits_snapshot(account)
|
||||
# Balance lines = the snapshot block minus the two trailing affordance lines
|
||||
# ("Top up: <url>" + "(or run /credits)") that build_nous_credits_snapshot
|
||||
# appends for the /usage surface. /credits renders its own button/panel.
|
||||
balance_lines: list[str] = []
|
||||
if snapshot is not None:
|
||||
rendered = render_account_usage_lines(snapshot, markdown=markdown)
|
||||
balance_lines = [
|
||||
line
|
||||
for line in rendered
|
||||
if not line.lstrip().startswith("Top up:")
|
||||
and not line.lstrip().startswith("(or run")
|
||||
]
|
||||
|
||||
# Identity line — shown before any open (roadmap §4.4).
|
||||
email = getattr(account, "email", None)
|
||||
org_name = getattr(account, "org_name", None)
|
||||
who: list[str] = []
|
||||
if email:
|
||||
who.append(str(email))
|
||||
if org_name:
|
||||
who.append(f"org {org_name}")
|
||||
identity_line = ("Topping up as " + " / ".join(who)) if who else None
|
||||
|
||||
return CreditsView(
|
||||
logged_in=True,
|
||||
balance_lines=tuple(balance_lines),
|
||||
identity_line=identity_line,
|
||||
topup_url=nous_portal_topup_url(account),
|
||||
depleted=getattr(account, "paid_service_access", None) is False,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_codex_usage_url(base_url: str) -> str:
|
||||
normalized = (base_url or "").strip().rstrip("/")
|
||||
if not normalized:
|
||||
|
||||
@@ -127,14 +127,21 @@ def _chat_content_to_responses_parts(content: Any, *, role: str = "user") -> Lis
|
||||
return converted
|
||||
|
||||
|
||||
def _summarize_user_message_for_log(content: Any) -> str:
|
||||
"""Return a short text summary of a user message for logging/trajectory.
|
||||
def _summarize_user_message_for_log(content: Any, *, sep: str = " ") -> str:
|
||||
"""Flatten message content to a plain-text summary.
|
||||
|
||||
Multimodal messages arrive as a list of ``{type:"text"|"image_url", ...}``
|
||||
parts from the API server. Logging, spinner previews, and trajectory
|
||||
files all want a plain string — this helper extracts the first chunk of
|
||||
text and notes any attached images. Returns an empty string for empty
|
||||
lists and ``str(content)`` for unexpected scalar types.
|
||||
parts from the API server. Several consumers want a plain string:
|
||||
|
||||
- Logging, spinner previews, and trajectory files (the default ``sep=" "``).
|
||||
- External memory providers, which feed the text to regexes
|
||||
(``sanitize_context``) and text APIs — a raw list crashes the sync with
|
||||
``expected string or bytes-like object, got 'list'`` (use ``sep="\\n"``).
|
||||
|
||||
Text parts are joined with ``sep``; images become a ``[N image(s)]`` marker
|
||||
so the turn isn't recorded as if the attachment never existed. Returns an
|
||||
empty string for empty lists and ``str(content)`` for unexpected scalar
|
||||
types.
|
||||
"""
|
||||
if content is None:
|
||||
return ""
|
||||
@@ -157,7 +164,7 @@ def _summarize_user_message_for_log(content: Any) -> str:
|
||||
text_bits.append(text)
|
||||
elif ptype in {"image_url", "input_image"}:
|
||||
image_count += 1
|
||||
summary = " ".join(text_bits).strip()
|
||||
summary = sep.join(text_bits).strip()
|
||||
if image_count:
|
||||
note = f"[{image_count} image{'s' if image_count != 1 else ''}]"
|
||||
summary = f"{note} {summary}" if summary else note
|
||||
|
||||
@@ -190,6 +190,10 @@ CODING_AGENT_GUIDANCE = (
|
||||
"Verify, and know when to stop:\n"
|
||||
"- Use `terminal` for git, builds, tests, and inspection. Run the relevant "
|
||||
"tests/linter/build and confirm they pass before claiming the work is done.\n"
|
||||
"- Terminal state persists across calls: current directory and exported "
|
||||
"environment variables carry forward. Activate a virtualenv or export setup "
|
||||
"vars once, then reuse that state instead of re-sourcing it before every "
|
||||
"test command.\n"
|
||||
"- Fix root causes, not symptoms: when you find a bug, check sibling call "
|
||||
"paths for the same flaw and fix the class, not just the reported site.\n"
|
||||
"- When fixing linter/type errors on a file, stop after about three "
|
||||
@@ -711,10 +715,13 @@ def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
|
||||
lines.append("- Branch: (detached HEAD)")
|
||||
|
||||
# Linked worktree: the per-worktree git dir differs from the shared common dir.
|
||||
# We surface the fact that it's a worktree (so the model knows branches/stashes
|
||||
# are shared state) but deliberately do NOT expose the primary tree path —
|
||||
# giving the model a second absolute path causes it to sometimes run commands
|
||||
# in the wrong directory.
|
||||
git_dir, common_dir = _git(root, "rev-parse", "--git-dir"), _git(root, "rev-parse", "--git-common-dir")
|
||||
if git_dir and common_dir and Path(git_dir).resolve() != Path(common_dir).resolve():
|
||||
main_tree = Path(common_dir).resolve().parent
|
||||
lines.append(f"- Worktree: linked (primary tree at {main_tree})")
|
||||
lines.append("- Worktree: linked (git state shared with primary tree)")
|
||||
|
||||
dirty = [f"{n} {label}" for label, n in (
|
||||
("staged", counts["staged"]), ("modified", counts["modified"]),
|
||||
|
||||
@@ -143,10 +143,18 @@ _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
|
||||
# become another unbounded transcript copy after the LLM summarizer failed.
|
||||
_FALLBACK_SUMMARY_MAX_CHARS = 8_000
|
||||
_FALLBACK_TURN_MAX_CHARS = 700
|
||||
_AUTO_FOCUS_MAX_TURNS = 3
|
||||
_AUTO_FOCUS_TURN_MAX_CHARS = 260
|
||||
_AUTO_FOCUS_MAX_CHARS = 700
|
||||
|
||||
|
||||
_PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+")
|
||||
|
||||
# MEDIA delivery directives must not reach the summarizer — if one leaks into
|
||||
# the summary, the downstream model may re-emit it as an active directive on
|
||||
# the next turn, triggering bogus attachment sends (#14665).
|
||||
_MEDIA_DIRECTIVE_RE = re.compile(r"MEDIA:\S+")
|
||||
|
||||
|
||||
def _dedupe_append(items: list[str], value: str, *, limit: int) -> None:
|
||||
value = value.strip()
|
||||
@@ -1007,6 +1015,7 @@ class ContextCompressor(ContextEngine):
|
||||
for msg in turns:
|
||||
role = msg.get("role", "unknown")
|
||||
content = redact_sensitive_text(msg.get("content") or "")
|
||||
content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content)
|
||||
|
||||
# Tool results: keep enough content for the summarizer
|
||||
if role == "tool":
|
||||
@@ -1454,7 +1463,7 @@ Use this exact structure:
|
||||
prompt += f"""
|
||||
|
||||
FOCUS TOPIC: "{focus_topic}"
|
||||
The user has requested that this compaction PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED]."""
|
||||
This compaction should PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED]."""
|
||||
|
||||
try:
|
||||
call_kwargs = {
|
||||
@@ -1623,6 +1632,39 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
return True
|
||||
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
|
||||
|
||||
@classmethod
|
||||
def _derive_auto_focus_topic(
|
||||
cls,
|
||||
messages: List[Dict[str, Any]],
|
||||
) -> Optional[str]:
|
||||
"""Infer a compact focus hint from the most recent real user turns."""
|
||||
candidates: list[str] = []
|
||||
for idx in range(len(messages) - 1, -1, -1):
|
||||
msg = messages[idx]
|
||||
if msg.get("role") != "user":
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if cls._is_context_summary_content(content):
|
||||
continue
|
||||
text = redact_sensitive_text(_content_text_for_contains(content).strip())
|
||||
if not text:
|
||||
continue
|
||||
text = " ".join(text.split())
|
||||
if len(text) > _AUTO_FOCUS_TURN_MAX_CHARS:
|
||||
text = text[: _AUTO_FOCUS_TURN_MAX_CHARS - 1].rstrip() + "…"
|
||||
candidates.append(text)
|
||||
if len(candidates) >= _AUTO_FOCUS_MAX_TURNS:
|
||||
break
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
candidates.reverse()
|
||||
focus = "Recent user focus:\n" + "\n".join(f"- {item}" for item in candidates)
|
||||
if len(focus) > _AUTO_FOCUS_MAX_CHARS:
|
||||
focus = focus[: _AUTO_FOCUS_MAX_CHARS - 1].rstrip() + "…"
|
||||
return focus
|
||||
|
||||
@classmethod
|
||||
def _find_latest_context_summary(
|
||||
cls,
|
||||
@@ -2070,7 +2112,8 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
)
|
||||
|
||||
# Phase 3: Generate structured summary
|
||||
summary = self._generate_summary(turns_to_summarize, focus_topic=focus_topic)
|
||||
summary_focus_topic = focus_topic or self._derive_auto_focus_topic(messages)
|
||||
summary = self._generate_summary(turns_to_summarize, focus_topic=summary_focus_topic)
|
||||
|
||||
# If summary generation failed, behavior splits on
|
||||
# ``abort_on_summary_failure`` (config: compression.abort_on_summary_failure):
|
||||
|
||||
@@ -286,6 +286,16 @@ def evaluate_credits_notices(
|
||||
for band in CREDITS_USAGE_BANDS: # ascending → last match wins = highest
|
||||
if uf >= band[0]:
|
||||
current_band = band
|
||||
# Top-up suppression: when the account holds purchased (top-up) credits,
|
||||
# the subscription-cap gauge is the wrong denominator — warning "90% used"
|
||||
# at a user sitting on $50 of top-up is noise (and it previously stuck
|
||||
# PERMANENTLY alongside grant_spent at >=100%). Suppress the usage band
|
||||
# entirely; the cap-reached case is covered by the grant_spent info notice
|
||||
# below, which already names the remaining top-up balance. A top-up landing
|
||||
# mid-session flips current_band → None and the clear path below removes
|
||||
# any showing band line.
|
||||
if state.purchased_micros > 0:
|
||||
current_band = None
|
||||
grant_cond = (
|
||||
state.denominator_kind == "subscription_cap"
|
||||
and uf is not None
|
||||
@@ -345,7 +355,7 @@ def evaluate_credits_notices(
|
||||
if show_depleted and "credits.depleted" not in active:
|
||||
to_show.append(
|
||||
AgentNotice(
|
||||
text="✕ Credit access paused · run /usage for balance",
|
||||
text="✕ Credit access paused · run /credits to top up",
|
||||
level="error",
|
||||
kind=CREDITS_NOTICE_KIND,
|
||||
key="credits.depleted",
|
||||
|
||||
@@ -489,6 +489,23 @@ PLATFORM_HINTS = {
|
||||
"files arrive as downloadable documents. You can also include image "
|
||||
"URLs in markdown format  and they will be sent as photos."
|
||||
),
|
||||
"whatsapp_cloud": (
|
||||
"You are on a text messaging communication platform, WhatsApp "
|
||||
"(via Meta's official Business Cloud API). Standard markdown "
|
||||
"(**bold**, ~~strike~~, # headers, [links](url)) is auto-converted "
|
||||
"to WhatsApp's native syntax (*bold*, ~strike~, etc.) — feel free "
|
||||
"to write in markdown. Tables are NOT supported — prefer bullet "
|
||||
"lists or labeled key:value pairs. "
|
||||
"You can send media files natively: include MEDIA:/absolute/path/to/file "
|
||||
"in your response. Images (.jpg, .png) become photo attachments, "
|
||||
"videos (.mp4) play inline, audio (.mp3, .ogg) sends as voice/audio "
|
||||
"messages, other files arrive as documents. Image URLs in markdown "
|
||||
"format  also work. "
|
||||
"IMPORTANT: this platform has a 24-hour conversation window — if the "
|
||||
"user hasn't messaged in 24h, free-form replies are refused by Meta "
|
||||
"(error 131047). This rarely matters for live chat, but is worth "
|
||||
"knowing if you're scheduling a delayed message."
|
||||
),
|
||||
"telegram": (
|
||||
"You are on a text messaging communication platform, Telegram. "
|
||||
"Standard markdown is automatically converted to Telegram format. "
|
||||
@@ -1418,13 +1435,13 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
|
||||
|
||||
lines = [
|
||||
"# Nous Subscription",
|
||||
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, and browser automation (Browser Use) by default. Modal execution is optional.",
|
||||
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, OpenAI Whisper STT, and browser automation (Browser Use) by default. Modal execution is optional.",
|
||||
"Current capability status:",
|
||||
]
|
||||
lines.extend(_status_line(feature) for feature in features.items())
|
||||
lines.extend(
|
||||
[
|
||||
"When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browser-Use API keys.",
|
||||
"When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, OpenAI Whisper, or Browser-Use API keys.",
|
||||
"If the user is not subscribed and asks for a capability that Nous subscription would unlock or simplify, suggest Nous subscription as one option alongside direct setup or local alternatives.",
|
||||
"Do not mention subscription unless the user asks about it or it directly solves the current missing capability.",
|
||||
"Useful commands: hermes setup, hermes setup tools, hermes setup terminal, hermes status.",
|
||||
|
||||
101
apps/desktop/electron/backend-env.cjs
Normal file
101
apps/desktop/electron/backend-env.cjs
Normal file
@@ -0,0 +1,101 @@
|
||||
const path = require('node:path')
|
||||
|
||||
// Match the POSIX fallback surface used by the Python terminal environment.
|
||||
// macOS apps launched from Finder/Dock often inherit only /usr/bin:/bin:/usr/sbin:/sbin,
|
||||
// which misses Apple Silicon Homebrew and user-installed CLI tools such as codex.
|
||||
const POSIX_SANE_PATH_ENTRIES = Object.freeze([
|
||||
'/opt/homebrew/bin',
|
||||
'/opt/homebrew/sbin',
|
||||
'/usr/local/sbin',
|
||||
'/usr/local/bin',
|
||||
'/usr/sbin',
|
||||
'/usr/bin',
|
||||
'/sbin',
|
||||
'/bin'
|
||||
])
|
||||
|
||||
function delimiterForPlatform(platform = process.platform) {
|
||||
return platform === 'win32' ? ';' : ':'
|
||||
}
|
||||
|
||||
function pathModuleForPlatform(platform = process.platform) {
|
||||
return platform === 'win32' ? path.win32 : path.posix
|
||||
}
|
||||
|
||||
function pathEnvKey(env = process.env, platform = process.platform) {
|
||||
if (platform !== 'win32') return 'PATH'
|
||||
return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH'
|
||||
}
|
||||
|
||||
function currentPathValue(env = process.env, platform = process.platform) {
|
||||
const key = pathEnvKey(env, platform)
|
||||
return env?.[key] || ''
|
||||
}
|
||||
|
||||
function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
|
||||
const seen = new Set()
|
||||
const ordered = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry) continue
|
||||
const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
|
||||
for (const part of parts) {
|
||||
if (!part || seen.has(part)) continue
|
||||
seen.add(part)
|
||||
ordered.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
return ordered.join(delimiter)
|
||||
}
|
||||
|
||||
function buildDesktopBackendPath({
|
||||
hermesHome,
|
||||
venvRoot,
|
||||
currentPath = '',
|
||||
platform = process.platform,
|
||||
pathModule = pathModuleForPlatform(platform)
|
||||
} = {}) {
|
||||
const delimiter = delimiterForPlatform(platform)
|
||||
const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', 'bin') : null
|
||||
const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null
|
||||
const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES
|
||||
|
||||
return appendUniquePathEntries(
|
||||
[hermesNodeBin, venvBin, currentPath, saneEntries],
|
||||
{ delimiter }
|
||||
)
|
||||
}
|
||||
|
||||
function buildDesktopBackendEnv({
|
||||
hermesHome,
|
||||
pythonPathEntries = [],
|
||||
venvRoot,
|
||||
currentEnv = process.env,
|
||||
platform = process.platform,
|
||||
pathModule = pathModuleForPlatform(platform)
|
||||
} = {}) {
|
||||
const delimiter = delimiterForPlatform(platform)
|
||||
const currentPythonPath = currentEnv?.PYTHONPATH || ''
|
||||
const key = pathEnvKey(currentEnv, platform)
|
||||
|
||||
return {
|
||||
PYTHONPATH: appendUniquePathEntries([...pythonPathEntries, currentPythonPath], { delimiter }),
|
||||
[key]: buildDesktopBackendPath({
|
||||
hermesHome,
|
||||
venvRoot,
|
||||
currentPath: currentPathValue(currentEnv, platform),
|
||||
platform,
|
||||
pathModule
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
POSIX_SANE_PATH_ENTRIES,
|
||||
appendUniquePathEntries,
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
delimiterForPlatform,
|
||||
pathEnvKey
|
||||
}
|
||||
95
apps/desktop/electron/backend-env.test.cjs
Normal file
95
apps/desktop/electron/backend-env.test.cjs
Normal file
@@ -0,0 +1,95 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const path = require('node:path')
|
||||
|
||||
const {
|
||||
POSIX_SANE_PATH_ENTRIES,
|
||||
appendUniquePathEntries,
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
pathEnvKey
|
||||
} = require('./backend-env.cjs')
|
||||
|
||||
test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => {
|
||||
const result = buildDesktopBackendPath({
|
||||
hermesHome: '/Users/test/.hermes',
|
||||
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
|
||||
currentPath: '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
|
||||
platform: 'darwin',
|
||||
pathModule: path.posix
|
||||
})
|
||||
|
||||
const entries = result.split(':')
|
||||
assert.equal(entries[0], '/Users/test/.hermes/node/bin')
|
||||
assert.equal(entries[1], '/Users/test/.hermes/hermes-agent/venv/bin')
|
||||
assert.ok(entries.includes('/opt/homebrew/bin'), 'Apple Silicon Homebrew bin is added')
|
||||
assert.ok(entries.includes('/opt/homebrew/sbin'), 'Apple Silicon Homebrew sbin is added')
|
||||
assert.ok(entries.includes('/usr/local/sbin'), 'missing standard sbin is added')
|
||||
|
||||
for (const expected of POSIX_SANE_PATH_ENTRIES) {
|
||||
assert.ok(entries.includes(expected), `${expected} should be present`)
|
||||
}
|
||||
})
|
||||
|
||||
test('desktop backend PATH preserves first occurrence and avoids duplicates', () => {
|
||||
const result = buildDesktopBackendPath({
|
||||
hermesHome: '/Users/test/.hermes',
|
||||
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
|
||||
currentPath: '/opt/homebrew/bin:/usr/bin:/opt/homebrew/bin:/bin',
|
||||
platform: 'darwin',
|
||||
pathModule: path.posix
|
||||
})
|
||||
|
||||
const entries = result.split(':')
|
||||
assert.equal(entries.filter(entry => entry === '/opt/homebrew/bin').length, 1)
|
||||
assert.ok(
|
||||
entries.indexOf('/opt/homebrew/bin') < entries.indexOf('/opt/homebrew/sbin'),
|
||||
'existing Homebrew bin keeps its precedence over appended missing sane entries'
|
||||
)
|
||||
})
|
||||
|
||||
test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () => {
|
||||
const env = buildDesktopBackendEnv({
|
||||
hermesHome: '/Users/test/.hermes',
|
||||
pythonPathEntries: ['/repo/hermes-agent'],
|
||||
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
|
||||
currentEnv: {
|
||||
PATH: '/usr/bin:/bin',
|
||||
PYTHONPATH: '/existing/pythonpath'
|
||||
},
|
||||
platform: 'darwin',
|
||||
pathModule: path.posix
|
||||
})
|
||||
|
||||
assert.equal(env.PYTHONPATH, '/repo/hermes-agent:/existing/pythonpath')
|
||||
assert.ok(env.PATH.startsWith('/Users/test/.hermes/node/bin:/Users/test/.hermes/hermes-agent/venv/bin:'))
|
||||
assert.ok(env.PATH.includes('/opt/homebrew/bin'))
|
||||
})
|
||||
|
||||
test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => {
|
||||
const env = buildDesktopBackendEnv({
|
||||
hermesHome: 'C:\\Users\\test\\AppData\\Local\\hermes',
|
||||
pythonPathEntries: ['C:\\repo\\hermes-agent'],
|
||||
venvRoot: 'C:\\Users\\test\\AppData\\Local\\hermes\\hermes-agent\\venv',
|
||||
currentEnv: {
|
||||
Path: 'C:\\Windows\\System32;C:\\Windows',
|
||||
PYTHONPATH: 'C:\\existing\\pythonpath'
|
||||
},
|
||||
platform: 'win32',
|
||||
pathModule: path.win32
|
||||
})
|
||||
|
||||
assert.equal(pathEnvKey({ Path: 'x' }, 'win32'), 'Path')
|
||||
assert.equal(env.PATH, undefined)
|
||||
assert.ok(env.Path.startsWith('C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin;'))
|
||||
assert.ok(env.Path.includes('\\venv\\Scripts;'))
|
||||
assert.ok(env.Path.includes(';C:\\Windows\\System32;C:\\Windows'))
|
||||
assert.equal(env.Path.includes('/opt/homebrew/bin'), false)
|
||||
})
|
||||
|
||||
test('appendUniquePathEntries drops empty entries and keeps first occurrence', () => {
|
||||
assert.equal(
|
||||
appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }),
|
||||
'/a:/b:/c'
|
||||
)
|
||||
})
|
||||
99
apps/desktop/electron/dashboard-token.cjs
Normal file
99
apps/desktop/electron/dashboard-token.cjs
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Helpers for local dashboard session-token discovery.
|
||||
*
|
||||
* The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it
|
||||
* spawns the local dashboard, but the dashboard is the source of truth for the
|
||||
* token it actually serves to the renderer. If those drift, HTTP readiness
|
||||
* probes still pass while /api/ws rejects the renderer's token.
|
||||
*/
|
||||
|
||||
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
|
||||
|
||||
async function fetchPublicText(url, options = {}) {
|
||||
const { protocol } = new URL(url)
|
||||
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||
throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
|
||||
}
|
||||
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
|
||||
if (error.name === 'TimeoutError') {
|
||||
throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
|
||||
}
|
||||
throw error
|
||||
})
|
||||
const text = await res.text()
|
||||
|
||||
if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`)
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
function extractInjectedDashboardToken(html) {
|
||||
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
|
||||
if (!match) return null
|
||||
try {
|
||||
return JSON.parse(match[1])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function dashboardIndexUrl(baseUrl) {
|
||||
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
|
||||
}
|
||||
|
||||
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
|
||||
const fetchText = options.fetchText || fetchPublicText
|
||||
const html = await fetchText(dashboardIndexUrl(baseUrl), {
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
|
||||
})
|
||||
const servedToken = extractInjectedDashboardToken(html)
|
||||
|
||||
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
|
||||
options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth')
|
||||
}
|
||||
|
||||
return servedToken || fallbackToken
|
||||
}
|
||||
|
||||
/**
|
||||
* A served token that differs from our spawn token while our child is DEAD
|
||||
* came from a process we did not spawn (orphan/port squatter that satisfied
|
||||
* the public /api/status readiness probe). With a live child the mismatch is
|
||||
* benign: our own backend regenerated the token because the env pin did not
|
||||
* survive the spawn.
|
||||
*/
|
||||
function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
|
||||
return Boolean(servedToken) && servedToken !== spawnToken && !childAlive
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the token the backend actually serves, adopting benign drift and
|
||||
* failing loudly on a foreign backend. `childAlive` is a thunk so liveness is
|
||||
* sampled after the fetch, not before.
|
||||
*/
|
||||
async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
|
||||
const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
|
||||
options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
|
||||
return spawnToken
|
||||
})
|
||||
|
||||
if (isForeignBackendToken({ servedToken, spawnToken, childAlive: childAlive() })) {
|
||||
throw new Error(
|
||||
`${label} exited and ${dashboardIndexUrl(baseUrl)} is served by a process we did not spawn; refusing its session token.`
|
||||
)
|
||||
}
|
||||
|
||||
return servedToken
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
|
||||
adoptServedDashboardToken,
|
||||
dashboardIndexUrl,
|
||||
extractInjectedDashboardToken,
|
||||
fetchPublicText,
|
||||
isForeignBackendToken,
|
||||
resolveServedDashboardToken
|
||||
}
|
||||
142
apps/desktop/electron/dashboard-token.test.cjs
Normal file
142
apps/desktop/electron/dashboard-token.test.cjs
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Tests for electron/dashboard-token.cjs.
|
||||
*
|
||||
* Run with: node --test electron/dashboard-token.test.cjs
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
adoptServedDashboardToken,
|
||||
dashboardIndexUrl,
|
||||
extractInjectedDashboardToken,
|
||||
fetchPublicText,
|
||||
isForeignBackendToken,
|
||||
resolveServedDashboardToken
|
||||
} = require('./dashboard-token.cjs')
|
||||
|
||||
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
|
||||
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
|
||||
assert.equal(extractInjectedDashboardToken(html), 'served-token')
|
||||
})
|
||||
|
||||
test('extractInjectedDashboardToken handles escaped token strings', () => {
|
||||
const html = '<script>window.__HERMES_SESSION_TOKEN__="served\\\\token\\"quoted";</script>'
|
||||
assert.equal(extractInjectedDashboardToken(html), 'served\\token"quoted')
|
||||
})
|
||||
|
||||
test('extractInjectedDashboardToken returns null for missing or malformed values', () => {
|
||||
assert.equal(extractInjectedDashboardToken('<html></html>'), null)
|
||||
assert.equal(extractInjectedDashboardToken('<script>window.__HERMES_SESSION_TOKEN__={bad}</script>'), null)
|
||||
})
|
||||
|
||||
test('dashboardIndexUrl preserves dashboard path prefixes', () => {
|
||||
assert.equal(dashboardIndexUrl('http://127.0.0.1:9120'), 'http://127.0.0.1:9120/')
|
||||
assert.equal(dashboardIndexUrl('https://host.example/hermes/'), 'https://host.example/hermes/')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
|
||||
const logs = []
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async url => {
|
||||
assert.equal(url, 'http://127.0.0.1:9120/')
|
||||
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
|
||||
},
|
||||
rememberLog: line => logs.push(line)
|
||||
})
|
||||
|
||||
assert.equal(token, 'served-token')
|
||||
assert.equal(logs.length, 1)
|
||||
assert.match(logs[0], /served a different session token/)
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken falls back when the served HTML has no token', async () => {
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async () => '<html></html>',
|
||||
rememberLog: () => {
|
||||
throw new Error('should not log when no served token is present')
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(token, 'spawn-token')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken does not log when served token matches fallback', async () => {
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'same-token', {
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="same-token";</script>',
|
||||
rememberLog: () => {
|
||||
throw new Error('should not log when token already matches')
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(token, 'same-token')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken propagates fetch errors so callers can fall back explicitly', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async () => {
|
||||
throw new Error('boom')
|
||||
}
|
||||
}),
|
||||
/boom/
|
||||
)
|
||||
})
|
||||
|
||||
test('fetchPublicText rejects unsupported protocols', async () => {
|
||||
await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/)
|
||||
})
|
||||
|
||||
test('isForeignBackendToken only flags a mismatched token from a dead child', () => {
|
||||
const cases = [
|
||||
[{ servedToken: 'other', spawnToken: 'mine', childAlive: false }, true],
|
||||
// Live child + drift = our backend regenerated the token (env pin lost).
|
||||
[{ servedToken: 'other', spawnToken: 'mine', childAlive: true }, false],
|
||||
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: false }, false],
|
||||
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: true }, false],
|
||||
[{ servedToken: null, spawnToken: 'mine', childAlive: false }, false],
|
||||
[{ servedToken: '', spawnToken: 'mine', childAlive: false }, false]
|
||||
]
|
||||
for (const [input, expected] of cases) {
|
||||
assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input))
|
||||
}
|
||||
})
|
||||
|
||||
test('adoptServedDashboardToken adopts drift from a live child', async () => {
|
||||
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => true,
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
|
||||
})
|
||||
|
||||
assert.equal(token, 'served-token')
|
||||
})
|
||||
|
||||
test('adoptServedDashboardToken refuses a foreign token when our child is dead', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => false,
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="squatter-token";</script>',
|
||||
label: 'Hermes backend for profile "work"'
|
||||
}),
|
||||
/profile "work".*process we did not spawn/
|
||||
)
|
||||
})
|
||||
|
||||
test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => {
|
||||
const logs = []
|
||||
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => true,
|
||||
fetchText: async () => {
|
||||
throw new Error('boom')
|
||||
},
|
||||
rememberLog: line => logs.push(line)
|
||||
})
|
||||
|
||||
assert.equal(token, 'spawn-token')
|
||||
assert.equal(logs.length, 1)
|
||||
assert.match(logs[0], /could not read served dashboard token \(Hermes backend\): boom/)
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { fileURLToPath } = require('node:url')
|
||||
|
||||
@@ -142,7 +143,14 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
|
||||
|
||||
function resolveRequestedPathForIpc(filePath, options = {}) {
|
||||
const purpose = String(options.purpose || 'File read')
|
||||
const raw = rejectUnsafePathSyntax(filePath, purpose)
|
||||
let raw = rejectUnsafePathSyntax(filePath, purpose)
|
||||
|
||||
// Gateway-reported cwds (config `terminal.cwd`, remote sessions) routinely
|
||||
// arrive as `~/...`. Node's fs has no shell — without expansion the path
|
||||
// resolves under process.cwd() and every read "ENOENT"s forever.
|
||||
if (raw === '~' || raw.startsWith('~/') || raw.startsWith('~\\')) {
|
||||
raw = path.join(os.homedir(), raw.slice(1))
|
||||
}
|
||||
|
||||
if (/^file:/i.test(raw)) {
|
||||
let resolvedPath
|
||||
|
||||
@@ -106,6 +106,19 @@ test('resolveRequestedPathForIpc resolves relative paths from the trimmed base d
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveRequestedPathForIpc expands ~ to the home directory', () => {
|
||||
assert.equal(resolveRequestedPathForIpc('~', { purpose: 'Directory read' }), path.resolve(os.homedir()))
|
||||
assert.equal(
|
||||
resolveRequestedPathForIpc('~/www/project', { purpose: 'Directory read' }),
|
||||
path.resolve(os.homedir(), 'www/project')
|
||||
)
|
||||
// `~user` shorthand is NOT expanded — only the caller's own home.
|
||||
assert.equal(
|
||||
resolveRequestedPathForIpc('~other/secret', { baseDir: os.tmpdir(), purpose: 'Directory read' }),
|
||||
path.resolve(os.tmpdir(), '~other/secret')
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
@@ -26,17 +26,22 @@ const { pathToFileURL } = require('node:url')
|
||||
const { execFileSync, spawn } = require('node:child_process')
|
||||
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
|
||||
const {
|
||||
buildSessionWindowUrl,
|
||||
createSessionWindowRegistry,
|
||||
SESSION_WINDOW_MIN_HEIGHT,
|
||||
SESSION_WINDOW_MIN_WIDTH
|
||||
} = require('./session-windows.cjs')
|
||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
|
||||
const { PortPool } = require('./port-pool.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
const { buildDesktopBackendEnv } = require('./backend-env.cjs')
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const {
|
||||
OFFICIAL_REPO_HTTPS_URL,
|
||||
isOfficialSshRemote
|
||||
} = require('./update-remote.cjs')
|
||||
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
|
||||
const {
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
@@ -93,6 +98,7 @@ try {
|
||||
nodePty = require(nodePtyDir)
|
||||
}
|
||||
} catch {
|
||||
console.log(`[terminal] failed to load node-pty from path ${nodePtyDir}`)
|
||||
nodePty = null
|
||||
nodePtyDir = null
|
||||
}
|
||||
@@ -107,6 +113,10 @@ if (USER_DATA_OVERRIDE) {
|
||||
|
||||
const PORT_FLOOR = 9120
|
||||
const PORT_CEILING = 9199
|
||||
// In-process port reservations that close the pickPort() TOCTOU window where
|
||||
// two concurrent backend spawns could be handed the same port. See
|
||||
// port-pool.cjs for the full rationale.
|
||||
const portPool = new PortPool(PORT_FLOOR, PORT_CEILING)
|
||||
const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
|
||||
const IS_PACKAGED = app.isPackaged
|
||||
const IS_MAC = process.platform === 'darwin'
|
||||
@@ -340,10 +350,110 @@ const APP_ICON_PATHS = [
|
||||
let rendererTitleBarTheme = null
|
||||
const terminalSessions = new Map()
|
||||
|
||||
// Force the NATIVE window appearance (vibrancy material, titlebar, the
|
||||
// pre-first-paint window background) to follow the APP theme instead of the
|
||||
// OS appearance. With `vibrancy` set, macOS paints an NSVisualEffectView that
|
||||
// tracks the window's effective appearance and ignores `backgroundColor` —
|
||||
// so a dark-themed app on a light-mode Mac flashes a white material on every
|
||||
// new window until the renderer covers it. The renderer reports its mode via
|
||||
// 'hermes:native-theme' ('dark' | 'light' | 'system'); we pin
|
||||
// nativeTheme.themeSource to it and persist the value so cold launches paint
|
||||
// correctly before the renderer has even loaded.
|
||||
const NATIVE_THEME_CONFIG_PATH = path.join(app.getPath('userData'), 'native-theme.json')
|
||||
const THEME_SOURCES = new Set(['dark', 'light', 'system'])
|
||||
|
||||
function readPersistedThemeSource() {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(NATIVE_THEME_CONFIG_PATH, 'utf8'))
|
||||
|
||||
if (parsed && THEME_SOURCES.has(parsed.themeSource)) {
|
||||
return parsed.themeSource
|
||||
}
|
||||
} catch {
|
||||
// Missing / malformed → follow the OS like a fresh install.
|
||||
}
|
||||
|
||||
return 'system'
|
||||
}
|
||||
|
||||
function writePersistedThemeSource(mode) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(NATIVE_THEME_CONFIG_PATH), { recursive: true })
|
||||
fs.writeFileSync(NATIVE_THEME_CONFIG_PATH, JSON.stringify({ themeSource: mode }, null, 2), 'utf8')
|
||||
} catch (error) {
|
||||
rememberLog(`[theme] write native theme failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
nativeTheme.themeSource = readPersistedThemeSource()
|
||||
|
||||
// Window translucency (see-through window). One lever, 0–100; 0 = off (the
|
||||
// default). Mapped to the native window opacity so the desktop shows through
|
||||
// the whole window. Persisted so a cold launch applies it at window creation,
|
||||
// before the renderer reports its value. macOS + Windows only; `setOpacity` is
|
||||
// a no-op on Linux. See store/translucency.
|
||||
const TRANSLUCENCY_CONFIG_PATH = path.join(app.getPath('userData'), 'translucency.json')
|
||||
|
||||
function clampIntensity(value) {
|
||||
const n = Math.round(Number(value))
|
||||
|
||||
return Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 0
|
||||
}
|
||||
|
||||
function readPersistedTranslucency() {
|
||||
try {
|
||||
return clampIntensity(JSON.parse(fs.readFileSync(TRANSLUCENCY_CONFIG_PATH, 'utf8')).intensity)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function writePersistedTranslucency(intensity) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(TRANSLUCENCY_CONFIG_PATH), { recursive: true })
|
||||
fs.writeFileSync(TRANSLUCENCY_CONFIG_PATH, JSON.stringify({ intensity }, null, 2), 'utf8')
|
||||
} catch (error) {
|
||||
rememberLog(`[translucency] write failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
let translucencyIntensity = readPersistedTranslucency()
|
||||
|
||||
// Map the 0–100 lever to a window opacity. Floor at 0.3 so the most see-through
|
||||
// setting is still usable rather than nearly invisible. 0 → fully opaque.
|
||||
function windowOpacity() {
|
||||
return 1 - (translucencyIntensity / 100) * 0.7
|
||||
}
|
||||
|
||||
// Re-apply translucency to a live window (runtime toggle, no recreation).
|
||||
// `setOpacity` is a no-op on Linux, which is fine — it just stays opaque there.
|
||||
function applyWindowTranslucency(win) {
|
||||
if (!win || win.isDestroyed() || typeof win.setOpacity !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
win.setOpacity(windowOpacity())
|
||||
} catch (error) {
|
||||
rememberLog(`[translucency] apply failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function isHexColor(value) {
|
||||
return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)
|
||||
}
|
||||
|
||||
// Background color to paint a window with BEFORE its renderer loads, so a new
|
||||
// (or reopened) window doesn't flash white/light in dark mode. Prefer the theme
|
||||
// the renderer last reported; fall back to the OS preference on first launch.
|
||||
function getWindowBackgroundColor() {
|
||||
if (rendererTitleBarTheme && isHexColor(rendererTitleBarTheme.background)) {
|
||||
return rendererTitleBarTheme.background
|
||||
}
|
||||
|
||||
return nativeTheme.shouldUseDarkColors ? '#111111' : '#f7f7f7'
|
||||
}
|
||||
|
||||
function getTitleBarOverlayOptions() {
|
||||
if (IS_MAC) {
|
||||
return { height: TITLEBAR_HEIGHT }
|
||||
@@ -1156,10 +1266,14 @@ function findSystemPython() {
|
||||
if (pyExe) {
|
||||
for (const version of SUPPORTED_VERSIONS) {
|
||||
try {
|
||||
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], hiddenWindowsChildOptions({
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
}))
|
||||
const out = execFileSync(
|
||||
pyExe,
|
||||
[`-${version}`, '-c', 'import sys; print(sys.executable)'],
|
||||
hiddenWindowsChildOptions({
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
)
|
||||
const candidate = out.trim()
|
||||
if (candidate && fileExists(candidate)) return candidate
|
||||
} catch {
|
||||
@@ -1294,11 +1408,15 @@ function resolveUpdateRoot() {
|
||||
|
||||
function runGit(args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({
|
||||
cwd: options.cwd,
|
||||
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}))
|
||||
const child = spawn(
|
||||
resolveGitBinary(),
|
||||
IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args,
|
||||
hiddenWindowsChildOptions({
|
||||
cwd: options.cwd,
|
||||
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
)
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
@@ -1735,11 +1853,15 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) {
|
||||
return new Promise(resolve => {
|
||||
let child
|
||||
try {
|
||||
child = spawn(command, args, hiddenWindowsChildOptions({
|
||||
cwd,
|
||||
env: { ...process.env, ...(env || {}) },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}))
|
||||
child = spawn(
|
||||
command,
|
||||
args,
|
||||
hiddenWindowsChildOptions({
|
||||
cwd,
|
||||
env: { ...process.env, ...(env || {}) },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
)
|
||||
} catch (err) {
|
||||
resolve({ code: 1, error: err.message })
|
||||
return
|
||||
@@ -2127,9 +2249,11 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
|
||||
label,
|
||||
command: python,
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
env: {
|
||||
PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
|
||||
},
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome: HERMES_HOME,
|
||||
pythonPathEntries: [root],
|
||||
venvRoot: path.join(root, 'venv')
|
||||
}),
|
||||
root,
|
||||
bootstrap: Boolean(options.bootstrap),
|
||||
shell: false
|
||||
@@ -2148,9 +2272,11 @@ function createActiveBackend(dashboardArgs) {
|
||||
label: `Hermes at ${ACTIVE_HERMES_ROOT}`,
|
||||
command: fileExists(venvPython) ? venvPython : findSystemPython(),
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
env: {
|
||||
PYTHONPATH: [ACTIVE_HERMES_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
|
||||
},
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome: HERMES_HOME,
|
||||
pythonPathEntries: [ACTIVE_HERMES_ROOT],
|
||||
venvRoot: VENV_ROOT
|
||||
}),
|
||||
root: ACTIVE_HERMES_ROOT,
|
||||
bootstrap: true,
|
||||
shell: false
|
||||
@@ -2452,10 +2578,11 @@ function isPortAvailable(port) {
|
||||
}
|
||||
|
||||
async function pickPort() {
|
||||
for (let port = PORT_FLOOR; port <= PORT_CEILING; port += 1) {
|
||||
if (await isPortAvailable(port)) return port
|
||||
const port = await portPool.reserve(isPortAvailable)
|
||||
if (port === null) {
|
||||
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
|
||||
}
|
||||
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
|
||||
return port
|
||||
}
|
||||
|
||||
function fetchJson(url, token, options = {}) {
|
||||
@@ -4539,31 +4666,46 @@ async function spawnPoolBackend(profile, entry) {
|
||||
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
|
||||
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
|
||||
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
|
||||
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||||
const hermesCwd = resolveHermesCwd()
|
||||
const webDist = resolveWebDist()
|
||||
let backend
|
||||
let hermesCwd
|
||||
let webDist
|
||||
try {
|
||||
backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||||
hermesCwd = resolveHermesCwd()
|
||||
webDist = resolveWebDist()
|
||||
} catch (error) {
|
||||
// These run before the child exists / its exit handler is attached, so a
|
||||
// throw here would otherwise leak the reservation and slowly exhaust the
|
||||
// 9120-9199 range across switch cycles in one app session.
|
||||
portPool.release(port)
|
||||
throw error
|
||||
}
|
||||
|
||||
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
|
||||
|
||||
const child = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
|
||||
cwd: hermesCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME,
|
||||
...backend.env,
|
||||
// Pin the gateway's tool/terminal cwd to the same directory we chose for
|
||||
// the child process. Inherited TERMINAL_CWD (or a stale config bridge)
|
||||
// can still point at the install dir even when spawn cwd is home.
|
||||
TERMINAL_CWD: hermesCwd,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
HERMES_WEB_DIST: webDist
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}))
|
||||
const child = spawn(
|
||||
backend.command,
|
||||
backend.args,
|
||||
hiddenWindowsChildOptions({
|
||||
cwd: hermesCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME,
|
||||
...backend.env,
|
||||
// Pin the gateway's tool/terminal cwd to the same directory we chose for
|
||||
// the child process. Inherited TERMINAL_CWD (or a stale config bridge)
|
||||
// can still point at the install dir even when spawn cwd is home.
|
||||
TERMINAL_CWD: hermesCwd,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
HERMES_WEB_DIST: webDist
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
)
|
||||
entry.process = child
|
||||
entry.port = port
|
||||
entry.token = token
|
||||
@@ -4579,11 +4721,13 @@ async function spawnPoolBackend(profile, entry) {
|
||||
child.once('error', error => {
|
||||
rememberLog(`Hermes backend for profile "${profile}" failed to start: ${error.message}`)
|
||||
backendPool.delete(profile)
|
||||
portPool.release(port)
|
||||
rejectStart?.(error)
|
||||
})
|
||||
child.once('exit', (code, signal) => {
|
||||
rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`)
|
||||
backendPool.delete(profile)
|
||||
portPool.release(port)
|
||||
if (!ready) {
|
||||
rejectStart?.(
|
||||
new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`)
|
||||
@@ -4594,15 +4738,21 @@ async function spawnPoolBackend(profile, entry) {
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await Promise.race([waitForHermes(baseUrl, token), startFailed])
|
||||
ready = true
|
||||
const authToken = await adoptServedDashboardToken(baseUrl, token, {
|
||||
childAlive: () => child.exitCode === null && !child.killed,
|
||||
label: `Hermes backend for profile "${profile}"`,
|
||||
rememberLog
|
||||
})
|
||||
entry.token = authToken
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'local',
|
||||
source: 'local',
|
||||
authMode: 'token',
|
||||
token,
|
||||
token: authToken,
|
||||
profile,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
@@ -4612,6 +4762,7 @@ function stopPoolBackend(profile) {
|
||||
const entry = backendPool.get(profile)
|
||||
if (!entry) return
|
||||
backendPool.delete(profile)
|
||||
if (entry.port) portPool.release(entry.port)
|
||||
if (entry.process && !entry.process.killed) {
|
||||
try {
|
||||
entry.process.kill('SIGTERM')
|
||||
@@ -4697,6 +4848,11 @@ async function startHermes() {
|
||||
}
|
||||
if (connectionPromise) return connectionPromise
|
||||
|
||||
// Hoisted so the outer .catch can release a port reserved by pickPort() when
|
||||
// a throw (e.g. ensureRuntime failing) happens before the child's exit
|
||||
// handler is attached. Stays null on the remote path (no port picked).
|
||||
let reservedPort = null
|
||||
|
||||
connectionPromise = (async () => {
|
||||
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
|
||||
// Resolve for the desktop's primary profile so a per-profile remote
|
||||
@@ -4726,6 +4882,7 @@ async function startHermes() {
|
||||
|
||||
await advanceBootProgress('backend.port', 'Finding an open local port', 16)
|
||||
const port = await pickPort()
|
||||
reservedPort = port
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
|
||||
// Pin the desktop's chosen profile via the global --profile flag. This is
|
||||
@@ -4745,30 +4902,34 @@ async function startHermes() {
|
||||
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
|
||||
rememberLog(`Starting Hermes backend via ${backend.label}`)
|
||||
|
||||
hermesProcess = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
|
||||
cwd: hermesCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
// Explicitly pin HERMES_HOME for the child so Python's get_hermes_home()
|
||||
// resolves to the SAME location our resolveHermesHome() picked. Without
|
||||
// this pin, Python falls back to ~/.hermes on every platform — fine on
|
||||
// mac/linux (where our default matches), but on Windows our default is
|
||||
// %LOCALAPPDATA%\hermes, which differs from C:\Users\<u>\.hermes.
|
||||
// Mismatch would split config / sessions / .env / logs across two
|
||||
// directories. install.ps1 sets HERMES_HOME via setx; the desktop
|
||||
// can't reliably do that, so we set it inline for every spawn.
|
||||
HERMES_HOME,
|
||||
...backend.env,
|
||||
TERMINAL_CWD: hermesCwd,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
HERMES_WEB_DIST: webDist
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}))
|
||||
hermesProcess = spawn(
|
||||
backend.command,
|
||||
backend.args,
|
||||
hiddenWindowsChildOptions({
|
||||
cwd: hermesCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
// Explicitly pin HERMES_HOME for the child so Python's get_hermes_home()
|
||||
// resolves to the SAME location our resolveHermesHome() picked. Without
|
||||
// this pin, Python falls back to ~/.hermes on every platform — fine on
|
||||
// mac/linux (where our default matches), but on Windows our default is
|
||||
// %LOCALAPPDATA%\hermes, which differs from C:\Users\<u>\.hermes.
|
||||
// Mismatch would split config / sessions / .env / logs across two
|
||||
// directories. install.ps1 sets HERMES_HOME via setx; the desktop
|
||||
// can't reliably do that, so we set it inline for every spawn.
|
||||
HERMES_HOME,
|
||||
...backend.env,
|
||||
TERMINAL_CWD: hermesCwd,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
HERMES_WEB_DIST: webDist
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
)
|
||||
|
||||
hermesProcess.stdout.on('data', rememberLog)
|
||||
hermesProcess.stderr.on('data', rememberLog)
|
||||
@@ -4790,6 +4951,7 @@ async function startHermes() {
|
||||
)
|
||||
hermesProcess = null
|
||||
connectionPromise = null
|
||||
portPool.release(port)
|
||||
sendBackendExit({ code: null, signal: null, error: error.message })
|
||||
rejectBackendStart?.(error)
|
||||
})
|
||||
@@ -4797,6 +4959,7 @@ async function startHermes() {
|
||||
rememberLog(`Hermes backend exited (${signal || code})`)
|
||||
hermesProcess = null
|
||||
connectionPromise = null
|
||||
portPool.release(port)
|
||||
sendBackendExit({ code, signal })
|
||||
if (!backendReady) {
|
||||
const message = `Hermes backend exited before it became ready (${signal || code}).`
|
||||
@@ -4821,6 +4984,11 @@ async function startHermes() {
|
||||
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
|
||||
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
|
||||
backendReady = true
|
||||
const authToken = await adoptServedDashboardToken(baseUrl, token, {
|
||||
// The exit/error handlers null hermesProcess when the child dies.
|
||||
childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed,
|
||||
rememberLog
|
||||
})
|
||||
updateBootProgress({
|
||||
phase: 'backend.ready',
|
||||
message: 'Hermes backend is ready. Finalizing desktop startup',
|
||||
@@ -4834,8 +5002,8 @@ async function startHermes() {
|
||||
mode: 'local',
|
||||
source: 'local',
|
||||
authMode: 'token',
|
||||
token,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
|
||||
token: authToken,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
@@ -4851,6 +5019,7 @@ async function startHermes() {
|
||||
{ allowDecrease: true }
|
||||
)
|
||||
connectionPromise = null
|
||||
portPool.release(reservedPort)
|
||||
throw error
|
||||
})
|
||||
|
||||
@@ -4898,21 +5067,29 @@ function focusWindow(win) {
|
||||
}
|
||||
|
||||
// Open (or focus) a standalone window for a single chat session.
|
||||
function createSessionWindow(sessionId) {
|
||||
function createSessionWindow(sessionId, { watch = false } = {}) {
|
||||
return sessionWindows.openOrFocus(sessionId, () => {
|
||||
const icon = getAppIconPath()
|
||||
const win = new BrowserWindow({
|
||||
width: 480,
|
||||
height: 800,
|
||||
minWidth: 420,
|
||||
minHeight: 620,
|
||||
width: SESSION_WINDOW_MIN_WIDTH,
|
||||
height: SESSION_WINDOW_MIN_HEIGHT,
|
||||
minWidth: SESSION_WINDOW_MIN_WIDTH,
|
||||
minHeight: SESSION_WINDOW_MIN_HEIGHT,
|
||||
title: 'Hermes',
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: getTitleBarOverlayOptions(),
|
||||
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
|
||||
vibrancy: IS_MAC ? 'sidebar' : undefined,
|
||||
opacity: windowOpacity(),
|
||||
icon,
|
||||
backgroundColor: '#f7f7f7',
|
||||
// Don't show until the renderer's first themed paint is ready. macOS
|
||||
// `vibrancy` ignores `backgroundColor` and paints a translucent OS
|
||||
// material (which follows the OS appearance, not the app theme), so a
|
||||
// dark-themed app on a light-mode Mac flashes white until the renderer
|
||||
// covers it. ready-to-show fires after the boot-time paint in
|
||||
// themes/context.tsx, so the window appears already themed.
|
||||
show: false,
|
||||
backgroundColor: getWindowBackgroundColor(),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
@@ -4927,6 +5104,10 @@ function createSessionWindow(sessionId) {
|
||||
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
|
||||
}
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
if (!win.isDestroyed()) win.show()
|
||||
})
|
||||
|
||||
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
|
||||
win.on('enter-full-screen', () => sendWindowStateChanged(true))
|
||||
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
||||
@@ -4937,7 +5118,8 @@ function createSessionWindow(sessionId) {
|
||||
win.loadURL(
|
||||
buildSessionWindowUrl(sessionId, {
|
||||
devServer: DEV_SERVER,
|
||||
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex()
|
||||
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
|
||||
watch
|
||||
})
|
||||
)
|
||||
|
||||
@@ -4963,8 +5145,13 @@ function createWindow() {
|
||||
titleBarOverlay: getTitleBarOverlayOptions(),
|
||||
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
|
||||
vibrancy: IS_MAC ? 'sidebar' : undefined,
|
||||
opacity: windowOpacity(),
|
||||
icon,
|
||||
backgroundColor: '#f7f7f7',
|
||||
// Hidden until the first themed paint so macOS `vibrancy` (which ignores
|
||||
// `backgroundColor` and follows the OS appearance) can't flash a light
|
||||
// material before the renderer paints the app theme. See createSessionWindow.
|
||||
show: false,
|
||||
backgroundColor: getWindowBackgroundColor(),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
@@ -5000,6 +5187,10 @@ function createWindow() {
|
||||
}
|
||||
}
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.show()
|
||||
})
|
||||
|
||||
mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true))
|
||||
mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true))
|
||||
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
||||
@@ -5111,12 +5302,12 @@ ipcMain.handle('hermes:backend:touch', async (_event, profile) => {
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile))
|
||||
ipcMain.handle('hermes:window:openSession', async (_event, sessionId) => {
|
||||
ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => {
|
||||
if (typeof sessionId !== 'string' || !sessionId.trim()) {
|
||||
return { ok: false, error: 'invalid-session-id' }
|
||||
}
|
||||
|
||||
createSessionWindow(sessionId.trim())
|
||||
createSessionWindow(sessionId.trim(), { watch: opts?.watch === true })
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -5125,8 +5316,8 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||
// reset connection state so the next startHermes() call restarts the
|
||||
// full backend flow (including a fresh runBootstrap pass).
|
||||
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
|
||||
await teardownPrimaryBackendAndWait()
|
||||
bootstrapFailure = null
|
||||
connectionPromise = null
|
||||
bootstrapState = {
|
||||
active: false,
|
||||
manifest: null,
|
||||
@@ -5524,6 +5715,35 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
|
||||
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
|
||||
})
|
||||
|
||||
// Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH).
|
||||
ipcMain.on('hermes:native-theme', (_event, mode) => {
|
||||
if (!THEME_SOURCES.has(mode)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nativeTheme.themeSource !== mode) {
|
||||
nativeTheme.themeSource = mode
|
||||
writePersistedThemeSource(mode)
|
||||
}
|
||||
})
|
||||
|
||||
// See-through window translucency. Persist + re-apply opacity to every open
|
||||
// window at runtime (no recreation, so caching/sessions are untouched).
|
||||
ipcMain.on('hermes:translucency', (_event, payload) => {
|
||||
const next = clampIntensity(payload && payload.intensity)
|
||||
|
||||
if (next === translucencyIntensity) {
|
||||
return
|
||||
}
|
||||
|
||||
translucencyIntensity = next
|
||||
writePersistedTranslucency(next)
|
||||
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
applyWindowTranslucency(win)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:openExternal', (_event, url) => {
|
||||
if (!openExternalUrl(url)) {
|
||||
throw new Error('Invalid external URL')
|
||||
@@ -5961,11 +6181,15 @@ async function getUninstallSummary() {
|
||||
resolve(value)
|
||||
}
|
||||
try {
|
||||
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], hiddenWindowsChildOptions({
|
||||
cwd: agentRoot,
|
||||
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
}))
|
||||
const child = spawn(
|
||||
py,
|
||||
['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'],
|
||||
hiddenWindowsChildOptions({
|
||||
cwd: agentRoot,
|
||||
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
)
|
||||
child.stdout.on('data', chunk => {
|
||||
stdout += chunk.toString()
|
||||
})
|
||||
@@ -6123,7 +6347,7 @@ let _rendererReadyForDeepLink = false
|
||||
|
||||
function _extractDeepLink(argv) {
|
||||
if (!Array.isArray(argv)) return null
|
||||
return argv.find((a) => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null
|
||||
return argv.find(a => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null
|
||||
}
|
||||
|
||||
function handleDeepLink(url) {
|
||||
@@ -6167,9 +6391,7 @@ ipcMain.handle('hermes:deep-link-ready', () => {
|
||||
_pendingDeepLink = null
|
||||
handleDeepLink(
|
||||
`${HERMES_PROTOCOL}://${queued.kind}/${encodeURIComponent(queued.name)}` +
|
||||
(Object.keys(queued.params).length
|
||||
? '?' + new URLSearchParams(queued.params).toString()
|
||||
: ''),
|
||||
(Object.keys(queued.params).length ? '?' + new URLSearchParams(queued.params).toString() : '')
|
||||
)
|
||||
}
|
||||
return { ok: true }
|
||||
@@ -6180,9 +6402,7 @@ function registerDeepLinkProtocol() {
|
||||
if (process.defaultApp && process.argv.length >= 2) {
|
||||
// Dev: register with the electron exec path + entry script so the OS can
|
||||
// relaunch us with the URL.
|
||||
app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [
|
||||
path.resolve(process.argv[1]),
|
||||
])
|
||||
app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [path.resolve(process.argv[1])])
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient(HERMES_PROTOCOL)
|
||||
}
|
||||
@@ -6215,7 +6435,6 @@ app.on('open-url', (event, url) => {
|
||||
handleDeepLink(url)
|
||||
})
|
||||
|
||||
|
||||
app.whenReady().then(() => {
|
||||
if (IS_MAC) {
|
||||
Menu.setApplicationMenu(buildApplicationMenu())
|
||||
|
||||
73
apps/desktop/electron/port-pool.cjs
Normal file
73
apps/desktop/electron/port-pool.cjs
Normal file
@@ -0,0 +1,73 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* In-process port reservation pool for the desktop backend launcher.
|
||||
*
|
||||
* pickPort() probes a localhost port with a throwaway server and closes it
|
||||
* before the real bind happens in a separate Python child. Between that probe
|
||||
* and the child's bind there is a TOCTOU window: a second concurrent spawn
|
||||
* (the primary backend racing a pool backend) can be handed the SAME port, and
|
||||
* one then dies with EADDRINUSE ("address already in use" -> "Object has been
|
||||
* destroyed" boot loop). Reserving the chosen port in THIS process until the
|
||||
* child exits closes that window.
|
||||
*
|
||||
* The OS bind remains the source of truth; this only deconflicts racers inside
|
||||
* this process — it can't stop a foreign squatter, which the probe + the
|
||||
* EADDRINUSE self-heal still cover.
|
||||
*
|
||||
* The pool is dependency-injected (the availability probe is passed in) and
|
||||
* free of Electron/Node socket I/O, so it is unit-tested without real sockets
|
||||
* (see port-pool.test.cjs).
|
||||
*/
|
||||
class PortPool {
|
||||
/**
|
||||
* @param {number} floor inclusive lowest port to hand out
|
||||
* @param {number} ceiling inclusive highest port to hand out
|
||||
*/
|
||||
constructor(floor, ceiling) {
|
||||
this.floor = floor
|
||||
this.ceiling = ceiling
|
||||
this._reserved = new Set()
|
||||
}
|
||||
|
||||
/** @returns {boolean} whether `port` is currently reserved in-process. */
|
||||
has(port) {
|
||||
return this._reserved.has(port)
|
||||
}
|
||||
|
||||
/** Release a previously reserved port. No-op if it was not reserved. */
|
||||
release(port) {
|
||||
this._reserved.delete(port)
|
||||
}
|
||||
|
||||
/** Drop all reservations. */
|
||||
clear() {
|
||||
this._reserved.clear()
|
||||
}
|
||||
|
||||
/** @returns {number} count of currently reserved ports. */
|
||||
get size() {
|
||||
return this._reserved.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve and return the lowest port in [floor, ceiling] that is neither
|
||||
* already reserved in-process nor rejected by `isAvailable(port)`, or null
|
||||
* if every port is taken. `isAvailable` may be sync (boolean) or async
|
||||
* (Promise<boolean>); it is awaited either way.
|
||||
*
|
||||
* @param {(port: number) => boolean | Promise<boolean>} isAvailable
|
||||
* @returns {Promise<number|null>}
|
||||
*/
|
||||
async reserve(isAvailable) {
|
||||
for (let port = this.floor; port <= this.ceiling; port += 1) {
|
||||
if (this._reserved.has(port)) continue
|
||||
if (!(await isAvailable(port))) continue
|
||||
this._reserved.add(port)
|
||||
return port
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PortPool }
|
||||
77
apps/desktop/electron/port-pool.test.cjs
Normal file
77
apps/desktop/electron/port-pool.test.cjs
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Tests for electron/port-pool.cjs.
|
||||
*
|
||||
* Run with: node --test electron/port-pool.test.cjs
|
||||
*
|
||||
* PortPool is the in-process reservation that closes the pickPort() TOCTOU
|
||||
* window. These cover selection order, skipping reserved/unavailable ports,
|
||||
* release/reuse, exhaustion, and async probes — without real sockets.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { PortPool } = require('./port-pool.cjs')
|
||||
|
||||
const allFree = () => true
|
||||
|
||||
test('reserve returns the lowest free port and reserves it', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
const port = await pool.reserve(allFree)
|
||||
assert.equal(port, 9120)
|
||||
assert.ok(pool.has(9120))
|
||||
assert.equal(pool.size, 1)
|
||||
})
|
||||
|
||||
test('reserve skips ports already reserved in-process', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
const first = await pool.reserve(allFree)
|
||||
const second = await pool.reserve(allFree)
|
||||
assert.equal(first, 9120)
|
||||
assert.equal(second, 9121)
|
||||
})
|
||||
|
||||
test('reserve skips ports the probe rejects', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
const busy = new Set([9120, 9121])
|
||||
const port = await pool.reserve(p => !busy.has(p))
|
||||
assert.equal(port, 9122)
|
||||
})
|
||||
|
||||
test('reserve returns null when every port is taken', async () => {
|
||||
const pool = new PortPool(9120, 9121)
|
||||
await pool.reserve(allFree)
|
||||
await pool.reserve(allFree)
|
||||
assert.equal(await pool.reserve(allFree), null)
|
||||
})
|
||||
|
||||
test('release frees a reserved port for reuse', async () => {
|
||||
const pool = new PortPool(9120, 9120)
|
||||
assert.equal(await pool.reserve(allFree), 9120)
|
||||
assert.equal(await pool.reserve(allFree), null) // exhausted
|
||||
pool.release(9120)
|
||||
assert.ok(!pool.has(9120))
|
||||
assert.equal(await pool.reserve(allFree), 9120) // reusable
|
||||
})
|
||||
|
||||
test('release is a no-op for an unreserved port', () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
pool.release(9120)
|
||||
assert.equal(pool.size, 0)
|
||||
})
|
||||
|
||||
test('reserve awaits an async probe', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
const busy = new Set([9120])
|
||||
const port = await pool.reserve(p => Promise.resolve(!busy.has(p)))
|
||||
assert.equal(port, 9121)
|
||||
})
|
||||
|
||||
test('clear drops all reservations', async () => {
|
||||
const pool = new PortPool(9120, 9199)
|
||||
await pool.reserve(allFree)
|
||||
await pool.reserve(allFree)
|
||||
assert.equal(pool.size, 2)
|
||||
pool.clear()
|
||||
assert.equal(pool.size, 0)
|
||||
})
|
||||
@@ -5,7 +5,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'),
|
||||
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
|
||||
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
|
||||
openSessionWindow: sessionId => ipcRenderer.invoke('hermes:window:openSession', sessionId),
|
||||
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
|
||||
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
||||
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
|
||||
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
||||
@@ -39,6 +39,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url),
|
||||
stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id),
|
||||
setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload),
|
||||
setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode),
|
||||
setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload),
|
||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
|
||||
|
||||
@@ -5,22 +5,30 @@
|
||||
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
// Secondary windows open at the minimum usable size — a compact side panel for
|
||||
// subagent watch / cmd-click session pop-out, not a second full desktop.
|
||||
const SESSION_WINDOW_MIN_WIDTH = 420
|
||||
const SESSION_WINDOW_MIN_HEIGHT = 620
|
||||
|
||||
// Build the renderer URL for a secondary window. The renderer uses a
|
||||
// HashRouter, so the session route lives after the '#'. The `?win=secondary`
|
||||
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
|
||||
// treated as the route by HashRouter and would break routeSessionId(). The
|
||||
// renderer reads the flag from window.location.search to suppress the install /
|
||||
// onboarding overlays and the global session sidebar.
|
||||
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath } = {}) {
|
||||
// onboarding overlays and the global session sidebar. `watch=1` marks a
|
||||
// spectator window (e.g. a running subagent's session): the renderer resumes
|
||||
// it lazily so the gateway never builds an agent just to stream into it.
|
||||
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch } = {}) {
|
||||
const query = `?win=secondary${watch ? '&watch=1' : ''}`
|
||||
const route = `#/${encodeURIComponent(sessionId)}`
|
||||
|
||||
if (devServer) {
|
||||
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
|
||||
|
||||
return `${base}/?win=secondary${route}`
|
||||
return `${base}/${query}${route}`
|
||||
}
|
||||
|
||||
return `${pathToFileURL(rendererIndexPath).toString()}?win=secondary${route}`
|
||||
return `${pathToFileURL(rendererIndexPath).toString()}${query}${route}`
|
||||
}
|
||||
|
||||
// A small registry keyed by sessionId that guarantees one window per chat:
|
||||
@@ -83,4 +91,9 @@ function createSessionWindowRegistry() {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { buildSessionWindowUrl, createSessionWindowRegistry }
|
||||
module.exports = {
|
||||
buildSessionWindowUrl,
|
||||
createSessionWindowRegistry,
|
||||
SESSION_WINDOW_MIN_HEIGHT,
|
||||
SESSION_WINDOW_MIN_WIDTH
|
||||
}
|
||||
|
||||
@@ -76,6 +76,12 @@ test('buildSessionWindowUrl builds a packaged file URL with the flag before the
|
||||
assert.match(url, /^file:\/\/.*index\.html\?win=secondary#\/abc$/)
|
||||
})
|
||||
|
||||
test('buildSessionWindowUrl adds the watch flag for spectator windows, before the hash', () => {
|
||||
const url = buildSessionWindowUrl('abc', { devServer: 'http://localhost:5173', watch: true })
|
||||
|
||||
assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc')
|
||||
})
|
||||
|
||||
test('registry opens one window per session and focuses on re-open', () => {
|
||||
const registry = createSessionWindowRegistry()
|
||||
let built = 0
|
||||
|
||||
@@ -8,7 +8,7 @@ const path = require('node:path')
|
||||
const ELECTRON_DIR = __dirname
|
||||
|
||||
function readElectronFile(name) {
|
||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8')
|
||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
function requireHiddenChildOptions(source, needle) {
|
||||
|
||||
@@ -9,6 +9,28 @@
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="shortcut icon" href="/apple-touch-icon.png" />
|
||||
<title>Hermes</title>
|
||||
<script>
|
||||
// Pre-paint the themed background before the app bundle loads. Without
|
||||
// this, the first frame (which is what `ready-to-show` waits for) is the
|
||||
// UA-default white page, and the real theme only lands once the whole
|
||||
// module graph has executed — i.e. the "white flash" on every new
|
||||
// window. applyTheme() in src/themes/context.tsx keeps these keys fresh.
|
||||
try {
|
||||
let bg = localStorage.getItem('hermes-boot-background')
|
||||
let scheme = localStorage.getItem('hermes-boot-color-scheme')
|
||||
if (!bg) {
|
||||
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
bg = dark ? '#111111' : '#f7f7f7'
|
||||
scheme = dark ? 'dark' : 'light'
|
||||
}
|
||||
document.documentElement.style.backgroundColor = bg
|
||||
if (scheme === 'dark' || scheme === 'light') {
|
||||
document.documentElement.style.colorScheme = scheme
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable — keep UA defaults.
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" class="scrollbar-dt"></div>
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
|
||||
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
|
||||
"start": "npm run build && electron .",
|
||||
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/assert-dist-built.cjs",
|
||||
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
|
||||
"postbuild": "node scripts/assert-dist-built.cjs",
|
||||
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
|
||||
"pack": "npm run build && npm run builder -- --dir",
|
||||
"dist": "npm run build && npm run builder",
|
||||
@@ -35,7 +36,7 @@
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/port-pool.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
||||
@@ -3,8 +3,8 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
@@ -25,7 +25,7 @@ import { OverlayView } from '../overlays/overlay-view'
|
||||
function statusGlyph(status: SubagentStatus, a: Translations['agents']): ReactNode {
|
||||
if (status === 'running' || status === 'queued') {
|
||||
return (
|
||||
<BrailleSpinner
|
||||
<GlyphSpinner
|
||||
ariaLabel={a.running}
|
||||
className="size-3.5 shrink-0 text-[0.95rem] text-muted-foreground/80"
|
||||
spinner="breathe"
|
||||
@@ -290,7 +290,7 @@ function StreamLine({
|
||||
<span className={cn('min-w-0 flex-1 wrap-anywhere', tone, isMono && 'font-mono text-[0.69rem]')}>
|
||||
{entry.text}
|
||||
{active ? (
|
||||
<BrailleSpinner
|
||||
<GlyphSpinner
|
||||
ariaLabel={t.agents.streaming}
|
||||
className="ml-1 inline-block size-2.5 align-middle text-muted-foreground/70"
|
||||
spinner="breathe"
|
||||
@@ -372,7 +372,9 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
|
||||
|
||||
{open && fileLines.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-0.5 pl-6">
|
||||
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">{t.agents.files}</p>
|
||||
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">
|
||||
{t.agents.files}
|
||||
</p>
|
||||
{fileLines.slice(0, 8).map(line => (
|
||||
<p className="wrap-break-word font-mono text-[0.67rem] leading-relaxed text-muted-foreground/80" key={line}>
|
||||
{line}
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from '@/components/ui/pagination'
|
||||
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getSessionMessages, listSessions } from '@/hermes'
|
||||
import { getSessionMessages, listAllProfileSessions } from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
|
||||
@@ -388,8 +388,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const sessions = (await listSessions(30, 1)).sessions
|
||||
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
|
||||
const sessions = (await listAllProfileSessions(30, 1)).sessions
|
||||
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id, session.profile)))
|
||||
const nextArtifacts: ArtifactRecord[] = []
|
||||
|
||||
results.forEach((result, index) => {
|
||||
|
||||
@@ -2,25 +2,21 @@ import type { Unstable_TriggerAdapter } from '@assistant-ui/core'
|
||||
import { ComposerPrimitive } from '@assistant-ui/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export const COMPLETION_DRAWER_CLASS = [
|
||||
'absolute bottom-[calc(100%+0.375rem)] left-0 z-50',
|
||||
'w-80 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-xl border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-lg',
|
||||
'backdrop-blur-md'
|
||||
].join(' ')
|
||||
import { composerFusedDockCard } from '@/components/chat/composer-dock'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const COMPLETION_DRAWER_BELOW_CLASS = [
|
||||
'absolute left-0 top-[calc(100%+0.375rem)] z-50',
|
||||
'w-80 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-xl border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-lg',
|
||||
'backdrop-blur-md'
|
||||
].join(' ')
|
||||
// Same docked chrome as the queue/status stack, but its own thing: a narrow,
|
||||
// left-aligned card (not full width) that fuses to the composer's edge instead
|
||||
// of floating above it. `left-1` matches the stack's `mx-1` inset; the negative
|
||||
// margin overlaps the seam so the composer's (now-transparent) edge border reads
|
||||
// as shared. Fused (opaque) fill — the composer surface swaps to the same fill
|
||||
// while a drawer is open, so the two paint as one panel.
|
||||
const DRAWER_SHELL =
|
||||
'absolute left-1 z-50 w-80 max-w-[calc(100%-0.5rem)] max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain p-1 text-xs text-popover-foreground'
|
||||
|
||||
export const COMPLETION_DRAWER_CLASS = cn(DRAWER_SHELL, 'bottom-full -mb-[9px]', composerFusedDockCard('top'))
|
||||
|
||||
export const COMPLETION_DRAWER_BELOW_CLASS = cn(DRAWER_SHELL, 'top-full -mt-[9px]', composerFusedDockCard('bottom'))
|
||||
|
||||
export function ComposerCompletionDrawer({
|
||||
adapter,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Kbd } from '@/components/ui/kbd'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -86,7 +87,7 @@ export function ContextMenu({
|
||||
|
||||
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
|
||||
{c.tipPre}
|
||||
<kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd>
|
||||
<Kbd size="sm">@</Kbd>
|
||||
{c.tipPost}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { KbdCombo } from '@/components/ui/kbd'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
@@ -63,7 +64,14 @@ export function ComposerControls({
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const steerLabel = `${c.steer} (${formatCombo('mod+enter')})`
|
||||
const steerCombo = formatCombo('mod+enter')
|
||||
const steerLabel = `${c.steer} (${steerCombo})`
|
||||
const steerTip = (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{c.steer}
|
||||
<KbdCombo combo="mod+enter" size="sm" variant="inverted" />
|
||||
</span>
|
||||
)
|
||||
|
||||
if (conversation.active) {
|
||||
return <ConversationPill {...conversation} disabled={disabled} />
|
||||
@@ -75,7 +83,7 @@ export function ComposerControls({
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
{canSteer && (
|
||||
<Tip label={steerLabel}>
|
||||
<Tip label={steerTip}>
|
||||
<Button
|
||||
aria-label={steerLabel}
|
||||
className={GHOST_ICON_BTN}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* steal focus from the composer effect.
|
||||
*/
|
||||
|
||||
import { RICH_INPUT_SLOT } from './rich-editor'
|
||||
import type { InlineRefInput } from './inline-refs'
|
||||
|
||||
export type ComposerTarget = 'edit' | 'main'
|
||||
@@ -123,3 +124,12 @@ export const focusComposerInput = (el: HTMLElement | null) => {
|
||||
window.requestAnimationFrame(focus)
|
||||
window.setTimeout(focus, 0)
|
||||
}
|
||||
|
||||
/** Drop focus from the main composer input (status-stack chrome, sidebar, etc.). */
|
||||
export const blurComposerInput = () => {
|
||||
const el = document.querySelector(`[data-slot="${RICH_INPUT_SLOT}"]`) as HTMLElement | null
|
||||
|
||||
if (el && document.activeElement === el) {
|
||||
el.blur()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { KbdCombo } from '@/components/ui/kbd'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
import { COMPLETION_DRAWER_CLASS } from './completion-drawer'
|
||||
|
||||
const COMMON_COMMAND_KEYS = ['/help', '/clear', '/resume', '/details', '/copy', '/quit']
|
||||
const HOTKEY_KEYS = ['@', '/', '?', 'Enter', 'Cmd/Ctrl+Shift+K', 'Cmd/Ctrl+/', 'Esc', '↑ / ↓']
|
||||
|
||||
/** Stable ids → i18n `hotkeyDescs` keys. Combos resolve mod labels per OS. */
|
||||
const COMPOSER_HOTKEY_ROWS = [
|
||||
{ id: 'composer.mention', combos: ['@'] },
|
||||
{ id: 'composer.slash', combos: ['/'] },
|
||||
{ id: 'composer.help', combos: ['?'] },
|
||||
{ id: 'composer.sendNewline', combos: ['enter', 'shift+enter'] },
|
||||
{ id: 'composer.sendQueued', combos: ['mod+shift+k'] },
|
||||
{ id: 'keybinds.openPanel', combos: ['mod+/'] },
|
||||
{ id: 'composer.cancel', combos: ['escape'] },
|
||||
{ id: 'composer.history', combos: ['up', 'down'] }
|
||||
] as const
|
||||
|
||||
export function HelpHint() {
|
||||
const { t } = useI18n()
|
||||
@@ -20,8 +32,8 @@ export function HelpHint() {
|
||||
</Section>
|
||||
|
||||
<Section title={c.hotkeys}>
|
||||
{HOTKEY_KEYS.map(key => (
|
||||
<Row description={c.hotkeyDescs[key] ?? ''} key={key} keyLabel={key} />
|
||||
{COMPOSER_HOTKEY_ROWS.map(row => (
|
||||
<HotkeyRow description={c.hotkeyDescs[row.id] ?? ''} combos={[...row.combos]} key={row.id} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
@@ -57,3 +69,16 @@ function Row({ description, keyLabel, mono = false }: { description: string; key
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HotkeyRow({ combos, description }: { combos: string[]; description: string }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1 text-xs">
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{combos.map(combo => (
|
||||
<KbdCombo combo={combo} key={combo} size="sm" />
|
||||
))}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground/80">{description}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from 'react'
|
||||
|
||||
import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text'
|
||||
import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
shouldAutoDrainOnSettle,
|
||||
updateQueuedPrompt
|
||||
} from '@/store/composer-queue'
|
||||
import { $statusItemsBySession } from '@/store/composer-status'
|
||||
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { useTheme } from '@/themes'
|
||||
@@ -80,12 +82,14 @@ import {
|
||||
import { QueuePanel } from './queue-panel'
|
||||
import {
|
||||
composerPlainText,
|
||||
normalizeComposerEditorDom,
|
||||
placeCaretEnd,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT,
|
||||
slashChipElement
|
||||
} from './rich-editor'
|
||||
import { ComposerStatusStack } from './status-stack'
|
||||
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
@@ -168,6 +172,7 @@ export function ChatBar({
|
||||
const draft = useAuiState(s => s.composer.text)
|
||||
const attachments = useStore($composerAttachments)
|
||||
const queuedPromptsBySession = useStore($queuedPromptsBySession)
|
||||
const statusItemsBySession = useStore($statusItemsBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const sessionMessages = useStore($messages)
|
||||
const activeQueueSessionKey = queueSessionKey || sessionId || null
|
||||
@@ -177,6 +182,17 @@ export function ChatBar({
|
||||
[activeQueueSessionKey, queuedPromptsBySession]
|
||||
)
|
||||
|
||||
// Status items (subagents, background processes) are keyed by the RUNTIME
|
||||
// session id — gateway events and process.list both speak that id. Only the
|
||||
// queue uses the stored-session fallback key (prompts can queue pre-resume).
|
||||
const statusSessionId = sessionId ?? null
|
||||
|
||||
const statusStackVisible = useMemo(
|
||||
() =>
|
||||
queuedPrompts.length > 0 || (statusSessionId ? (statusItemsBySession[statusSessionId]?.length ?? 0) > 0 : false),
|
||||
[queuedPrompts.length, statusItemsBySession, statusSessionId]
|
||||
)
|
||||
|
||||
const composerRef = useRef<HTMLFormElement | null>(null)
|
||||
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
@@ -602,9 +618,7 @@ export function ChatBar({
|
||||
// (which drives `hasComposerPayload` → the send button). Shared by the input
|
||||
// and compositionend paths so committed IME text reaches state through either.
|
||||
const flushEditorToDraft = (editor: HTMLDivElement) => {
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
|
||||
editor.replaceChildren()
|
||||
}
|
||||
normalizeComposerEditorDom(editor)
|
||||
|
||||
const nextDraft = composerPlainText(editor)
|
||||
|
||||
@@ -688,8 +702,7 @@ export function ChatBar({
|
||||
// already an arg pick (`/personality alice`), so it commits normally.
|
||||
const command = (item.metadata as { command?: string } | undefined)?.command ?? ''
|
||||
|
||||
const expandsToArgs =
|
||||
trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command)
|
||||
const expandsToArgs = trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command)
|
||||
|
||||
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
|
||||
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
|
||||
@@ -1113,11 +1126,8 @@ export function ChatBar({
|
||||
}
|
||||
}
|
||||
|
||||
const stashAt = (
|
||||
scope: string | null,
|
||||
text = draftRef.current,
|
||||
attachments = $composerAttachments.get()
|
||||
) => stashSessionDraft(scope, text, attachments)
|
||||
const stashAt = (scope: string | null, text = draftRef.current, attachments = $composerAttachments.get()) =>
|
||||
stashSessionDraft(scope, text, attachments)
|
||||
|
||||
// Per-thread draft swap — the composer's only session coupling. Lifecycle
|
||||
// never clears composer state; this effect alone stashes on leave, restores
|
||||
@@ -1669,6 +1679,7 @@ export function ChatBar({
|
||||
className="group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]"
|
||||
data-drag-active={dragActive ? '' : undefined}
|
||||
data-slot="composer-root"
|
||||
data-status-stack={statusStackVisible ? '' : undefined}
|
||||
data-thread-scrolled-up={scrolledUp ? '' : undefined}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -1696,26 +1707,30 @@ export function ChatBar({
|
||||
onPick={replaceTriggerWithChip}
|
||||
/>
|
||||
)}
|
||||
{activeQueueSessionKey && queuedPrompts.length > 0 && (
|
||||
// Out of flow so the queue never inflates the composer's measured
|
||||
// height (that drives thread bottom padding → chat resizes on
|
||||
// queue). Overlaps -mb-2 onto the surface's top border for a shared
|
||||
// edge; capped + scrollable. Overlays the chat instead of pushing it.
|
||||
<div className="absolute inset-x-0 bottom-full z-6 -mb-2 max-h-[40vh] overflow-y-auto">
|
||||
<QueuePanel
|
||||
busy={busy}
|
||||
editingId={queueEdit?.entryId ?? null}
|
||||
entries={queuedPrompts}
|
||||
onDelete={id => {
|
||||
if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) {
|
||||
exitQueuedEdit('cancel')
|
||||
}
|
||||
}}
|
||||
onEdit={beginQueuedEdit}
|
||||
onSendNow={id => void sendQueuedNow(id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Session-scoped status stack (todos, subagents, background tasks,
|
||||
queue). Out of flow so it never inflates the composer's measured
|
||||
height; it overlays the chat instead of pushing it, and publishes
|
||||
its own --status-stack-measured-height so the thread's clearance
|
||||
accounts for it. Collapses to nothing when every status is empty. */}
|
||||
<ComposerStatusStack
|
||||
queue={
|
||||
activeQueueSessionKey && queuedPrompts.length > 0 ? (
|
||||
<QueuePanel
|
||||
busy={busy}
|
||||
editingId={queueEdit?.entryId ?? null}
|
||||
entries={queuedPrompts}
|
||||
onDelete={id => {
|
||||
if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) {
|
||||
exitQueuedEdit('cancel')
|
||||
}
|
||||
}}
|
||||
onEdit={beginQueuedEdit}
|
||||
onSendNow={id => void sendQueuedNow(id)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
sessionId={statusSessionId}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-[inherit]"
|
||||
style={{ background: COMPOSER_FADE_BACKGROUND }}
|
||||
@@ -1723,10 +1738,10 @@ export function ChatBar({
|
||||
<div className="relative w-full rounded-[inherit]">
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out',
|
||||
'group/composer-surface relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out focus-within:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
|
||||
COMPOSER_DROP_FADE_CLASS,
|
||||
'group-focus-within/composer:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
|
||||
'group-has-data-[state=open]/composer:border-t-transparent',
|
||||
'group-data-[status-stack]/composer:border-t-transparent',
|
||||
dragActive && COMPOSER_DROP_ACTIVE_CLASS
|
||||
)}
|
||||
data-slot="composer-surface"
|
||||
@@ -1736,20 +1751,14 @@ export function ChatBar({
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
|
||||
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
|
||||
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
|
||||
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
|
||||
'transition-[background-color] duration-150 ease-out',
|
||||
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
|
||||
'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
|
||||
composerFill,
|
||||
composerSurfaceGlass
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
|
||||
scrolledUp
|
||||
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
|
||||
: 'opacity-100'
|
||||
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer-surface:opacity-100' : 'opacity-100'
|
||||
)}
|
||||
data-slot="composer-fade"
|
||||
>
|
||||
@@ -1824,12 +1833,8 @@ export function ChatBarFallback() {
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
|
||||
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
|
||||
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
|
||||
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
|
||||
'transition-[background-color] duration-150 ease-out',
|
||||
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
|
||||
'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
|
||||
composerFill,
|
||||
composerSurfaceGlass
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,12 @@ import { contextPath } from '@/lib/chat-runtime'
|
||||
|
||||
import type { DroppedFile } from '../hooks/use-composer-actions'
|
||||
|
||||
import { composerPlainText, escapeHtml, placeCaretEnd, refChipHtml } from './rich-editor'
|
||||
import {
|
||||
composerPlainText,
|
||||
normalizeComposerEditorDom,
|
||||
placeCaretEnd,
|
||||
refChipElement
|
||||
} from './rich-editor'
|
||||
|
||||
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
|
||||
export type InlineRefInput = string | { kind: string; label?: string; value: string }
|
||||
@@ -89,56 +94,102 @@ export function droppedFileInlineRefs(candidates: DroppedFile[], cwd: string | n
|
||||
return candidates.map(candidate => droppedFileInlineRef(candidate, cwd)).filter((ref): ref is string => Boolean(ref))
|
||||
}
|
||||
|
||||
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
|
||||
if (!refs.length) {
|
||||
function parseInlineRef(ref: InlineRefInput): { kind: string; label?: string; rawValue: string } | null {
|
||||
if (typeof ref !== 'string') {
|
||||
return { kind: ref.kind, label: ref.label, rawValue: ref.value }
|
||||
}
|
||||
|
||||
const match = ref.match(/^@([^:]+):(.+)$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const refsHtml = refs
|
||||
.map(ref => {
|
||||
if (typeof ref !== 'string') {
|
||||
return refChipHtml(ref.kind, ref.value, ref.label)
|
||||
}
|
||||
return { kind: match[1] || 'file', rawValue: match[2] || '' }
|
||||
}
|
||||
|
||||
const match = ref.match(/^@([^:]+):(.+)$/)
|
||||
function plainTextInRange(editor: HTMLDivElement, range: Range, edge: 'after' | 'before') {
|
||||
const slice = range.cloneRange()
|
||||
slice.selectNodeContents(editor)
|
||||
|
||||
return match ? refChipHtml(match[1], match[2]) : escapeHtml(ref)
|
||||
})
|
||||
.join(' ')
|
||||
if (edge === 'before') {
|
||||
slice.setEnd(range.startContainer, range.startOffset)
|
||||
} else {
|
||||
slice.setStart(range.endContainer, range.endOffset)
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.appendChild(slice.cloneContents())
|
||||
|
||||
return composerPlainText(container)
|
||||
}
|
||||
|
||||
function buildRefFragment(
|
||||
refs: readonly { kind: string; label?: string; rawValue: string }[],
|
||||
{ needsBeforeSpace, needsAfterSpace }: { needsAfterSpace: boolean; needsBeforeSpace: boolean }
|
||||
) {
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
if (needsBeforeSpace) {
|
||||
fragment.append(document.createTextNode(' '))
|
||||
}
|
||||
|
||||
refs.forEach((ref, index) => {
|
||||
if (index > 0) {
|
||||
fragment.append(document.createTextNode(' '))
|
||||
}
|
||||
|
||||
fragment.append(refChipElement(ref.kind, ref.rawValue, ref.label))
|
||||
})
|
||||
|
||||
if (needsAfterSpace) {
|
||||
fragment.append(document.createTextNode(' '))
|
||||
}
|
||||
|
||||
return fragment
|
||||
}
|
||||
|
||||
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
|
||||
const parsed = refs.map(parseInlineRef).filter((ref): ref is NonNullable<typeof ref> => ref !== null)
|
||||
|
||||
if (!parsed.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
editor.focus({ preventScroll: true })
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
const range =
|
||||
selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer)
|
||||
? selection.getRangeAt(0)
|
||||
: null
|
||||
|
||||
editor.focus({ preventScroll: true })
|
||||
if (range && selection) {
|
||||
const beforeText = plainTextInRange(editor, range, 'before')
|
||||
const afterText = plainTextInRange(editor, range, 'after')
|
||||
|
||||
if (range) {
|
||||
const beforeRange = range.cloneRange()
|
||||
beforeRange.selectNodeContents(editor)
|
||||
beforeRange.setEnd(range.startContainer, range.startOffset)
|
||||
const beforeContainer = document.createElement('div')
|
||||
beforeContainer.appendChild(beforeRange.cloneContents())
|
||||
|
||||
const afterRange = range.cloneRange()
|
||||
afterRange.selectNodeContents(editor)
|
||||
afterRange.setStart(range.endContainer, range.endOffset)
|
||||
const afterContainer = document.createElement('div')
|
||||
afterContainer.appendChild(afterRange.cloneContents())
|
||||
|
||||
const beforeText = composerPlainText(beforeContainer)
|
||||
const afterText = composerPlainText(afterContainer)
|
||||
const needsBeforeSpace = beforeText.length > 0 && !/\s$/.test(beforeText)
|
||||
const needsAfterSpace = afterText.length === 0 || !/^\s/.test(afterText)
|
||||
|
||||
document.execCommand('insertHTML', false, `${needsBeforeSpace ? ' ' : ''}${refsHtml}${needsAfterSpace ? ' ' : ''}`)
|
||||
range.insertNode(
|
||||
buildRefFragment(parsed, {
|
||||
needsAfterSpace: afterText.length === 0 || !/^\s/.test(afterText),
|
||||
needsBeforeSpace: beforeText.length > 0 && !/\s$/.test(beforeText)
|
||||
})
|
||||
)
|
||||
range.collapse(false)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
} else {
|
||||
const current = composerPlainText(editor)
|
||||
|
||||
editor.append(
|
||||
buildRefFragment(parsed, {
|
||||
needsAfterSpace: true,
|
||||
needsBeforeSpace: current.length > 0 && !/\s$/.test(current)
|
||||
})
|
||||
)
|
||||
placeCaretEnd(editor)
|
||||
document.execCommand('insertHTML', false, `${current && !/\s$/.test(current) ? ' ' : ''}${refsHtml} `)
|
||||
}
|
||||
|
||||
normalizeComposerEditorDom(editor)
|
||||
|
||||
return composerPlainText(editor)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { StatusRow } from '@/components/chat/status-row'
|
||||
import { StatusSection } from '@/components/chat/status-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { ArrowUp, Pencil, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { QueuedPromptEntry } from '@/store/composer-queue'
|
||||
|
||||
@@ -23,108 +20,70 @@ const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
|
||||
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-t-2xl border border-b-0 border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] pt-0.5 pb-1 mx-1">
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 px-2 text-left text-[0.6rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90"
|
||||
onClick={() => setCollapsed(open => !open)}
|
||||
type="button"
|
||||
>
|
||||
<DisclosureCaret className="shrink-0" open={!collapsed} size="1em" />
|
||||
<span className="truncate">{c.queued(entries.length)}</span>
|
||||
</button>
|
||||
<StatusSection label={c.queued(entries.length)}>
|
||||
{entries.map(entry => {
|
||||
const isEditing = editingId === entry.id
|
||||
const attachmentsCount = entry.attachments.length
|
||||
|
||||
{!collapsed && (
|
||||
<div className="space-y-0.5 px-1 pb-0.5">
|
||||
{entries.map(entry => {
|
||||
const isEditing = editingId === entry.id
|
||||
const attachmentsCount = entry.attachments.length
|
||||
const sendLabel = busy ? c.sendQueuedNext : c.sendQueuedNow
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/queue-row flex items-center gap-1.5 rounded-lg border border-transparent px-1.5 py-0.5',
|
||||
'transition-colors duration-300 ease-out hover:bg-(--chrome-action-hover) hover:transition-none',
|
||||
isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
|
||||
)}
|
||||
key={entry.id}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-3.5 w-3.5 shrink-0 rounded-full border border-foreground/35 bg-transparent"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p>
|
||||
{(attachmentsCount > 0 || isEditing) && (
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
|
||||
{attachmentsCount > 0 && <span>{c.attachments(attachmentsCount)}</span>}
|
||||
{isEditing && (
|
||||
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
|
||||
{c.editingInComposer}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-0 transition-opacity',
|
||||
isEditing
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100'
|
||||
)}
|
||||
return (
|
||||
<StatusRow
|
||||
className={cn(
|
||||
'border border-transparent',
|
||||
isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
|
||||
)}
|
||||
key={entry.id}
|
||||
leading={
|
||||
<span aria-hidden className="size-3.5 shrink-0 rounded-full border border-foreground/35 bg-transparent" />
|
||||
}
|
||||
trailing={
|
||||
<>
|
||||
<Button
|
||||
disabled={Boolean(editingId) && !isEditing}
|
||||
onClick={() => onEdit(entry)}
|
||||
size="micro"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
<Tip label={c.editQueued}>
|
||||
<Button
|
||||
aria-label={c.editQueued}
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={Boolean(editingId) && !isEditing}
|
||||
onClick={() => onEdit(entry)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={sendLabel}>
|
||||
<Button
|
||||
aria-label={sendLabel}
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={isEditing}
|
||||
onClick={() => onSendNow(entry.id)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowUp size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={c.deleteQueued}>
|
||||
<Button
|
||||
aria-label={c.deleteQueued}
|
||||
className="h-5 w-5 rounded-md"
|
||||
onClick={() => onDelete(entry.id)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
{c.queueEdit}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isEditing}
|
||||
onClick={() => onSendNow(entry.id)}
|
||||
size="micro"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
{busy ? c.queueSendNext : c.queueSend}
|
||||
</Button>
|
||||
<Button onClick={() => onDelete(entry.id)} size="micro" type="button" variant="text">
|
||||
{c.queueDelete}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
trailingVisible={isEditing}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p>
|
||||
{(attachmentsCount > 0 || isEditing) && (
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
|
||||
{attachmentsCount > 0 && <span>{c.attachments(attachmentsCount)}</span>}
|
||||
{isEditing && (
|
||||
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
|
||||
{c.editingInComposer}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StatusRow>
|
||||
)
|
||||
})}
|
||||
</StatusSection>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
|
||||
import { insertInlineRefsIntoEditor } from './inline-refs'
|
||||
import {
|
||||
composerPlainText,
|
||||
normalizeComposerEditorDom,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
|
||||
describe('renderComposerContents', () => {
|
||||
it('renders refs and raw text without interpreting user text as HTML', () => {
|
||||
@@ -16,3 +23,39 @@ describe('renderComposerContents', () => {
|
||||
expect(composerPlainText(editor)).toBe('@file:`<img src=x onerror=alert(1)>` <b>raw</b>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeComposerEditorDom', () => {
|
||||
it('unwraps a single insertHTML wrapper div so plain text stays one line', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.innerHTML = '<div><span data-ref-text="@file:`src/foo.ts`" contenteditable="false">foo.ts</span> </div>'
|
||||
|
||||
normalizeComposerEditorDom(editor)
|
||||
|
||||
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
|
||||
expect(editor.querySelector(':scope > div')).toBeNull()
|
||||
})
|
||||
|
||||
it('removes a trailing br after a ref chip', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.append(refChipElement('file', '`src/foo.ts`'), document.createElement('br'))
|
||||
|
||||
normalizeComposerEditorDom(editor)
|
||||
|
||||
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts`')
|
||||
expect(editor.querySelector('br')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertInlineRefsIntoEditor', () => {
|
||||
it('inserts chips without wrapper divs or spurious newlines', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
|
||||
insertInlineRefsIntoEditor(editor, ['@file:`src/foo.ts`'])
|
||||
|
||||
expect(editor.querySelector(':scope > div')).toBeNull()
|
||||
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -184,3 +184,36 @@ export function placeCaretEnd(element: HTMLElement) {
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
/** Drop contenteditable junk that serializes as `\n` and falsely expands the composer. */
|
||||
export function normalizeComposerEditorDom(editor: HTMLElement) {
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
|
||||
editor.replaceChildren()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeType === Node.ELEMENT_NODE) {
|
||||
const wrapper = editor.firstChild as HTMLElement
|
||||
|
||||
if (wrapper.tagName === 'DIV' && wrapper.dataset.slot !== RICH_INPUT_SLOT) {
|
||||
editor.replaceChildren(...Array.from(wrapper.childNodes))
|
||||
}
|
||||
}
|
||||
|
||||
const last = editor.lastChild
|
||||
|
||||
if (last?.nodeName !== 'BR') {
|
||||
return
|
||||
}
|
||||
|
||||
let prev: ChildNode | null = last.previousSibling
|
||||
|
||||
while (prev?.nodeType === Node.TEXT_NODE && !(prev.textContent || '').trim()) {
|
||||
prev = prev.previousSibling
|
||||
}
|
||||
|
||||
if ((prev as HTMLElement | null)?.dataset.refText) {
|
||||
editor.removeChild(last)
|
||||
}
|
||||
}
|
||||
|
||||
194
apps/desktop/src/app/chat/composer/status-stack/index.tsx
Normal file
194
apps/desktop/src/app/chat/composer/status-stack/index.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { blurComposerInput } from '@/app/chat/composer/focus'
|
||||
import { AGENTS_ROUTE } from '@/app/routes'
|
||||
import { composerDockCard } from '@/components/chat/composer-dock'
|
||||
import { StatusSection } from '@/components/chat/status-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$statusItemsBySession,
|
||||
type ComposerStatusItem,
|
||||
dismissBackgroundProcess,
|
||||
groupStatusItems,
|
||||
refreshBackgroundProcesses,
|
||||
type StatusGroup,
|
||||
stopBackgroundProcess
|
||||
} from '@/store/composer-status'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import { StatusItemRow } from './status-row'
|
||||
|
||||
// Slow safety-net poll for silent exits (processes without notify_on_complete
|
||||
// emit no event when they die). Only armed while a running row is on screen.
|
||||
const BACKGROUND_POLL_MS = 5_000
|
||||
|
||||
const groupLabel = (group: StatusGroup, s: Translations['statusStack']) => {
|
||||
if (group.type === 'todo') {
|
||||
return s.todos(group.items.filter(i => i.todoStatus === 'completed').length, group.items.length)
|
||||
}
|
||||
|
||||
return group.type === 'subagent' ? s.subagents(group.items.length) : s.background(group.items.length)
|
||||
}
|
||||
|
||||
interface ComposerStatusStackProps {
|
||||
/** The queue, built by the composer (it owns the queue's callbacks). Rendered
|
||||
* as the last group so it stays fused to the composer like before. */
|
||||
queue: ReactNode
|
||||
sessionId: null | string
|
||||
}
|
||||
|
||||
/**
|
||||
* The status "sink" above the composer: one card (the queue's chrome) holding
|
||||
* every session-scoped status — subagents, background tasks, queue — grouped by
|
||||
* type and separated by light dividers. Collapses to nothing when empty.
|
||||
*/
|
||||
export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackProps) {
|
||||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const itemsBySession = useStore($statusItemsBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []),
|
||||
[itemsBySession, sessionId]
|
||||
)
|
||||
|
||||
// Seed from the registry on session open; event-driven refreshes (terminal /
|
||||
// process tool completions) live in use-message-stream.
|
||||
useEffect(() => {
|
||||
if (sessionId) {
|
||||
void refreshBackgroundProcesses(sessionId)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
const hasRunningBackground = groups.some(g => g.type === 'background' && g.items.some(i => i.state === 'running'))
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !hasRunningBackground) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setInterval(() => void refreshBackgroundProcesses(sessionId), BACKGROUND_POLL_MS)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [hasRunningBackground, sessionId])
|
||||
|
||||
const openAgents = () => navigate(AGENTS_ROUTE)
|
||||
|
||||
const openSubagent = (item: ComposerStatusItem) =>
|
||||
item.sessionId ? void openSessionInNewWindow(item.sessionId, { watch: true }) : openAgents()
|
||||
|
||||
const sections: { key: string; node: ReactNode }[] = groups.map(group => ({
|
||||
key: group.type,
|
||||
node: (
|
||||
<StatusSection
|
||||
accessory={
|
||||
group.type === 'subagent' ? (
|
||||
<Button
|
||||
className="text-muted-foreground/75 hover:text-foreground/90"
|
||||
onClick={openAgents}
|
||||
size="micro"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{t.statusStack.agents}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
defaultCollapsed={group.type !== 'todo'}
|
||||
icon={
|
||||
group.type === 'todo' ? (
|
||||
<Codicon className="text-muted-foreground/70" name="checklist" size="0.8rem" />
|
||||
) : undefined
|
||||
}
|
||||
label={groupLabel(group, t.statusStack)}
|
||||
>
|
||||
{group.items.map(item => (
|
||||
<StatusItemRow
|
||||
item={item}
|
||||
key={item.id}
|
||||
onDismiss={sessionId ? id => dismissBackgroundProcess(sessionId, id) : undefined}
|
||||
onOpen={() => openSubagent(item)}
|
||||
onStop={sessionId ? id => stopBackgroundProcess(sessionId, id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</StatusSection>
|
||||
)
|
||||
}))
|
||||
|
||||
if (queue) {
|
||||
sections.push({ key: 'queue', node: queue })
|
||||
}
|
||||
|
||||
const visible = sections.length > 0
|
||||
const stackRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// The stack is out of flow (overlays the thread), so the composer's measured
|
||||
// height never sees it. Publish our own measured height — bucketed like the
|
||||
// composer's, to avoid style invalidation churn — so the thread's
|
||||
// last-message clearance can add it and the stack never hides messages.
|
||||
useLayoutEffect(() => {
|
||||
const root = document.documentElement
|
||||
const el = stackRef.current
|
||||
|
||||
if (!visible || !el) {
|
||||
root.style.removeProperty('--status-stack-measured-height')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let last = -1
|
||||
|
||||
const sync = () => {
|
||||
const bucket = Math.round(el.getBoundingClientRect().height / 8) * 8
|
||||
|
||||
if (bucket !== last) {
|
||||
last = bucket
|
||||
root.style.setProperty('--status-stack-measured-height', `${bucket}px`)
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(sync)
|
||||
observer.observe(el)
|
||||
sync()
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
root.style.removeProperty('--status-stack-measured-height')
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
if (!visible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-full z-6 -mb-[9px] max-h-[40vh] overflow-y-auto"
|
||||
onPointerDownCapture={() => blurComposerInput()}
|
||||
ref={stackRef}
|
||||
>
|
||||
{/* The card paints the shared --composer-fill (rest / scrolled / focused
|
||||
all match the composer surface by construction); on scroll we only
|
||||
ghost the CONTENT — element opacity on the card would kill the blur. */}
|
||||
<div className={cn(composerDockCard('top'), 'mx-1 pt-0.5 pb-1')}>
|
||||
<div
|
||||
className={cn(
|
||||
'transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{sections.map(section => (
|
||||
<div key={section.key}>{section.node}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
155
apps/desktop/src/app/chat/composer/status-stack/status-row.tsx
Normal file
155
apps/desktop/src/app/chat/composer/status-stack/status-row.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Fragment, memo, type ReactNode, useState } from 'react'
|
||||
|
||||
import { StatusRow } from '@/components/chat/status-row'
|
||||
import { TerminalOutput } from '@/components/chat/terminal-output'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { ArrowUpRight, X } from '@/lib/icons'
|
||||
import type { TodoStatus } from '@/lib/todos'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ComposerStatusItem } from '@/store/composer-status'
|
||||
|
||||
const toolLabel = (name: string) =>
|
||||
name
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map(part => part[0]!.toUpperCase() + part.slice(1))
|
||||
.join(' ') || name
|
||||
|
||||
// Todo rows speak checkbox, not spinner-and-dot: a dashed ring while the item
|
||||
// is still open (pending), codicons once it resolves, a live spinner only on
|
||||
// the in-progress item.
|
||||
const TODO_GLYPHS: Record<Exclude<TodoStatus, 'in_progress' | 'pending'>, { icon: string; tone: string }> = {
|
||||
cancelled: { icon: 'circle-slash', tone: 'text-muted-foreground/45' },
|
||||
completed: { icon: 'pass-filled', tone: 'text-emerald-500/80' }
|
||||
}
|
||||
|
||||
// Left slot: braille spinner while running, otherwise a small status dot
|
||||
// (green = done, red = failed) so the slot is always filled and rows align.
|
||||
function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']): ReactNode {
|
||||
if (item.todoStatus === 'pending') {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="box-border size-[0.7rem] rounded-full border border-dashed border-muted-foreground/60"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.todoStatus && item.todoStatus !== 'in_progress') {
|
||||
const glyph = TODO_GLYPHS[item.todoStatus]
|
||||
|
||||
return <Codicon className={glyph.tone} name={glyph.icon} size="0.8rem" />
|
||||
}
|
||||
|
||||
if (item.state === 'running') {
|
||||
return (
|
||||
<GlyphSpinner
|
||||
ariaLabel={s.running}
|
||||
className="text-[0.9rem] leading-none text-muted-foreground/80"
|
||||
spinner="braille"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn('size-1.5 rounded-full', item.state === 'failed' ? 'bg-destructive/80' : 'bg-emerald-500/70')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface StatusItemRowProps {
|
||||
item: ComposerStatusItem
|
||||
/** Clear a finished background task from the stack. */
|
||||
onDismiss?: (id: string) => void
|
||||
/** Open the subagent's own session window, livestreamed by the gateway's
|
||||
* child-session mirror (Agents view fallback for older gateways). */
|
||||
onOpen?: () => void
|
||||
/** Cancel a running background task. */
|
||||
onStop?: (id: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one {@link ComposerStatusItem} into the shared {@link StatusRow}.
|
||||
* Memoised + keyed by id so parent re-renders never remount it (the spinner
|
||||
* keeps ticking instead of resetting).
|
||||
*/
|
||||
export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOpen, onStop }: StatusItemRowProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.statusStack
|
||||
const [outputOpen, setOutputOpen] = useState(false)
|
||||
const failed = item.state === 'failed'
|
||||
const running = item.state === 'running'
|
||||
|
||||
const action =
|
||||
item.type === 'background'
|
||||
? running
|
||||
? onStop && { label: s.stop, onClick: () => onStop(item.id) }
|
||||
: onDismiss && { label: s.dismiss, onClick: () => onDismiss(item.id) }
|
||||
: null
|
||||
|
||||
const canOpen = item.type === 'subagent' && !!onOpen
|
||||
const hasOutput = item.type === 'background' && !!item.output
|
||||
const onActivate = canOpen ? onOpen : hasOutput ? () => setOutputOpen(open => !open) : undefined
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<StatusRow
|
||||
leading={leadingGlyph(item, s)}
|
||||
onActivate={onActivate}
|
||||
trailing={
|
||||
action ? (
|
||||
<Tip label={action.label}>
|
||||
<Button
|
||||
aria-label={action.label}
|
||||
className="-my-1 size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
action.onClick()
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : canOpen ? (
|
||||
<ArrowUpRight aria-hidden className="size-3.5 text-muted-foreground/55" />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4',
|
||||
failed
|
||||
? 'text-destructive/90'
|
||||
: item.todoStatus && item.todoStatus !== 'in_progress'
|
||||
? 'text-muted-foreground/75'
|
||||
: 'text-foreground/92'
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
{item.type === 'subagent' && item.currentTool && (
|
||||
<span className="shrink-0 truncate text-[0.62rem] leading-4 text-muted-foreground/70">
|
||||
{toolLabel(item.currentTool)}
|
||||
</span>
|
||||
)}
|
||||
{failed && typeof item.exitCode === 'number' && item.exitCode !== 0 && (
|
||||
<span className="shrink-0 rounded bg-destructive/15 px-1 text-[0.58rem] font-semibold text-destructive tabular-nums">
|
||||
{s.exit(item.exitCode)}
|
||||
</span>
|
||||
)}
|
||||
{hasOutput && <DisclosureCaret className="shrink-0 text-muted-foreground/45" open={outputOpen} size="0.8em" />}
|
||||
</StatusRow>
|
||||
{hasOutput && outputOpen && <TerminalOutput className="mx-auto mb-1 max-w-[90%]" text={item.output!} />}
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
@@ -1,16 +1,12 @@
|
||||
import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { Fragment } from 'react'
|
||||
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
COMPLETION_DRAWER_BELOW_CLASS,
|
||||
COMPLETION_DRAWER_CLASS,
|
||||
CompletionDrawerEmpty
|
||||
} from './completion-drawer'
|
||||
import { COMPLETION_DRAWER_BELOW_CLASS, COMPLETION_DRAWER_CLASS, CompletionDrawerEmpty } from './completion-drawer'
|
||||
|
||||
const AT_ICON_BY_TYPE: Record<string, string> = {
|
||||
diff: 'diff',
|
||||
@@ -87,7 +83,7 @@ export function ComposerTriggerPopover({
|
||||
{items.length === 0 ? (
|
||||
loading ? (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-(--ui-text-tertiary)">
|
||||
<BrailleSpinner ariaLabel={copy.lookupLoading} className="text-foreground/70" spinner="braille" />
|
||||
<GlyphSpinner ariaLabel={copy.lookupLoading} className="text-foreground/70" spinner="braille" />
|
||||
<span>{copy.lookupLoading}</span>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
|
||||
import { requestComposerFocus, requestComposerInsert, requestComposerInsertRefs } from '@/app/chat/composer/focus'
|
||||
import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs'
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
|
||||
@@ -286,6 +287,26 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
[currentCwd]
|
||||
)
|
||||
|
||||
const insertContextPathInlineRef = useCallback(
|
||||
(path: string, isDirectory = false) => {
|
||||
if (!path) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ref = droppedFileInlineRef({ isDirectory, path }, currentCwd)
|
||||
|
||||
if (!ref) {
|
||||
return false
|
||||
}
|
||||
|
||||
requestComposerInsertRefs([ref])
|
||||
requestComposerFocus('main')
|
||||
|
||||
return true
|
||||
},
|
||||
[currentCwd]
|
||||
)
|
||||
|
||||
const attachContextFilePath = useCallback(
|
||||
(filePath: string) => {
|
||||
if (!filePath) {
|
||||
@@ -546,6 +567,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
attachDroppedItems,
|
||||
attachImageBlob,
|
||||
attachImagePath,
|
||||
insertContextPathInlineRef,
|
||||
pasteClipboardImage,
|
||||
pickContextPaths,
|
||||
pickImages,
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import { routeSessionId } from '../routes'
|
||||
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
|
||||
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitleClass } from '../shell/titlebar'
|
||||
|
||||
import { ChatDropOverlay } from './chat-drop-overlay'
|
||||
import { ChatSwapOverlay } from './chat-swap-overlay'
|
||||
@@ -80,6 +80,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
|
||||
onEdit: (message: AppendMessage) => Promise<void>
|
||||
onReload: (parentId: string | null) => Promise<void>
|
||||
onRestoreToMessage?: (messageId: string) => Promise<void>
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
}
|
||||
|
||||
@@ -124,13 +125,7 @@ function ChatHeader({
|
||||
|
||||
return (
|
||||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||
<div
|
||||
className="min-w-0 flex-1"
|
||||
style={{
|
||||
maxWidth:
|
||||
'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)'
|
||||
}}
|
||||
>
|
||||
<div className={titlebarHeaderTitleClass}>
|
||||
<SessionActionsMenu
|
||||
align="start"
|
||||
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
|
||||
@@ -141,7 +136,7 @@ function ChatHeader({
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
className="pointer-events-auto flex h-6 min-w-0 max-w-full gap-1 border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
|
||||
className="pointer-events-auto flex h-6 w-full min-w-0 max-w-full gap-1 overflow-hidden border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
@@ -176,6 +171,7 @@ export function ChatView({
|
||||
onThreadMessagesChange,
|
||||
onEdit,
|
||||
onReload,
|
||||
onRestoreToMessage,
|
||||
onTranscribeAudio
|
||||
}: ChatViewProps) {
|
||||
const location = useLocation()
|
||||
@@ -362,6 +358,7 @@ export function ChatView({
|
||||
loading={threadLoading}
|
||||
onBranchInNewChat={onBranchInNewChat}
|
||||
onCancel={onCancel}
|
||||
onRestoreToMessage={onRestoreToMessage}
|
||||
sessionId={activeSessionId}
|
||||
sessionKey={threadKey}
|
||||
/>
|
||||
|
||||
@@ -10,12 +10,16 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import ShikiHighlighter from 'react-shiki'
|
||||
import { Streamdown } from 'streamdown'
|
||||
|
||||
import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/composer/focus'
|
||||
import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs'
|
||||
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { isAddSelectionShortcut } from '@/app/right-sidebar/terminal/selection'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { translateNow, useI18n } from '@/i18n'
|
||||
import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
|
||||
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
|
||||
@@ -287,7 +291,7 @@ const MARKDOWN_COMPONENTS = {
|
||||
|
||||
function MarkdownPreview({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground">
|
||||
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground" data-selectable-text="true">
|
||||
<Streamdown components={MARKDOWN_COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}>
|
||||
{text}
|
||||
</Streamdown>
|
||||
@@ -357,6 +361,38 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
|
||||
startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line })
|
||||
}
|
||||
|
||||
// ⌘/Ctrl+L with a line selection drops the same `@line:path:start-end` ref the
|
||||
// gutter drag produces — so the keyboard path mirrors dragging the lines into
|
||||
// the composer. Capture-phase + stopPropagation so it beats the terminal's
|
||||
// global ⌘L handler (which would otherwise grab the native text selection).
|
||||
useEffect(() => {
|
||||
if (!selection) {
|
||||
return
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isAddSelectionShortcut(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
const lineEnd = selection.end > selection.start ? selection.end : undefined
|
||||
const ref = droppedFileInlineRef({ line: selection.start, lineEnd, path: filePath }, $currentCwd.get())
|
||||
|
||||
if (!ref) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
requestComposerInsertRefs([ref])
|
||||
requestComposerFocus('main')
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
}, [filePath, selection])
|
||||
|
||||
return (
|
||||
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed">
|
||||
<div className="select-none py-3 text-right text-muted-foreground/55">
|
||||
@@ -383,7 +419,10 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!">
|
||||
<div
|
||||
className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!"
|
||||
data-selectable-text="true"
|
||||
>
|
||||
{selection && (
|
||||
<div
|
||||
aria-hidden
|
||||
|
||||
@@ -168,7 +168,7 @@ export function SidebarCronJobsSection({
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<SidebarGroupContent className="flex max-h-72 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75 compact:max-h-none compact:overflow-visible">
|
||||
<SidebarGroupContent className="flex max-h-72 flex-col gap-px overflow-x-hidden overflow-y-auto overscroll-contain pb-1.75 compact:max-h-none compact:overflow-visible">
|
||||
{shown.map(job => (
|
||||
<CronJobSidebarRow
|
||||
expanded={peekJobId === job.id}
|
||||
|
||||
@@ -39,6 +39,7 @@ import { Tip } from '@/components/ui/tooltip'
|
||||
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { profileColor } from '@/lib/profile-color'
|
||||
import { comboTokens } from '@/lib/keybinds/combo'
|
||||
import { sessionMatchesSearch } from '@/lib/session-search'
|
||||
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -108,11 +109,7 @@ const VIRTUALIZE_THRESHOLD = 25
|
||||
const NON_SESSION_INITIAL_ROWS = 3
|
||||
const NON_SESSION_LOAD_STEP = 10
|
||||
|
||||
// Render the modifier key the user actually presses on this platform. The
|
||||
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
|
||||
// else) in desktop-controller.tsx, but the hint should match muscle memory.
|
||||
const NEW_SESSION_KBD: readonly string[] =
|
||||
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') ? ['⌘', 'N'] : ['Ctrl', 'N']
|
||||
const NEW_SESSION_KBD = comboTokens('mod+n')
|
||||
|
||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{
|
||||
@@ -144,8 +141,11 @@ const GROUP_DND_ID_PREFIX = 'group:'
|
||||
// the next — the flexbox `min-height: auto` overlap trap that caused the bug.
|
||||
const COMPACT_FLAT = 'compact:max-h-none compact:overflow-visible'
|
||||
|
||||
// Vertical scroll only — never a horizontal bar from glow bleed, long titles, etc.
|
||||
const SCROLL_Y = 'overflow-y-auto overflow-x-hidden overscroll-contain'
|
||||
|
||||
// A non-session group's scroll body: own scroller when tall, flattened when compact.
|
||||
const GROUP_BODY = cn('overflow-y-auto overscroll-contain', COMPACT_FLAT)
|
||||
const GROUP_BODY = cn(SCROLL_Y, COMPACT_FLAT)
|
||||
|
||||
const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`
|
||||
|
||||
@@ -830,8 +830,9 @@ export function ChatSidebar({
|
||||
<span className="min-w-0 flex-1 truncate">{s.nav[item.id] ?? item.label}</span>
|
||||
{isNewSession && (
|
||||
<KbdGroup
|
||||
className={cn('ml-auto', newSessionKbdFlash && 'opacity-100!')}
|
||||
className={cn('ml-auto opacity-55', newSessionKbdFlash && 'opacity-100!')}
|
||||
keys={[...NEW_SESSION_KBD]}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -857,11 +858,11 @@ export function ChatSidebar({
|
||||
)}
|
||||
|
||||
{contentVisible && showSessionSections && (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75">
|
||||
<div className={cn('flex min-h-0 flex-1 flex-col pb-1.75', SCROLL_Y)}>
|
||||
{trimmedQuery && (
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
|
||||
contentClassName={cn('flex min-h-0 flex-1 flex-col gap-px pb-1.75', SCROLL_Y)}
|
||||
emptyState={
|
||||
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
|
||||
{s.noMatch(trimmedQuery)}
|
||||
@@ -908,7 +909,8 @@ export function ChatSidebar({
|
||||
<SidebarSessionsSection
|
||||
activeSessionId={activeSidebarSessionId}
|
||||
contentClassName={cn(
|
||||
'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75',
|
||||
'flex min-h-0 flex-1 flex-col pb-1.75',
|
||||
SCROLL_Y,
|
||||
// Separate profile sections clearly in the ALL view; rows inside
|
||||
// each group keep their own tight gap-px rhythm.
|
||||
showAllProfiles ? 'gap-3' : 'gap-px',
|
||||
|
||||
@@ -88,7 +88,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
label: r.export,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
void exportSession(sessionId, { title })
|
||||
void exportSession(sessionId, { profile, title })
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -102,7 +102,7 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
})
|
||||
|
||||
const list = (
|
||||
<div className={cn('relative min-h-0 flex-1 overflow-y-auto overscroll-contain', className)} ref={scrollerRef}>
|
||||
<div className={cn('relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain', className)} ref={scrollerRef}>
|
||||
<div className="grid gap-px" style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
|
||||
{rows}
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,8 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
|
||||
import { setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { KbdGroup } from '@/components/ui/kbd'
|
||||
import { getHermesConfigRecord, listSessions } from '@/hermes'
|
||||
import { KbdCombo } from '@/components/ui/kbd'
|
||||
import { getHermesConfigRecord, listAllProfileSessions } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import {
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
Wrench,
|
||||
Zap
|
||||
} from '@/lib/icons'
|
||||
import { comboTokens } from '@/lib/keybinds/combo'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
|
||||
import { $bindings } from '@/store/keybinds'
|
||||
@@ -119,7 +118,7 @@ const paletteFilter = (value: string, search: string, keywords?: string[]): numb
|
||||
return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0
|
||||
}
|
||||
|
||||
type SessionRow = Awaited<ReturnType<typeof listSessions>>['sessions'][number]
|
||||
type SessionRow = Awaited<ReturnType<typeof listAllProfileSessions>>['sessions'][number]
|
||||
|
||||
const toSessionEntry = (session: SessionRow): SessionEntry => ({
|
||||
id: session.id,
|
||||
@@ -218,13 +217,13 @@ export function CommandPalette() {
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ['command-palette', 'sessions'],
|
||||
queryFn: () => listSessions(200, 1, 'exclude'),
|
||||
queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
|
||||
enabled: open
|
||||
})
|
||||
|
||||
const archivedQuery = useQuery({
|
||||
queryKey: ['command-palette', 'archived'],
|
||||
queryFn: () => listSessions(200, 0, 'only'),
|
||||
queryFn: () => listAllProfileSessions(200, 0, 'only'),
|
||||
enabled: open
|
||||
})
|
||||
|
||||
@@ -620,7 +619,6 @@ export function CommandPalette() {
|
||||
{group.items.map(item => {
|
||||
const Icon = item.icon
|
||||
const combo = item.action ? bindings[item.action]?.[0] : undefined
|
||||
const keys = combo ? comboTokens(combo) : null
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
@@ -632,10 +630,10 @@ export function CommandPalette() {
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{keys && <KbdGroup className="ml-auto" keys={keys} />}
|
||||
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
|
||||
{item.to && (
|
||||
<ChevronRight
|
||||
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !keys && 'ml-auto')}
|
||||
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')}
|
||||
/>
|
||||
)}
|
||||
</CommandItem>
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Pane, PaneMain } from '@/components/pane-shell'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus'
|
||||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
|
||||
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
MESSAGING_SESSION_SOURCE_IDS,
|
||||
normalizeSessionSource
|
||||
} from '../lib/session-source'
|
||||
import { latestSessionTodos } from '../lib/todos'
|
||||
import { setCronFocusJobId, setCronJobs } from '../store/cron'
|
||||
import {
|
||||
$panesFlipped,
|
||||
@@ -76,10 +76,12 @@ import {
|
||||
setSessionsLoading,
|
||||
setSessionsTotal
|
||||
} from '../store/session'
|
||||
import { clearSessionTodos, setSessionTodos, todoListActive } from '../store/todos'
|
||||
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
|
||||
import { isSecondaryWindow } from '../store/windows'
|
||||
|
||||
import { ChatView } from './chat'
|
||||
import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus'
|
||||
import { useComposerActions } from './chat/hooks/use-composer-actions'
|
||||
import {
|
||||
ChatPreviewRail,
|
||||
@@ -141,7 +143,7 @@ const CRON_POLL_INTERVAL_MS = 30_000
|
||||
// self-managed sidebar section (refreshMessagingSessions). Excluding both here
|
||||
// keeps "Load more" paging through interactive local chats instead of
|
||||
// interleaving gateway threads that bury them.
|
||||
const SIDEBAR_EXCLUDED_SOURCES = ['cron', ...MESSAGING_SESSION_SOURCE_IDS]
|
||||
const SIDEBAR_EXCLUDED_SOURCES = ['cron', 'subagent', 'tool', ...MESSAGING_SESSION_SOURCE_IDS]
|
||||
// The messaging slice is the inverse: drop cron + every local source so only
|
||||
// external-platform conversations remain, then split per platform in the UI.
|
||||
const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS]
|
||||
@@ -273,22 +275,27 @@ export function DesktopController() {
|
||||
// the shared command handler) creates the job. Signal readiness so a link
|
||||
// that arrived during boot is flushed exactly once.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onDeepLink?.((payload) => {
|
||||
const unsubscribe = window.hermesDesktop?.onDeepLink?.(payload => {
|
||||
if (!payload || payload.kind !== 'blueprint' || !payload.name) {
|
||||
return
|
||||
}
|
||||
|
||||
const slots = Object.entries(payload.params || {})
|
||||
.map(([k, v]) => {
|
||||
const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v
|
||||
|
||||
return `${k}=${sval}`
|
||||
})
|
||||
.join(' ')
|
||||
|
||||
const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}`
|
||||
requestComposerInsert(command, { mode: 'block', target: 'main' })
|
||||
requestComposerFocus('main')
|
||||
})
|
||||
|
||||
// Tell the main process the renderer is ready to receive deep links.
|
||||
void window.hermesDesktop?.signalDeepLinkReady?.()
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
|
||||
@@ -547,20 +554,34 @@ export function DesktopController() {
|
||||
return
|
||||
}
|
||||
|
||||
const storedProfile = $sessions.get().find(session => session.id === storedSessionId)?.profile
|
||||
const storedProfile = $sessions
|
||||
.get()
|
||||
.find(session => session.id === storedSessionId || session._lineage_root_id === storedSessionId)?.profile
|
||||
|
||||
for (let index = 0; index < Math.max(1, attempts); index += 1) {
|
||||
try {
|
||||
const latest = await getSessionMessages(storedSessionId, storedProfile)
|
||||
const messages = toChatMessages(latest.messages)
|
||||
updateSessionState(
|
||||
runtimeSessionId,
|
||||
state => ({
|
||||
...state,
|
||||
messages: preserveLocalAssistantErrors(toChatMessages(latest.messages), state.messages)
|
||||
messages: preserveLocalAssistantErrors(messages, state.messages)
|
||||
}),
|
||||
storedSessionId
|
||||
)
|
||||
|
||||
// Seed the status stack's todo group from history — but only while
|
||||
// the plan is still in flight, so reopening an old chat doesn't pin
|
||||
// its finished todo list above the composer forever.
|
||||
const todos = latestSessionTodos(messages)
|
||||
|
||||
if (todos && todoListActive(todos)) {
|
||||
setSessionTodos(runtimeSessionId, todos)
|
||||
} else {
|
||||
clearSessionTodos(runtimeSessionId)
|
||||
}
|
||||
|
||||
return
|
||||
} catch {
|
||||
// Best-effort fallback when live stream payloads are empty.
|
||||
@@ -580,6 +601,7 @@ export function DesktopController() {
|
||||
queryClient,
|
||||
refreshHermesConfig,
|
||||
refreshSessions,
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
@@ -709,6 +731,7 @@ export function DesktopController() {
|
||||
editMessage,
|
||||
handleThreadMessagesChange,
|
||||
reloadFromMessage,
|
||||
restoreToMessage,
|
||||
steerPrompt,
|
||||
submitText,
|
||||
transcribeVoiceAudio
|
||||
@@ -943,6 +966,7 @@ export function DesktopController() {
|
||||
onPickImages={() => void composer.pickImages()}
|
||||
onReload={reloadFromMessage}
|
||||
onRemoveAttachment={id => void composer.removeAttachment(id)}
|
||||
onRestoreToMessage={restoreToMessage}
|
||||
onSteer={steerPrompt}
|
||||
onSubmit={submitText}
|
||||
onThreadMessagesChange={handleThreadMessagesChange}
|
||||
@@ -988,8 +1012,8 @@ export function DesktopController() {
|
||||
width={FILE_BROWSER_DEFAULT_WIDTH}
|
||||
>
|
||||
<RightSidebarPane
|
||||
onActivateFile={composer.attachContextFilePath}
|
||||
onActivateFolder={composer.attachContextFolderPath}
|
||||
onActivateFile={path => composer.insertContextPathInlineRef(path)}
|
||||
onActivateFolder={path => composer.insertContextPathInlineRef(path, true)}
|
||||
onChangeCwd={changeSessionCwd}
|
||||
/>
|
||||
</Pane>
|
||||
|
||||
@@ -12,6 +12,8 @@ import type { TreeNode } from './use-project-tree'
|
||||
|
||||
const ROW_HEIGHT = 22
|
||||
const INDENT = 10
|
||||
/** Base inset for every row; react-arborist owns paddingLeft for depth indent. */
|
||||
const TREE_ROW_INSET = 12
|
||||
|
||||
interface ProjectTreeProps {
|
||||
collapseNonce: number
|
||||
@@ -200,18 +202,16 @@ function ProjectTreeRow({
|
||||
event.dataTransfer.setData('text/plain', node.data.id)
|
||||
}}
|
||||
ref={dragHandle}
|
||||
style={style}
|
||||
style={{
|
||||
...style,
|
||||
paddingLeft:
|
||||
(typeof style.paddingLeft === 'number'
|
||||
? style.paddingLeft
|
||||
: Number.parseFloat(String(style.paddingLeft ?? 0)) || 0) + TREE_ROW_INSET
|
||||
}}
|
||||
>
|
||||
{isFolder && !isPlaceholder && (
|
||||
<span aria-hidden className="flex w-3 items-center justify-center">
|
||||
<Codicon
|
||||
className="text-(--ui-text-tertiary)"
|
||||
name={node.isOpen ? 'chevron-down' : 'chevron-right'}
|
||||
size="0.75rem"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{!isFolder && <span aria-hidden className="w-3 shrink-0" />}
|
||||
{/* No chevron column — the folder icon (open/closed) already carries the
|
||||
expand state, so the extra glyph was pure noise. */}
|
||||
<span aria-hidden className="flex w-3.5 items-center justify-center text-(--ui-text-tertiary)">
|
||||
{isPlaceholder && !isErrorPlaceholder ? (
|
||||
<Codicon name="loading" size="0.75rem" spinning />
|
||||
|
||||
@@ -221,6 +221,36 @@ describe('useProjectTree', () => {
|
||||
expect(readDir).toHaveBeenLastCalledWith('/b')
|
||||
})
|
||||
|
||||
it('falls back to the sanitized workspace dir when the session cwd is gone', async () => {
|
||||
const sanitizeWorkspaceCwd = vi.fn(async () => ({ cwd: '/home/me/projects', sanitized: true }))
|
||||
readDir.mockImplementation(async path => {
|
||||
if (path === '/deleted/worktree') return { entries: [], error: 'ENOENT' }
|
||||
if (path === '/home/me/projects') return ok([{ name: 'repo', path: '/home/me/projects/repo', isDirectory: true }])
|
||||
throw new Error(`unexpected path ${path}`)
|
||||
})
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { readDir, sanitizeWorkspaceCwd }
|
||||
|
||||
const { result } = renderHook(() => useProjectTree('/deleted/worktree'))
|
||||
|
||||
await waitFor(() => expect(result.current.data.length).toBe(1))
|
||||
|
||||
expect(sanitizeWorkspaceCwd).toHaveBeenCalledWith('/deleted/worktree')
|
||||
expect(result.current.rootError).toBeNull()
|
||||
expect(result.current.effectiveCwd).toBe('/home/me/projects')
|
||||
expect(result.current.data[0]?.name).toBe('repo')
|
||||
})
|
||||
|
||||
it('keeps the root error when sanitize offers no usable fallback', async () => {
|
||||
const sanitizeWorkspaceCwd = vi.fn(async () => ({ cwd: '/deleted/worktree', sanitized: false }))
|
||||
readDir.mockResolvedValue({ entries: [], error: 'ENOENT' })
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { readDir, sanitizeWorkspaceCwd }
|
||||
|
||||
const { result } = renderHook(() => useProjectTree('/deleted/worktree'))
|
||||
|
||||
await waitFor(() => expect(result.current.rootError).toBe('ENOENT'))
|
||||
expect(result.current.effectiveCwd).toBe('/deleted/worktree')
|
||||
})
|
||||
|
||||
it('returns no-bridge gracefully when window.hermesDesktop is missing', async () => {
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
|
||||
|
||||
@@ -64,6 +64,10 @@ export interface UseProjectTreeResult {
|
||||
/** Bumped by collapseAll so callers can remount the tree fully collapsed. */
|
||||
collapseNonce: number
|
||||
data: TreeNode[]
|
||||
/** Directory actually displayed — differs from the requested cwd when the
|
||||
* session's recorded cwd no longer exists and we fell back to the default
|
||||
* workspace dir. */
|
||||
effectiveCwd: string
|
||||
openState: Record<string, boolean>
|
||||
rootError: string | null
|
||||
rootLoading: boolean
|
||||
@@ -80,6 +84,8 @@ interface ProjectTreeState {
|
||||
loaded: boolean
|
||||
openState: Record<string, boolean>
|
||||
requestId: number
|
||||
/** Directory the displayed entries were read from ('' until first load). */
|
||||
resolvedCwd: string
|
||||
rootError: string | null
|
||||
rootLoading: boolean
|
||||
}
|
||||
@@ -91,6 +97,7 @@ const initialState: ProjectTreeState = {
|
||||
loaded: false,
|
||||
openState: {},
|
||||
requestId: 0,
|
||||
resolvedCwd: '',
|
||||
rootError: null,
|
||||
rootLoading: false
|
||||
}
|
||||
@@ -100,6 +107,11 @@ const $projectTree = atom<ProjectTreeState>(initialState)
|
||||
let nextRootRequestId = 0
|
||||
let lastConnectionKey = ''
|
||||
|
||||
// While the root is errored (ENOENT during a session's cwd race, a folder that
|
||||
// reappears after a checkout, a remote that wasn't ready), keep retrying on a
|
||||
// slow cadence so the tree self-heals instead of staying "UNREADABLE" forever.
|
||||
const ROOT_ERROR_RETRY_MS = 3_000
|
||||
|
||||
function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) {
|
||||
$projectTree.set(updater($projectTree.get()))
|
||||
}
|
||||
@@ -110,6 +122,31 @@ function clearProjectTree() {
|
||||
$projectTree.set({ ...initialState, requestId: nextRootRequestId })
|
||||
}
|
||||
|
||||
/** Sessions record their launch cwd; deleted worktrees and remote-backend
|
||||
* paths arrive here as directories that don't exist on this machine. Rather
|
||||
* than bricking the tree, display the sanitized workspace fallback (main
|
||||
* prefers the configured default project dir). Local connections only —
|
||||
* remote trees are read through the remote bridge. */
|
||||
async function fallbackRootFor(cwd: string): Promise<string | null> {
|
||||
if ($connection.get()?.mode === 'remote') {
|
||||
return null
|
||||
}
|
||||
|
||||
const sanitize = window.hermesDesktop?.sanitizeWorkspaceCwd
|
||||
|
||||
if (!sanitize) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const { cwd: fallback, sanitized } = await sanitize(cwd)
|
||||
|
||||
return sanitized && fallback && fallback !== cwd ? fallback : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}) {
|
||||
if (!cwd) {
|
||||
clearProjectTree()
|
||||
@@ -138,11 +175,27 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
|
||||
loaded: false,
|
||||
openState: current.cwd === cwd ? current.openState : {},
|
||||
requestId,
|
||||
resolvedCwd: '',
|
||||
rootError: null,
|
||||
rootLoading: true
|
||||
})
|
||||
|
||||
const { entries, error } = await readProjectDir(cwd, cwd)
|
||||
let resolvedCwd = cwd
|
||||
let { entries, error } = await readProjectDir(cwd, cwd)
|
||||
|
||||
if (error) {
|
||||
const fallback = await fallbackRootFor(cwd)
|
||||
|
||||
if (fallback) {
|
||||
const retry = await readProjectDir(fallback, fallback)
|
||||
|
||||
if (!retry.error) {
|
||||
resolvedCwd = fallback
|
||||
entries = retry.entries
|
||||
error = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setProjectTree(latest => {
|
||||
if (latest.cwd !== cwd || latest.requestId !== requestId) {
|
||||
@@ -153,6 +206,7 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
|
||||
...latest,
|
||||
data: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory)),
|
||||
loaded: true,
|
||||
resolvedCwd,
|
||||
rootError: error || null,
|
||||
rootLoading: false
|
||||
}
|
||||
@@ -230,7 +284,8 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
}
|
||||
})
|
||||
|
||||
const { entries, error } = await readProjectDir(id, cwd)
|
||||
const rootPath = $projectTree.get().resolvedCwd || cwd
|
||||
const { entries, error } = await readProjectDir(id, rootPath)
|
||||
|
||||
inflight.delete(id)
|
||||
|
||||
@@ -256,19 +311,62 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
useEffect(() => {
|
||||
const connectionChanged = lastConnectionKey !== '' && lastConnectionKey !== connectionKey
|
||||
lastConnectionKey = connectionKey
|
||||
|
||||
if (connectionChanged) {
|
||||
clearProjectDirCache()
|
||||
void loadRoot(cwd, { force: true })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void loadRoot(cwd)
|
||||
}, [connectionKey, cwd])
|
||||
|
||||
// Self-heal: an errored root re-probes every few seconds while the tree is
|
||||
// mounted. Each attempt bumps requestId, so a persistent error re-arms the
|
||||
// timer; a success clears rootError and stops it.
|
||||
useEffect(() => {
|
||||
if (!cwd || state.cwd !== cwd || !state.rootError) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => void loadRoot(cwd, { force: true }), ROOT_ERROR_RETRY_MS)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [cwd, state.cwd, state.requestId, state.rootError])
|
||||
|
||||
// While showing the fallback root, quietly re-probe the session's real cwd
|
||||
// (a worktree re-created, a checkout restored) and switch back when it
|
||||
// reappears. The probe never touches state, so there's no flicker.
|
||||
const usingFallback = state.cwd === cwd && Boolean(state.resolvedCwd) && state.resolvedCwd !== cwd
|
||||
|
||||
useEffect(() => {
|
||||
if (!cwd || !usingFallback) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
void readProjectDir(cwd, cwd).then(({ error }) => {
|
||||
if (!cancelled && !error) {
|
||||
void loadRoot(cwd, { force: true })
|
||||
}
|
||||
})
|
||||
}, ROOT_ERROR_RETRY_MS)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [cwd, usingFallback])
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
collapseAll,
|
||||
collapseNonce: state.cwd === cwd ? state.collapseNonce : 0,
|
||||
data: state.cwd === cwd ? state.data : [],
|
||||
effectiveCwd: state.cwd === cwd && state.resolvedCwd ? state.resolvedCwd : cwd,
|
||||
loadChildren,
|
||||
openState: state.cwd === cwd ? state.openState : {},
|
||||
refreshRoot,
|
||||
@@ -286,6 +384,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
state.cwd,
|
||||
state.data,
|
||||
state.openState,
|
||||
state.resolvedCwd,
|
||||
state.rootError,
|
||||
state.rootLoading
|
||||
]
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ErrorBoundary } from '@/components/error-boundary'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { selectDesktopPaths } from '@/lib/desktop-fs'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
@@ -34,17 +33,11 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
const currentCwd = useStore($currentCwd).trim()
|
||||
const hasCwd = currentCwd.length > 0
|
||||
|
||||
const cwdName = hasCwd
|
||||
? (currentCwd
|
||||
.split(/[\\/]+/)
|
||||
.filter(Boolean)
|
||||
.pop() ?? currentCwd)
|
||||
: r.noFolderSelected
|
||||
|
||||
const {
|
||||
collapseAll,
|
||||
collapseNonce,
|
||||
data,
|
||||
effectiveCwd,
|
||||
loadChildren,
|
||||
openState,
|
||||
refreshRoot,
|
||||
@@ -53,11 +46,18 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
setNodeOpen
|
||||
} = useProjectTree(currentCwd)
|
||||
|
||||
const cwdName = hasCwd
|
||||
? (effectiveCwd
|
||||
.split(/[\\/]+/)
|
||||
.filter(Boolean)
|
||||
.pop() ?? effectiveCwd)
|
||||
: r.noFolderSelected
|
||||
|
||||
const canCollapse = Object.values(openState).some(Boolean)
|
||||
|
||||
const chooseFolder = async () => {
|
||||
const selected = await selectDesktopPaths({
|
||||
defaultPath: hasCwd ? currentCwd : undefined,
|
||||
defaultPath: hasCwd ? effectiveCwd : undefined,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
title: r.changeCwdTitle
|
||||
@@ -70,7 +70,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
|
||||
const previewFile = async (path: string) => {
|
||||
try {
|
||||
const preview = await normalizeOrLocalPreviewTarget(path, currentCwd || undefined)
|
||||
const preview = await normalizeOrLocalPreviewTarget(path, effectiveCwd || undefined)
|
||||
|
||||
if (!preview) {
|
||||
throw new Error(r.couldNotPreview(path))
|
||||
@@ -97,7 +97,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
<FilesystemTab
|
||||
canCollapse={canCollapse}
|
||||
collapseNonce={collapseNonce}
|
||||
cwd={currentCwd}
|
||||
cwd={effectiveCwd}
|
||||
cwdName={cwdName}
|
||||
data={data}
|
||||
error={rootError}
|
||||
@@ -126,13 +126,12 @@ interface FilesystemTabProps extends FileTreeBodyProps {
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
// Sidebar-specific color/hover treatment only — size, radius, cursor and the
|
||||
// base focus ring come from <Button size="icon-xs">. This constant exists
|
||||
// purely to share the sidebar palette + the hover-reveal behavior below.
|
||||
// Sidebar palette + hover-reveal: refresh tracks label hover; collapse-all
|
||||
// stays visible while any folder is expanded.
|
||||
const HEADER_ACTION_CLASS =
|
||||
'text-sidebar-foreground/70 hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-sidebar-ring'
|
||||
|
||||
const HEADER_ACTION_REVEAL_CLASS = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
|
||||
const HEADER_ACTION_LABEL_REVEAL = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100 peer-focus-visible/project-label:pointer-events-auto peer-focus-visible/project-label:opacity-100 peer-hover/project-label:pointer-events-auto peer-hover/project-label:opacity-100`
|
||||
|
||||
function FilesystemTab({
|
||||
canCollapse,
|
||||
@@ -157,20 +156,20 @@ function FilesystemTab({
|
||||
const r = t.rightSidebar
|
||||
|
||||
return (
|
||||
<div className="group/project-header flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<RightSidebarSectionHeader>
|
||||
<Tip label={hasCwd ? r.folderTip(cwd) : r.openFolder}>
|
||||
<div className="peer/project-label flex min-w-0 flex-1">
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
|
||||
className="flex w-full min-w-0 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => void onChangeFolder()}
|
||||
type="button"
|
||||
>
|
||||
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
|
||||
</button>
|
||||
</Tip>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={r.refreshTree}
|
||||
className={HEADER_ACTION_CLASS}
|
||||
className={HEADER_ACTION_LABEL_REVEAL}
|
||||
disabled={!hasCwd || loading}
|
||||
onClick={onRefresh}
|
||||
size="icon-xs"
|
||||
@@ -189,7 +188,7 @@ function FilesystemTab({
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={r.collapseAll}
|
||||
className={HEADER_ACTION_REVEAL_CLASS}
|
||||
className={cn(HEADER_ACTION_CLASS, !canCollapse && 'pointer-events-none opacity-0')}
|
||||
disabled={!hasCwd || !canCollapse}
|
||||
onClick={onCollapseAll}
|
||||
size="icon-xs"
|
||||
@@ -209,6 +208,7 @@ function FilesystemTab({
|
||||
onLoadChildren={onLoadChildren}
|
||||
onNodeOpenChange={onNodeOpenChange}
|
||||
onPreviewFile={onPreviewFile}
|
||||
onRetry={onRefresh}
|
||||
openState={openState}
|
||||
/>
|
||||
</div>
|
||||
@@ -230,6 +230,9 @@ interface FileTreeBodyProps {
|
||||
onLoadChildren: (id: string) => void | Promise<void>
|
||||
onNodeOpenChange: (id: string, open: boolean) => void
|
||||
onPreviewFile?: (path: string) => void
|
||||
/** Force-reload the root. The hook also auto-retries while errored, so this
|
||||
* is the impatient-user path. */
|
||||
onRetry?: () => void
|
||||
openState: ReturnType<typeof useProjectTree>['openState']
|
||||
}
|
||||
|
||||
@@ -244,6 +247,7 @@ function FileTreeBody({
|
||||
onLoadChildren,
|
||||
onNodeOpenChange,
|
||||
onPreviewFile,
|
||||
onRetry,
|
||||
openState
|
||||
}: FileTreeBodyProps) {
|
||||
const { t } = useI18n()
|
||||
@@ -254,7 +258,20 @@ function FileTreeBody({
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <EmptyState body={r.unreadableBody(error)} title={r.unreadableTitle} />
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<EmptyState body={r.unreadableBody(error)} title={r.unreadableTitle} />
|
||||
{onRetry && (
|
||||
<button
|
||||
className="text-[0.68rem] font-medium text-muted-foreground transition hover:text-foreground"
|
||||
onClick={onRetry}
|
||||
type="button"
|
||||
>
|
||||
{r.tryAgain}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading && data.length === 0) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useI18n } from '@/i18n'
|
||||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||
import { setTerminalTakeover } from '../store'
|
||||
|
||||
import { addSelectionShortcutLabel } from './selection'
|
||||
import { KbdCombo } from '@/components/ui/kbd'
|
||||
import { useTerminalSession } from './use-terminal-session'
|
||||
|
||||
interface TerminalTabProps {
|
||||
@@ -69,7 +69,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||
variant="secondary"
|
||||
>
|
||||
{t.rightSidebar.addToChat}
|
||||
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel()}</span>
|
||||
<KbdCombo className="ml-1 opacity-70" combo="mod+l" size="sm" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -99,8 +99,6 @@ export function resolveSurfaceColor(fallback: string): string {
|
||||
|
||||
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
|
||||
|
||||
export const addSelectionShortcutLabel = () => (isMacPlatform() ? '⌘L' : 'Ctrl+L')
|
||||
|
||||
export function isAddSelectionShortcut(event: KeyboardEvent) {
|
||||
const mod = isMacPlatform() ? event.metaKey : event.ctrlKey
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { makeTerminalReader, setActiveTerminalReader } from './buffer'
|
||||
@@ -20,6 +21,17 @@ import {
|
||||
|
||||
type TerminalStatus = 'closed' | 'open' | 'starting'
|
||||
|
||||
// ⌘/Ctrl+L is a global shortcut, so a text selection in the file preview pane
|
||||
// lands in this handler with no xterm selection. Label those with the previewed
|
||||
// file's name instead of the shell, so the composer ref reads as a file quote
|
||||
// rather than a bogus "zsh:N lines".
|
||||
function previewSelectionLabel(): string {
|
||||
const target = $filePreviewTarget.get() ?? $previewTarget.get()
|
||||
const source = target?.path || target?.url || ''
|
||||
|
||||
return source.split(/[\\/]/).filter(Boolean).pop() || target?.label?.trim() || ''
|
||||
}
|
||||
|
||||
const HERMES_PATHS_MIME = 'application/x-hermes-paths'
|
||||
|
||||
function readEscapeSequence(data: string, index: number) {
|
||||
@@ -257,16 +269,20 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
)
|
||||
|
||||
const addSelectionToChat = useCallback(() => {
|
||||
const selectedText = readSelection() || selectionRef.current
|
||||
const termSelection = (termRef.current?.getSelection() || selectionRef.current).trim()
|
||||
const selectedText = termSelection || window.getSelection()?.toString() || ''
|
||||
const trimmed = selectedText.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
|
||||
const label =
|
||||
selectionLabelRef.current ||
|
||||
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
|
||||
// Terminal selection → shell-anchored label; anything else came from the
|
||||
// preview pane sharing this global shortcut → label it with the file.
|
||||
const label = termSelection
|
||||
? selectionLabelRef.current ||
|
||||
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
|
||||
: previewSelectionLabel() || 'selection'
|
||||
|
||||
onAddSelectionToChatRef.current(trimmed, label)
|
||||
termRef.current?.clearSelection()
|
||||
@@ -275,7 +291,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
setSelection('')
|
||||
setSelectionStyle(null)
|
||||
triggerHaptic('selection')
|
||||
}, [readSelection])
|
||||
}, [])
|
||||
|
||||
// Always listen — gating on the React selection state misses selections the
|
||||
// TUI redraw races. Only swallow ⌘/Ctrl+L when there's text to send, else it
|
||||
@@ -315,8 +331,11 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
allowTransparency: true,
|
||||
convertEol: true,
|
||||
cursorBlink: true,
|
||||
fontFamily: "'SF Mono', 'Menlo', 'Cascadia Code', 'JetBrains Mono', monospace",
|
||||
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace",
|
||||
fontSize: 11,
|
||||
fontWeight: '400',
|
||||
fontWeightBold: '700',
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1.12,
|
||||
// Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag
|
||||
// can't select — ⌥-drag (macOS) / Shift-drag (else) forces a native
|
||||
@@ -598,13 +617,13 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
startSession()
|
||||
}
|
||||
|
||||
const fonts = typeof document !== 'undefined' ? document.fonts : undefined
|
||||
// fonts.ready settles only already-requested faces; bold/italic aren't asked
|
||||
// for until styled output paints (past atlas init), so warm them up front.
|
||||
const warm = document.fonts?.load
|
||||
? Promise.allSettled(['400', '700', 'italic 400'].map(v => document.fonts.load(`${v} 11px 'JetBrains Mono'`)))
|
||||
: Promise.resolve()
|
||||
|
||||
if (fonts?.ready) {
|
||||
void fonts.ready.then(mount, mount)
|
||||
} else {
|
||||
mount()
|
||||
}
|
||||
void warm.then(mount, mount)
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
|
||||
@@ -18,7 +18,9 @@ import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from
|
||||
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { parseTodos } from '@/lib/todos'
|
||||
import { setClarifyRequest } from '@/store/clarify'
|
||||
import { refreshBackgroundProcesses } from '@/store/composer-status'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
@@ -37,6 +39,7 @@ import {
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
|
||||
import { setSessionTodos } from '@/store/todos'
|
||||
import { recordToolDiff } from '@/store/tool-diffs'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
@@ -52,6 +55,7 @@ interface MessageStreamOptions {
|
||||
queryClient: QueryClient
|
||||
refreshHermesConfig: () => Promise<void>
|
||||
refreshSessions: () => Promise<void>
|
||||
sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>>
|
||||
updateSessionState: (
|
||||
sessionId: string,
|
||||
updater: (state: ClientSessionState) => ClientSessionState,
|
||||
@@ -67,15 +71,7 @@ interface QueuedStreamDeltas {
|
||||
type SessionRuntimeStatePatch = Partial<
|
||||
Pick<
|
||||
ClientSessionState,
|
||||
| 'branch'
|
||||
| 'cwd'
|
||||
| 'fast'
|
||||
| 'model'
|
||||
| 'personality'
|
||||
| 'provider'
|
||||
| 'reasoningEffort'
|
||||
| 'serviceTier'
|
||||
| 'yolo'
|
||||
'branch' | 'cwd' | 'fast' | 'model' | 'personality' | 'provider' | 'reasoningEffort' | 'serviceTier' | 'yolo'
|
||||
>
|
||||
>
|
||||
|
||||
@@ -253,8 +249,14 @@ export function useMessageStream({
|
||||
queryClient,
|
||||
refreshHermesConfig,
|
||||
refreshSessions,
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState
|
||||
}: MessageStreamOptions) {
|
||||
const sessionInterrupted = useCallback(
|
||||
(sessionId: string) => sessionStateByRuntimeIdRef.current.get(sessionId)?.interrupted ?? false,
|
||||
[sessionStateByRuntimeIdRef]
|
||||
)
|
||||
|
||||
// Patch the in-flight assistant message (or seed it). Centralises the
|
||||
// streamId/groupId bookkeeping every event callback would otherwise repeat.
|
||||
const mutateStream = useCallback(
|
||||
@@ -478,6 +480,20 @@ export function useMessageStream({
|
||||
// a tool part can't jump ahead of the text that preceded it.
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
if (sessionInterrupted(sessionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
// The composer status stack owns todo display now (no inline panel) —
|
||||
// mirror every todo state the tool reports into its session store.
|
||||
if (payload?.name === 'todo') {
|
||||
const todos = parseTodos(payload.todos) ?? parseTodos(payload.result) ?? parseTodos(payload.args)
|
||||
|
||||
if (todos) {
|
||||
setSessionTodos(sessionId, todos)
|
||||
}
|
||||
}
|
||||
|
||||
if (!nativeSubagentSessionsRef.current.has(sessionId)) {
|
||||
for (const subagentPayload of delegateTaskPayloads(payload, phase, sourceEventType)) {
|
||||
upsertSubagent(
|
||||
@@ -496,7 +512,7 @@ export function useMessageStream({
|
||||
{ pending: m => phase !== 'complete' || (m.pending ?? false) }
|
||||
)
|
||||
},
|
||||
[flushQueuedDeltas, mutateStream]
|
||||
[flushQueuedDeltas, mutateStream, sessionInterrupted]
|
||||
)
|
||||
|
||||
const completeAssistantMessage = useCallback(
|
||||
@@ -677,9 +693,11 @@ export function useMessageStream({
|
||||
(event: RpcEvent) => {
|
||||
const payload = event.payload as GatewayEventPayload | undefined
|
||||
const explicitSid = event.session_id || ''
|
||||
|
||||
if (!explicitSid && gatewayEventRequiresSessionId(event.type)) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = explicitSid || activeSessionIdRef.current
|
||||
const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current
|
||||
|
||||
@@ -875,13 +893,22 @@ export function useMessageStream({
|
||||
// the sidebar indicator clears as soon as it's answered, not only at
|
||||
// message.complete.
|
||||
updateSessionState(sessionId, state => (state.needsInput ? { ...state, needsInput: false } : state))
|
||||
|
||||
// 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')
|
||||
) {
|
||||
void refreshBackgroundProcesses(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
|
||||
recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff)
|
||||
}
|
||||
} else if (SUBAGENT_EVENT_TYPES.has(event.type)) {
|
||||
if (sessionId && payload) {
|
||||
if (sessionId && payload && !sessionInterrupted(sessionId)) {
|
||||
if (!nativeSubagentSessionsRef.current.has(sessionId)) {
|
||||
pruneDelegateFallbackSubagents(sessionId)
|
||||
}
|
||||
@@ -933,6 +960,8 @@ export function useMessageStream({
|
||||
// raise it and wait — the sidebar flags "needs input" and the inline bar
|
||||
// surfaces once the user focuses that chat.
|
||||
setApprovalRequest({
|
||||
// false only when a tirith warning forbids it; backend omits the field otherwise.
|
||||
allowPermanent: payload?.allow_permanent !== false,
|
||||
command: typeof payload?.command === 'string' ? payload.command : '',
|
||||
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
|
||||
sessionId: sessionId ?? null
|
||||
@@ -985,6 +1014,12 @@ export function useMessageStream({
|
||||
text: result ? JSON.stringify(result) : ''
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'status.update') {
|
||||
// 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') {
|
||||
const errorMessage = payload?.message || 'Hermes reported an error'
|
||||
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
|
||||
@@ -1025,6 +1060,7 @@ export function useMessageStream({
|
||||
flushQueuedDeltas,
|
||||
queryClient,
|
||||
refreshHermesConfig,
|
||||
sessionInterrupted,
|
||||
updateSessionState,
|
||||
upsertToolCall
|
||||
]
|
||||
|
||||
@@ -3,8 +3,9 @@ import type { MutableRefObject } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { textPart } from '@/lib/chat-messages'
|
||||
import { $composerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||
import { $connection, $sessions, setSessions } from '@/store/session'
|
||||
import { $busy, $connection, $messages, $sessions, setSessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { uploadComposerAttachment, usePromptActions } from './use-prompt-actions'
|
||||
@@ -43,6 +44,7 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
|
||||
interface HarnessHandle {
|
||||
cancelRun: () => Promise<void>
|
||||
restoreToMessage: (messageId: string) => Promise<void>
|
||||
steerPrompt: (text: string) => Promise<boolean>
|
||||
submitText: (
|
||||
text: string,
|
||||
@@ -57,6 +59,7 @@ function Harness({
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession,
|
||||
seedMessages,
|
||||
storedSessionId
|
||||
}: {
|
||||
busyRef?: MutableRefObject<boolean>
|
||||
@@ -65,6 +68,7 @@ function Harness({
|
||||
refreshSessions: () => Promise<void>
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
|
||||
seedMessages?: unknown[]
|
||||
storedSessionId?: null | string
|
||||
}) {
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
||||
@@ -73,7 +77,7 @@ function Harness({
|
||||
}
|
||||
const localBusyRef = busyRef ?? { current: false }
|
||||
const stateRef = useRef({
|
||||
messages: [],
|
||||
messages: seedMessages ?? [],
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
interrupted: true
|
||||
@@ -105,10 +109,11 @@ function Harness({
|
||||
useEffect(() => {
|
||||
onReady({
|
||||
cancelRun: actions.cancelRun,
|
||||
restoreToMessage: actions.restoreToMessage,
|
||||
steerPrompt: actions.steerPrompt,
|
||||
submitText: actions.submitText
|
||||
})
|
||||
}, [actions.cancelRun, actions.steerPrompt, actions.submitText, onReady])
|
||||
}, [actions.cancelRun, actions.restoreToMessage, actions.steerPrompt, actions.submitText, onReady])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -395,6 +400,125 @@ describe('usePromptActions steerPrompt', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions restoreToMessage', () => {
|
||||
beforeEach(() => {
|
||||
$busy.set(false)
|
||||
$messages.set([
|
||||
{ id: 'u1', role: 'user', parts: [textPart('first prompt')] },
|
||||
{ id: 'a1', role: 'assistant', parts: [textPart('first answer')] },
|
||||
{ id: 'u2', role: 'user', parts: [textPart('second prompt')] },
|
||||
{ id: 'a2', role: 'assistant', parts: [textPart('second answer')] }
|
||||
])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$busy.set(false)
|
||||
$messages.set([])
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('rewinds to the target user turn and resubmits its text', async () => {
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
let lastState: Record<string, unknown> = {}
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={state => (lastState = state)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
seedMessages={$messages.get()}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.restoreToMessage('u1')
|
||||
|
||||
// Ordinal 0 = "truncate before the first visible user message": the gateway
|
||||
// drops that turn and everything after, then runs the same text again.
|
||||
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
text: 'first prompt',
|
||||
truncate_before_user_ordinal: 0
|
||||
})
|
||||
expect((lastState.messages as { id: string }[]).map(m => m.id)).toEqual(['u1'])
|
||||
expect(lastState.busy).toBe(true)
|
||||
})
|
||||
|
||||
it('rethrows gateway failures and clears the busy flags for the dialog to surface', async () => {
|
||||
const requestGateway = vi.fn(async () => {
|
||||
throw new Error('gateway exploded')
|
||||
})
|
||||
|
||||
let lastState: Record<string, unknown> = {}
|
||||
let handle: HarnessHandle | null = null
|
||||
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={state => (lastState = state)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
await expect(handle!.restoreToMessage('u2')).rejects.toThrow('gateway exploded')
|
||||
expect(lastState.busy).toBe(false)
|
||||
})
|
||||
|
||||
it('interrupts the live turn and retries past "session busy" when reverting mid-stream', async () => {
|
||||
$busy.set(true)
|
||||
|
||||
let submitAttempts = 0
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'prompt.submit') {
|
||||
submitAttempts += 1
|
||||
|
||||
// The cooperative interrupt hasn't wound the turn down yet on the first
|
||||
// try; the second attempt lands once the gateway reports idle.
|
||||
if (submitAttempts === 1) {
|
||||
throw new Error('session busy')
|
||||
}
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
seedMessages={$messages.get()}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.restoreToMessage('u1')
|
||||
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.interrupt', { session_id: RUNTIME_SESSION_ID })
|
||||
expect(submitAttempts).toBe(2)
|
||||
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
text: 'first prompt',
|
||||
truncate_before_user_ordinal: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores non-user targets and unknown ids without touching the gateway', async () => {
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
await handle!.restoreToMessage('a1')
|
||||
await handle!.restoreToMessage('missing')
|
||||
|
||||
expect(requestGateway).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions file attachment sync', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
terminalContextBlocksFromDraft,
|
||||
updateComposerAttachment
|
||||
} from '@/store/composer'
|
||||
import { resetSessionBackground } from '@/store/composer-status'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
@@ -52,6 +53,8 @@ import {
|
||||
setSessions,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { clearSessionSubagents } from '@/store/subagents'
|
||||
import { clearSessionTodos } from '@/store/todos'
|
||||
|
||||
import type {
|
||||
ClientSessionState,
|
||||
@@ -114,6 +117,18 @@ function isSessionNotFoundError(error: unknown): boolean {
|
||||
return /session not found/i.test(message)
|
||||
}
|
||||
|
||||
// The gateway refuses prompt.submit while a turn is running (4009 "session
|
||||
// busy"). Edit/restore (revert) can fire mid-turn, so they interrupt first then
|
||||
// retry the submit until the cooperative interrupt has wound the turn down.
|
||||
const REWIND_INTERRUPT_TIMEOUT_MS = 6_000
|
||||
const REWIND_RETRY_INTERVAL_MS = 150
|
||||
|
||||
function isSessionBusyError(error: unknown): boolean {
|
||||
return /session busy/i.test(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||
|
||||
function base64FromDataUrl(dataUrl: string): string {
|
||||
const comma = dataUrl.indexOf(',')
|
||||
|
||||
@@ -523,6 +538,7 @@ export function usePromptActions({
|
||||
// Images use their base64 preview so the thumbnail renders inline without
|
||||
// a (remote-mode 403-prone) /api/media fetch — see optimisticAttachmentRef.
|
||||
let attachmentRefs = attachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r))
|
||||
|
||||
const buildContextText = (atts: ComposerAttachment[]): string => {
|
||||
const contextRefs = atts
|
||||
.map(a => a.refText)
|
||||
@@ -540,6 +556,7 @@ export function usePromptActions({
|
||||
// bounce the drained send. The drain lock serializes them; the user path
|
||||
// keeps the guard so a stray Enter mid-turn can't double-submit.
|
||||
const hasSendable = Boolean(visibleText || terminalContextBlocks || attachments.length || hasImage)
|
||||
|
||||
if (!hasSendable || (!options?.fromQueue && busyRef.current)) {
|
||||
return false
|
||||
}
|
||||
@@ -652,6 +669,7 @@ export function usePromptActions({
|
||||
const syncedAttachments = await syncAttachmentsForSubmit(sessionId, attachments, {
|
||||
updateComposerAttachments: usingComposerAttachments
|
||||
})
|
||||
|
||||
// Rewrite the optimistic message + prompt text with the synced refs so
|
||||
// the gateway receives @file: paths that resolve in its workspace.
|
||||
// (Images keep their inline base64 preview — see optimisticAttachmentRef.)
|
||||
@@ -672,6 +690,7 @@ export function usePromptActions({
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: selectedStoredSessionIdRef.current
|
||||
})
|
||||
|
||||
const recoveredId = resumed?.session_id
|
||||
|
||||
if (recoveredId) {
|
||||
@@ -1234,12 +1253,13 @@ export function usePromptActions({
|
||||
|
||||
const cancelRun = useCallback(async () => {
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
const releaseBusy = () => {
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
setAwaitingResponse(false)
|
||||
|
||||
// Interrupting keeps whatever was already generated and just
|
||||
// stops — no "[interrupted]" marker. A pending/streaming message with no
|
||||
// body text is dropped entirely so we never leave an empty bubble behind.
|
||||
const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) =>
|
||||
messages
|
||||
.filter(
|
||||
@@ -1251,8 +1271,7 @@ export function usePromptActions({
|
||||
)
|
||||
|
||||
if (!sessionId) {
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
releaseBusy()
|
||||
setMessages(finalizeMessages($messages.get()))
|
||||
|
||||
return
|
||||
@@ -1260,13 +1279,12 @@ export function usePromptActions({
|
||||
|
||||
updateSessionState(sessionId, state => {
|
||||
const streamId = state.streamId
|
||||
|
||||
const messages = finalizeMessages(state.messages, streamId)
|
||||
|
||||
return {
|
||||
...state,
|
||||
messages,
|
||||
busy: true,
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
streamId: null,
|
||||
pendingBranchGroup: null,
|
||||
@@ -1274,8 +1292,13 @@ export function usePromptActions({
|
||||
}
|
||||
})
|
||||
|
||||
clearSessionTodos(sessionId)
|
||||
clearSessionSubagents(sessionId)
|
||||
resetSessionBackground(sessionId)
|
||||
|
||||
try {
|
||||
await requestGateway('session.interrupt', { session_id: sessionId })
|
||||
releaseBusy()
|
||||
} catch (err) {
|
||||
let stopError = err
|
||||
|
||||
@@ -1284,11 +1307,13 @@ export function usePromptActions({
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: selectedStoredSessionIdRef.current
|
||||
})
|
||||
|
||||
const recoveredId = resumed?.session_id
|
||||
|
||||
if (recoveredId) {
|
||||
activeSessionIdRef.current = recoveredId
|
||||
await requestGateway('session.interrupt', { session_id: recoveredId })
|
||||
releaseBusy()
|
||||
|
||||
return
|
||||
}
|
||||
@@ -1297,8 +1322,7 @@ export function usePromptActions({
|
||||
}
|
||||
}
|
||||
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
releaseBusy()
|
||||
notifyError(stopError, copy.stopFailed)
|
||||
}
|
||||
}, [
|
||||
@@ -1421,13 +1445,116 @@ export function usePromptActions({
|
||||
[activeSessionId, copy.regenerateFailed, requestGateway, updateSessionState]
|
||||
)
|
||||
|
||||
// Cursor-style "restore checkpoint": rewind the conversation to a past user
|
||||
// prompt and run it again from there. Reuses the edit composer's rewind
|
||||
// mechanism — `prompt.submit` with `truncate_before_user_ordinal` drops that
|
||||
// user turn and everything after it from the session history, then the same
|
||||
// text is submitted as a fresh turn. Callers confirm before invoking; errors
|
||||
// are rethrown so the confirmation dialog can surface them inline.
|
||||
// Submit a rewind (truncate-before-ordinal + resubmit). Because edit/restore
|
||||
// can fire while a turn is streaming, interrupt the live turn first, then
|
||||
// retry the submit until the gateway stops reporting "session busy" — the
|
||||
// interrupt is cooperative, so the running turn takes a beat to wind down.
|
||||
const submitRewindPrompt = useCallback(
|
||||
async (sessionId: string, text: string, truncateOrdinal: number | undefined, wasRunning: boolean) => {
|
||||
if (wasRunning) {
|
||||
try {
|
||||
await requestGateway('session.interrupt', { session_id: sessionId })
|
||||
} catch {
|
||||
// Best-effort — the busy-retry below still gates the submit.
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + REWIND_INTERRUPT_TIMEOUT_MS
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
await requestGateway('prompt.submit', {
|
||||
session_id: sessionId,
|
||||
text,
|
||||
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
|
||||
})
|
||||
|
||||
return
|
||||
} catch (err) {
|
||||
if (isSessionBusyError(err) && Date.now() < deadline) {
|
||||
await sleep(REWIND_RETRY_INTERVAL_MS)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
}
|
||||
},
|
||||
[requestGateway]
|
||||
)
|
||||
|
||||
const restoreToMessage = useCallback(
|
||||
async (messageId: string) => {
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const messages = $messages.get()
|
||||
const sourceIndex = messages.findIndex(m => m.id === messageId)
|
||||
const source = messages[sourceIndex]
|
||||
|
||||
if (!source || source.role !== 'user') {
|
||||
return
|
||||
}
|
||||
|
||||
const text = chatMessageText(source).trim()
|
||||
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
const wasRunning = $busy.get()
|
||||
const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, sourceIndex)
|
||||
|
||||
// The turns we're discarding may have spawned todos and background
|
||||
// processes; they belong to the abandoned timeline, so wipe their status
|
||||
// rows (and kill the live processes) before the fresh run repopulates.
|
||||
clearSessionTodos(sessionId)
|
||||
resetSessionBackground(sessionId)
|
||||
|
||||
clearNotifications()
|
||||
setMutableRef(busyRef, true)
|
||||
setBusy(true)
|
||||
setAwaitingResponse(true)
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
busy: true,
|
||||
awaitingResponse: true,
|
||||
pendingBranchGroup: null,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: false,
|
||||
messages: state.messages.slice(0, sourceIndex + 1)
|
||||
}))
|
||||
|
||||
try {
|
||||
await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, wasRunning)
|
||||
} catch (err) {
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[activeSessionId, activeSessionIdRef, busyRef, submitRewindPrompt, updateSessionState]
|
||||
)
|
||||
|
||||
const editMessage = useCallback(
|
||||
async (edited: AppendMessage) => {
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
const sourceId = edited.sourceId || edited.parentId
|
||||
const text = appendText(edited)
|
||||
|
||||
if (!sessionId || !sourceId || !text || edited.role !== 'user' || $busy.get()) {
|
||||
if (!sessionId || !sourceId || !text || edited.role !== 'user') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1439,12 +1566,23 @@ export function usePromptActions({
|
||||
return
|
||||
}
|
||||
|
||||
// Sending an edit is a revert: rewind to this prompt and re-run with the
|
||||
// new text. It can fire mid-turn, so capture the live state — the submit
|
||||
// helper interrupts first when a turn is running.
|
||||
const wasRunning = $busy.get()
|
||||
|
||||
// Failed turn: optimistic user msg never reached the gateway, so truncating
|
||||
// by ordinal would 422. Submit as a plain resend instead.
|
||||
const nextMessage = messages[sourceIndex + 1]
|
||||
const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error)
|
||||
const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] }
|
||||
|
||||
// Editing rewinds the conversation to this prompt — same as restore — so
|
||||
// drop the abandoned timeline's todos/background rows (and kill the live
|
||||
// processes) before the re-run repopulates them.
|
||||
clearSessionTodos(sessionId)
|
||||
resetSessionBackground(sessionId)
|
||||
|
||||
clearNotifications()
|
||||
setMutableRef(busyRef, true)
|
||||
setBusy(true)
|
||||
@@ -1459,24 +1597,18 @@ export function usePromptActions({
|
||||
messages: [...state.messages.slice(0, sourceIndex), editedMessage]
|
||||
}))
|
||||
|
||||
const submit = (truncateOrdinal?: number) =>
|
||||
requestGateway('prompt.submit', {
|
||||
session_id: sessionId,
|
||||
text,
|
||||
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
|
||||
})
|
||||
|
||||
const isStaleTargetError = (err: unknown) =>
|
||||
/no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err))
|
||||
|
||||
try {
|
||||
await submit(isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex))
|
||||
await submitRewindPrompt(sessionId, text, isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex), wasRunning)
|
||||
} catch (err) {
|
||||
let surfaced = err
|
||||
|
||||
if (!isFailedTurn && isStaleTargetError(err)) {
|
||||
try {
|
||||
await submit()
|
||||
// Already interrupted on the first attempt — submit as a plain resend.
|
||||
await submitRewindPrompt(sessionId, text, undefined, false)
|
||||
|
||||
return
|
||||
} catch (retryErr) {
|
||||
@@ -1491,7 +1623,7 @@ export function usePromptActions({
|
||||
notifyError(surfaced, copy.editFailed)
|
||||
}
|
||||
},
|
||||
[activeSessionId, activeSessionIdRef, busyRef, copy.editFailed, requestGateway, updateSessionState]
|
||||
[activeSessionId, activeSessionIdRef, busyRef, copy.editFailed, submitRewindPrompt, updateSessionState]
|
||||
)
|
||||
|
||||
const handleThreadMessagesChange = useCallback(
|
||||
@@ -1534,6 +1666,7 @@ export function usePromptActions({
|
||||
handleThreadMessagesChange,
|
||||
handoffSession,
|
||||
reloadFromMessage,
|
||||
restoreToMessage,
|
||||
steerPrompt,
|
||||
submitText,
|
||||
transcribeVoiceAudio
|
||||
|
||||
119
apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
Normal file
119
apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { cleanup, render, waitFor } from '@testing-library/react'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
import { useSessionActions } from './use-session-actions'
|
||||
|
||||
vi.mock('@/hermes', async importOriginal => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
deleteSession: vi.fn(),
|
||||
getSessionMessages: vi.fn(),
|
||||
listAllProfileSessions: vi.fn(),
|
||||
setApiRequestProfile: vi.fn(),
|
||||
setSessionArchived: vi.fn()
|
||||
}))
|
||||
|
||||
const RUNTIME_SESSION_ID = 'rt-new-001'
|
||||
|
||||
function Harness({
|
||||
onReady,
|
||||
requestGateway
|
||||
}: {
|
||||
onReady: (create: (preview?: string | null) => Promise<string | null>) => void
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}) {
|
||||
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
|
||||
|
||||
const actions = useSessionActions({
|
||||
activeSessionId: null,
|
||||
activeSessionIdRef: ref<string | null>(null),
|
||||
busyRef: ref(false),
|
||||
creatingSessionRef: ref(false),
|
||||
ensureSessionState: () => ({}) as ClientSessionState,
|
||||
getRouteToken: () => 'token',
|
||||
navigate: vi.fn() as never,
|
||||
requestGateway,
|
||||
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
|
||||
selectedStoredSessionId: null,
|
||||
selectedStoredSessionIdRef: ref<string | null>(null),
|
||||
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
|
||||
syncSessionStateToView: vi.fn(),
|
||||
updateSessionState: () => ({}) as ClientSessionState
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
onReady(actions.createBackendSessionForSend)
|
||||
}, [actions.createBackendSessionForSend, onReady])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function createWith(profileSetup: () => void): Promise<Record<string, unknown> | undefined> {
|
||||
let createParams: Record<string, unknown> | undefined
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.create') {
|
||||
createParams = params
|
||||
|
||||
return { session_id: RUNTIME_SESSION_ID, stored_session_id: null } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
$currentCwd.set('')
|
||||
profileSetup()
|
||||
|
||||
let create: ((preview?: string | null) => Promise<string | null>) | null = null
|
||||
render(<Harness onReady={c => (create = c)} requestGateway={requestGateway} />)
|
||||
await waitFor(() => expect(create).not.toBeNull())
|
||||
await create!()
|
||||
|
||||
return createParams
|
||||
}
|
||||
|
||||
describe('createBackendSessionForSend profile routing', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$newChatProfile.set(null)
|
||||
$activeGatewayProfile.set('default')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('routes a plain new chat (no explicit profile) to the live gateway profile', async () => {
|
||||
// The "rubberband to default" bug: the top New Session button clears
|
||||
// $newChatProfile to null. In global-remote mode one backend serves every
|
||||
// profile, so an omitted `profile` lands the chat on the launch (default)
|
||||
// profile. The session must instead carry the active gateway profile.
|
||||
const params = await createWith(() => {
|
||||
$activeGatewayProfile.set('coder')
|
||||
$newChatProfile.set(null)
|
||||
})
|
||||
|
||||
expect(params).toMatchObject({ profile: 'coder' })
|
||||
})
|
||||
|
||||
it('honours an explicit per-profile "+" selection', async () => {
|
||||
const params = await createWith(() => {
|
||||
$activeGatewayProfile.set('coder')
|
||||
$newChatProfile.set('analyst')
|
||||
})
|
||||
|
||||
expect(params).toMatchObject({ profile: 'analyst' })
|
||||
})
|
||||
|
||||
it('passes the default profile for single-profile users (backend resolves it to launch)', async () => {
|
||||
const params = await createWith(() => {
|
||||
$activeGatewayProfile.set('default')
|
||||
$newChatProfile.set(null)
|
||||
})
|
||||
|
||||
expect(params).toMatchObject({ profile: 'default' })
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { NavigateFunction } from 'react-router-dom'
|
||||
|
||||
import { deleteSession, 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'
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
workspaceCwdForNewSession
|
||||
} from '@/store/session'
|
||||
import { reportBackendContract } from '@/store/updates'
|
||||
import { isWatchWindow } from '@/store/windows'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes'
|
||||
|
||||
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
|
||||
@@ -209,6 +210,46 @@ function patchSessionWorkspace(sessionId: string, cwd: string | undefined) {
|
||||
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
|
||||
}
|
||||
|
||||
function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): boolean {
|
||||
return session.id === storedSessionId || session._lineage_root_id === storedSessionId
|
||||
}
|
||||
|
||||
function upsertResolvedSession(session: SessionInfo, storedSessionId: string) {
|
||||
const lineage = session._lineage_root_id ?? session.id
|
||||
|
||||
setSessions(prev => [
|
||||
session,
|
||||
...prev.filter(existing => {
|
||||
if (sessionMatchesStoredId(existing, storedSessionId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (existing._lineage_root_id ?? existing.id) !== lineage
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
async function resolveStoredSession(storedSessionId: string): Promise<SessionInfo | undefined> {
|
||||
const cached = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await listAllProfileSessions(500, 0, 'include', 'recent', 'all')
|
||||
const resolved = result.sessions.find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
|
||||
if (resolved) {
|
||||
upsertResolvedSession(resolved, storedSessionId)
|
||||
}
|
||||
|
||||
return resolved
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
type SessionRuntimeStatePatch = Partial<
|
||||
Pick<
|
||||
ClientSessionState,
|
||||
@@ -366,13 +407,17 @@ export function useSessionActions({
|
||||
creatingSessionRef.current = true
|
||||
|
||||
try {
|
||||
// Route the new chat to the chosen profile's backend (null = primary,
|
||||
// so single-profile users are unaffected).
|
||||
await ensureGatewayProfile($newChatProfile.get())
|
||||
// A plain new session (top "New Session", /new, keybind) leaves
|
||||
// $newChatProfile null to mean "use the live context"; the per-profile
|
||||
// "+" sets it explicitly. Resolve null to the active gateway profile so
|
||||
// session.create always carries it: in global-remote mode one backend
|
||||
// serves every profile, so an omitted profile param silently lands the
|
||||
// chat on the launch (default) profile — the "rubberbands back to
|
||||
// default" bug. This is a no-op for single-profile/local-pooled users:
|
||||
// a backend resolves its own launch profile to None (_profile_home).
|
||||
const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
|
||||
await ensureGatewayProfile(newChatProfile)
|
||||
const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession()
|
||||
// Pass the owning profile so a new chat under a non-launch profile (global
|
||||
// remote mode) builds its agent + persists against THAT profile's home/db.
|
||||
const newChatProfile = $newChatProfile.get()
|
||||
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
@@ -480,8 +525,13 @@ export function useSessionActions({
|
||||
|
||||
// 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).
|
||||
const storedForProfile = $sessions.get().find(session => session.id === storedSessionId)
|
||||
const storedForProfile = await resolveStoredSession(storedSessionId)
|
||||
const sessionProfile = storedForProfile?.profile
|
||||
|
||||
if (resumeRequestRef.current !== requestId) {
|
||||
return
|
||||
}
|
||||
|
||||
await ensureGatewayProfile(sessionProfile)
|
||||
|
||||
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
@@ -489,6 +539,7 @@ export function useSessionActions({
|
||||
|
||||
if (cachedRuntimeId && cachedState) {
|
||||
const stored = $sessions.get().find(session => session.id === storedSessionId)
|
||||
|
||||
const cachedViewState =
|
||||
!cachedState.model && stored?.model != null
|
||||
? {
|
||||
@@ -549,7 +600,7 @@ export function useSessionActions({
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
setSessionStartedAt(Date.now())
|
||||
const stored = $sessions.get().find(session => session.id === storedSessionId)
|
||||
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
applyStoredSessionPreviewRuntimeInfo(stored)
|
||||
|
||||
if (stored) {
|
||||
@@ -561,26 +612,23 @@ export function useSessionActions({
|
||||
}))
|
||||
}
|
||||
|
||||
let resumedRunning = false
|
||||
|
||||
try {
|
||||
// Load the local snapshot first, then ask the gateway to resume.
|
||||
// Previously these raced:
|
||||
// 1. clear messages to []
|
||||
// 2. local getSessionMessages -> 45 msgs
|
||||
// 3. a second resume path cleared [] again
|
||||
// 4. gateway resume -> 43 msgs
|
||||
// That is the ctrl+R flash chain. Avoid showing an empty thread
|
||||
// while we already have a route-scoped session id, and don't race the
|
||||
// local snapshot against gateway resume.
|
||||
const watchWindow = isWatchWindow()
|
||||
let localSnapshot = $messages.get()
|
||||
|
||||
try {
|
||||
const storedMessages = await getSessionMessages(storedSessionId, sessionProfile)
|
||||
// Watch windows skip REST prefetch — lazy resume attaches the live mirror.
|
||||
if (!watchWindow) {
|
||||
const storedMessages = await getSessionMessages(storedSessionId, sessionProfile)
|
||||
|
||||
if (isCurrentResume()) {
|
||||
localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get())
|
||||
if (isCurrentResume()) {
|
||||
localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get())
|
||||
|
||||
if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) {
|
||||
setMessages(localSnapshot)
|
||||
if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) {
|
||||
setMessages(localSnapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -590,9 +638,7 @@ export function useSessionActions({
|
||||
const resumed = await requestGateway<SessionResumeResponse>('session.resume', {
|
||||
session_id: storedSessionId,
|
||||
cols: 96,
|
||||
// Owning profile: in app-global remote mode one backend serves every
|
||||
// profile, so the gateway opens this profile's state.db + home to
|
||||
// resume + persist the right session (no-op for single/launch profile).
|
||||
...(watchWindow ? { lazy: true } : {}),
|
||||
...(sessionProfile ? { profile: sessionProfile } : {})
|
||||
})
|
||||
|
||||
@@ -606,15 +652,7 @@ export function useSessionActions({
|
||||
reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages),
|
||||
currentMessages
|
||||
)
|
||||
// Avoid a second visible transcript rebuild on resume/switch.
|
||||
// `getSessionMessages()` is the stable stored transcript snapshot and
|
||||
// paints first; `session.resume` can return a slightly different
|
||||
// runtime-shaped projection (e.g. tool/system coalescing), which was
|
||||
// causing a second full message-list replacement a second later.
|
||||
// Keep the already-painted local snapshot for the view/cache when it
|
||||
// exists; use gateway messages only as a fallback when no local
|
||||
// snapshot was available.
|
||||
|
||||
// Keep the local snapshot when resume would only reshuffle runtime projection.
|
||||
const preferredMessages =
|
||||
localSnapshot.length > 0
|
||||
? localSnapshot
|
||||
@@ -630,14 +668,16 @@ export function useSessionActions({
|
||||
|
||||
patchSessionWorkspace(storedSessionId, runtimeInfo?.cwd)
|
||||
|
||||
resumedRunning = Boolean((resumed as { running?: boolean }).running)
|
||||
|
||||
updateSessionState(
|
||||
resumed.session_id,
|
||||
state => ({
|
||||
...state,
|
||||
...(runtimeInfo ?? {}),
|
||||
messages: messagesForView,
|
||||
busy: false,
|
||||
awaitingResponse: false
|
||||
busy: resumedRunning,
|
||||
awaitingResponse: resumedRunning
|
||||
}),
|
||||
storedSessionId
|
||||
)
|
||||
@@ -656,9 +696,9 @@ export function useSessionActions({
|
||||
notifyError(err, copy.resumeFailed)
|
||||
} finally {
|
||||
if (isCurrentResume()) {
|
||||
busyRef.current = false
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
busyRef.current = resumedRunning
|
||||
setBusy(resumedRunning)
|
||||
setAwaitingResponse(resumedRunning)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -799,7 +839,7 @@ export function useSessionActions({
|
||||
async (storedSessionId: string) => {
|
||||
clearNotifications()
|
||||
|
||||
const removed = $sessions.get().find(s => s.id === storedSessionId)
|
||||
const removed = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
const wasSelected = selectedStoredSessionId === storedSessionId
|
||||
const closingRuntimeId = wasSelected ? activeSessionId : null
|
||||
const previousMessages = $messages.get()
|
||||
@@ -808,7 +848,7 @@ export function useSessionActions({
|
||||
// live tip after compression. Drop both so the pin can't linger.
|
||||
const removedPinId = removed ? sessionPinId(removed) : storedSessionId
|
||||
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
|
||||
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
|
||||
// doesn't keep claiming the removed row is still on the server.
|
||||
setSessionsTotal(prev => Math.max(0, prev - 1))
|
||||
@@ -843,7 +883,7 @@ export function useSessionActions({
|
||||
setFreshDraftReady(false)
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
const stored = $sessions.get().find(session => session.id === storedSessionId)
|
||||
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
|
||||
if (stored) {
|
||||
setCurrentUsage(current => ({
|
||||
@@ -882,7 +922,7 @@ export function useSessionActions({
|
||||
async (storedSessionId: string) => {
|
||||
clearNotifications()
|
||||
|
||||
const archived = $sessions.get().find(s => s.id === storedSessionId)
|
||||
const archived = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
const wasSelected = selectedStoredSessionId === storedSessionId
|
||||
const previousPinned = $pinnedSessionIds.get()
|
||||
// Pins are keyed on the durable lineage-root id; the stored id may be the
|
||||
@@ -890,7 +930,7 @@ export function useSessionActions({
|
||||
const archivedPinId = archived ? sessionPinId(archived) : storedSessionId
|
||||
|
||||
// Soft-hide: drop from the sidebar immediately, keep the data.
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
|
||||
// Archived sessions are hidden by the listSessions(min_messages=1) query
|
||||
// on the next refresh, so they count as "removed" for the load-more
|
||||
// footer math.
|
||||
@@ -907,12 +947,12 @@ export function useSessionActions({
|
||||
// in flight and briefly reinsert the still-unarchived backend row. Win
|
||||
// that race after the mutation succeeds so right-click → Archive does
|
||||
// not appear to do nothing until the next full refresh.
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
|
||||
$pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId))
|
||||
notify({ durationMs: 2_000, kind: 'success', message: copy.archived })
|
||||
} catch (err) {
|
||||
if (archived) {
|
||||
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
|
||||
setSessions(prev => [archived, ...prev.filter(session => !sessionMatchesStoredId(session, storedSessionId))])
|
||||
setSessionsTotal(prev => prev + 1)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
||||
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
||||
import { $translucency, setTranslucency } from '@/store/translucency'
|
||||
import { useTheme } from '@/themes/context'
|
||||
import { installVscodeThemeFromMarketplace } from '@/themes/install'
|
||||
import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes'
|
||||
@@ -135,6 +136,7 @@ export function AppearanceSettings() {
|
||||
const { t, isSavingLocale } = useI18n()
|
||||
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
|
||||
const toolViewMode = useStore($toolViewMode)
|
||||
const translucency = useStore($translucency)
|
||||
const profiles = useStore($profiles)
|
||||
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
|
||||
const a = t.settings.appearance
|
||||
@@ -183,6 +185,32 @@ export function AppearanceSettings() {
|
||||
title={a.colorMode}
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
aria-label={a.translucencyTitle}
|
||||
className="h-1 w-40 cursor-pointer appearance-none rounded-full bg-(--ui-stroke-tertiary)"
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={event => {
|
||||
triggerHaptic('selection')
|
||||
setTranslucency(Number(event.target.value))
|
||||
}}
|
||||
step={5}
|
||||
style={{ accentColor: 'var(--dt-primary)' }}
|
||||
type="range"
|
||||
value={translucency}
|
||||
/>
|
||||
<span className="w-9 text-right text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)">
|
||||
{translucency}%
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
description={a.translucencyDesc}
|
||||
title={a.translucencyTitle}
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
below={
|
||||
<>
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment }
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { startManualProviderOAuth } from '@/store/onboarding'
|
||||
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
|
||||
@@ -224,10 +224,23 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
}, [apiKeyDraft, selectedProviderRow])
|
||||
|
||||
// OAuth / external providers can't be activated with a pasted key — hand off
|
||||
// to the shared onboarding flow scoped to this provider's real sign-in.
|
||||
// to the shared onboarding flow scoped to this provider's real sign-in. The
|
||||
// custom / local endpoint is NOT an OAuth provider, so it gets the dedicated
|
||||
// local-endpoint form (URL + optional API key) instead of being dead-ended
|
||||
// on the OAuth picker (the original "booted back to the first screen" loop).
|
||||
const startProviderSetup = useCallback(() => {
|
||||
if (selectedProviderRow?.slug) {
|
||||
startManualProviderOAuth(selectedProviderRow.slug)
|
||||
const slug = selectedProviderRow?.slug
|
||||
|
||||
if (!slug) {
|
||||
return
|
||||
}
|
||||
|
||||
const lower = slug.toLowerCase()
|
||||
|
||||
if (lower === 'custom' || lower === 'local' || lower.startsWith('custom:')) {
|
||||
startManualLocalEndpoint()
|
||||
} else {
|
||||
startManualProviderOAuth(slug)
|
||||
}
|
||||
}, [selectedProviderRow])
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
|
||||
import { deleteSession, listAllProfileSessions, setSessionArchived } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
@@ -43,14 +43,14 @@ export function SessionsSettings() {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
|
||||
const result = await listAllProfileSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
|
||||
setLocalSessions(result.sessions)
|
||||
} catch (err) {
|
||||
notifyError(err, s.failedLoad)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [s.failedLoad])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Kbd, KbdCombo } from '@/components/ui/kbd'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
KEYBIND_ACTIONS,
|
||||
@@ -166,15 +167,11 @@ function KeybindRow({ action }: { action: KeybindActionMeta }) {
|
||||
type="button"
|
||||
>
|
||||
{capturing ? (
|
||||
<span className="kbd-cap kbd-capturing">{k.pressKey}</span>
|
||||
<Kbd variant="capturing">{k.pressKey}</Kbd>
|
||||
) : combos.length > 0 ? (
|
||||
combos.map(combo => (
|
||||
<span className="kbd-cap" key={combo}>
|
||||
{formatCombo(combo)}
|
||||
</span>
|
||||
))
|
||||
combos.map(combo => <KbdCombo combo={combo} key={combo} />)
|
||||
) : (
|
||||
<span className="kbd-cap kbd-cap--ghost">{k.set}</span>
|
||||
<Kbd variant="ghost">{k.set}</Kbd>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -209,9 +206,7 @@ function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
|
||||
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/75">{label}</span>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{shortcut.keys.map(key => (
|
||||
<span className="kbd-cap" key={key}>
|
||||
{formatCombo(key)}
|
||||
</span>
|
||||
<KbdCombo combo={key} key={key} />
|
||||
))}
|
||||
</div>
|
||||
<span aria-hidden className="size-6 shrink-0" />
|
||||
|
||||
@@ -19,7 +19,10 @@ export const titlebarButtonClass =
|
||||
'text-muted-foreground/85 hover:bg-(--ui-control-hover-background) hover:text-foreground'
|
||||
|
||||
export const titlebarHeaderBaseClass =
|
||||
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]'
|
||||
'pointer-events-none relative z-3 flex h-(--titlebar-height) w-full min-w-0 shrink-0 items-center justify-start gap-3 overflow-hidden border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))] pr-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,0px)+0.75rem)]'
|
||||
|
||||
// Title row inside the header — must stay in the flex truncate chain.
|
||||
export const titlebarHeaderTitleClass = 'min-w-0 flex-1 overflow-hidden'
|
||||
|
||||
export const titlebarHeaderShadowClass =
|
||||
"after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-4 after:bg-linear-to-b after:from-(--ui-chat-surface-background) after:to-transparent after:content-['']"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useSt
|
||||
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { KbdCombo } from '@/components/ui/kbd'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
@@ -229,7 +230,10 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
value={draft}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[0.6875rem] text-muted-foreground/85">{copy.shortcut}</span>
|
||||
<span className="inline-flex items-center gap-1 text-[0.6875rem] text-muted-foreground/85">
|
||||
<KbdCombo combo="mod+enter" size="sm" />
|
||||
{copy.shortcutSuffix}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasChoices && (
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { MessageRenderBoundary } from './message-render-boundary'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function Boom({ error }: { error: Error | null }): null {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const lookupError = new Error('tapClientLookup: Index 2 out of bounds (length: 2)')
|
||||
|
||||
describe('MessageRenderBoundary', () => {
|
||||
it('renders children when nothing throws', () => {
|
||||
render(
|
||||
<MessageRenderBoundary resetKey="a">
|
||||
<div>content</div>
|
||||
</MessageRenderBoundary>
|
||||
)
|
||||
|
||||
expect(screen.getByText('content')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('swallows the transient tapClientLookup out-of-bounds store race', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
const { container } = render(
|
||||
<MessageRenderBoundary resetKey="a">
|
||||
<Boom error={lookupError} />
|
||||
</MessageRenderBoundary>
|
||||
)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('recovers on the next consistent snapshot when resetKey changes', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
const { rerender } = render(
|
||||
<MessageRenderBoundary resetKey="a">
|
||||
<Boom error={lookupError} />
|
||||
</MessageRenderBoundary>
|
||||
)
|
||||
|
||||
rerender(
|
||||
<MessageRenderBoundary resetKey="b">
|
||||
<Boom error={null} />
|
||||
</MessageRenderBoundary>
|
||||
)
|
||||
|
||||
rerender(
|
||||
<MessageRenderBoundary resetKey="b">
|
||||
<div>recovered</div>
|
||||
</MessageRenderBoundary>
|
||||
)
|
||||
|
||||
expect(screen.getByText('recovered')).toBeTruthy()
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('re-throws unrelated errors so real bugs still surface', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
expect(() =>
|
||||
render(
|
||||
<MessageRenderBoundary resetKey="a">
|
||||
<Boom error={new Error('genuine render bug')} />
|
||||
</MessageRenderBoundary>
|
||||
)
|
||||
).toThrow('genuine render bug')
|
||||
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Component, type ReactNode } from 'react'
|
||||
|
||||
// `@assistant-ui/store`'s index-keyed child-scope lookup (`tapClientLookup`)
|
||||
// throws — rather than returning undefined — when a subscriber reads an index
|
||||
// that the message/parts list no longer has. This races during high-frequency
|
||||
// store replacement (session switch mid-stream, gateway reconnect replay): a
|
||||
// subscriber from the previous, longer list is still in React's notification
|
||||
// queue and reads one slot past the new, shorter array before it can unmount.
|
||||
// The throw is transient and self-heals on the next consistent snapshot, but
|
||||
// without a local boundary it unwinds to the root and blanks the whole app.
|
||||
// Upstream-tracked: assistant-ui/assistant-ui#4051, #3652.
|
||||
const isTransientLookupError = (error: unknown): boolean =>
|
||||
error instanceof Error && /tapClient(Lookup|Resource).*out of bounds/.test(error.message)
|
||||
|
||||
interface Props {
|
||||
// Changes whenever the message list mutates; remounting clears the caught
|
||||
// error so the next consistent render recovers silently.
|
||||
resetKey: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export class MessageRenderBoundary extends Component<Props, { error: Error | null }> {
|
||||
state: { error: Error | null } = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidUpdate(prev: Props) {
|
||||
if (this.state.error && prev.resetKey !== this.props.resetKey) {
|
||||
this.setState({ error: null })
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
// Only swallow the transient store race; re-throw anything else so real
|
||||
// bugs still reach the root error boundary.
|
||||
if (!isTransientLookupError(this.state.error)) {
|
||||
throw this.state.error
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -216,36 +216,6 @@ function assistantTodoMessage(
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantReasoningTodoMessage(
|
||||
todos: Array<{ content: string; id: string; status: 'cancelled' | 'completed' | 'in_progress' | 'pending' }>
|
||||
): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-reasoning-todo-1',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'reasoning', text: 'Let me make a quick todo list.' },
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'todo-1',
|
||||
toolName: 'todo',
|
||||
args: { todos },
|
||||
argsText: JSON.stringify({ todos }),
|
||||
result: { todos }
|
||||
},
|
||||
{ type: 'text', text: 'Done — fake list created.' }
|
||||
],
|
||||
status: { 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)
|
||||
@@ -718,7 +688,7 @@ describe('assistant-ui streaming renderer', () => {
|
||||
expect(container.textContent).toContain('Interim answer.')
|
||||
})
|
||||
|
||||
it('renders live todo rows during a running turn', () => {
|
||||
it('does not render an inline todo panel — todos live in the composer status stack', () => {
|
||||
const { container } = render(
|
||||
<TodoHarness
|
||||
message={assistantTodoMessage([
|
||||
@@ -728,52 +698,6 @@ describe('assistant-ui streaming renderer', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
const ui = within(container)
|
||||
|
||||
expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeTruthy()
|
||||
expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0)
|
||||
expect(ui.getByText('Gather ingredients')).toBeTruthy()
|
||||
expect(ui.queryByText(/pending/i)).toBeNull()
|
||||
expect(ui.queryByRole('button', { name: /todo/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders archived todos after turn completion regardless of pending state', () => {
|
||||
const first = render(
|
||||
<TodoHarness message={assistantTodoMessage([{ content: 'Boil water', id: 'boil', status: 'pending' }], false)} />
|
||||
)
|
||||
|
||||
const ui = within(first.container)
|
||||
|
||||
expect(ui.getAllByText('Boil water').length).toBeGreaterThan(0)
|
||||
|
||||
first.unmount()
|
||||
|
||||
const second = render(
|
||||
<TodoHarness
|
||||
message={assistantTodoMessage([{ content: 'Serve latte', id: 'serve', status: 'completed' }], false)}
|
||||
/>
|
||||
)
|
||||
|
||||
const archivedUi = within(second.container)
|
||||
|
||||
expect(archivedUi.getAllByText('Serve latte').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('hoists todo outside the thinking disclosure when reasoning is present', () => {
|
||||
const { container } = render(
|
||||
<TodoHarness
|
||||
message={assistantReasoningTodoMessage([
|
||||
{ content: 'Buy oats', id: 'oats', status: 'completed' },
|
||||
{ content: "Reply to Sam's email", id: 'email', status: 'in_progress' }
|
||||
])}
|
||||
/>
|
||||
)
|
||||
|
||||
const todoPanel = container.querySelector('[data-slot="aui_todo-hoisted"]')
|
||||
const thinkingDisclosure = container.querySelector('[data-slot="aui_thinking-disclosure"]')
|
||||
|
||||
expect(todoPanel).toBeTruthy()
|
||||
expect(thinkingDisclosure).toBeTruthy()
|
||||
expect(Boolean(thinkingDisclosure?.contains(todoPanel as Node))).toBe(false)
|
||||
expect(container.querySelector('[data-slot="aui_todo-hoisted"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,8 @@ import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { setThreadScrolledUp } from '@/store/thread-scroll'
|
||||
|
||||
import { MessageRenderBoundary } from './message-render-boundary'
|
||||
|
||||
const ESTIMATED_ITEM_HEIGHT = 220
|
||||
const OVERSCAN = 4
|
||||
const AT_BOTTOM_THRESHOLD = 4
|
||||
@@ -180,18 +182,20 @@ const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
|
||||
key={virtualItem.key}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
{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 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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -58,7 +58,6 @@ 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 { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer'
|
||||
import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool'
|
||||
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'
|
||||
@@ -70,6 +69,7 @@ import { ImageGenerationPlaceholder } from '@/components/chat/image-generation-p
|
||||
import { Intro, type IntroProps } from '@/components/chat/intro'
|
||||
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -136,6 +136,7 @@ export const Thread: FC<{
|
||||
loading?: ThreadLoadingState
|
||||
onBranchInNewChat?: (messageId: string) => void
|
||||
onCancel?: () => Promise<void> | void
|
||||
onRestoreToMessage?: (messageId: string) => Promise<void> | void
|
||||
sessionId?: string | null
|
||||
sessionKey?: string | null
|
||||
}> = ({
|
||||
@@ -146,6 +147,7 @@ export const Thread: FC<{
|
||||
loading,
|
||||
onBranchInNewChat,
|
||||
onCancel,
|
||||
onRestoreToMessage,
|
||||
sessionId = null,
|
||||
sessionKey
|
||||
}) => {
|
||||
@@ -154,9 +156,9 @@ export const Thread: FC<{
|
||||
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
|
||||
SystemMessage,
|
||||
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
|
||||
UserMessage: () => <UserMessage onCancel={onCancel} />
|
||||
UserMessage: () => <UserMessage onCancel={onCancel} onRestoreToMessage={onRestoreToMessage} />
|
||||
}),
|
||||
[cwd, gateway, onBranchInNewChat, onCancel, sessionId]
|
||||
[cwd, gateway, onBranchInNewChat, onCancel, onRestoreToMessage, sessionId]
|
||||
)
|
||||
|
||||
const emptyPlaceholder = intro ? (
|
||||
@@ -216,7 +218,6 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
const hoistedTodos = useMemo(() => todosFromMessageContent(content), [content])
|
||||
|
||||
const previewTargets = useMemo(() => {
|
||||
if (!messageText || !/(https?:\/\/|file:\/\/)/i.test(messageText)) {
|
||||
@@ -246,7 +247,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground"
|
||||
data-slot="aui_assistant-message-content"
|
||||
>
|
||||
{hoistedTodos.length > 0 && <HoistedTodoPanel todos={hoistedTodos} />}
|
||||
{/* Todos render in the composer status stack now, not inline. */}
|
||||
<MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
|
||||
{messageStatus === 'running' && <StreamStallIndicator activity={`${content.length}:${messageText.length}`} />}
|
||||
{previewTargets.length > 0 && (
|
||||
@@ -737,11 +738,46 @@ const USER_ACTION_ICON_BUTTON_CLASS =
|
||||
const USER_ACTION_ICON_SIZE = '0.6875rem'
|
||||
const StopGlyph = <IconPlayerStopFilled aria-hidden className="size-3.5 -translate-y-px" />
|
||||
|
||||
// Background-process notifications are injected into the conversation as user
|
||||
// messages (the agent must react to them, and message-role alternation forbids
|
||||
// a synthetic system row mid-loop). They are NOT something the human typed, so
|
||||
// render them as a compact system-style notice instead of a user bubble.
|
||||
// Shape: see tools/process_registry.py format_process_notification().
|
||||
const PROCESS_NOTIFICATION_RE = /^\[IMPORTANT: Background process [\s\S]*\]$/
|
||||
|
||||
const ProcessNotificationNote: FC<{ text: string }> = ({ text }) => {
|
||||
const body = text.replace(/^\[IMPORTANT:\s*/, '').replace(/\]$/, '')
|
||||
const newline = body.indexOf('\n')
|
||||
const headline = (newline === -1 ? body : body.slice(0, newline)).trim()
|
||||
const detail = newline === -1 ? '' : body.slice(newline + 1).trim()
|
||||
|
||||
return (
|
||||
<div className="flex max-w-[min(86%,44rem)] flex-col gap-0.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Codicon className="shrink-0 text-muted-foreground/55" name="terminal" size="0.75rem" />
|
||||
<span className="wrap-anywhere">{headline}</span>
|
||||
</span>
|
||||
{detail && (
|
||||
<details className="pl-[1.3125rem]">
|
||||
<summary className="cursor-pointer select-none text-muted-foreground/45 hover:text-muted-foreground/70">
|
||||
output
|
||||
</summary>
|
||||
<pre className="mt-0.5 max-h-48 overflow-auto whitespace-pre-wrap font-mono text-[0.625rem] leading-4 text-muted-foreground/55">
|
||||
{detail}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const UserMessage: FC<{
|
||||
onCancel?: () => Promise<void> | void
|
||||
}> = ({ onCancel }) => {
|
||||
onRestoreToMessage?: (messageId: string) => Promise<void> | void
|
||||
}> = ({ onCancel, onRestoreToMessage }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false)
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
@@ -791,15 +827,32 @@ const UserMessage: FC<{
|
||||
|
||||
useResizeObserver(measureClamp, clampInnerRef)
|
||||
|
||||
// Injected background-process notification, not a human prompt — render the
|
||||
// compact system-style notice (after all hooks above have run).
|
||||
if (PROCESS_NOTIFICATION_RE.test(messageText.trim())) {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="flex w-full min-w-0 flex-col items-stretch"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
<ProcessNotificationNote text={messageText.trim()} />
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const hasBody = messageText.trim().length > 0
|
||||
const isLatestUser = messageId === latestUserId
|
||||
const showStop = isLatestUser && threadRunning && Boolean(onCancel)
|
||||
const showRestore = !isLatestUser && !threadRunning
|
||||
// Restore (re-run this exact prompt) is available everywhere the Stop button
|
||||
// isn't — including mid-stream on older prompts, since the action interrupts
|
||||
// the live turn before rewinding.
|
||||
const showRestore = !showStop && Boolean(onRestoreToMessage) && hasBody
|
||||
|
||||
const bubbleClassName = cn(
|
||||
USER_BUBBLE_BASE_CLASS,
|
||||
'border-(--ui-stroke-tertiary) pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors',
|
||||
!threadRunning && 'cursor-pointer hover:border-(--ui-stroke-secondary)'
|
||||
'cursor-pointer pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors',
|
||||
'border-(--ui-stroke-tertiary) hover:border-(--ui-stroke-secondary)'
|
||||
)
|
||||
|
||||
const bubbleContent = (
|
||||
@@ -828,21 +881,19 @@ const UserMessage: FC<{
|
||||
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
|
||||
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
|
||||
<div className="relative w-full">
|
||||
{threadRunning ? (
|
||||
<div className={bubbleClassName}>{bubbleContent}</div>
|
||||
) : (
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<button
|
||||
aria-label={copy.editMessage}
|
||||
className={bubbleClassName}
|
||||
onClick={() => triggerHaptic('selection')}
|
||||
title={copy.editMessage}
|
||||
type="button"
|
||||
>
|
||||
{bubbleContent}
|
||||
</button>
|
||||
</ActionBarPrimitive.Edit>
|
||||
)}
|
||||
{/* Always editable — clicking opens the edit composer even while a
|
||||
turn streams; sending the edit reverts (interrupt + rewind). */}
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<button
|
||||
aria-label={copy.editMessage}
|
||||
className={bubbleClassName}
|
||||
onClick={() => triggerHaptic('selection')}
|
||||
title={copy.editMessage}
|
||||
type="button"
|
||||
>
|
||||
{bubbleContent}
|
||||
</button>
|
||||
</ActionBarPrimitive.Edit>
|
||||
{(showStop || showRestore) && (
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
|
||||
{showStop ? (
|
||||
@@ -860,13 +911,20 @@ const UserMessage: FC<{
|
||||
{StopGlyph}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary)"
|
||||
title={copy.editableCheckpoint}
|
||||
<button
|
||||
aria-label={copy.restoreCheckpoint}
|
||||
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
setRestoreConfirmOpen(true)
|
||||
}}
|
||||
title={copy.restoreFromHere}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="discard" size="0.875rem" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -894,6 +952,17 @@ const UserMessage: FC<{
|
||||
</BranchPickerPrimitive.Root>
|
||||
</div>
|
||||
</ActionBarPrimitive.Root>
|
||||
{showRestore && (
|
||||
<ConfirmDialog
|
||||
confirmLabel={copy.restoreConfirm}
|
||||
description={copy.restoreBody}
|
||||
destructive
|
||||
onClose={() => setRestoreConfirmOpen(false)}
|
||||
onConfirm={() => onRestoreToMessage?.(messageId)}
|
||||
open={restoreConfirmOpen}
|
||||
title={copy.restoreTitle}
|
||||
/>
|
||||
)}
|
||||
</StickyHumanMessageContainer>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { type FC } from 'react'
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Loader2Icon } from '@/lib/icons'
|
||||
import { parseTodos, type TodoItem, type TodoStatus } from '@/lib/todos'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function todosFromMessageContent(content: unknown): TodoItem[] {
|
||||
if (!Array.isArray(content)) {
|
||||
return []
|
||||
}
|
||||
|
||||
let latest: null | TodoItem[] = null
|
||||
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== 'object') {
|
||||
continue
|
||||
}
|
||||
|
||||
const row = part as Record<string, unknown>
|
||||
|
||||
if (row.type !== 'tool-call' || row.toolName !== 'todo') {
|
||||
continue
|
||||
}
|
||||
|
||||
const parsed = parseTodos(row.result) ?? parseTodos(row.args)
|
||||
|
||||
if (parsed !== null) {
|
||||
latest = parsed
|
||||
}
|
||||
}
|
||||
|
||||
return latest ?? []
|
||||
}
|
||||
|
||||
const headerLabel = (todos: readonly TodoItem[]): string =>
|
||||
todos.find(t => t.status === 'in_progress')?.content ??
|
||||
todos.find(t => t.status === 'pending')?.content ??
|
||||
todos.at(-1)?.content ??
|
||||
'Tasks'
|
||||
|
||||
const Checkmark: FC<{ status: TodoStatus; label: string }> = ({ status, label }) => {
|
||||
if (status === 'in_progress') {
|
||||
return (
|
||||
<span
|
||||
aria-label={`In progress: ${label}`}
|
||||
className="grid size-[1.1rem] shrink-0 place-items-center rounded-full border border-ring/65 bg-[color-mix(in_srgb,var(--dt-ring)_14%,transparent)]"
|
||||
>
|
||||
<Loader2Icon className="size-3 animate-spin text-ring" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const checked = status === 'completed'
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
aria-label={label}
|
||||
checked={checked}
|
||||
className={cn(
|
||||
'size-[1.1rem] shrink-0 rounded-full border-border/80 pointer-events-none disabled:cursor-default disabled:opacity-100',
|
||||
checked &&
|
||||
'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground [&_[data-slot=checkbox-indicator]_svg]:size-3',
|
||||
status === 'cancelled' && 'border-muted-foreground/40'
|
||||
)}
|
||||
disabled
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => {
|
||||
if (!todos.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const label = headerLabel(todos)
|
||||
|
||||
return (
|
||||
<section
|
||||
className="mt-1 mb-3 inline-block w-fit max-w-full overflow-hidden rounded-2xl border border-border/70 bg-card align-top shadow-[0_1px_2px_0_hsl(var(--foreground)/0.04),0_1px_4px_-1px_hsl(var(--foreground)/0.06)]"
|
||||
data-slot="aui_todo-hoisted"
|
||||
>
|
||||
<header className="px-3 pt-3 pb-2">
|
||||
<span
|
||||
className="block max-w-full truncate text-[0.85rem] font-semibold leading-tight tracking-tight text-foreground"
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</header>
|
||||
<ul className="grid min-w-0 gap-0.5 px-3 pb-3">
|
||||
{todos.map(todo => (
|
||||
<li
|
||||
// Active row at full presence; everything else fades. Opacity on
|
||||
// the row so the checkbox glyph dims with the text.
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-3 py-1.5 transition-opacity',
|
||||
todo.status === 'in_progress' ? 'opacity-100' : 'opacity-45'
|
||||
)}
|
||||
key={todo.id}
|
||||
>
|
||||
<Checkmark label={todo.content} status={todo.status} />
|
||||
<span className="min-w-0 wrap-anywhere text-[0.8rem] leading-[1.2rem] text-foreground">{todo.content}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
@@ -9,13 +9,30 @@ import { $activeSessionId } from '@/store/session'
|
||||
import { PendingToolApproval } from './tool-approval'
|
||||
import type { ToolPart } from './tool-fallback-model'
|
||||
|
||||
// Radix's DropdownMenu touches pointer-capture + scrollIntoView, which jsdom
|
||||
// doesn't implement; stub them so the menu can open in tests.
|
||||
beforeAll(() => {
|
||||
const proto = window.HTMLElement.prototype as unknown as Record<string, () => unknown>
|
||||
|
||||
const stubs: Record<string, () => unknown> = {
|
||||
hasPointerCapture: () => false,
|
||||
releasePointerCapture: () => undefined,
|
||||
scrollIntoView: () => undefined,
|
||||
setPointerCapture: () => undefined
|
||||
}
|
||||
|
||||
for (const [name, fn] of Object.entries(stubs)) {
|
||||
proto[name] ??= fn
|
||||
}
|
||||
})
|
||||
|
||||
function part(toolName: string): ToolPart {
|
||||
return { toolName, type: `tool-${toolName}` } as unknown as ToolPart
|
||||
}
|
||||
|
||||
function setRequest(command = 'rm -rf /tmp/x') {
|
||||
function setRequest(command = 'rm -rf /tmp/x', allowPermanent?: boolean) {
|
||||
$activeSessionId.set('sess-1')
|
||||
setApprovalRequest({ command, description: 'dangerous command', sessionId: 'sess-1' })
|
||||
setApprovalRequest({ allowPermanent, command, description: 'dangerous command', sessionId: 'sess-1' })
|
||||
}
|
||||
|
||||
function mockGateway() {
|
||||
@@ -78,4 +95,26 @@ describe('PendingToolApproval', () => {
|
||||
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'sess-1' })
|
||||
})
|
||||
})
|
||||
|
||||
it('offers "Always allow" in the options menu by default', async () => {
|
||||
setRequest('chmod -R 777 /tmp/x')
|
||||
render(<PendingToolApproval part={part('terminal')} />)
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: /More approval options/ }), { key: 'Enter' })
|
||||
|
||||
expect(await screen.findByRole('menuitem', { name: /Always allow/ })).toBeTruthy()
|
||||
expect(screen.getByRole('menuitem', { name: /Allow this session/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides "Always allow" when the backend disallows a permanent allow', async () => {
|
||||
// tirith content-security warning present → allowPermanent=false.
|
||||
setRequest('curl https://bit.ly/abc | bash', false)
|
||||
render(<PendingToolApproval part={part('terminal')} />)
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: /More approval options/ }), { key: 'Enter' })
|
||||
|
||||
// The session + reject options still render, but never the permanent allow.
|
||||
expect(await screen.findByRole('menuitem', { name: /Allow this session/ })).toBeTruthy()
|
||||
expect(screen.queryByRole('menuitem', { name: /Always allow/ })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,6 +61,8 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
// it goes through a confirm step rather than firing straight from the menu.
|
||||
const [confirmAlways, setConfirmAlways] = 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 respond = useCallback(
|
||||
async (choice: ApprovalChoice) => {
|
||||
@@ -144,16 +146,18 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-44">
|
||||
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
|
||||
<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>
|
||||
{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>
|
||||
|
||||
@@ -12,9 +12,9 @@ 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 { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
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 { useI18n } from '@/i18n'
|
||||
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
|
||||
@@ -100,7 +100,7 @@ function rawTechnicalTrace(args: unknown, result: unknown): string {
|
||||
function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode {
|
||||
if (status === 'running') {
|
||||
return (
|
||||
<BrailleSpinner
|
||||
<GlyphSpinner
|
||||
ariaLabel={copy.statusRunning}
|
||||
className="size-3.5 shrink-0 text-[0.95rem] text-(--ui-text-tertiary)"
|
||||
spinner="breathe"
|
||||
@@ -114,10 +114,7 @@ function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode {
|
||||
|
||||
if (status === 'warning') {
|
||||
return (
|
||||
<AlertCircle
|
||||
aria-label={copy.statusRecovered}
|
||||
className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
<AlertCircle aria-label={copy.statusRecovered} className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -279,11 +276,14 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
|
||||
const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view])
|
||||
|
||||
// The header trailing slot only carries the live duration timer while the
|
||||
// tool is running. The copy control used to live here too, but an
|
||||
// `opacity-0` (yet still clickable) button straddling the caret/duration made
|
||||
// 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} />
|
||||
) : !isPending && copyAction.text ? (
|
||||
<CopyButton appearance="tool-row" label={copyAction.label} stopPropagation text={copyAction.text} />
|
||||
) : undefined
|
||||
|
||||
return (
|
||||
@@ -322,7 +322,18 @@ function ToolEntry({ part }: ToolEntryProps) {
|
||||
</div>
|
||||
{isPending && <PendingToolApproval part={part} />}
|
||||
{open && (
|
||||
<div className="grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
|
||||
<div className="relative grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
|
||||
{copyAction.text && (
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="absolute right-1.5 top-1.5 z-10 h-5 gap-0 rounded-md border border-(--ui-stroke-tertiary) bg-background/80 px-1 opacity-60 backdrop-blur-sm transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
iconClassName="size-3"
|
||||
label={copyAction.label}
|
||||
showLabel={false}
|
||||
stopPropagation
|
||||
text={copyAction.text}
|
||||
/>
|
||||
)}
|
||||
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
|
||||
<PreviewAttachment source="tool-result" target={view.previewTarget} />
|
||||
)}
|
||||
|
||||
@@ -127,7 +127,9 @@ const InlineSegmentView: FC<{ text: string }> = ({ text }) => {
|
||||
const nodes = useMemo(() => splitInlineCode(text), [text])
|
||||
|
||||
return (
|
||||
<span className="wrap-anywhere block whitespace-pre-line">
|
||||
// styles.css bidi hook (#44150); whitespace-pre-line makes each line its own
|
||||
// UAX#9 paragraph so it resolves direction independently.
|
||||
<span className="wrap-anywhere block whitespace-pre-line" data-slot="aui_user-inline-text">
|
||||
{nodes.map((node, nodeIndex) =>
|
||||
node.kind === 'inline-code' ? (
|
||||
<code
|
||||
|
||||
31
apps/desktop/src/components/chat/composer-dock.ts
Normal file
31
apps/desktop/src/components/chat/composer-dock.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* The composer surface and everything docked to it (slash·@ popover, `?` help)
|
||||
* paint ONE shared `--composer-fill` var. The state ladder (rest / scrolled /
|
||||
* focused / drawer-open) lives in styles.css on `[data-slot='composer-root']`,
|
||||
* so the two layers can never disagree — drawer-open forces an opaque fill via
|
||||
* `:has()`, because translucent glass sampling different backdrops (thread vs
|
||||
* fade gradient) renders as different colors even with identical tints.
|
||||
*/
|
||||
export const composerFill = 'bg-(--composer-fill)'
|
||||
|
||||
/** Backdrop treatment for the composer input surface. Harmless when the fill
|
||||
* goes opaque (drawer open) — nothing shows through to blur. */
|
||||
export const composerSurfaceGlass = cn(
|
||||
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12] [-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
|
||||
'transition-[background-color] duration-150 ease-out'
|
||||
)
|
||||
|
||||
const composerDockEdge = (edge: 'bottom' | 'top') =>
|
||||
cn('border border-border/65', edge === 'top' ? 'rounded-t-2xl border-b-0' : 'rounded-b-2xl border-t-0')
|
||||
|
||||
/** Glassy docked card — the status stack / queue. Paints the SAME
|
||||
* `--composer-fill` as the surface, so rest / scrolled / focused / drawer-open
|
||||
* all match the composer by construction. */
|
||||
export const composerDockCard = (edge: 'bottom' | 'top' = 'top') =>
|
||||
cn(composerDockEdge(edge), composerFill, composerSurfaceGlass)
|
||||
|
||||
/** Fused docked card — completion drawers. Shares `--composer-fill` with the
|
||||
* composer surface, which goes opaque while a drawer is open. */
|
||||
export const composerFusedDockCard = (edge: 'bottom' | 'top' = 'top') => cn(composerDockEdge(edge), composerFill)
|
||||
68
apps/desktop/src/components/chat/status-row.tsx
Normal file
68
apps/desktop/src/components/chat/status-row.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface StatusRowProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
/** Leading glyph slot (spinner / status dot / selection circle). */
|
||||
leading?: ReactNode
|
||||
/** Makes the whole row activatable (adds `cursor-pointer` + keyboard a11y).
|
||||
* Trailing-slot buttons should `stopPropagation` so they don't also fire it. */
|
||||
onActivate?: () => void
|
||||
/** Right-aligned actions. Revealed on row hover/focus unless `trailingVisible`. */
|
||||
trailing?: ReactNode
|
||||
trailingVisible?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared row chrome for everything in the composer status stack — status items
|
||||
* (subagents, background) AND queued prompts. Fixed height, a leading glyph
|
||||
* slot, flexible content, and a trailing actions slot that reveals on hover.
|
||||
* Hover background matches the session sidebar. Consumers fill the three slots;
|
||||
* they never re-implement the row container.
|
||||
*/
|
||||
export function StatusRow({
|
||||
children,
|
||||
className,
|
||||
leading,
|
||||
onActivate,
|
||||
trailing,
|
||||
trailingVisible = false
|
||||
}: StatusRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/status-row flex min-h-6 items-center gap-2 rounded-md px-1.5 py-1 hover:bg-(--ui-row-hover-background)',
|
||||
onActivate && 'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
onClick={onActivate}
|
||||
onKeyDown={
|
||||
onActivate
|
||||
? event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onActivate()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
role={onActivate ? 'button' : undefined}
|
||||
tabIndex={onActivate ? 0 : undefined}
|
||||
>
|
||||
<span className="flex size-3.5 shrink-0 items-center justify-center">{leading}</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">{children}</div>
|
||||
{trailing && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-0.5',
|
||||
!trailingVisible && 'opacity-0 group-hover/status-row:opacity-100 group-focus-within/status-row:opacity-100'
|
||||
)}
|
||||
>
|
||||
{trailing}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
apps/desktop/src/components/chat/status-section.tsx
Normal file
42
apps/desktop/src/components/chat/status-section.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { type ReactNode, useState } from 'react'
|
||||
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
|
||||
interface StatusSectionProps {
|
||||
/** Optional right-aligned actions (text links / micro buttons). Pass
|
||||
* `Button` with `size="micro"` + `variant="text"` or `"link"`. */
|
||||
accessory?: ReactNode
|
||||
children: ReactNode
|
||||
defaultCollapsed?: boolean
|
||||
/** Optional glyph between the caret and the label (e.g. a `Codicon`). */
|
||||
icon?: ReactNode
|
||||
label: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* One collapsible group inside the composer status stack. Pure chrome — header
|
||||
* (caret + label) + body — styled to match the queue exactly so every status
|
||||
* (queue, subagents, background) reads as one piece. The stack supplies the
|
||||
* outer card and the dividers between groups; this owns only its own collapse.
|
||||
*/
|
||||
export function StatusSection({ accessory, children, defaultCollapsed = true, icon, label }: StatusSectionProps) {
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-1 pr-1">
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 px-2 py-1 text-left text-xs font-normal text-muted-foreground/92 transition-colors hover:text-foreground/90"
|
||||
onClick={() => setCollapsed(open => !open)}
|
||||
type="button"
|
||||
>
|
||||
<DisclosureCaret className="shrink-0" open={!collapsed} size="1em" />
|
||||
{icon && <span className="flex shrink-0 items-center">{icon}</span>}
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
{accessory && <div className="flex shrink-0 items-center gap-1">{accessory}</div>}
|
||||
</div>
|
||||
{!collapsed && <div className="px-1 pb-0.5">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
apps/desktop/src/components/chat/terminal-output.tsx
Normal file
50
apps/desktop/src/components/chat/terminal-output.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TerminalOutputProps {
|
||||
className?: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const NEAR_BOTTOM_PX = 24
|
||||
|
||||
/**
|
||||
* Tiny read-only terminal viewer: monospace, non-wrapping (long lines scroll
|
||||
* horizontally), vertical scroll past `max-h`. Jumps to the bottom on mount,
|
||||
* then tails — sticking to the bottom as `text` grows, but only when the user
|
||||
* is already near the bottom so scrolling up to read earlier output isn't
|
||||
* interrupted.
|
||||
*
|
||||
* Self-contained so any surface (status rows, tool calls, inspectors) can drop
|
||||
* in a stdout/stderr box without re-implementing the scroll logic.
|
||||
*/
|
||||
export function TerminalOutput({ className, text }: TerminalOutputProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
// On open: jump straight to the latest output (no animation, before paint).
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current
|
||||
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}, [])
|
||||
|
||||
// On growth: tail only when already pinned near the bottom.
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
|
||||
if (el && el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}, [text])
|
||||
|
||||
return (
|
||||
<div className={cn('max-h-16 overflow-auto overscroll-contain', className)} ref={ref}>
|
||||
<pre className="w-max min-w-full font-mono text-[0.5625rem] leading-[0.85rem] whitespace-pre text-muted-foreground/70">
|
||||
{text}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -26,7 +26,8 @@ function setProviders(providers: OAuthProvider[]) {
|
||||
reason: null,
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false
|
||||
manual: false,
|
||||
localEndpoint: false
|
||||
} satisfies DesktopOnboardingState)
|
||||
}
|
||||
|
||||
@@ -49,7 +50,8 @@ afterEach(() => {
|
||||
reason: null,
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false
|
||||
manual: false,
|
||||
localEndpoint: false
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -430,19 +430,24 @@ const persistShowAll = (value: boolean) => {
|
||||
|
||||
export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
||||
const { t } = useI18n()
|
||||
const { manual, mode, providers } = useStore($desktopOnboarding)
|
||||
const { localEndpoint, manual, mode, providers } = useStore($desktopOnboarding)
|
||||
const [showAll, setShowAll] = useState(readShowAll)
|
||||
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
|
||||
const hasOauth = ordered.length > 0
|
||||
const apiKeyOptions = useApiKeyCatalog()
|
||||
|
||||
if (mode === 'apikey' || !hasOauth) {
|
||||
// localEndpoint forces the key form regardless of `mode` (which a manual
|
||||
// provider refresh may flip back to 'oauth'); it preselects the local option
|
||||
// and hides the "back to sign in" link since the user came specifically to
|
||||
// configure a custom endpoint.
|
||||
if (localEndpoint || mode === 'apikey' || !hasOauth) {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<ApiKeyForm
|
||||
canGoBack={hasOauth}
|
||||
canGoBack={hasOauth && !localEndpoint}
|
||||
initialEnvKey={localEndpoint ? 'OPENAI_BASE_URL' : undefined}
|
||||
onBack={() => setOnboardingMode('oauth')}
|
||||
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
|
||||
onSave={(envKey, value, name, apiKey) => saveOnboardingApiKey(envKey, value, name, ctx, apiKey)}
|
||||
options={apiKeyOptions}
|
||||
/>
|
||||
{manual ? null : (
|
||||
@@ -630,6 +635,7 @@ export function ProviderRow({
|
||||
// surfaces render the identical form.
|
||||
export function ApiKeyForm({
|
||||
canGoBack,
|
||||
initialEnvKey,
|
||||
isSet,
|
||||
onBack,
|
||||
onClear,
|
||||
@@ -638,16 +644,31 @@ export function ApiKeyForm({
|
||||
redactedValue
|
||||
}: {
|
||||
canGoBack: boolean
|
||||
/** Preselect a specific option by env key (e.g. 'OPENAI_BASE_URL' to land on
|
||||
* the local / custom endpoint form). Falls back to the first option. */
|
||||
initialEnvKey?: string
|
||||
isSet?: (envKey: string) => boolean
|
||||
onBack: () => void
|
||||
onClear?: (envKey: string) => void
|
||||
onSave: (envKey: string, value: string, name: string) => Promise<{ message?: string; ok: boolean }>
|
||||
onSave: (
|
||||
envKey: string,
|
||||
value: string,
|
||||
name: string,
|
||||
apiKey?: string
|
||||
) => Promise<{ message?: string; ok: boolean }>
|
||||
options?: ApiKeyOption[]
|
||||
redactedValue?: (envKey: string) => null | string | undefined
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const [option, setOption] = useState<ApiKeyOption>(options[0])
|
||||
|
||||
const [option, setOption] = useState<ApiKeyOption>(
|
||||
() => options.find(o => o.envKey === initialEnvKey) ?? options[0]
|
||||
)
|
||||
|
||||
const [value, setValue] = useState('')
|
||||
// Optional endpoint API key, only used by the local / custom endpoint option
|
||||
// (whose `value` is the base URL). Cleared whenever the option changes.
|
||||
const [localKey, setLocalKey] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
// `options` can change at runtime when callers filter the catalog (e.g. the
|
||||
@@ -657,6 +678,7 @@ export function ApiKeyForm({
|
||||
if (options.length > 0 && !options.some(o => o.envKey === option.envKey)) {
|
||||
setOption(options[0])
|
||||
setValue('')
|
||||
setLocalKey('')
|
||||
setError(null)
|
||||
}
|
||||
}, [option.envKey, options])
|
||||
@@ -668,6 +690,7 @@ export function ApiKeyForm({
|
||||
const pick = (o: ApiKeyOption) => {
|
||||
setOption(o)
|
||||
setValue('')
|
||||
setLocalKey('')
|
||||
setError(null)
|
||||
requestAnimationFrame(() => {
|
||||
entryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
@@ -693,10 +716,11 @@ export function ApiKeyForm({
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
const result = await onSave(option.envKey, value, option.name)
|
||||
const result = await onSave(option.envKey, value, option.name, isLocal ? localKey : undefined)
|
||||
|
||||
if (result.ok) {
|
||||
setValue('')
|
||||
setLocalKey('')
|
||||
} else {
|
||||
setError(result.message ?? t.onboarding.couldNotSave)
|
||||
}
|
||||
@@ -759,6 +783,17 @@ export function ApiKeyForm({
|
||||
type={isLocal ? 'text' : 'password'}
|
||||
value={value}
|
||||
/>
|
||||
{isLocal ? (
|
||||
<Input
|
||||
autoComplete="off"
|
||||
className="font-mono"
|
||||
onChange={e => setLocalKey(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && void submit()}
|
||||
placeholder={t.onboarding.localApiKeyPlaceholder}
|
||||
type="password"
|
||||
value={localKey}
|
||||
/>
|
||||
) : null}
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@ function resetStores() {
|
||||
reason: null,
|
||||
requested: false,
|
||||
firstRunSkipped: false,
|
||||
manual: false
|
||||
manual: false,
|
||||
localEndpoint: false
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { getGlobalModelOptions } from '@/hermes'
|
||||
@@ -69,9 +69,7 @@ export function ModelVisibilityDialog({
|
||||
next.delete(key)
|
||||
|
||||
// Check if this was the last real model for this provider.
|
||||
const remainingForProvider = [...next].some(
|
||||
k => k.startsWith(`${provider.slug}::`) && !isProviderSentinel(k)
|
||||
)
|
||||
const remainingForProvider = [...next].some(k => k.startsWith(`${provider.slug}::`) && !isProviderSentinel(k))
|
||||
|
||||
if (!remainingForProvider) {
|
||||
next.add(sentinel)
|
||||
@@ -110,7 +108,7 @@ export function ModelVisibilityDialog({
|
||||
<div className="max-h-[55vh] overflow-y-auto pb-1">
|
||||
{providers.length === 0 ? (
|
||||
<div className="px-3 py-5 text-center text-xs text-muted-foreground">
|
||||
{modelOptions.isPending ? <BrailleSpinner className="mx-auto text-sm" /> : copy.noAuthenticatedProviders}
|
||||
{modelOptions.isPending ? <GlyphSpinner className="mx-auto text-sm" /> : copy.noAuthenticatedProviders}
|
||||
</div>
|
||||
) : (
|
||||
providers.map(provider => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { listSessions } from '@/hermes'
|
||||
import { listAllProfileSessions } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { Check, MessageCircle } from '@/lib/icons'
|
||||
@@ -35,7 +35,7 @@ export function SessionPickerDialog({
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
enabled: open,
|
||||
queryFn: () => listSessions(200, 1, 'exclude'),
|
||||
queryFn: () => listAllProfileSessions(200, 1, 'exclude'),
|
||||
queryKey: ['session-picker', 'sessions']
|
||||
})
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Text+icon actions underline the label on hover, not the glyph.
|
||||
const TEXT_ACTION_ICON = '[&_.codicon]:no-underline [&_svg]:no-underline'
|
||||
|
||||
// Text buttons are square (no radius) and sized by padding + line-height — no
|
||||
// fixed heights — so they stay snug and scale with content. Only icon buttons
|
||||
// (inherently square) carry the shared 4px radius.
|
||||
@@ -22,13 +25,13 @@ const buttonVariants = cva(
|
||||
secondary:
|
||||
'bg-(--ui-bg-quaternary) text-(--ui-text-primary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
|
||||
ghost: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
|
||||
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline',
|
||||
link: `text-primary underline-offset-4 decoration-current/20 hover:underline ${TEXT_ACTION_ICON}`,
|
||||
// Boxless inline-text action (no bg/border). Quiet by default — reads as
|
||||
// muted label text, underlines on hover (e.g. "Cancel", "Clear").
|
||||
text: 'text-muted-foreground underline-offset-4 hover:text-foreground hover:underline',
|
||||
text: `text-muted-foreground underline-offset-4 hover:text-foreground hover:underline ${TEXT_ACTION_ICON}`,
|
||||
// Emphasized inline-text action: bold + always-underlined link. Use for
|
||||
// the actionable affordance in a row ("Change", "Set", "Open logs", …).
|
||||
textStrong: 'font-semibold text-muted-foreground underline underline-offset-4 hover:text-foreground'
|
||||
textStrong: `font-semibold text-muted-foreground underline underline-offset-4 hover:text-foreground ${TEXT_ACTION_ICON}`
|
||||
},
|
||||
size: {
|
||||
default: 'px-3 py-1.5 has-[>svg]:px-2.5',
|
||||
@@ -39,6 +42,9 @@ const buttonVariants = cva(
|
||||
// variants when the button must sit inline in a heading or sentence
|
||||
// (replaces ad-hoc `h-auto px-0 py-0` overrides).
|
||||
inline: 'h-auto gap-1 p-0 has-[>svg]:px-0',
|
||||
// Status-stack headers, table footers — 12px text actions beside a label.
|
||||
micro:
|
||||
"h-auto gap-0.5 px-1 py-0 text-xs leading-4 font-normal has-[>svg]:px-0.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
icon: 'size-9 rounded-[4px]',
|
||||
'icon-xs': "size-6 rounded-[4px] [&_svg:not([class*='size-'])]:size-3",
|
||||
'icon-sm': 'size-8 rounded-[4px]',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user